diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index feb8cb53c..6670a43a8 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -19,7 +19,7 @@ import { resolvePublicDirectoy, } from './setup/index.js'; import { ONTIME_VERSION } from './ONTIME_VERSION.js'; -import { consoleSuccess, consoleHighlight } from './utils/console.js'; +import { consoleSuccess, consoleHighlight, consoleError } from './utils/console.js'; // Import Routers import { appRouter } from './api-data/index.js'; @@ -285,12 +285,18 @@ export const shutdown = async (exitCode = 0) => { process.on('exit', (code) => consoleHighlight(`Ontime shutdown with code: ${code}`)); process.on('unhandledRejection', async (error) => { + if (!isProduction && error instanceof Error && error.stack) { + consoleError(error.stack); + } generateCrashReport(error); logger.crash(LogOrigin.Server, `Uncaught exception | ${error}`); await shutdown(1); }); process.on('uncaughtException', async (error) => { + if (!isProduction && error instanceof Error && error.stack) { + consoleError(error.stack); + } generateCrashReport(error); logger.crash(LogOrigin.Server, `Uncaught exception | ${error}`); await shutdown(1); diff --git a/apps/server/src/services/TimerService.ts b/apps/server/src/services/EventTimer.ts similarity index 91% rename from apps/server/src/services/TimerService.ts rename to apps/server/src/services/EventTimer.ts index ff1c16a2d..3617ae369 100644 --- a/apps/server/src/services/TimerService.ts +++ b/apps/server/src/services/EventTimer.ts @@ -9,7 +9,7 @@ type UpdateCallbackFn = (updateResult: UpdateResult) => void; /** * Service manages Ontime's main timer */ -export class TimerService { +export class EventTimer { private readonly _interval: NodeJS.Timeout; /** how often we recalculate */ static _refreshInterval: number; @@ -26,15 +26,14 @@ export class TimerService { * @param {function} [timerConfig.onUpdateCallback] how often we update the socket */ constructor(timerConfig: { refresh: number; updateInterval: number }) { - TimerService._refreshInterval = timerConfig.refresh; + EventTimer._refreshInterval = timerConfig.refresh; this._interval = setInterval(() => { this.update(); - }, TimerService._refreshInterval); + }, EventTimer._refreshInterval); } /** * Allows setting a callback for when the timer updates - * @param callback */ setOnUpdateCallback(callback: (updateResult: UpdateResult) => void) { this.onUpdateCallback = callback; @@ -73,7 +72,6 @@ export class TimerService { /** * Adds time to running timer by given amount - * @param {number} amount */ addTime(amount: number): boolean { if (!runtimeState.addTime(amount)) { @@ -104,10 +102,9 @@ export class TimerService { /** * Loads roll information into timer service - * @param {OntimeEvent[]} rundown -- list of events to run */ roll(rundown: OntimeRundown) { - runtimeState.roll(rundown); + return runtimeState.roll(rundown); } shutdown() { diff --git a/apps/server/src/services/__tests__/rollUtils.test.ts b/apps/server/src/services/__tests__/rollUtils.test.ts new file mode 100644 index 000000000..9612fe481 --- /dev/null +++ b/apps/server/src/services/__tests__/rollUtils.test.ts @@ -0,0 +1,448 @@ +import { OntimeEvent, SupportedEvent } from 'ontime-types'; +import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils'; + +import { loadRoll } from '../rollUtils.js'; + +const baseEvent = { + type: SupportedEvent.Event, + skip: false, +}; + +function makeOntimeEvent(patch: Partial): OntimeEvent { + return { + ...baseEvent, + ...patch, + } as OntimeEvent; +} + +function prepareTimedEvents(events: Partial[]): OntimeEvent[] { + return events.map(makeOntimeEvent); +} + +describe('loadRoll()', () => { + const eventlist = [ + { + id: '1', + timeStart: 5, + timeEnd: 10, + isPublic: false, + }, + { + id: '2', + timeStart: 10, + timeEnd: 20, + isPublic: false, + }, + { + id: '3', + timeStart: 20, + timeEnd: 30, + isPublic: false, + }, + { + id: '4', + timeStart: 30, + timeEnd: 40, + isPublic: false, + }, + { + id: '5', + timeStart: 40, + timeEnd: 50, + isPublic: true, + }, + { + id: '6', + timeStart: 50, + timeEnd: 60, + isPublic: false, + }, + { + id: '7', + timeStart: 60, + timeEnd: 70, + isPublic: true, + }, + { + id: '8', + timeStart: 70, + timeEnd: 80, + isPublic: false, + }, + ]; + const timedEvents = prepareTimedEvents(eventlist); + + it('should roll to the day after if timer is at 100', () => { + const now = 100; + const expected = { + event: timedEvents[0], + index: 0, + isPending: true, + }; + + const state = loadRoll(timedEvents, now); + expect(state).toStrictEqual(expected); + }); + + it('should be waiting to start if timer is at 0', () => { + const now = 0; + const expected = { + event: timedEvents[0], + index: 0, + isPending: true, + }; + + const state = loadRoll(timedEvents, now); + expect(state).toStrictEqual(expected); + }); + + it('should start the first event if timer is at 5', () => { + const now = 5; + const expected = { + event: timedEvents[0], + index: 0, + }; + + const state = loadRoll(timedEvents, now); + expect(state).toStrictEqual(expected); + }); + + it('should start the second event if timer is at 15', () => { + const now = 15; + const expected = { + event: timedEvents[1], + index: 1, + }; + + const state = loadRoll(timedEvents, now); + expect(state).toStrictEqual(expected); + }); + + it('should start the third event if timer is at 10', () => { + const now = 20; + const expected = { + event: timedEvents[2], + index: 2, + }; + + const state = loadRoll(timedEvents, now); + expect(state).toStrictEqual(expected); + }); + + it('should start the fifth event if timer is at 49', () => { + const now = 49; + const expected = { + event: timedEvents[4], + index: 4, + }; + + const state = loadRoll(timedEvents, now); + expect(state).toStrictEqual(expected); + }); + + it('should start the seventh event if timer is at 63', () => { + const now = 63; + const expected = { + event: timedEvents[6], + index: 6, + }; + + const state = loadRoll(timedEvents, now); + expect(state).toStrictEqual(expected); + }); + + it('should start the eight event if timer is at 75', () => { + const now = 75; + const expected = { + event: timedEvents[7], + index: 7, + }; + + const state = loadRoll(timedEvents, now); + expect(state).toStrictEqual(expected); + }); +}); + +describe('loadRoll() handle edge cases with midnight', () => { + it('should find an event that crosses midnight', () => { + const now = 23 * MILLIS_PER_HOUR; + const eventlist = [ + { + id: '0', + timeStart: 9 * MILLIS_PER_HOUR, + timeEnd: 10 * MILLIS_PER_HOUR, + isPublic: true, + }, + { + id: '1', + timeStart: 20 * MILLIS_PER_HOUR, + timeEnd: 22 * MILLIS_PER_HOUR, + isPublic: true, + }, + { + id: '2', + timeStart: 22 * MILLIS_PER_HOUR, + timeEnd: 1 * MILLIS_PER_HOUR, + isPublic: true, + }, + { + id: '3', + timeStart: 1 * MILLIS_PER_HOUR, + timeEnd: 1 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE, + isPublic: true, + }, + { + id: '4', + timeStart: 1 * MILLIS_PER_HOUR, + timeEnd: 2 * MILLIS_PER_HOUR, + isPublic: true, + }, + ]; + const timedEvents = prepareTimedEvents(eventlist); + + const expected = { + event: timedEvents[2], + index: 2, + }; + const state = loadRoll(timedEvents, now); + expect(state).toStrictEqual(expected); + }); + + it('should not skip to the second day', () => { + /** + * NOTE: this is a potentially contentious decision + * + * The idea here is that it makes no sense for us to jump to the second / third day on activating roll + * if the user wants to skip a portion of the rundown, they can manually jump to the event and activate roll + * + * On our side, this simplifies logic and makes behaviour more predictable + */ + const now = 8 * MILLIS_PER_HOUR; + const eventlist = [ + { + id: '0', + timeStart: 21 * MILLIS_PER_HOUR, + timeEnd: 22 * MILLIS_PER_HOUR, + }, + { + id: '1', + timeStart: 22 * MILLIS_PER_HOUR, + timeEnd: 3 * MILLIS_PER_HOUR, + }, + { + id: '2', + timeStart: 3 * MILLIS_PER_HOUR, + timeEnd: 10 * MILLIS_PER_HOUR, + }, + ]; + const timedEvents = prepareTimedEvents(eventlist); + const expected = { + event: timedEvents[0], + index: 0, + isPending: true, + }; + const state = loadRoll(timedEvents, now); + expect(state).toStrictEqual(expected); + }); +}); + +describe('loadRoll() handle edge cases with before and after start', () => { + it('should prepare first event, if we are not yet in the rundown start', () => { + const now = 7 * MILLIS_PER_HOUR; + const singleEventList = [ + makeOntimeEvent({ + id: '1', + timeStart: 10 * MILLIS_PER_HOUR, + timeEnd: 11 * MILLIS_PER_HOUR, + isPublic: true, + }), + ]; + + const expected = { + event: singleEventList[0], + index: 0, + isPending: true, + }; + const state = loadRoll(singleEventList, now); + expect(state).toStrictEqual(expected); + }); + + it('should prepare first event, if we are over the rundown end', () => { + const now = 18 * MILLIS_PER_HOUR; + const singleEventList = [ + makeOntimeEvent({ + id: '1', + timeStart: 10 * MILLIS_PER_HOUR, + timeEnd: 11 * MILLIS_PER_HOUR, + isPublic: true, + }), + ]; + + const expected = { + event: singleEventList[0], + index: 0, + isPending: true, + }; + const state = loadRoll(singleEventList, now); + expect(state).toStrictEqual(expected); + }); + + it('should account for a rundown that goes through midnight', () => { + const now = 1 * MILLIS_PER_HOUR; + const singleEventList = [ + makeOntimeEvent({ + id: '1', + timeStart: 10 * MILLIS_PER_HOUR, + timeEnd: 2 * MILLIS_PER_HOUR, + isPublic: true, + }), + ]; + const expected = { + event: singleEventList[0], + index: 0, + }; + const state = loadRoll(singleEventList, now); + expect(state.isPending).toBeUndefined(); + expect(state).toStrictEqual(expected); + }); + + it('loads upcoming event while waiting to roll', () => { + const now = 6000; // 00:01 + const singleEventList = [ + makeOntimeEvent({ + id: '1', + timeStart: 72000000, // 20:00 + timeEnd: 72010000, // 20:10 + isPublic: true, + }), + ]; + const expected = { + event: singleEventList[0], + index: 0, + isPending: true, + }; + const state = loadRoll(singleEventList, now); + expect(state).toStrictEqual(expected); + }); +}); + +describe('loadRoll() test that roll behaviour with overlapping times', () => { + const eventlist = [ + { + id: '1', + timeStart: 10, + timeEnd: 10, + isPublic: false, + }, + { + id: '2', + timeStart: 10, + timeEnd: 20, + isPublic: true, + }, + { + id: '3', + timeStart: 10, + timeEnd: 30, + isPublic: false, + }, + ]; + const timedEvents = prepareTimedEvents(eventlist); + + it('if timer is at 0', () => { + const now = 0; + const expected = { + event: timedEvents[0], + index: 0, + isPending: true, + }; + + const state = loadRoll(timedEvents, now); + expect(state).toStrictEqual(expected); + }); + + it('if timer is at 10, it ignores events with 0 duration', () => { + const now = 10; + const expected = { + event: timedEvents[1], + index: 1, + }; + + const state = loadRoll(timedEvents, now); + expect(state).toStrictEqual(expected); + }); + + it('if timer is at 15', () => { + const now = 15; + const expected = { + event: timedEvents[1], + index: 1, + }; + + const state = loadRoll(timedEvents, now); + expect(state).toStrictEqual(expected); + }); + + it('if timer is at 20', () => { + const now = 20; + const expected = { + event: timedEvents[2], + index: 2, + }; + + const state = loadRoll(timedEvents, now); + expect(state).toStrictEqual(expected); + }); + + it('if timer is at 25', () => { + const now = 25; + const expected = { + event: timedEvents[2], + index: 2, + }; + + const state = loadRoll(timedEvents, now); + expect(state).toStrictEqual(expected); + }); +}); + +// issue #58 +describe('loadRoll() test that roll behaviour multi day event edge cases', () => { + it('should recognise a playing event where its schedule spans over midnight', () => { + const now = 66600000; // 19:30 + const eventlist = [ + makeOntimeEvent({ + id: '1', + timeStart: 66000000, // 19:20 + timeEnd: 54600000, // 16:10 + isPublic: false, + }), + ]; + const expected = { + event: eventlist[0], + index: 0, + }; + + const state = loadRoll(eventlist, now); + expect(state).toStrictEqual(expected); + }); + + it('if the start time is the day after end time, and both are later than now', () => { + const now = 66840000; // 19:34 + const eventlist = [ + makeOntimeEvent({ + id: '1', + timeStart: 67200000, // 19:40 + timeEnd: 66900000, // 19:35 + isPublic: false, + }), + ]; + const expected = { + event: eventlist[0], + index: 0, + }; + + const state = loadRoll(eventlist, now); + expect(state).toStrictEqual(expected); + }); +}); diff --git a/apps/server/src/services/__tests__/timerUtils.test.ts b/apps/server/src/services/__tests__/timerUtils.test.ts index 1121f359e..30d0b571a 100644 --- a/apps/server/src/services/__tests__/timerUtils.test.ts +++ b/apps/server/src/services/__tests__/timerUtils.test.ts @@ -1,10 +1,9 @@ import { MILLIS_PER_HOUR, dayInMs, millisToString } from 'ontime-utils'; -import { EndAction, OntimeEvent, Playback, TimeStrategy, TimerPhase, TimerType } from 'ontime-types'; +import { EndAction, Playback, TimeStrategy, TimerPhase, TimerType } from 'ontime-types'; import { getCurrent, getExpectedFinish, - getRollTimers, getRuntimeOffset, getTimerPhase, getTotalDuration, @@ -696,520 +695,6 @@ describe('skippedOutOfEvent()', () => { }); }); -describe('getRollTimers()', () => { - const eventlist: Partial[] = [ - { - id: '1', - timeStart: 5, - timeEnd: 10, - isPublic: false, - }, - { - id: '2', - timeStart: 10, - timeEnd: 20, - isPublic: false, - }, - { - id: '3', - timeStart: 20, - timeEnd: 30, - isPublic: false, - }, - { - id: '4', - timeStart: 30, - timeEnd: 40, - isPublic: false, - }, - { - id: '5', - timeStart: 40, - timeEnd: 50, - isPublic: true, - }, - { - id: '6', - timeStart: 50, - timeEnd: 60, - isPublic: false, - }, - { - id: '7', - timeStart: 60, - timeEnd: 70, - isPublic: true, - }, - { - id: '8', - timeStart: 70, - timeEnd: 80, - isPublic: false, - }, - ]; - - it('if timer is at 0', () => { - const now = 0; - const expected = { - nowIndex: null, - nowId: null, - publicIndex: null, - nextIndex: 0, - publicNextIndex: 4, - timeToNext: 5, - nextEvent: eventlist[0], - nextPublicEvent: eventlist[4], - currentEvent: null, - currentPublicEvent: null, - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('if timer is at 5', () => { - const now = 5; - const expected = { - nowIndex: 0, - nowId: eventlist[0].id, - publicIndex: null, - nextIndex: 1, - publicNextIndex: 4, - timeToNext: 5, - nextEvent: eventlist[1], - nextPublicEvent: eventlist[4], - currentEvent: eventlist[0], - currentPublicEvent: null, - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('if timer is at 15', () => { - const now = 15; - const expected = { - nowIndex: 1, - nowId: eventlist[1].id, - publicIndex: null, - nextIndex: 2, - publicNextIndex: 4, - timeToNext: 5, - nextEvent: eventlist[2], - nextPublicEvent: eventlist[4], - currentEvent: eventlist[1], - currentPublicEvent: null, - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('if timer is at 20', () => { - const now = 20; - const expected = { - nowIndex: 2, - nowId: eventlist[2].id, - publicIndex: null, - nextIndex: 3, - publicNextIndex: 4, - timeToNext: 10, - nextEvent: eventlist[3], - nextPublicEvent: eventlist[4], - currentEvent: eventlist[2], - currentPublicEvent: null, - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('if timer is at 49', () => { - const now = 49; - const expected = { - nowIndex: 4, - nowId: eventlist[4].id, - publicIndex: 4, - nextIndex: 5, - publicNextIndex: 6, - timeToNext: 1, - nextEvent: eventlist[5], - nextPublicEvent: eventlist[6], - currentEvent: eventlist[4], - currentPublicEvent: eventlist[4], - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('if timer is at 63', () => { - const now = 63; - const expected = { - nowIndex: 6, - nowId: eventlist[6].id, - publicIndex: 6, - nextIndex: 7, - publicNextIndex: null, - timeToNext: 7, - nextEvent: eventlist[7], - nextPublicEvent: null, - currentEvent: eventlist[6], - currentPublicEvent: eventlist[6], - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('if timer is at 75', () => { - const now = 75; - const expected = { - nowIndex: 7, - nowId: eventlist[7].id, - publicIndex: 6, - nextIndex: null, - publicNextIndex: null, - timeToNext: null, - nextEvent: null, - nextPublicEvent: null, - currentEvent: eventlist[7], - currentPublicEvent: eventlist[6], - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('if timer is at 100 we roll to day after', () => { - const now = 100; - const expected = { - nowIndex: null, - nowId: null, - publicIndex: null, - nextIndex: 0, - publicNextIndex: 4, - timeToNext: dayInMs - now + eventlist[0].timeStart!, - nextEvent: eventlist[0], - nextPublicEvent: eventlist[4], - currentEvent: null, - currentPublicEvent: null, - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('handles rolls to next day with real values', () => { - const singleEventList: Partial[] = [ - { - id: '1', - timeStart: 36000000, // 10:00 - timeEnd: 39600000, // 11:00 - isPublic: true, - }, - ]; - const now = 64800000; // 18:00 - const expected = { - nowIndex: null, - nowId: null, - publicIndex: null, - nextIndex: 0, - publicNextIndex: 0, - timeToNext: dayInMs - now + singleEventList[0].timeStart!, - nextEvent: singleEventList[0], - nextPublicEvent: singleEventList[0], - currentEvent: null, - currentPublicEvent: null, - }; - const state = getRollTimers(singleEventList as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('handles rolls to next day with real values', () => { - const singleEventList: Partial[] = [ - { - id: '1', - timeStart: 36000000, // 10:00 - timeEnd: 3600000, // 01:00 - isPublic: true, - }, - ]; - const now = 60000; // 00:01 - const expected = { - nowIndex: 0, - nowId: singleEventList[0].id, - publicIndex: 0, - nextIndex: null, - publicNextIndex: null, - timeToNext: null, - nextEvent: null, - nextPublicEvent: null, - currentEvent: singleEventList[0], - currentPublicEvent: singleEventList[0], - }; - const state = getRollTimers(singleEventList as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - it('handles rolls to next day with real values', () => { - const singleEventList: Partial[] = [ - { - id: '1', - timeStart: 36000000, // 10:00 - timeEnd: 3600000, // 01:00 - isPublic: true, - }, - ]; - const now = 60000; // 00:01 - const expected = { - nowIndex: 0, - nowId: singleEventList[0].id, - publicIndex: 0, - nextIndex: null, - publicNextIndex: null, - timeToNext: null, - nextEvent: null, - nextPublicEvent: null, - currentEvent: singleEventList[0], - currentPublicEvent: singleEventList[0], - }; - const state = getRollTimers(singleEventList as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('loads upcoming event while waiting to roll', () => { - const singleEventList: Partial[] = [ - { - id: '1', - timeStart: 72000000, // 20:00 - timeEnd: 72010000, // 20:10 - isPublic: true, - }, - ]; - const now = 6000; // 00:01 - const expected = { - nowIndex: null, - nowId: null, - publicIndex: null, - nextIndex: 0, - publicNextIndex: 0, - timeToNext: 72000000 - now, - nextEvent: singleEventList[0], - nextPublicEvent: singleEventList[0], - currentEvent: null, - currentPublicEvent: null, - }; - const state = getRollTimers(singleEventList as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('handles roll that goes over midnight', () => { - const singleEventList: Partial[] = [ - { - id: '1', - timeStart: 72000000, // 20:00 - timeEnd: 60000, // 00:10 - isPublic: true, - }, - ]; - const now = 6000; // 00:01 - const expected = { - nowIndex: 0, - nowId: singleEventList[0].id, - publicIndex: 0, - nextIndex: null, - publicNextIndex: null, - timeToNext: null, - nextEvent: null, - nextPublicEvent: null, - currentEvent: singleEventList[0], - currentPublicEvent: singleEventList[0], - }; - const state = getRollTimers(singleEventList as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); -}); - -describe('getRollTimers() test that roll behaviour with overlapping times', () => { - const eventlist: Partial[] = [ - { - id: '1', - timeStart: 10, - timeEnd: 10, - isPublic: false, - }, - { - id: '2', - timeStart: 10, - timeEnd: 20, - isPublic: true, - }, - { - id: '3', - timeStart: 10, - timeEnd: 30, - isPublic: false, - }, - ]; - - it('if timer is at 0', () => { - const now = 0; - const expected = { - nowIndex: null, - nowId: null, - publicIndex: null, - nextIndex: 0, - publicNextIndex: 1, - timeToNext: 10, - nextEvent: eventlist[0], - nextPublicEvent: eventlist[1], - currentEvent: null, - currentPublicEvent: null, - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('if timer is at 10', () => { - const now = 10; - const expected = { - nowIndex: 1, - nowId: eventlist[1].id, - publicIndex: 1, - nextIndex: 2, - publicNextIndex: null, - timeToNext: 0, - nextEvent: eventlist[2], - nextPublicEvent: null, - currentEvent: eventlist[1], - currentPublicEvent: eventlist[1], - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('if timer is at 15', () => { - const now = 15; - const expected = { - nowIndex: 1, - nowId: eventlist[1].id, - publicIndex: 1, - nextIndex: 2, - publicNextIndex: null, - timeToNext: -5, - nextEvent: eventlist[2], - nextPublicEvent: null, - currentEvent: eventlist[1], - currentPublicEvent: eventlist[1], - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('if timer is at 20', () => { - const now = 20; - const expected = { - nowIndex: 2, - nowId: eventlist[2].id, - publicIndex: 1, - nextIndex: null, - publicNextIndex: null, - timeToNext: null, - nextEvent: null, - nextPublicEvent: null, - currentEvent: eventlist[2], - currentPublicEvent: eventlist[1], - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('if timer is at 25', () => { - const now = 25; - const expected = { - nowIndex: 2, - nowId: eventlist[2].id, - publicIndex: 1, - nextIndex: null, - publicNextIndex: null, - timeToNext: null, - nextEvent: null, - nextPublicEvent: null, - currentEvent: eventlist[2], - currentPublicEvent: eventlist[1], - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); -}); - -// issue #58 -describe('getRollTimers() test that roll behaviour multi day event edge cases', () => { - it('if the start time is the day after end time, and start time is earlier than now', () => { - const now = 66600000; // 19:30 - const eventlist: Partial[] = [ - { - id: '1', - timeStart: 66000000, // 19:20 - timeEnd: 54600000, // 16:10 - isPublic: false, - }, - ]; - const expected = { - nowIndex: 0, - nowId: '1', - publicIndex: null, - nextIndex: null, - publicNextIndex: null, - timeToNext: null, - nextEvent: null, - nextPublicEvent: null, - currentEvent: eventlist[0], - currentPublicEvent: null, - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('if the start time is the day after end time, and both are later than now', () => { - const now = 66840000; // 19:34 - const eventlist: Partial[] = [ - { - id: '1', - timeStart: 67200000, // 19:40 - timeEnd: 66900000, // 19:35 - isPublic: false, - }, - ]; - const expected = { - currentEvent: { - id: '1', - isPublic: false, - timeEnd: 66900000, - timeStart: 67200000, - }, - currentPublicEvent: null, - nextEvent: null, - nextIndex: null, - nextPublicEvent: null, - nowId: '1', - nowIndex: 0, - publicIndex: null, - publicNextIndex: null, - timeToNext: null, - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); -}); - test('normaliseEndTime()', () => { const t1 = { start: 10, diff --git a/apps/server/src/services/rollUtils.ts b/apps/server/src/services/rollUtils.ts new file mode 100644 index 000000000..862753c13 --- /dev/null +++ b/apps/server/src/services/rollUtils.ts @@ -0,0 +1,93 @@ +import { dayInMs, getFirstEvent, getLastEvent } from 'ontime-utils'; +import { OntimeEvent, MaybeNumber, PlayableEvent, isPlayableEvent } from 'ontime-types'; + +import { normaliseEndTime } from './timerUtils.js'; + +/** + * Finds current event in a rolling rundown + */ +export function loadRoll( + timedEvents: OntimeEvent[], + timeNow: number, +): { + event: PlayableEvent | null; + index: MaybeNumber; + isPending?: boolean; +} { + const { firstEvent } = getFirstEvent(timedEvents); + const { lastEvent } = getLastEvent(timedEvents); + + if (!firstEvent || !lastEvent) { + return { event: null, index: null }; + } + + // check that the rundown wraps around midnight + const wrapsAroundMidnight = firstEvent.timeStart > lastEvent.timeEnd; + + if (!wrapsAroundMidnight) { + // check whether we are before or after the rundown + const lastNormalEnd = normaliseEndTime(lastEvent.timeStart, lastEvent.timeEnd); + const isAfterRundown = timeNow > lastNormalEnd; + const isBeforeRundown = timeNow < firstEvent.timeStart && !isAfterRundown; + + if (isAfterRundown || isBeforeRundown) { + return { event: firstEvent, index: 0, isPending: true }; + } + } + + // we know we are in the middle of the rundown and we need to find the current event + // account for number of times we went over midnight + let daySpan = 0; + + for (let i = 0; i < timedEvents.length; i++) { + const event = timedEvents[i]; + + if (!isPlayableEvent(event)) { + continue; + } + + // we check if event crosses midnight + if (event.timeStart > event.timeEnd) { + daySpan++; + } + + const correctedDays = dayInMs * daySpan; + const correctedStart = event.timeStart + correctedDays; + const correctedEnd = event.timeEnd + correctedDays; + + /** + * there are 3 possible states for an event + * 1. event is already finished + * 2. event is running + * 3. event is in the future + */ + + // 1. event is already finished + // when does the event end (handle midnight) + const normalEnd = normaliseEndTime(correctedStart, correctedEnd); + if (normalEnd <= timeNow) { + continue; + } + + // 2. event is running and is the first event in our time slot + const isFromDayBefore = normalEnd > dayInMs && timeNow < event.timeEnd; + const hasStarted = isFromDayBefore || timeNow >= event.timeStart; + if (hasStarted) { + return { event, index: i }; + } + + // 3. event will run in the future + // we set the isPending flag to indicate that the event is currently playing + return { event, index: i, isPending: true }; + } + + // in case we were unable to find anything, we load the first event + return { event: firstEvent, index: 0, isPending: true }; +} + +/** + * Utility function, checks whether the event start is the day after + */ +export function normaliseRollStart(start: number, clock: number) { + return start < clock ? start + dayInMs : start; +} diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index 8133e0443..4de58f9fa 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -20,7 +20,7 @@ import { updateRundownData } from '../../stores/runtimeState.js'; import { runtimeService } from '../runtime-service/RuntimeService.js'; import * as cache from './rundownCache.js'; -import { getPlayableEvents } from './rundownUtils.js'; +import { getPlayableEvents, getTimedEvents } from './rundownUtils.js'; import { eventStore } from '../../stores/EventStore.js'; type PatchWithId = (Partial | Partial | Partial) & { id: string }; @@ -215,8 +215,8 @@ export async function swapEvents(from: string, to: string) { * Called when we make changes to the rundown object */ function updateRuntimeOnChange() { - const playableEvents = getPlayableEvents(); - const numEvents = playableEvents.length; + const timedEvents = getTimedEvents(); + const numEvents = timedEvents.length; const metadata = cache.getMetadata(); // schedule an update for the end of the event loop diff --git a/apps/server/src/services/rundown-service/rundownUtils.ts b/apps/server/src/services/rundown-service/rundownUtils.ts index c1f79f4a7..44021a733 100644 --- a/apps/server/src/services/rundown-service/rundownUtils.ts +++ b/apps/server/src/services/rundown-service/rundownUtils.ts @@ -1,14 +1,17 @@ -import { OntimeEvent, OntimeRundown, isOntimeEvent, RundownCached, OntimeRundownEntry } from 'ontime-types'; +import { OntimeEvent, OntimeRundown, RundownCached, OntimeRundownEntry, PlayableEvent } from 'ontime-types'; +import { filterPlayable, filterTimedEvents } from 'ontime-utils'; import * as cache from './rundownCache.js'; +/** + * returns the normalised rundown + */ export function getNormalisedRundown(): RundownCached { return cache.get(); } /** * returns entire unfiltered rundown - * @return {array} */ export function getRundown(): OntimeRundown { return cache.getPersistedRundown(); @@ -16,32 +19,20 @@ export function getRundown(): OntimeRundown { /** * returns all events of type OntimeEvent - * @return {array} */ export function getTimedEvents(): OntimeEvent[] { - return getRundown().filter((event) => isOntimeEvent(event)) as OntimeEvent[]; + return filterTimedEvents(getRundown()); } /** * returns all events that can be loaded - * @return {array} */ -export function getPlayableEvents(): OntimeEvent[] { - return getRundown().filter((event) => isOntimeEvent(event) && !event.skip) as OntimeEvent[]; -} - -/** - * returns number of events that can be loaded - * @return {number} - */ -export function getNumEvents(): number { - return getPlayableEvents().length; +export function getPlayableEvents(): PlayableEvent[] { + return filterPlayable(getRundown()); } /** * returns an event given its index after filtering for OntimeEvents - * @param {number} eventIndex - * @return {OntimeEvent | undefined} */ export function getEventAtIndex(eventIndex: number): OntimeEvent | undefined { const timedEvents = getTimedEvents(); @@ -50,8 +41,6 @@ export function getEventAtIndex(eventIndex: number): OntimeEvent | undefined { /** * returns first event that matches a given ID - * @param {string} eventId - * @return {object | undefined} */ export function getEventWithId(eventId: string): OntimeRundownEntry | undefined { const rundown = getRundown(); @@ -60,17 +49,14 @@ export function getEventWithId(eventId: string): OntimeRundownEntry | undefined /** * returns first event that matches a given cue - * @param {string} targetCue - * @param {number} currentEventIndex - * @return {object | undefined} */ export function getNextEventWithCue(targetCue: string, currentEventIndex = 0): OntimeEvent | undefined { - const timedEvents = getPlayableEvents(); + const playableEvents = getPlayableEvents(); const lowerCaseCue = targetCue.toLowerCase(); - for (let i = currentEventIndex; i < timedEvents.length; i++) { - const event = timedEvents.at(i); - if (event && event.cue.toLowerCase() === lowerCaseCue) { + for (let i = currentEventIndex; i < playableEvents.length; i++) { + const event = playableEvents.at(i); + if (event?.cue.toLowerCase() === lowerCaseCue) { return event; } } @@ -78,43 +64,41 @@ export function getNextEventWithCue(targetCue: string, currentEventIndex = 0): O /** * finds the previous event - * @return {object | undefined} */ export function findPrevious(currentEventId?: string): OntimeEvent | null { - const timedEvents = getPlayableEvents(); - if (!timedEvents || !timedEvents.length) { + const playableEvents = getPlayableEvents(); + if (!playableEvents || !playableEvents.length) { return null; } // if there is no event running, go to first if (!currentEventId) { - return timedEvents.at(0) ?? null; + return playableEvents.at(0) ?? null; } - const currentIndex = timedEvents.findIndex((event) => event.id === currentEventId); + const currentIndex = playableEvents.findIndex((event) => event.id === currentEventId); const newIndex = Math.max(currentIndex - 1, 0); - const previousEvent = timedEvents.at(newIndex) ?? null; + const previousEvent = playableEvents.at(newIndex) ?? null; return previousEvent; } /** * finds the next event - * @return {object | undefined} */ -export function findNext(currentEventId?: string): OntimeEvent | null { - const timedEvents = getPlayableEvents(); - if (!timedEvents || !timedEvents.length) { +export function findNext(currentEventId?: string): PlayableEvent | null { + const playableEvents = getPlayableEvents(); + if (!playableEvents.length) { return null; } // if there is no event running, go to first if (!currentEventId) { - return timedEvents.at(0) ?? null; + return playableEvents.at(0) ?? null; } - const currentIndex = timedEvents.findIndex((event) => event.id === currentEventId); + const currentIndex = playableEvents.findIndex((event) => event.id === currentEventId); const newIndex = currentIndex + 1; - const nextEvent = timedEvents.at(newIndex); + const nextEvent = playableEvents.at(newIndex); return nextEvent ?? null; } diff --git a/apps/server/src/services/runtime-service/RuntimeService.ts b/apps/server/src/services/runtime-service/RuntimeService.ts index 5691a3e17..0782cb4fe 100644 --- a/apps/server/src/services/runtime-service/RuntimeService.ts +++ b/apps/server/src/services/runtime-service/RuntimeService.ts @@ -1,6 +1,7 @@ import { EndAction, isOntimeEvent, + isPlayableEvent, LogOrigin, MaybeNumber, OntimeEvent, @@ -9,7 +10,7 @@ import { TimerLifeCycle, TimerPhase, } from 'ontime-types'; -import { filterPlayable, millisToString, validatePlayback } from 'ontime-utils'; +import { millisToString, validatePlayback } from 'ontime-utils'; import { deepEqual } from 'fast-equals'; @@ -19,7 +20,7 @@ import type { RuntimeState } from '../../stores/runtimeState.js'; import { timerConfig } from '../../config/config.js'; import { eventStore } from '../../stores/EventStore.js'; -import { TimerService } from '../TimerService.js'; +import { EventTimer } from '../EventTimer.js'; import { RestorePoint, restoreService } from '../RestoreService.js'; import { findNext, @@ -27,20 +28,20 @@ import { getEventAtIndex, getNextEventWithCue, getEventWithId, - getPlayableEvents, getRundown, + getTimedEvents, } from '../rundown-service/rundownUtils.js'; -import { skippedOutOfEvent } from '../timerUtils.js'; import { integrationService } from '../integration-service/IntegrationService.js'; import { getForceUpdate, getShouldClockUpdate, getShouldTimerUpdate } from './rundownService.utils.js'; +import { skippedOutOfEvent } from '../timerUtils.js'; /** * Service manages runtime status of app * Coordinating with necessary services */ class RuntimeService { - private eventTimer: TimerService; + private eventTimer: EventTimer; private lastIntegrationClockUpdate = -1; private lastIntegrationTimerValue = -1; @@ -52,8 +53,8 @@ class RuntimeService { /** last known state */ static previousState: RuntimeState; - constructor(timerService: TimerService) { - this.eventTimer = timerService; + constructor(eventTimer: EventTimer) { + this.eventTimer = eventTimer; RuntimeService.previousTimerUpdate = -1; RuntimeService.previousTimerValue = -1; @@ -63,7 +64,7 @@ class RuntimeService { /** Checks result of an update and notifies integrations as needed */ @broadcastResult - checkTimerUpdate({ shouldCallRoll, hasTimerFinished }: runtimeState.UpdateResult) { + checkTimerUpdate({ hasTimerFinished, hasSecondaryTimerFinished }: runtimeState.UpdateResult) { const newState = runtimeState.getState(); // 1. find if we need to dispatch integrations related to the phase @@ -82,35 +83,36 @@ class RuntimeService { // 2. handle edge cases related to roll if (newState.timer.playback === Playback.Roll) { - // check if we need to call roll again - const needsEvent = - newState.eventNow === null - ? true - : skippedOutOfEvent(newState, this.lastIntegrationClockUpdate, timerConfig.skipLimit); - const hasFinishedRoll = hasTimerFinished && shouldCallRoll; - if (shouldCallRoll || needsEvent) { - if (hasFinishedRoll) { - process.nextTick(() => { - integrationService.dispatch(TimerLifeCycle.onFinish); - }); - } + // check if we need to call any side effects - // we dont call this.roll because we need to bypass the checks - const rundown = getRundown(); - // TODO: by not calling roll, we dont get the events - this.eventTimer.roll(rundown); + if (hasSecondaryTimerFinished) { + // if the secondary timer has finished, we need to call roll + // since event is already loaded + this.rollLoaded(); + } else if (hasTimerFinished) { + // if the timer has finished, we need to load next and keep rolling + process.nextTick(() => { + integrationService.dispatch(TimerLifeCycle.onFinish); + }); + this.loadNext(); + this.rollLoaded(); + } else if (skippedOutOfEvent(newState, this.lastIntegrationClockUpdate, timerConfig.skipLimit)) { + // if we have skipped out of the event, we will recall roll + // to push the playback to the right place + // this comes with the caveat that we will lose our runtime data + this.roll(true); } } // 3. find if we need to process actions related to the timer finishing - if (newState.timer.playback !== Playback.Roll && hasTimerFinished) { + if (newState.timer.playback === Playback.Play && hasTimerFinished) { process.nextTick(() => { integrationService.dispatch(TimerLifeCycle.onFinish); }); // handle end action if there was a timer playing // actions are added to the queue stack to ensure that the order of operations is maintained - if (newState.timer.playback === Playback.Play && newState.eventNow) { + if (newState.eventNow) { if (newState.eventNow.endAction === EndAction.Stop) { setTimeout(this.stop.bind(this), 0); } else if (newState.eventNow.endAction === EndAction.LoadNext) { @@ -177,7 +179,7 @@ class RuntimeService { } private isNewNext() { - const timedEvents = getPlayableEvents(); + const timedEvents = getTimedEvents(); const state = runtimeState.getState(); const now = state.eventNow?.id; const next = state.eventNext?.id; @@ -233,41 +235,41 @@ class RuntimeService { // 3. the edited event replaces next event let isNext = false; + // TODO: review logic if (safeOption || eventInMemory) { - if (state.timer.playback === Playback.Roll) { - this.roll(); - } - // load stuff again, but keep running if our events still exist - const eventNow = getEventWithId(state.eventNow.id); - if (!isOntimeEvent(eventNow)) { + if (state.eventNow !== null) { + // load stuff again, but keep running if our events still exist + const eventNow = getEventWithId(state.eventNow.id); + if (!isOntimeEvent(eventNow) || !isPlayableEvent(eventNow)) { + return; + } + const onlyChangedNow = affectedIds?.length === 1 && affectedIds.at(0) === eventNow.id; + if (onlyChangedNow) { + runtimeState.reload(eventNow); + } else { + const rundown = getRundown(); + runtimeState.reloadAll(rundown); + } return; } - const onlyChangedNow = affectedIds?.length === 1 && affectedIds.at(0) === eventNow.id; - if (onlyChangedNow) { - runtimeState.reload(eventNow); - } else { - const rundown = getRundown(); - runtimeState.reloadAll(eventNow, rundown); - } - return; } // Maybe the event will become the next isNext = this.isNewNext(); if (isNext) { - const rundown = getRundown(); - runtimeState.loadNext(rundown); + const timedEvents = getTimedEvents(); + runtimeState.loadNext(timedEvents); } } /** * makes calls for loading and starting given event - * @param {OntimeEvent} event + * @param {PlayableEvent} event * @return {boolean} success - whether an event was loaded */ @broadcastResult loadEvent(event: OntimeEvent): boolean { - if (event.skip) { + if (!isPlayableEvent(event)) { logger.warning(LogOrigin.Playback, `Refused skipped event with ID ${event.id}`); return false; } @@ -407,14 +409,16 @@ class RuntimeService { */ @broadcastResult start(): boolean { - const state = runtimeState.getState(); - const canStart = validatePlayback(state.timer.playback).start; + const previousState = runtimeState.getState(); + const canStart = validatePlayback(previousState.timer.playback).start; if (!canStart) { return false; } const didStart = this.eventTimer?.start() ?? false; - logger.info(LogOrigin.Playback, `Play Mode ${state.timer.playback.toUpperCase()}`); + const newState = runtimeState.getState(); + logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`); + if (didStart) { process.nextTick(() => { integrationService.dispatch(TimerLifeCycle.onStart); @@ -505,29 +509,56 @@ class RuntimeService { } } + /** + * Handles special case to call roll on a loaded event which we do not want to discard + */ + rollLoaded() { + const rundown = getRundown(); + try { + this.eventTimer.roll(rundown); + } catch (error) { + logger.error(LogOrigin.Server, `Roll: ${error}`); + } + } + /** * Sets playback to roll */ @broadcastResult - roll() { - const beforeState = runtimeState.getState(); - const canRoll = validatePlayback(beforeState.timer.playback).roll; - if (!canRoll) { + roll(skipCheck: boolean = false) { + const previousState = runtimeState.getState(); + if (!skipCheck) { + const canRoll = validatePlayback(previousState.timer.playback).roll; + if (!canRoll) { + return; + } + } + + try { + const rundown = getRundown(); + const result = this.eventTimer.roll(rundown); + if (result.eventId !== previousState.eventNow?.id) { + logger.info(LogOrigin.Playback, `Loaded event with ID ${result.eventId}`); + process.nextTick(() => { + integrationService.dispatch(TimerLifeCycle.onLoad); + }); + } + + if (result.didStart) { + process.nextTick(() => { + integrationService.dispatch(TimerLifeCycle.onStart); + }); + } + } catch (error) { + logger.error(LogOrigin.Server, `Roll: ${error}`); return; } - const rundown = getRundown(); - const playableEvents = filterPlayable(rundown); - if (playableEvents.length === 0) { - logger.warning(LogOrigin.Server, 'Roll: no events found'); - return; + const newState = runtimeState.getState(); + + if (previousState.timer.playback !== newState.timer.playback) { + logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`); } - - this.eventTimer.roll(rundown); - - const state = runtimeState.getState(); - const newState = state.timer.playback; - logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`); } /** @@ -549,7 +580,7 @@ class RuntimeService { // the db would have to change for the event not to exist // we do not know the reason for the crash, so we check anyway const event = getEventWithId(selectedEventId); - if (!event || !isOntimeEvent(event)) { + if (!isOntimeEvent(event) || !isPlayableEvent(event)) { return; } @@ -570,7 +601,7 @@ class RuntimeService { } // calculate at 30fps, refresh at 1fps -const eventTimer = new TimerService({ +const eventTimer = new EventTimer({ refresh: timerConfig.updateRate, updateInterval: timerConfig.notificationRate, }); diff --git a/apps/server/src/services/timerUtils.ts b/apps/server/src/services/timerUtils.ts index 2f78bee39..4f2da2425 100644 --- a/apps/server/src/services/timerUtils.ts +++ b/apps/server/src/services/timerUtils.ts @@ -1,4 +1,4 @@ -import { MaybeNumber, MaybeString, OntimeEvent, Playback, TimerPhase, TimerType } from 'ontime-types'; +import { MaybeNumber, Playback, TimerPhase, TimerType } from 'ontime-types'; import { dayInMs } from 'ontime-utils'; import { RuntimeState } from '../stores/runtimeState.js'; @@ -93,12 +93,11 @@ export function getCurrent(state: RuntimeState): number { * @returns {boolean} */ export function skippedOutOfEvent(state: RuntimeState, previousTime: number, skipLimit: number): boolean { - // eslint-disable-next-line no-unused-labels -- dev code path - DEV: { - if (state.timer.expectedFinish === null || state.timer.startedAt === null) { - throw new Error('timerUtils.skippedOutOfEvent: invalid state received'); - } + // we cant have skipped if we havent started + if (state.timer.expectedFinish === null || state.timer.startedAt === null) { + return false; } + const { startedAt, expectedFinish } = state.timer; const { clock } = state; @@ -112,155 +111,6 @@ export function skippedOutOfEvent(state: RuntimeState, previousTime: number, ski return hasSkipped && (adjustedClock > adjustedExpectedFinish || adjustedClock < startedAt); } -type RollTimers = { - nowIndex: MaybeNumber; - nowId: MaybeString; - publicIndex: MaybeNumber; - nextIndex: MaybeNumber; - publicNextIndex: MaybeNumber; - timeToNext: MaybeNumber; - nextEvent: OntimeEvent | null; - nextPublicEvent: OntimeEvent | null; - currentEvent: OntimeEvent | null; - currentPublicEvent: OntimeEvent | null; -}; - -/** - * Finds loading information given a current rundown and time - * @param {OntimeEvent[]} playableEvents - List of playable events - * @param {number} timeNow - time now in ms - */ -export const getRollTimers = ( - playableEvents: OntimeEvent[], - timeNow: number, - currentIndex?: number | null, -): RollTimers => { - let nowIndex: MaybeNumber = null; // index of event now - let nowId: MaybeString = null; // id of event now - let publicIndex: MaybeNumber = null; // index of public event now - let nextIndex: MaybeNumber = null; // index of next event - let publicNextIndex: MaybeNumber = null; // index of next public event - let timeToNext: MaybeNumber = null; // counter: time for next event - let publicTimeToNext: MaybeNumber = null; // counter: time for next public event - - const hasLoaded = currentIndex !== null; - const canFilter = hasLoaded && currentIndex === playableEvents.length - 1; - const filteredRundown = canFilter ? playableEvents.slice(currentIndex) : playableEvents; - - const lastEvent = filteredRundown.at(-1); - const lastNormalEnd = normaliseEndTime(lastEvent.timeStart, lastEvent.timeEnd); - - let nextEvent: OntimeEvent | null = null; - let nextPublicEvent: OntimeEvent | null = null; - let currentEvent: OntimeEvent | null = null; - let currentPublicEvent: OntimeEvent | null = null; - - if (timeNow > lastNormalEnd) { - // we are past last end - // preload first and find next - - const firstEvent = filteredRundown.at(0); - nextIndex = 0; - nextEvent = firstEvent; - timeToNext = firstEvent.timeStart + dayInMs - timeNow; - - if (firstEvent.isPublic) { - nextPublicEvent = firstEvent; - publicNextIndex = 0; - } else { - // look for next public - // dev note: we feel that this is more efficient than filtering - // since the next event will likely be close to the one playing - for (const event of filteredRundown) { - if (event.isPublic) { - nextPublicEvent = event; - // we need the index before this was sorted - publicNextIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id); - break; - } - } - } - } else { - // flags: select first event if several overlapping - let nowFound = false; - // keep track of the end times when looking for public - let publicTime = -1; - - for (const event of filteredRundown) { - // When does the event end (handle midnight) - const normalEnd = normaliseEndTime(event.timeStart, event.timeEnd); - - const hasNotEnded = normalEnd > timeNow; - const isFromDayBefore = normalEnd > dayInMs && timeNow < event.timeEnd; - const hasStarted = isFromDayBefore || timeNow >= event.timeStart; - - if (normalEnd <= timeNow) { - // event ran already - - if (event.isPublic && normalEnd > publicTime) { - // public event might not be the one running - publicTime = normalEnd; - currentPublicEvent = event; - publicIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id); - } - } else if (hasNotEnded && hasStarted && !nowFound) { - // event is running - currentEvent = event; - nowIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id); - nowId = event.id; - nowFound = true; - - // it could also be public - if (event.isPublic) { - publicTime = normalEnd; - currentPublicEvent = event; - publicIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id); - } - } else if (normalEnd > timeNow) { - // event will run - - // we already know whats next and next-public - if (nextIndex !== null && publicNextIndex !== null) { - continue; - } - - // look for next events - // check how far the start is from now - const timeToEventStart = event.timeStart - timeNow; - - // we don't have a next or this one starts sooner than current next - if (nextIndex === null || timeToEventStart < timeToNext) { - timeToNext = timeToEventStart; - nextEvent = event; - nextIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id); - } - - if (event.isPublic) { - // if we don't have a public next or this one start sooner than assigned next - if (publicNextIndex === null || timeToEventStart < publicTimeToNext) { - publicTimeToNext = timeToEventStart; - nextPublicEvent = event; - publicNextIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id); - } - } - } - } - } - - return { - nowIndex, - nowId, - publicIndex, - nextIndex, - publicNextIndex, - timeToNext, - nextEvent, - nextPublicEvent, - currentEvent, - currentPublicEvent, - }; -}; - /** * Calculates difference between the runtime and the schedule of an event * Positive offset is time ahead diff --git a/apps/server/src/stores/__tests__/runtimeState.test.ts b/apps/server/src/stores/__tests__/runtimeState.test.ts index 17481a016..4a2ff6c36 100644 --- a/apps/server/src/stores/__tests__/runtimeState.test.ts +++ b/apps/server/src/stores/__tests__/runtimeState.test.ts @@ -1,4 +1,4 @@ -import { OntimeEvent, Playback } from 'ontime-types'; +import { PlayableEvent, Playback } from 'ontime-types'; import { deepmerge } from 'ontime-utils'; import { RuntimeState, addTime, clear, getState, load, pause, start, stop } from '../runtimeState.js'; @@ -11,7 +11,8 @@ const mockEvent = { timeStart: 0, timeEnd: 1000, duration: 1000, -} as OntimeEvent; + skip: false, +} as PlayableEvent; const mockState = { clock: 666, @@ -144,6 +145,7 @@ describe('mutation on runtimeState', () => { // 5. Stop event success = stop(); + newState = getState(); expect(success).toBe(true); expect(newState.eventNow).toBe(null); expect(newState.timer).toMatchObject({ diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index 1430b7ae3..62c25a4ac 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -1,14 +1,17 @@ import { CurrentBlockState, + isPlayableEvent, MaybeNumber, + MaybeString, OntimeEvent, OntimeRundown, + PlayableEvent, Playback, Runtime, TimerPhase, TimerState, } from 'ontime-types'; -import { calculateDuration, dayInMs, filterPlayable, getRelevantBlock } from 'ontime-utils'; +import { calculateDuration, checkIsNow, dayInMs, filterTimedEvents, getRelevantBlock } from 'ontime-utils'; import { clock } from '../services/Clock.js'; import { RestorePoint } from '../services/RestoreService.js'; @@ -16,12 +19,12 @@ import { getCurrent, getExpectedEnd, getExpectedFinish, - getRollTimers, getRuntimeOffset, getTimerPhase, isPlaybackActive, } from '../services/timerUtils.js'; import { timerConfig } from '../config/config.js'; +import { loadRoll, normaliseRollStart } from '../services/rollUtils.js'; const initialRuntime: Runtime = { selectedEventIndex: null, // changes if rundown changes or we load a new event @@ -38,8 +41,7 @@ const initialTimer: TimerState = { current: null, // changes on every update duration: null, // only changes if event changes elapsed: null, // changes on every update - // TODO: expected finish could account for midnight, we cleanup in the clients - expectedFinish: null, // change can only be initiated by user + expectedFinish: null, // change can only be initiated by user, can roll over midnight finishedAt: null, // can change on update or user action phase: TimerPhase.None, // can change on update or user action playback: Playback.Stop, // change initiated by user @@ -49,11 +51,11 @@ const initialTimer: TimerState = { export type RuntimeState = { clock: number; // realtime clock - eventNow: OntimeEvent | null; + eventNow: PlayableEvent | null; currentBlock: CurrentBlockState; - publicEventNow: OntimeEvent | null; - eventNext: OntimeEvent | null; - publicEventNext: OntimeEvent | null; + publicEventNow: PlayableEvent | null; + eventNext: PlayableEvent | null; + publicEventNext: PlayableEvent | null; runtime: Runtime; timer: TimerState; // private properties of the timer calculations @@ -61,6 +63,7 @@ export type RuntimeState = { forceFinish: MaybeNumber; // wether we should declare an event as finished, will contain the finish time totalDelay: number; // this value comes from rundown service pausedAt: MaybeNumber; + secondaryTarget: MaybeNumber; }; _prevCurrentBlock: CurrentBlockState; }; @@ -81,6 +84,7 @@ const runtimeState: RuntimeState = { forceFinish: null, totalDelay: 0, pausedAt: null, + secondaryTarget: null, }, _prevCurrentBlock: { block: null, @@ -89,7 +93,17 @@ const runtimeState: RuntimeState = { }; export function getState(): Readonly { - return runtimeState; + // create a shallow copy of the state + return { + ...runtimeState, + eventNow: runtimeState.eventNow ? { ...runtimeState.eventNow } : null, + eventNext: runtimeState.eventNext ? { ...runtimeState.eventNext } : null, + publicEventNow: runtimeState.publicEventNow ? { ...runtimeState.publicEventNow } : null, + publicEventNext: runtimeState.publicEventNext ? { ...runtimeState.publicEventNext } : null, + runtime: { ...runtimeState.runtime }, + timer: { ...runtimeState.timer }, + _timer: { ...runtimeState._timer }, + }; } export function clear() { @@ -129,7 +143,7 @@ function patchTimer(newState: Partial) { } type RundownData = { - numEvents: number; + numEvents: number; // length of rundown filtered for timed events firstStart: MaybeNumber; lastEnd: MaybeNumber; totalDelay: number; @@ -152,30 +166,33 @@ export function updateRundownData(rundownData: RundownData) { /** * Loads a given event into state - * @param event - * @param {OntimeEvent[]} playableEvents list of events availebe for playback - * @param {OntimeRundown} rundown the full rundown - * @param initialData potential data from restore point */ export function load( - event: OntimeEvent, + event: PlayableEvent, rundown: OntimeRundown, initialData?: Partial, ): boolean { clear(); - const eventIndex = rundown.findIndex((eventInMemory) => eventInMemory.id === event.id); + // filter rundown + const timedEvents = filterTimedEvents(rundown); + const eventIndex = timedEvents.findIndex((eventInMemory) => eventInMemory.id === event.id); - runtimeState.runtime.selectedEventIndex = eventIndex; + if (timedEvents.length === 0 || eventIndex === -1 || !isPlayableEvent(event)) { + return false; + } - loadNow(event, rundown); - loadNext(rundown); + // load events in memory along with their data + loadNow(timedEvents, eventIndex); + loadNext(timedEvents, eventIndex); - runtimeState.clock = clock.timeNow(); + // update state runtimeState.timer.playback = Playback.Armed; runtimeState.timer.duration = calculateDuration(event.timeStart, event.timeEnd); runtimeState.timer.current = getCurrent(runtimeState); + runtimeState.runtime.numEvents = timedEvents.length; + // patch with potential provided data if (initialData) { patchTimer(initialData); @@ -190,11 +207,25 @@ export function load( return event.id === runtimeState.eventNow?.id; } -export function loadNow(event: OntimeEvent, rundown: OntimeRundown) { - runtimeState.eventNow = event; - runtimeState.currentBlock.block = getRelevantBlock(rundown, event.id); +/** + * Loads current event and its public counterpart + */ +export function loadNow(timedEvents: OntimeEvent[], eventIndex: MaybeNumber = runtimeState.runtime.selectedEventIndex) { + if (eventIndex === null) { + // reset the state to indicate there is no selection + runtimeState.runtime.selectedEventIndex = null; + runtimeState.eventNow = null; + runtimeState.currentBlock.block = null; + runtimeState.currentBlock.startedAt = null; + return; + } - //if we are still in the same block keep the startedAt time + const event = timedEvents[eventIndex] as PlayableEvent; + runtimeState.runtime.selectedEventIndex = eventIndex; + runtimeState.eventNow = event; + runtimeState.currentBlock.block = getRelevantBlock(timedEvents, event.id); + + // if we are still in the same block keep the startedAt time if (runtimeState._prevCurrentBlock.block?.id === runtimeState.currentBlock.block?.id) { runtimeState.currentBlock.startedAt = runtimeState._prevCurrentBlock.startedAt; } @@ -207,73 +238,81 @@ export function loadNow(event: OntimeEvent, rundown: OntimeRundown) { runtimeState.publicEventNow = null; // if there is nothing before, return - if (!runtimeState.runtime.selectedEventIndex) { + if (!eventIndex) { return; } - const playableEvents = filterPlayable(rundown); - // iterate backwards to find it - for (let i = runtimeState.runtime.selectedEventIndex; i >= 0; i--) { - if (playableEvents[i].isPublic) { - runtimeState.publicEventNow = playableEvents[i]; + for (let i = eventIndex; i >= 0; i--) { + const event = timedEvents[i]; + // we dont deal with events that are not playable + if (!isPlayableEvent(event)) { + continue; + } + + if (event.isPublic) { + runtimeState.publicEventNow = event; break; } } } } -export function loadNext(rundown: OntimeRundown) { - // assume there are no next events - runtimeState.eventNext = null; - runtimeState.publicEventNext = null; - - if (runtimeState.runtime.selectedEventIndex === null) { +/** + * Loads the next event and its public counterpart + */ +export function loadNext( + timedEvents: OntimeEvent[], + eventIndex: MaybeNumber = runtimeState.runtime.selectedEventIndex, +) { + if (eventIndex === null) { + // reset the state to indicate there is no future event + runtimeState.eventNext = null; + runtimeState.publicEventNext = null; return; } - const playableEvents = filterPlayable(rundown); - const numEvents = playableEvents.length; + for (let i = eventIndex + 1; i < timedEvents.length; i++) { + const event = timedEvents[i]; + // we dont deal with events that are not playable + if (!isPlayableEvent(event)) { + continue; + } - if (runtimeState.runtime.selectedEventIndex < numEvents - 1) { - let nextPublic = false; - let nextProduction = false; + // the private event is the one immediately after the current event + if (runtimeState.eventNext === null) { + runtimeState.eventNext = event; + } - for (let i = runtimeState.runtime.selectedEventIndex + 1; i < numEvents; i++) { - // if we have not set private - if (!nextProduction) { - runtimeState.eventNext = playableEvents[i]; - nextProduction = true; - } + // if event is public + if (event.isPublic) { + runtimeState.publicEventNext = event; + } - // if event is public - if (playableEvents[i].isPublic) { - runtimeState.publicEventNext = playableEvents[i]; - nextPublic = true; - } - - // Stop if both are set - if (nextPublic && nextProduction) break; + // Stop if both are set + if (runtimeState.eventNext !== null && runtimeState.publicEventNext !== null) { + return; } } } /** * Resume from restore point - * @param restorePoint - * @param event - * @param playableEvents list of events availebe for playback - * @param rundown the full rundown */ -export function resume(restorePoint: RestorePoint, event: OntimeEvent, rundown: OntimeRundown) { +export function resume(restorePoint: RestorePoint, event: PlayableEvent, rundown: OntimeRundown) { load(event, rundown, restorePoint); } /** * We only pass an event if we are hot reloading - * @param {OntimeEvent} event only passed if we are changing the data if a playing timer + * @param {PlayableEvent} event only passed if we are changing the data if a playing timer */ -export function reload(event?: OntimeEvent) { +export function reload(event?: PlayableEvent): string | undefined { + // if there is no event loaded, nothing to do + if (runtimeState.eventNow === null) { + return; + } + // we only pass an event for hot reloading, ie: the event has changed if (event) { runtimeState.eventNow = event; @@ -282,19 +321,39 @@ export function reload(event?: OntimeEvent) { runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd); runtimeState.timer.current = getCurrent(runtimeState); runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState); + + // handle edge cases with roll + if (runtimeState.timer.playback === Playback.Roll) { + // if waiting to roll, we update the targets and potentially start the timer + if (runtimeState._timer.secondaryTarget !== null) { + if ( + runtimeState.eventNow.timeStart < runtimeState.clock && + runtimeState.clock < runtimeState.eventNow.timeEnd + ) { + // if the event is now, we queue a start + runtimeState._timer.secondaryTarget = runtimeState.eventNow.timeStart; + runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - runtimeState.clock; + } else { + runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, runtimeState.clock); + } + } + } return runtimeState.eventNow.id; } + + // reset changes to timer progress runtimeState.timer.playback = Playback.Armed; runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd); runtimeState.timer.current = runtimeState.timer.duration; - runtimeState.timer.elapsed = null; runtimeState.timer.startedAt = null; runtimeState.timer.finishedAt = null; - runtimeState._timer.pausedAt = null; runtimeState.timer.addedTime = 0; + runtimeState._timer.pausedAt = null; + // this could be looked after by the timer + runtimeState.timer.elapsed = null; runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState); runtimeState.currentBlock.startedAt = null; @@ -302,16 +361,13 @@ export function reload(event?: OntimeEvent) { } /** - * Used in situations when we want to reload all events - * without interrupting timer - * @param eventNow - * @param playableEvents - * @param rundown + * Used in situations when we want to hot-reload all events without interrupting timer */ -export function reloadAll(eventNow: OntimeEvent, rundown: OntimeRundown) { - loadNow(eventNow, rundown); - loadNext(rundown); - reload(eventNow); +export function reloadAll(rundown: OntimeRundown) { + const timedEvents = filterTimedEvents(rundown); + loadNow(timedEvents); + loadNext(timedEvents); + reload(runtimeState.eventNow ?? undefined); } export function start(state: RuntimeState = runtimeState): boolean { @@ -336,7 +392,6 @@ export function start(state: RuntimeState = runtimeState): boolean { } if (state.currentBlock.startedAt === null) { - console.log('currentBlock.startedAt is null, setting new start'); state.currentBlock.startedAt = state.clock; } @@ -381,11 +436,22 @@ export function stop(state: RuntimeState = runtimeState): boolean { return true; } +/** + * Exposes functionality to add user time to the timer externally + */ export function addTime(amount: number) { if (runtimeState.timer.current === null) { return false; } + // as long as there is a timer, we need an expected finish + // eslint-disable-next-line no-unused-labels -- dev code path + DEV: { + if (runtimeState.timer.expectedFinish === null) { + throw new Error('runtimeState.addTime: invalid state received'); + } + } + // handle edge cases // !!! we need to handle side effects before updating the state const willGoNegative = amount < 0 && Math.abs(amount) > runtimeState.timer.current; @@ -415,7 +481,7 @@ export function addTime(amount: number) { export type UpdateResult = { hasTimerFinished: boolean; - shouldCallRoll: boolean; + hasSecondaryTimerFinished: boolean; }; export function update(): UpdateResult { @@ -429,18 +495,21 @@ export function update(): UpdateResult { // 2. are we waiting to roll? if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) { - return updateIfWaitingToRoll(runtimeState.timer.secondaryTimer); + return updateIfWaitingToRoll(); } // 3. at this point we know that we are playing an event // reset data runtimeState.timer.secondaryTimer = null; - // update timer state - if (!runtimeState.timer.duration) { - throw new Error('Timer duration is not set'); + // eslint-disable-next-line no-unused-labels -- dev code path + DEV: { + if (!runtimeState.timer.duration) { + throw new Error('runtimeState.update: invalid state received'); + } } + // update timer state runtimeState.timer.current = getCurrent(runtimeState); runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState); runtimeState.timer.phase = getTimerPhase(runtimeState); @@ -462,55 +531,128 @@ export function update(): UpdateResult { runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState); } - return { hasTimerFinished: finishedNow, shouldCallRoll: finishedNow }; + return { hasTimerFinished: finishedNow, hasSecondaryTimerFinished: false }; function updateIfIdle() { // if nothing is running, nothing to do - return { hasTimerFinished: false, shouldCallRoll: false }; + return { hasTimerFinished: false, hasSecondaryTimerFinished: false }; } - function updateIfWaitingToRoll(targetTime: number) { - runtimeState.timer.secondaryTimer = targetTime - runtimeState.clock; + function updateIfWaitingToRoll() { + // eslint-disable-next-line no-unused-labels -- dev code path + DEV: { + if (runtimeState.eventNow === null || runtimeState._timer.secondaryTarget === null) { + throw new Error('runtimeState.updateIfWaitingToRoll: invalid state received'); + } + } + runtimeState.timer.phase = TimerPhase.Pending; - return { hasTimerFinished: false, shouldCallRoll: runtimeState.timer.secondaryTimer < 0 }; + runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - runtimeState.clock; + return { hasTimerFinished: false, hasSecondaryTimerFinished: runtimeState.timer.secondaryTimer < 0 }; } } -export function roll(rundown: OntimeRundown) { - const selectedEventIndex = runtimeState.runtime.selectedEventIndex; - const playableEvents = filterPlayable(rundown); +export function roll(rundown: OntimeRundown): { eventId: MaybeString; didStart: boolean } { + // 1. if an event is running, we simply take over the playback + if (runtimeState.timer.playback === Playback.Play && runtimeState.runtime.selectedEventIndex !== null) { + runtimeState.timer.playback = Playback.Roll; + return { eventId: runtimeState.eventNow?.id ?? null, didStart: false }; + } - clear(); - runtimeState.runtime.numEvents = playableEvents.length; - - const { nextEvent, currentEvent } = getRollTimers(playableEvents, runtimeState.clock, selectedEventIndex); - - if (currentEvent) { - // there is something running, load - runtimeState.timer.secondaryTimer = null; + // 2. if there is an event armed, we use it + if (runtimeState.timer.playback === Playback.Armed && runtimeState.eventNow !== null) { + runtimeState.timer.playback = Playback.Roll; // account for event that finishes the day after - const endTime = - currentEvent.timeEnd < currentEvent.timeStart ? currentEvent.timeEnd + dayInMs : currentEvent.timeEnd; + const normalisedEndTime = + runtimeState.eventNow.timeEnd < runtimeState.eventNow.timeStart + ? runtimeState.eventNow.timeEnd + dayInMs + : runtimeState.eventNow.timeEnd; + runtimeState.timer.expectedFinish = normalisedEndTime; - // when we load a timer in roll, we do the same things as before - // but also pre-populate some data as to the running state - load(currentEvent, rundown, { - startedAt: currentEvent.timeStart, - expectedFinish: currentEvent.timeEnd, - current: endTime - runtimeState.clock, - }); - } else if (nextEvent) { - if (nextEvent.isPublic) { - runtimeState.publicEventNext = nextEvent; + // state catch up + runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, normalisedEndTime); + runtimeState.timer.current = runtimeState.timer.duration; + runtimeState.timer.elapsed = 0; + + // check if the event is ready to start or if needs to be waited + const isNow = checkIsNow(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd, runtimeState.clock); + + if (isNow) { + runtimeState.timer.startedAt = runtimeState.clock; + + // update runtime + if (!runtimeState.runtime.actualStart) { + runtimeState.runtime.actualStart = runtimeState.clock; + } + } else { + runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, runtimeState.clock); + runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - runtimeState.clock; + runtimeState.timer.phase = TimerPhase.Pending; } - runtimeState.eventNext = nextEvent; - // account for day after - const nextStart = nextEvent.timeStart < runtimeState.clock ? nextEvent.timeStart + dayInMs : nextEvent.timeStart; - // nothing now, but something coming up - runtimeState.timer.phase = TimerPhase.Pending; - runtimeState.timer.secondaryTimer = nextStart - runtimeState.clock; + + return { eventId: runtimeState.eventNow.id, didStart: isNow }; } + // 3. if there is no event running, we need to find the next event + const timedEvents = filterTimedEvents(rundown); + if (timedEvents.length === 0) { + throw new Error('No playable events found'); + } + + clear(); + const { index, isPending } = loadRoll(timedEvents, runtimeState.clock); + + // load events in memory along with their data + loadNow(timedEvents, index); + loadNext(timedEvents, index); + + // update roll state runtimeState.timer.playback = Playback.Roll; + runtimeState.runtime.numEvents = timedEvents.length; + + // in roll mode spec, there should always be something to load + // as long as playableEvents is not empty + // eslint-disable-next-line no-unused-labels -- dev code path + DEV: { + if (runtimeState.eventNow === null) { + throw new Error('runtimeState.roll: invalid state received'); + } + } + + if (isPending) { + // there is nothing now, but something coming up + runtimeState.timer.phase = TimerPhase.Pending; + // we need to normalise start time in case it is the day after + runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, runtimeState.clock); + runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - runtimeState.clock; + + // preload timer properties + runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd); + runtimeState.timer.current = runtimeState.timer.duration; + return { eventId: runtimeState.eventNow.id, didStart: false }; + } + + // there is something to run, load event + + // event will finish on time + // account for event that finishes the day after + const endTime = + runtimeState.eventNow.timeEnd < runtimeState.eventNow.timeStart + ? runtimeState.eventNow.timeEnd + dayInMs + : runtimeState.eventNow.timeEnd; + runtimeState.timer.startedAt = runtimeState.clock; + runtimeState.timer.expectedFinish = endTime; + + // we add time to allow timer to catch up + runtimeState.timer.addedTime = -(runtimeState.clock - runtimeState.eventNow.timeStart); + + // state catch up + runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, endTime); + runtimeState.timer.current = getCurrent(runtimeState); + runtimeState.timer.elapsed = 0; + + // update runtime + runtimeState.runtime.actualStart = runtimeState.clock; + return { eventId: runtimeState.eventNow.id, didStart: true }; } diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 0e9b4333b..6daff1a8c 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "strict": false, "target": "ESNext", "module": "Node16", "moduleResolution": "Node16", diff --git a/apps/spec/roll.md b/apps/spec/roll.md new file mode 100644 index 000000000..b41664a41 --- /dev/null +++ b/apps/spec/roll.md @@ -0,0 +1,63 @@ +# ROLL Mode + +Roll mode is intended to be a fully automatic playback that takes precedence over event end actions. +It can be user either on its own, or as in conjunction with manual playback to allow for automated rundown sections. + +## Overview +- As long as there are non-skipped events in the rundown, we will always accept roll mode +- If there are no events in the current time frame, we load the next event and count-down to its start +- Roll will always load the first matching event in the current time, this could cause issues if there are multiple days planned or if the rundown is not in order. +- If we go from manual playback, to Roll mode, the playback should continue as is. Roll mode will automate loading the next event when the current is finished + +## Implementation details + +### starting to roll +> RuntimeService.roll(rundown: OntimeRundown) + +When calling the roll function, we try and find events to load. There should always be an event as long as the rundown is not empty. + +#### Taking over playback +If we are currently in "Play" mode and an event is playing, roll simply takes over playback. No other data changes are made + +#### Starting an event +If there is nothing playing and roll finds an element that in the current time frame playing, it will start the event + +#### Waiting to start +If we do not find an event that should be playing now, but find an event for the future, we load the next event, set roll mode and wait + +### tick update +> RuntimeState.onUpdate() + +Updating in roll mode attempts to have the least amount of custom logic in relation to normal updates. The only difference in behaviour is the automation of loading the next event when the current one is finished. + +#### normal update +On the update of timers, there is no logic specific to Roll mode, all side effects (ie: integrations) should have the same behaviour as Play mode + +#### waiting to start +If we are currently waiting to start, we just need to update the `secondaryTimer`. +If waiting to start is finished, we load the next event and start it + +#### an event is finished +If an event is finished, we check if the next event is ready to start, this is similarly as if we had a conditional `load-next` `play-next` automation +If there is a gap between the events, we add `secondaryTimer` to match and wait for the next event to start +Finish time should account for `timer.addedTime` +Finish actions are ignored in roll mode + +#### time has skipped +If we find that the new time update has slid in comparison to the old update (either too long, or time went backwards), we re-calculate + +### Finding an event +> loadRoll(timedEvents: OntimeEvent[], timeNow: number) +This is a helper function which iterates trough a rundown to find the first matching element in the current time. As a trade-off, the wrong event will be loaded if the rundown is not in order. + +It is important to note that all times in the rundown are in milliseconds from midnight. In the case of multiple days being scheduled, Roll will return the first match. + +### Assumptions +The function receives a pre-filtered list of `TimedEvents`. This is to avoid issues with inconsistent index references. +- `TimedEvents` cannot be an empty array +- `TimedEvents` is assumed to be in order + +### Specification +- we should always receive an `eventNow` or `eventNext` +- if the current clock is past the last event end, we roll for the events tomorrow +- if the current clock is before the first event, we do not load next day events, even if there is a match, we always start on first day diff --git a/packages/types/src/definitions/core/OntimeEvent.type.ts b/packages/types/src/definitions/core/OntimeEvent.type.ts index 3af0a2221..ac9d08a25 100644 --- a/packages/types/src/definitions/core/OntimeEvent.type.ts +++ b/packages/types/src/definitions/core/OntimeEvent.type.ts @@ -43,3 +43,5 @@ export type OntimeEvent = OntimeBaseEvent & { timeDanger: number; custom: EventCustomFields; }; + +export type PlayableEvent = OntimeEvent & { skip: false }; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 7041d36b7..3a9aa8b6a 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -8,6 +8,7 @@ export { type OntimeDelay, type OntimeBlock, type OntimeEvent, + type PlayableEvent, SupportedEvent, } from './definitions/core/OntimeEvent.type.js'; export type { OntimeEntryCommonKeys, OntimeRundown, OntimeRundownEntry } from './definitions/core/Rundown.type.js'; @@ -71,5 +72,12 @@ export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './defini export type { Client, ClientList, ClientType } from './definitions/Clients.type.js'; // TYPE UTILITIES -export { isOntimeBlock, isOntimeDelay, isOntimeEvent, isOntimeCycle, isKeyOfType } from './utils/guards.js'; +export { + isOntimeBlock, + isOntimeDelay, + isOntimeEvent, + isPlayableEvent, + isOntimeCycle, + isKeyOfType, +} from './utils/guards.js'; export type { DeepPartial, MaybeNumber, MaybeString } from './utils/utils.type.js'; diff --git a/packages/types/src/utils/guards.ts b/packages/types/src/utils/guards.ts index 0c5eafb05..1741c26c5 100644 --- a/packages/types/src/utils/guards.ts +++ b/packages/types/src/utils/guards.ts @@ -1,4 +1,4 @@ -import type { OntimeBlock, OntimeDelay, OntimeEvent } from '../definitions/core/OntimeEvent.type.js'; +import type { OntimeBlock, OntimeDelay, OntimeEvent, PlayableEvent } from '../definitions/core/OntimeEvent.type.js'; import { SupportedEvent } from '../definitions/core/OntimeEvent.type.js'; import type { OntimeRundownEntry } from '../definitions/core/Rundown.type.js'; import type { TimerLifeCycleKey } from '../definitions/core/TimerLifecycle.type.js'; @@ -10,6 +10,10 @@ export function isOntimeEvent(event: MaybeEvent): event is OntimeEvent { return event?.type === SupportedEvent.Event; } +export function isPlayableEvent(event: OntimeEvent): event is PlayableEvent { + return !event.skip; +} + export function isOntimeDelay(event: MaybeEvent): event is OntimeDelay { return event?.type === SupportedEvent.Delay; } diff --git a/packages/utils/index.ts b/packages/utils/index.ts index 6a294595d..623666d6b 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -71,6 +71,7 @@ export { validateEndAction, validateTimerType } from './src/validate-events/vali // feature business logic // feature business logic - rundown +export { checkIsNow } from './src/date-utils/checkIsNow.js'; export { checkIsNextDay } from './src/date-utils/checkIsNextDay.js'; // feature business logic - spreadsheet import diff --git a/packages/utils/src/date-utils/checkIsNow.test.ts b/packages/utils/src/date-utils/checkIsNow.test.ts new file mode 100644 index 000000000..89ab659a7 --- /dev/null +++ b/packages/utils/src/date-utils/checkIsNow.test.ts @@ -0,0 +1,29 @@ +import { checkIsNow } from './checkIsNow'; +import { MILLIS_PER_HOUR } from './conversionUtils'; + +describe('checkIsNow()', () => { + test('should return true if now is between timeStart and timeEnd', () => { + const timeStart = 9; + const timeEnd = 16; + const now = 10; + expect(checkIsNow(timeStart, timeEnd, now)).toBe(true); + }); + + test('should return false if now is before start', () => { + const timeStart = 9; + const timeEnd = 16; + const now = 8; + expect(checkIsNow(timeStart, timeEnd, now)).toBe(false); + }); + + test('should return false if now is after end', () => { + const timeStart = 9; + const timeEnd = 16; + const now = 20; + expect(checkIsNow(timeStart, timeEnd, now)).toBe(false); + }); + + test('should return true accounting for events that roll over midnight', () => { + expect(checkIsNow(22 * MILLIS_PER_HOUR, 8 * MILLIS_PER_HOUR, 23 * MILLIS_PER_HOUR)).toBe(true); + }); +}); diff --git a/packages/utils/src/date-utils/checkIsNow.ts b/packages/utils/src/date-utils/checkIsNow.ts new file mode 100644 index 000000000..f5a4591fa --- /dev/null +++ b/packages/utils/src/date-utils/checkIsNow.ts @@ -0,0 +1,9 @@ +import { dayInMs } from './conversionUtils.js'; + +/** + * Utility function checks whether a given event should be playing now + */ +export function checkIsNow(timeStart: number, timeEnd: number, clock: number): boolean { + const normalisedEnd = timeEnd < timeStart ? timeEnd + dayInMs : timeEnd; + return timeStart <= clock && clock <= normalisedEnd; +} diff --git a/packages/utils/src/rundown-utils/rundownUtils.test.ts b/packages/utils/src/rundown-utils/rundownUtils.test.ts index 0e1030c35..c8ee9e29c 100644 --- a/packages/utils/src/rundown-utils/rundownUtils.test.ts +++ b/packages/utils/src/rundown-utils/rundownUtils.test.ts @@ -3,7 +3,6 @@ import { SupportedEvent } from 'ontime-types'; import { filterPlayable, - filterTimedEvents, getLastEvent, getLastNormal, getNext, @@ -300,24 +299,19 @@ describe('getLastEvent', () => { }); }); - describe('filter event', () => { - const eventA = { id: 'a', type: SupportedEvent.Event } as OntimeEvent; - const eventB = { id: 'b', skip: true, type: SupportedEvent.Event } as OntimeEvent; - const testRundown = [ - eventA, - eventB, - { id: 'c', type: SupportedEvent.Delay }, - { id: 'd', type: SupportedEvent.Block }, - ]; + describe('filterPlayable()', () => { + test('should return an array with only playable events', () => { + const eventA = { id: 'a', type: SupportedEvent.Event } as OntimeEvent; + const eventB = { id: 'b', skip: true, type: SupportedEvent.Event } as OntimeEvent; + const testRundown = [ + eventA, + eventB, + { id: 'c', type: SupportedEvent.Delay }, + { id: 'd', type: SupportedEvent.Block }, + ]; - test('filterPlayable', () => { const result = filterPlayable(testRundown as unknown as OntimeRundown); expect(result).toMatchObject([eventA]); }); - - test('filterTimedEvents', () => { - const result = filterTimedEvents(testRundown as unknown as OntimeRundown); - expect(result).toMatchObject([eventA, eventB]); - }); }); }); diff --git a/packages/utils/src/rundown-utils/rundownUtils.ts b/packages/utils/src/rundown-utils/rundownUtils.ts index 1415e2e6b..05e8935ad 100644 --- a/packages/utils/src/rundown-utils/rundownUtils.ts +++ b/packages/utils/src/rundown-utils/rundownUtils.ts @@ -1,14 +1,19 @@ -import type { NormalisedRundown, OntimeBlock, OntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types'; -import { isOntimeBlock, isOntimeEvent } from 'ontime-types'; +import type { + NormalisedRundown, + OntimeBlock, + OntimeEvent, + OntimeRundown, + OntimeRundownEntry, + PlayableEvent, +} from 'ontime-types'; +import { isOntimeBlock, isOntimeEvent, isPlayableEvent } from 'ontime-types'; type IndexAndEntry = { entry: OntimeRundownEntry | null; index: number | null }; /** * Gets first event in rundown, if it exists - * @param {OntimeRundownEntry[]} rundown - * @return {OntimeRundownEntry | null} */ -export function getFirst(rundown: OntimeRundownEntry[]) { +export function getFirst(rundown: OntimeRundown) { return rundown.length ? rundown[0] : null; } @@ -25,16 +30,14 @@ export function getFirstNormal(rundown: NormalisedRundown, order: string[]) { /** * Gets first scheduled event in rundown, if it exists - * @param {OntimeRundownEntry[]} rundown - * @return {{ firstEvent: OntimeEvent | null; firstIndex: number | null } } */ -export function getFirstEvent(rundown: OntimeRundownEntry[]): { - firstEvent: OntimeEvent | null; +export function getFirstEvent(rundown: OntimeRundown): { + firstEvent: PlayableEvent | null; firstIndex: number | null; } { for (let i = 0; i < rundown.length; i++) { const firstEvent = rundown[i]; - if (isOntimeEvent(firstEvent) && !firstEvent.skip) { + if (isOntimeEvent(firstEvent) && isPlayableEvent(firstEvent)) { return { firstEvent, firstIndex: i }; } } @@ -43,21 +46,18 @@ export function getFirstEvent(rundown: OntimeRundownEntry[]): { /** * Gets first scheduled event in a normalised rundown, if it exists - * @param rundown - * @param order - * @returns */ export function getFirstEventNormal( rundown: NormalisedRundown, order: string[], ): { - firstEvent: OntimeEvent | null; + firstEvent: PlayableEvent | null; firstIndex: number | null; } { for (let i = 0; i < order.length; i++) { const firstId = order[i]; const firstEvent = rundown[firstId]; - if (isOntimeEvent(firstEvent) && !firstEvent.skip) { + if (isOntimeEvent(firstEvent) && isPlayableEvent(firstEvent)) { return { firstEvent, firstIndex: i }; } } @@ -66,9 +66,6 @@ export function getFirstEventNormal( /** * Gets last event in a normalised rundown, if it exists - * @param rundown - * @param order - * @returns */ export function getLastNormal(rundown: NormalisedRundown, order: string[]): OntimeRundownEntry | null { const lastId = order.at(-1); @@ -80,11 +77,9 @@ export function getLastNormal(rundown: NormalisedRundown, order: string[]): Onti /** * Gets last scheduled event in rundown, if it exists - * @param {OntimeRundownEntry[]} rundown - * @return {{ firstEvent: OntimeEvent | null; firstIndex: number | null } } */ export function getLastEvent(rundown: OntimeRundown): { - lastEvent: OntimeEvent | null; + lastEvent: PlayableEvent | null; lastIndex: number | null; } { if (rundown.length < 1) { @@ -93,7 +88,7 @@ export function getLastEvent(rundown: OntimeRundown): { for (let i = rundown.length - 1; i >= 0; i--) { const lastEvent = rundown.at(i); - if (isOntimeEvent(lastEvent) && !lastEvent.skip) { + if (isOntimeEvent(lastEvent) && isPlayableEvent(lastEvent)) { return { lastEvent, lastIndex: i }; } } @@ -102,9 +97,6 @@ export function getLastEvent(rundown: OntimeRundown): { /** * Gets last scheduled event in a normalised rundown, if it exists - * @param rundown - * @param order - * @return {{ firstEvent: OntimeEvent | null; firstIndex: number | null } } */ export function getLastEventNormal( rundown: NormalisedRundown, @@ -129,12 +121,9 @@ export function getLastEventNormal( /** * Gets next entry in rundown, if it exists - * @param {OntimeRundownEntry[]} rundown - * @param {string} currentId - * @return {{ nextEvent: OntimeRundownEntry | null; nextIndex: number | null } } */ export function getNext( - rundown: OntimeRundownEntry[], + rundown: OntimeRundown, currentId: string, ): { nextEvent: OntimeRundownEntry | null; nextIndex: number | null } { const index = rundown.findIndex((event) => event.id === currentId); @@ -149,10 +138,6 @@ export function getNext( /** * Gets next entry in rundown, if it exists - * @param rundown - * @param order - * @param currentId - * @returns */ export function getNextNormal(rundown: NormalisedRundown, order: string[], currentId: string): IndexAndEntry { const currentIndex = order.findIndex((id) => id === currentId); @@ -168,12 +153,9 @@ export function getNextNormal(rundown: NormalisedRundown, order: string[], curre /** * Gets next scheduled event in rundown, if it exists - * @param {OntimeRundownEntry[]} rundown - * @param {string} currentId - * @return {{ nextEvent: OntimeEvent | null; nextIndex: number | null } } */ export function getNextEvent( - rundown: OntimeRundownEntry[], + rundown: OntimeRundown, currentId: string, ): { nextEvent: OntimeEvent | null; nextIndex: number | null } { const index = rundown.findIndex((event) => event.id === currentId); @@ -192,10 +174,6 @@ export function getNextEvent( /** * Gets next scheduled event in a normalised rundown, if it exists - * @param rundown - * @param order - * @param {string} currentId - * @return {{ nextEvent: OntimeEvent | null; nextIndex: number | null } } */ export function getNextEventNormal( rundown: NormalisedRundown, @@ -219,10 +197,8 @@ export function getNextEventNormal( /** * Gets previous entry in rundown, if it exists - * @param {OntimeRundownEntry[]} rundown - * @param {string} currentId */ -export function getPrevious(rundown: OntimeRundownEntry[], currentId: string): IndexAndEntry { +export function getPrevious(rundown: OntimeRundown, currentId: string): IndexAndEntry { const currentIndex = rundown.findIndex((event) => event.id === currentId); if (currentIndex !== -1 && currentIndex - 1 >= 0) { const index = currentIndex - 1; @@ -235,9 +211,6 @@ export function getPrevious(rundown: OntimeRundownEntry[], currentId: string): I /** * Gets previous entry in a nornalised rundown, if it exists - * @param rundown - * @param order - * @param {string} currentId */ export function getPreviousNormal(rundown: NormalisedRundown, order: string[], currentId: string): IndexAndEntry { const currentIndex = order.findIndex((id) => id === currentId); @@ -253,12 +226,9 @@ export function getPreviousNormal(rundown: NormalisedRundown, order: string[], c /** * Gets previous scheduled event in rundown, if it exists - * @param {OntimeRundownEntry[]} rundown - * @param {string} currentId - * @return {{ previousEvent: OntimeRundownEntry | null; previousIndex: number | null } } */ export function getPreviousEvent( - rundown: OntimeRundownEntry[], + rundown: OntimeRundown, currentId: string, ): { previousEvent: OntimeEvent | null; previousIndex: number | null } { const index = rundown.findIndex((event) => event.id === currentId); @@ -302,8 +272,6 @@ export function getPreviousEventNormal( /** * @description swaps two OntimeEvents in the rundown - * @param {OntimeEvent} eventA - * @param {OntimeEvent} eventB */ export const swapEventData = (eventA: OntimeEvent, eventB: OntimeEvent): { newA: OntimeEvent; newB: OntimeEvent } => { const newA = { @@ -333,10 +301,6 @@ export function getEventWithId(rundown: OntimeRundown, id: string): OntimeRundow /** * Gets relevant block element for a given ID - * @param rundown - * @param order - * @param {string} currentId - * @return {OntimeBlock | null} */ export function getRelevantBlock(rundown: OntimeRundown, currentId: string): OntimeBlock | null { let inBlock = false; @@ -357,16 +321,14 @@ export function getRelevantBlock(rundown: OntimeRundown, currentId: string): Ont } /** - * returns all events that can be loaded - * @return {array} + * filters a rundown to timed events */ -export function filterPlayable(rundown: OntimeRundown): OntimeEvent[] { - return rundown.filter((event) => isOntimeEvent(event) && !event.skip) as OntimeEvent[]; +export function filterPlayable(rundown: OntimeRundown): PlayableEvent[] { + return rundown.filter((event) => isOntimeEvent(event) && !event.skip) as PlayableEvent[]; } /** - * returns all events of type OntimeEvent - * @return {array} + * filters a rundown to events that can be played */ export function filterTimedEvents(rundown: OntimeRundown): OntimeEvent[] { return rundown.filter((event) => isOntimeEvent(event)) as OntimeEvent[];