* refactor: cleanup routes

* style: smaller base font

* chore: upgrade dependencies

* chore: lock node version to electron

* refactor: pass HTTP to integration controller (#652)

* refactor: deprecate onair control

* refactor: remove playback router

* Several project files user folder (#617)

* chore: automated screenshots (#667)

* feat: app settings (#658)

* refactor: remove deprecated event data (#674)

* Studio clock (#663)

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* Feat: reorder events with alt+ctrl + arrow up/down (#645)

* Warning and danger per event (#677)

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* refactor: stabilise actionHandler (#683)

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* improvement: hide seconds (#675)

* wip: overview (#688)

* fix: focus cursor (#695)

* refactor: update lower third (#665)

* Refactor/time formatting (#696)

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* feat: multiple selection (#703)

---------

Co-authored-by: asharonbaltazar <asharonbaltazar@outlook.com>
Co-authored-by: Alex <ac@omnivox.dk>

* fix: test - go to `Edit mode` befor tying to click `Event options` button (#708)

* refactor: runtime service (#715)

* fix: issue with loosing cursor position on message (#719)

* remove info panel (#721)

* Event editor continue (#722)

* update API - part  (#709)

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* refactor: update timers (#729)

* feat: many timers (#706)

---------

Co-authored-by: arc-alex <ac@omnivox.dk>

* refactor: excel cleanup (#734)

* refactor: allow import of blocks and skip import (#735)

* Project manager (#697)

* refactor: UI for linking events (#763)

* upgraded pipeline actions (#777)

* Over under (#771)

* custom fields (#744)


---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* Sheets settings (#774)

---------

Co-authored-by: arc-alex <ac@omnivox.dk>

* style: tweaks to lower thirds (#785)

* refactor: delays account for gaps (#784)

* refactor: partial state updates (#780)

* feat: generate crash report (#787)

* Sheet use limited input device auth flow (#782)

---------

Co-authored-by: cv <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* Custom fields views (#789)

* refactor: deprecate presenter and subtitle (#795)

* refactor: organise API around resources (#798)

---------

Co-authored-by: Bianca Procopio <biancahprocopio@gmail.com>

* Time to end (#804)

* Skip fixes (#805)

* fix: onair derives from playback

* Param nav (#822)

---------

Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>

* refactor: download files from interface (#831)

* Quick options (#814)

* End pause (#832)

* chore: bump node version in docker (#834)

* refactor: follow in run mode (#840)

* fix: uncaught error in http integration (#837)

* Apply project (#843)

Co-authored-by: Matteo Gheza <matteo.gheza07@gmail.com>
Co-authored-by: Ary <arylmoraesn@gmail.com>
Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
Co-authored-by: Fabian Posenau <19673098+kellhogs@users.noreply.github.com>
Co-authored-by: Fabian Posenau <fabian@fphome.de>
Co-authored-by: Alex Rohleder <alexrohleder96@gmail.com>
Co-authored-by: asharonbaltazar <asharonbaltazar@outlook.com>
Co-authored-by: Bianca Procopio <biancahprocopio@gmail.com>
Co-authored-by: Fabian Posenau <fabianpos99+github@gmail.com>
This commit is contained in:
Carlos Valente
2024-04-13 10:06:46 +02:00
committed by GitHub
parent debdbd1c5a
commit 55d1aca9b6
640 changed files with 26702 additions and 21623 deletions
+3
View File
@@ -3,6 +3,9 @@ enum Source {
MIDI = 'MIDI',
}
/**
* Service manages retrieving current time from a managed time source
*/
class Clock {
private static instance: Clock;
private readonly source: Source;
-294
View File
@@ -1,294 +0,0 @@
import { LogOrigin, OntimeEvent, Playback } from 'ontime-types';
import { validatePlayback } from 'ontime-utils';
import { eventLoader, EventLoader } from '../classes/event-loader/EventLoader.js';
import { eventStore } from '../stores/EventStore.js';
import { eventTimer } from './TimerService.js';
import { clock } from './Clock.js';
import { logger } from '../classes/Logger.js';
import { RestorePoint } from './RestoreService.js';
/**
* Service manages playback status of app
* Coordinating with necessary services
*/
export class PlaybackService {
/**
* makes calls for loading and starting given event
* @param {OntimeEvent} event
* @return {boolean} success
*/
static loadEvent(event: OntimeEvent): boolean {
let success = false;
if (!event) {
logger.error(LogOrigin.Playback, 'No event found');
} else if (event.skip) {
logger.warning(LogOrigin.Playback, `Refused playback of skipped event ID ${event.id}`);
} else {
eventLoader.loadEvent(event);
eventTimer.load(event);
success = true;
}
eventStore.broadcast();
return success;
}
/**
* starts event matching given ID
* @param {string} eventId
* @return {boolean} success
*/
static startById(eventId: string): boolean {
const event = EventLoader.getEventWithId(eventId);
const success = PlaybackService.loadEvent(event);
if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
PlaybackService.start();
}
return success;
}
/**
* starts an event at index
* @param {number} eventIndex
* @return {boolean} success
*/
static startByIndex(eventIndex: number): boolean {
const event = EventLoader.getEventAtIndex(eventIndex);
const success = PlaybackService.loadEvent(event);
if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
PlaybackService.start();
}
return success;
}
/**
* starts first event matching given cue
* @param {string} cue
* @return {boolean} success
*/
static startByCue(cue: string): boolean {
const event = EventLoader.getEventWithCue(cue);
const success = PlaybackService.loadEvent(event);
if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
PlaybackService.start();
}
return success;
}
/**
* loads event matching given ID
* @param {string} eventId
* @return {boolean} success
*/
static loadById(eventId: string): boolean {
const event = EventLoader.getEventWithId(eventId);
const success = PlaybackService.loadEvent(event);
if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
}
return success;
}
/**
* loads event matching given ID
* @param {number} eventIndex
* @return {boolean} success
*/
static loadByIndex(eventIndex: number): boolean {
const event = EventLoader.getEventAtIndex(eventIndex);
const success = PlaybackService.loadEvent(event);
if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
}
return success;
}
/**
* loads first event matching given cue
* @param {string} cue
* @return {boolean} success
*/
static loadByCue(cue: string): boolean {
const event = EventLoader.getEventWithCue(cue);
const success = PlaybackService.loadEvent(event);
if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
}
return success;
}
/**
* Loads event before currently selected
*/
static loadPrevious() {
const previousEvent = eventLoader.findPrevious();
if (previousEvent) {
const success = PlaybackService.loadEvent(previousEvent);
if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${previousEvent.id}`);
}
}
}
/**
* Loads event after currently selected
* @param {string} [fallbackAction] - 'stop', 'pause'
* @return {boolean} success
*/
static loadNext(fallbackAction?: 'stop' | 'pause'): boolean {
const nextEvent = eventLoader.findNext();
if (nextEvent) {
const success = PlaybackService.loadEvent(nextEvent);
if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${nextEvent.id}`);
return true;
}
} else if (fallbackAction === 'stop') {
logger.info(LogOrigin.Playback, 'No next event found! Stopping playback');
PlaybackService.stop();
return false;
} else if (fallbackAction === 'pause') {
logger.info(LogOrigin.Playback, 'No next event found! Pausing playback');
PlaybackService.pause();
return false;
} else {
logger.info(LogOrigin.Playback, 'No next event found! Continuing playback');
return false;
}
}
/**
* Starts playback on selected event
*/
static start() {
if (validatePlayback(eventTimer.playback).start) {
eventTimer.start();
const newState = eventTimer.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
}
}
/**
* Starts playback on next event
* @param {string} [fallbackAction] - 'stop', 'pause'
*/
static startNext(fallbackAction?: 'stop' | 'pause') {
const success = PlaybackService.loadNext(fallbackAction);
if (success) {
PlaybackService.start();
}
}
/**
* Pauses playback on selected event
*/
static pause() {
if (validatePlayback(eventTimer.playback).pause) {
eventTimer.pause();
const newState = eventTimer.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
}
}
/**
* Stops timer and unloads any events
*/
static stop() {
if (validatePlayback(eventTimer.playback).stop) {
eventLoader.reset();
eventTimer.stop();
const newState = eventTimer.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
}
}
/**
* Reloads current event
*/
static reload() {
if (eventTimer.loadedTimerId) {
this.loadById(eventTimer.loadedTimerId);
}
}
/**
* Sets playback to roll
*/
static roll() {
if (EventLoader.getPlayableEvents()) {
const rollTimers = eventLoader.findRoll(clock.timeNow());
// nothing to play
if (rollTimers === null) {
logger.warning(LogOrigin.Server, 'Roll: no events found');
PlaybackService.stop();
return;
}
const { currentEvent, nextEvent } = rollTimers;
if (!currentEvent && !nextEvent) {
logger.warning(LogOrigin.Server, 'Roll: no events found');
PlaybackService.stop();
return;
}
eventTimer.roll(currentEvent, nextEvent);
const newState = eventTimer.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
}
}
/**
* @description resume playback state given a restore point
* @param restorePoint
*/
static resume(restorePoint: RestorePoint) {
const willResume = () => logger.info(LogOrigin.Server, 'Resuming playback');
if (restorePoint.playback === Playback.Roll) {
willResume();
PlaybackService.roll();
}
if (restorePoint.selectedEventId) {
const event = EventLoader.getEventWithId(restorePoint.selectedEventId);
// the db would have to change for the event not to exist
// we do not kow the reason for the crash, so we check anyway
if (!event) {
return;
}
eventLoader.loadEvent(event);
eventTimer.resume(event, restorePoint);
eventStore.broadcast();
return;
}
}
/**
* Adds time to current event
* @param {number} time - time to add in seconds
*/
static addTime(time: number) {
if (eventTimer.loadedTimerId) {
const timeInMs = time * 1000;
eventTimer.addTime(timeInMs);
timeInMs > 0
? logger.info(LogOrigin.Playback, `Added ${time} sec`)
: logger.info(LogOrigin.Playback, `Removed ${time} sec`);
}
}
/**
* Adds delay to current event
* @deprecated Use addTime
* @param {number} delayTime time in minutes
*/
static setDelay(delayTime: number) {
this.addTime(delayTime * 60);
}
}
+42 -57
View File
@@ -1,16 +1,16 @@
import { Playback } from 'ontime-types';
import { MaybeNumber, MaybeString, Playback } from 'ontime-types';
import { readFileSync } from 'fs';
import { Writer } from 'steno';
import { resolveRestoreFile } from '../setup.js';
import { JSONFile } from 'lowdb/node';
import { resolveRestoreFile } from '../setup/index.js';
import { deepEqual } from 'fast-equals';
export type RestorePoint = {
playback: Playback;
selectedEventId: string | null;
startedAt: number | null;
addedTime: number | null;
pausedAt: number | null;
selectedEventId: MaybeString;
startedAt: MaybeNumber;
addedTime: number;
pausedAt: MaybeNumber;
firstStart: MaybeNumber;
};
/**
@@ -37,7 +37,7 @@ export function isRestorePoint(obj: unknown): obj is RestorePoint {
return false;
}
if (typeof restorePoint.addedTime !== 'number' && restorePoint.addedTime !== null) {
if (typeof restorePoint.addedTime !== 'number') {
return false;
}
@@ -45,6 +45,10 @@ export function isRestorePoint(obj: unknown): obj is RestorePoint {
return false;
}
if (typeof restorePoint.firstStart !== 'number' && restorePoint.pausedAt !== null) {
return false;
}
return true;
}
@@ -57,33 +61,25 @@ export function isRestorePoint(obj: unknown): obj is RestorePoint {
* that can then be restored when reopening
*/
export class RestoreService {
private readonly filePath: string | null;
private lastStore: string | null;
private file: Writer | null;
private readonly filePath: MaybeString;
private readonly file: JSONFile<RestorePoint | null>;
private failedCreateAttempts: number;
private savedState: RestorePoint | null;
constructor(filePath: string) {
this.filePath = filePath;
this.lastStore = null;
this.file = null;
this.savedState = null;
this.file = new JSONFile(this.filePath);
this.failedCreateAttempts = 0;
}
/**
* Utility, creates a restore file
*/
create() {
this.file = new Writer(this.filePath);
}
/**
* Utility, reads from file
* @private
*/
private read() {
return readFileSync(this.filePath, 'utf-8');
private async read() {
return this.file.read();
}
/**
@@ -91,13 +87,8 @@ export class RestoreService {
* @throws
* @param stringifiedState
*/
private async write(stringifiedState: string) {
// Create a file if it doesnt exist
if (!this.file) {
this.create();
}
// steno is async, and it uses a queue to avoid unnecessary re-writes
await this.file.write(stringifiedState);
private async write(data: RestorePoint) {
await this.file.write(data);
}
/**
@@ -110,15 +101,16 @@ export class RestoreService {
return;
}
const stringifiedStore = JSON.stringify(newState);
if (stringifiedStore !== this.lastStore) {
try {
await this.write(stringifiedStore);
this.lastStore = stringifiedStore;
this.failedCreateAttempts = 0;
} catch (_err) {
this.failedCreateAttempts += 1;
}
if (deepEqual(newState, this.savedState)) {
return;
}
try {
await this.write(newState);
this.savedState = { ...newState };
this.failedCreateAttempts = 0;
} catch (_error) {
this.failedCreateAttempts += 1;
}
}
@@ -126,34 +118,27 @@ export class RestoreService {
* Attempts reading a restore point from a given file path
* Returns null if none found, restore point otherwise
*/
load(): RestorePoint | null {
async load(): Promise<RestorePoint | null> {
try {
const data = this.read();
const maybeRestorePoint = JSON.parse(data);
if (!isRestorePoint(maybeRestorePoint)) {
return null;
const maybeRestorePoint = await this.read();
if (isRestorePoint(maybeRestorePoint)) {
return maybeRestorePoint;
}
return maybeRestorePoint;
} catch (_error) {
// no need to notify the user
return null;
}
return null;
}
/**
* Clears the restore file
*/
async clear() {
if (this.file && this.failedCreateAttempts <= 3) {
try {
await this.file.write('');
} catch (_error) {
// nothing to do
}
try {
await this.file.write(null);
} catch (_error) {
// nothing to do
}
this.file = undefined;
}
}
+178 -479
View File
@@ -1,525 +1,224 @@
import { EndAction, LogOrigin, OntimeEvent, Playback, TimerLifeCycle, TimerState, TimerType } from 'ontime-types';
import { calculateDuration, dayInMs } from 'ontime-utils';
import { OntimeEvent, Playback, RuntimeStore } from 'ontime-types';
import { deepEqual } from 'fast-equals';
import { eventStore } from '../stores/EventStore.js';
import { PlaybackService } from './PlaybackService.js';
import { updateRoll } from './rollUtils.js';
import { integrationService } from './integration-service/IntegrationService.js';
import { getCurrent, getExpectedFinish, skippedOutOfEvent } from './timerUtils.js';
import { clock } from './Clock.js';
import { logger } from '../classes/Logger.js';
import type { RestorePoint } from './RestoreService.js';
import * as runtimeState from '../stores/runtimeState.js';
import type { RuntimeState, UpdateResult } from '../stores/runtimeState.js';
type initialLoadingData = {
startedAt?: number | null;
expectedFinish?: number | null;
current?: number | null;
};
type RestoreCallback = (newState: RestorePoint) => Promise<void>;
export const timeSkipLimit = 3 * 32;
import { restoreService } from './RestoreService.js';
/**
* Service manages Ontime's main timer
* It is responsible for streaming the data to the event store
*/
export class TimerService {
private readonly _interval: NodeJS.Timer;
private _updateInterval: number;
private _lastUpdate: number | null;
private _skipThreshold: number;
/** how often we update the socket */
static _updateInterval: number;
/** how often we recalculate */
static _refreshInterval: number;
/** last time we updated the socket */
static previousUpdate: number;
/** last known state */
static previousState: RuntimeState;
playback: Playback;
timer: TimerState;
/** when timer will be finished */
private endCallback: NodeJS.Timer;
loadedTimerId: string | null;
private loadedTimerStart: number | null;
private loadedTimerEnd: number | null;
private onUpdateCallback: (updateResult: UpdateResult) => void;
private pausedTime: number;
private pausedAt: number | null;
private secondaryTarget: number | null;
private saveRestorePoint: RestoreCallback;
/**
* @constructor
* @param {object} [timerConfig]
* @param {number} [timerConfig.refresh]
* @param {number} [timerConfig.updateInterval]
* @param {number} [timerConfig.skipThreshold]
* @param {number} [timerConfig.refresh] how often we recalculate
* @param {number} [timerConfig.updateInterval] how often we update the socket
* @param {function} [timerConfig.onUpdateCallback] how often we update the socket
*/
constructor(timerConfig: { refresh: number; updateInterval: number; skipThreshold: number }) {
this._clear();
this._interval = setInterval(() => this.update(), timerConfig.refresh);
this._updateInterval = timerConfig.updateInterval;
this._skipThreshold = timerConfig.skipThreshold;
}
/**
* Provides callback to save restore point
* @param cb
*/
setRestoreCallback(cb: RestoreCallback) {
this.saveRestorePoint = cb;
}
/**
* Clears internal state
* @private
*/
_clear() {
this.playback = Playback.Stop;
this.timer = {
clock: clock.timeNow(),
current: null,
elapsed: null,
expectedFinish: null,
addedTime: 0,
startedAt: null,
finishedAt: null,
secondaryTimer: null,
selectedEventId: null,
duration: null,
timerType: null,
endAction: null,
};
this.loadedTimerId = null;
this.loadedTimerStart = null;
this.loadedTimerEnd = null;
this.pausedTime = 0;
this.pausedAt = null;
this.secondaryTarget = null;
this._lastUpdate = null;
}
/**
* Resumes a given playback state, same as load
* @param {RestorePoint} restorePoint
* @param {OntimeEvent} timer
*/
resume(timer: OntimeEvent, restorePoint: RestorePoint) {
this._clear();
// this is pretty much the same as load, with a few exceptions
this.loadedTimerId = timer.id;
this.loadedTimerStart = timer.timeStart;
this.loadedTimerEnd = timer.timeEnd;
this.timer.duration = calculateDuration(timer.timeStart, timer.timeEnd);
this.playback = restorePoint.playback;
this.timer.timerType = timer.timerType;
this.timer.endAction = timer.endAction;
this.timer.startedAt = restorePoint.startedAt;
this.timer.addedTime = restorePoint.addedTime;
this.pausedTime = 0;
this.pausedAt = restorePoint.pausedAt;
this.timer.current = this.timer.duration;
if (this.timer.timerType === TimerType.TimeToEnd) {
const now = clock.timeNow();
this.timer.current = getCurrent(now, this.timer.duration, 0, 0, now, timer.timeEnd, this.timer.timerType);
}
this._onResume();
}
_onResume() {
eventStore.batchSet({
playback: this.playback,
timer: this.timer,
});
}
/**
* Reloads information for currently running timer
* @param timer
*/
hotReload(timer) {
if (typeof timer === 'undefined') {
this.stop();
return;
}
if (timer?.id !== this.loadedTimerId) {
// event timer only concerns itself with current event
return;
}
if (timer?.skip) {
this.stop();
}
// TODO: check if any relevant information warrants update
// update relevant information and force update
this.timer.duration = calculateDuration(timer.timeStart, timer.timeEnd);
this.timer.timerType = timer.timerType;
this.timer.endAction = timer.endAction;
this.loadedTimerStart = timer.timeStart;
this.loadedTimerEnd = timer.timeEnd;
// this might not be ideal
this.timer.finishedAt = null;
this.timer.expectedFinish = getExpectedFinish(
this.timer.startedAt,
this.timer.finishedAt,
this.timer.duration,
this.pausedTime,
this.timer.addedTime,
this.loadedTimerEnd,
this.timer.timerType,
);
if (this.timer.startedAt === null) {
this.timer.current = this.timer.duration;
}
this.update(true);
}
/**
* Loads given timer to object
* @param {OntimeEvent} timer
* @param {initialLoadingData} initialData
*/
load(timer: OntimeEvent, initialData?: initialLoadingData) {
if (timer.skip) {
throw new Error('Refuse load of skipped event');
}
this._clear();
this.loadedTimerId = timer.id;
this.loadedTimerStart = timer.timeStart;
this.loadedTimerEnd = timer.timeEnd;
this.timer.duration = calculateDuration(timer.timeStart, timer.timeEnd);
this.playback = Playback.Armed;
this.timer.timerType = timer.timerType;
this.timer.endAction = timer.endAction;
this.pausedTime = 0;
this.pausedAt = 0;
this.timer.current = this.timer.duration;
if (this.timer.timerType === TimerType.TimeToEnd) {
const now = clock.timeNow();
this.timer.current = getCurrent(now, this.timer.duration, 0, 0, now, timer.timeEnd, this.timer.timerType);
}
if (initialData) {
this.timer = { ...this.timer, ...initialData };
}
this._onLoad();
}
/**
* Handles side effects related to onLoad event
* @private
*/
_onLoad() {
eventStore.batchSet({
playback: this.playback,
timer: this.timer,
});
integrationService.dispatch(TimerLifeCycle.onLoad);
this._saveState();
constructor(timerConfig: {
refresh: number;
updateInterval: number;
onUpdateCallback: (updateResult: UpdateResult) => void;
}) {
TimerService.previousUpdate = -1;
TimerService.previousState = {} as RuntimeState;
TimerService._updateInterval = timerConfig.updateInterval;
TimerService._refreshInterval = timerConfig.refresh;
this.onUpdateCallback = timerConfig.onUpdateCallback;
this._interval = setInterval(() => {
this.update();
}, TimerService._refreshInterval);
}
@broadcastResult
start() {
if (!this.loadedTimerId) {
if (this.playback === Playback.Roll) {
logger.error(LogOrigin.Playback, 'Cannot start while waiting for event');
}
return;
if (!runtimeState.start()) {
return false;
}
if (this.playback === Playback.Play) {
return;
}
this.timer.clock = clock.timeNow();
this.timer.secondaryTimer = null;
this.secondaryTarget = null;
// add paused time if it exists
if (this.pausedTime) {
this.timer.addedTime += this.pausedTime;
this.pausedAt = null;
this.pausedTime = 0;
} else if (this.timer.startedAt === null) {
this.timer.startedAt = this.timer.clock;
}
this.playback = Playback.Play;
this.timer.expectedFinish = getExpectedFinish(
this.timer.startedAt,
this.timer.finishedAt,
this.timer.duration,
this.pausedTime,
this.timer.addedTime,
this.loadedTimerEnd,
this.timer.timerType,
);
this._onStart();
}
/**
* Handles side effects related to onStart event
* @private
*/
_onStart() {
eventStore.batchSet({
playback: this.playback,
timer: this.timer,
});
integrationService.dispatch(TimerLifeCycle.onStart);
this._saveState();
const state = runtimeState.getState();
const endTime = state.timer.current - 10;
this.endCallback = setTimeout(() => this.update(), endTime);
return true;
}
@broadcastResult
pause() {
this.playback = Playback.Pause;
this.timer.clock = clock.timeNow();
this.pausedAt = this.timer.clock;
this._onPause();
}
_onPause() {
eventStore.batchSet({
playback: this.playback,
timer: this.timer,
});
integrationService.dispatch(TimerLifeCycle.onPause);
this._saveState();
}
stop() {
if (this.playback === Playback.Stop) {
return;
if (!runtimeState.pause()) {
return false;
}
this._clear();
this._onStop();
// cancel end callback
clearTimeout(this.endCallback);
return true;
}
_onStop() {
eventStore.batchSet({
playback: this.playback,
timer: this.timer,
});
integrationService.dispatch(TimerLifeCycle.onStop);
this._saveState();
@broadcastResult
stop() {
if (!runtimeState.stop()) {
return false;
}
// cancel end callback
clearTimeout(this.endCallback);
return true;
}
/**
* Adds time to running timer by given amount
* @param {number} amount
*/
addTime(amount: number) {
if (!this.loadedTimerId) {
return;
@broadcastResult
addTime(amount: number): boolean {
if (!runtimeState.addTime(amount)) {
return false;
}
this.timer.addedTime += amount;
// handle edge cases
if (amount < 0 && Math.abs(amount) > this.timer.current) {
if (this.timer.finishedAt === null) {
// if we will make the clock negative
this.timer.finishedAt = clock.timeNow();
}
} else if (this.timer.current < 0 && this.timer.current + amount > 0) {
// clock will go from negative to positive
this.timer.finishedAt = null;
}
// force an update
this.update(true);
this._saveState();
// renew end callback
clearTimeout(this.endCallback);
const state = runtimeState.getState();
this.endCallback = setTimeout(() => this.update(), state.timer.expectedFinish);
return true;
}
private updateRoll() {
const tempCurrentTimer = {
selectedEventId: this.loadedTimerId,
current: this.timer.current,
// safeguard on midnight rollover
_finishAt:
this.timer.expectedFinish >= this.timer.startedAt
? this.timer.expectedFinish
: this.timer.expectedFinish + dayInMs,
clock: this.timer.clock,
secondaryTimer: this.timer.secondaryTimer,
secondaryTarget: this.secondaryTarget,
};
const { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished } = updateRoll(tempCurrentTimer);
this.timer.current = updatedTimer;
this.timer.secondaryTimer = updatedSecondaryTimer;
this.timer.elapsed = this.timer.duration - this.timer.current;
if (isFinished) {
this.timer.selectedEventId = null;
this.loadedTimerId = null;
this._onFinish();
}
// to load the next event we have to escalate to parent service
if (doRollLoad) {
PlaybackService.roll();
}
}
private updatePlay() {
if (this.playback === Playback.Pause) {
this.pausedTime = this.timer.clock - this.pausedAt;
}
const finishedNow = this.timer.current <= 0 && this.timer.finishedAt === null;
if (this.playback === Playback.Play && finishedNow) {
this.timer.finishedAt = this.timer.clock;
this._onFinish();
} else {
this.timer.expectedFinish = getExpectedFinish(
this.timer.startedAt,
this.timer.finishedAt,
this.timer.duration,
this.pausedTime,
this.timer.addedTime,
this.loadedTimerEnd,
this.timer.timerType,
);
}
this.timer.current = getCurrent(
this.timer.startedAt,
this.timer.duration,
this.timer.addedTime,
this.pausedTime,
this.timer.clock,
this.loadedTimerEnd,
this.timer.timerType,
);
this.timer.elapsed = this.timer.duration - this.timer.current;
}
update(force = false) {
const previousTime = this.timer.clock;
this.timer.clock = clock.timeNow();
if (previousTime > this.timer.clock) {
force = true;
}
// we call integrations if we update timers
let shouldNotify = false;
if (this.playback === Playback.Roll) {
shouldNotify = true;
if (
skippedOutOfEvent(
previousTime,
this.timer.clock,
this.timer.startedAt,
this.timer.expectedFinish,
this._skipThreshold,
)
) {
PlaybackService.roll();
} else {
this.updateRoll();
}
} else if (this.timer.startedAt !== null) {
// we only update timer if a timer has been started
shouldNotify = true;
this.updatePlay();
}
// we only update the store at the updateInterval
// side effects such as onFinish will still be triggered in the update functions
if (force || this.timer.clock > this._lastUpdate + this._updateInterval) {
this._lastUpdate = this.timer.clock;
this._onUpdate(shouldNotify);
}
}
_onUpdate(shouldNotify: boolean) {
eventStore.set('timer', this.timer);
if (shouldNotify) {
integrationService.dispatch(TimerLifeCycle.onUpdate);
}
}
_onFinish() {
eventStore.set('timer', this.timer);
integrationService.dispatch(TimerLifeCycle.onFinish);
if (this.playback === Playback.Play) {
if (this.timer.endAction === EndAction.Stop) {
PlaybackService.stop();
} else if (this.timer.endAction === EndAction.LoadNext) {
// we need to delay here to put this action in the queue stack. otherwise it won't be executed properly
setTimeout(() => {
PlaybackService.loadNext();
}, 0);
} else if (this.timer.endAction === EndAction.PlayNext) {
PlaybackService.startNext();
}
}
this._saveState();
/**
* Update the app at regular intervals
*/
@broadcastResult
update() {
const updateResult = runtimeState.update();
// pass the result to the parent
this.onUpdateCallback(updateResult);
}
/**
* Loads roll information into timer service
* @param {OntimeEvent | null} currentEvent -- both current event and next event cant be null
* @param {OntimeEvent | null} nextEvent -- both current event and next event cant be null
* @param {OntimeEvent[]} rundown -- list of events to run
*/
roll(currentEvent: OntimeEvent | null, nextEvent: OntimeEvent | null) {
this._clear();
this.timer.clock = clock.timeNow();
if (currentEvent) {
// there is something running, load
this.timer.secondaryTimer = null;
this.secondaryTarget = null;
// account for event that finishes the day after
const endTime =
currentEvent.timeEnd < currentEvent.timeStart ? currentEvent.timeEnd + dayInMs : currentEvent.timeEnd;
// 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
this.load(currentEvent, {
startedAt: currentEvent.timeStart,
expectedFinish: currentEvent.timeEnd,
current: endTime - this.timer.clock,
});
} else if (nextEvent) {
// account for day after
const nextStart = nextEvent.timeStart < this.timer.clock ? nextEvent.timeStart + dayInMs : nextEvent.timeStart;
// nothing now, but something coming up
this.timer.secondaryTimer = nextStart - this.timer.clock;
this.secondaryTarget = nextStart;
}
this.playback = Playback.Roll;
this._onRoll();
this.update(true);
}
_onRoll() {
eventStore.set('playback', this.playback);
this._saveState();
}
async _saveState() {
if (this.saveRestorePoint) {
await this.saveRestorePoint({
playback: this.playback,
selectedEventId: this.loadedTimerId,
startedAt: this.timer.startedAt,
addedTime: this.timer.addedTime,
pausedAt: this.pausedAt,
});
}
@broadcastResult
roll(rundown: OntimeEvent[]) {
runtimeState.roll(rundown);
}
shutdown() {
clearInterval(this._interval);
clearTimeout(this.endCallback);
}
}
// calculate at 30fps, refresh at 1fps
// we consider a skip at 3 lost updates
export const eventTimer = new TimerService({ refresh: 32, updateInterval: 1000, skipThreshold: 32 * 3 });
function broadcastResult(_target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
// call the original method and get the state
const result = originalMethod.apply(this, args);
const state = runtimeState.getState();
// we do the comparison by explicitly for each property
// to apply custom logic for different datasets
// some of the data, we only update at intervals
const isTimeToUpdate =
state.clock < TimerService.previousUpdate ||
state.clock - TimerService.previousUpdate >= TimerService._updateInterval;
// some changes need an immediate update
const hasNewLoaded = state.eventNow?.id !== TimerService.previousState?.eventNow?.id;
const hasSkippedBack = state.clock < TimerService.previousUpdate;
const justStarted = !TimerService.previousState?.timer;
const hasChangedPlayback = TimerService.previousState.timer?.playback !== state.timer.playback;
const hasImmediateChanges = hasNewLoaded || hasSkippedBack || justStarted || hasChangedPlayback;
if (hasChangedPlayback) {
eventStore.set('onAir', state.timer.playback !== Playback.Stop);
}
if (hasImmediateChanges || (isTimeToUpdate && !deepEqual(TimerService.previousState?.timer, state.timer))) {
eventStore.set('timer', state.timer);
TimerService.previousState.timer = { ...state.timer };
}
if (hasChangedPlayback || (isTimeToUpdate && !deepEqual(TimerService.previousState?.runtime, state.runtime))) {
eventStore.set('runtime', state.runtime);
TimerService.previousState.runtime = { ...state.runtime };
}
// Update the events if they have changed
updateEventIfChanged('eventNow', state);
updateEventIfChanged('publicEventNow', state);
updateEventIfChanged('eventNext', state);
updateEventIfChanged('publicEventNext', state);
if (isTimeToUpdate) {
TimerService.previousUpdate = state.clock;
eventStore.set('clock', state.clock);
saveRestoreState(state);
}
// Helper function to update an event if it has changed
function updateEventIfChanged(eventKey: keyof RuntimeStore, state: RuntimeState) {
const previous = TimerService.previousState?.[eventKey];
const now = state[eventKey];
// if there was nothing, and there is nothing, noop
if (!previous?.id && !now?.id) {
return;
}
// if load status changed, save new
if (previous?.id !== now?.id) {
storeKey(eventKey);
return;
}
// maybe the event itself has changed
if (!deepEqual(TimerService.previousState?.[eventKey], state[eventKey])) {
storeKey(eventKey);
return;
}
function storeKey(eventKey: keyof RuntimeStore) {
eventStore.set(eventKey, state[eventKey]);
TimerService.previousState[eventKey] = { ...state[eventKey] };
}
}
// Helper function to save the restore state
function saveRestoreState(state: RuntimeState) {
restoreService.save({
playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null,
startedAt: state.timer.startedAt,
addedTime: state.timer.addedTime,
pausedAt: state._timer.pausedAt,
firstStart: state.runtime.actualStart,
});
}
return result;
};
return descriptor;
}
@@ -7,21 +7,23 @@ import { isRestorePoint, RestorePoint, RestoreService } from '../RestoreService.
describe('isRestorePoint()', () => {
it('validates a well defined object', () => {
let restorePoint = {
playback: 'play',
let restorePoint: RestorePoint = {
playback: Playback.Roll,
selectedEventId: '123',
startedAt: 1,
addedTime: 2,
pausedAt: 3,
firstStart: 1,
};
expect(isRestorePoint(restorePoint)).toBe(true);
restorePoint = {
playback: 'roll',
playback: Playback.Roll,
selectedEventId: '123',
startedAt: null,
addedTime: null,
addedTime: 0,
pausedAt: null,
firstStart: 1,
};
expect(isRestorePoint(restorePoint)).toBe(true);
});
@@ -32,7 +34,7 @@ describe('isRestorePoint()', () => {
playback: 'unknown',
selectedEventId: '123',
startedAt: null,
addedTime: null,
addedTime: 0,
pausedAt: null,
};
expect(isRestorePoint(restorePoint)).toBe(false);
@@ -41,17 +43,17 @@ describe('isRestorePoint()', () => {
const restorePoint = {
selectedEventId: '123',
startedAt: null,
addedTime: null,
addedTime: 0,
pausedAt: null,
};
expect(isRestorePoint(restorePoint)).toBe(false);
});
it('with incorrect value', () => {
const restorePoint = {
playback: 'roll',
playback: Playback.Roll,
selectedEventId: '123',
startedAt: 'testing',
addedTime: null,
addedTime: 0,
pausedAt: null,
};
expect(isRestorePoint(restorePoint)).toBe(false);
@@ -61,51 +63,54 @@ describe('isRestorePoint()', () => {
describe('RestoreService()', () => {
describe('load()', () => {
it('loads working file with times', () => {
const expected = {
it('loads working file with times', async () => {
const expected: RestorePoint = {
playback: Playback.Play,
selectedEventId: 'da5b4',
startedAt: 1234,
addedTime: 5678,
pausedAt: 9087,
firstStart: 1234,
};
const restoreService = new RestoreService('/path/to/restore/file');
vi.spyOn<any, any>(restoreService, 'read').mockImplementation(() => JSON.stringify(expected));
vi.spyOn<any, any>(restoreService, 'read').mockImplementation(() => expected);
const testLoad = restoreService.load();
const testLoad = await restoreService.load();
expect(testLoad).toStrictEqual(expected);
});
it('loads working file without times', () => {
const expected = {
it('loads working file without times', async () => {
const expected: RestorePoint = {
playback: Playback.Stop,
selectedEventId: null,
startedAt: null,
addedTime: null,
addedTime: 0,
pausedAt: null,
firstStart: 1234,
};
const restoreService = new RestoreService('/path/to/restore/file');
vi.spyOn<any, any>(restoreService, 'read').mockImplementation(() => JSON.stringify(expected));
vi.spyOn<any, any>(restoreService, 'read').mockImplementation(() => expected);
const testLoad = restoreService.load();
const testLoad = await restoreService.load();
expect(testLoad).toStrictEqual(expected);
});
it('does not load wrong play state', () => {
it('does not load wrong play state', async () => {
const expected = {
playback: 'does-not-exist',
selectedEventId: 'da5b4',
startedAt: 1234,
addedTime: 1234,
pausedAt: 1234,
firstStart: 1234,
};
const restoreService = new RestoreService('/path/to/restore/file');
vi.spyOn<any, any>(restoreService, 'read').mockImplementation(() => JSON.stringify(expected));
vi.spyOn<any, any>(restoreService, 'read').mockImplementation(() => expected);
const testLoad = restoreService.load();
const testLoad = await restoreService.load();
expect(testLoad).toBe(null);
});
});
@@ -118,12 +123,13 @@ describe('RestoreService()', () => {
startedAt: 1234,
addedTime: 1234,
pausedAt: 1234,
firstStart: 1234,
};
const restoreService = new RestoreService('/path/to/restore/file');
const writeSpy = vi.spyOn<any, any>(restoreService, 'write').mockImplementation(() => undefined);
await restoreService.save(testData);
expect(writeSpy).toHaveBeenCalledWith(JSON.stringify(testData));
expect(writeSpy).toHaveBeenCalledWith(testData);
});
});
});
@@ -1,776 +0,0 @@
import { OntimeEvent } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import { getRollTimers, normaliseEndTime, sortArrayByProperty, updateRoll } from '../rollUtils.js';
// test sortArrayByProperty()
describe('sort simple arrays of objects', () => {
it('sort array 1-5', () => {
const arr1 = [{ timeStart: 1 }, { timeStart: 5 }, { timeStart: 3 }, { timeStart: 2 }, { timeStart: 4 }];
const arr1Expected = [{ timeStart: 1 }, { timeStart: 2 }, { timeStart: 3 }, { timeStart: 4 }, { timeStart: 5 }];
const sorted = sortArrayByProperty(arr1, 'timeStart');
expect(sorted).toStrictEqual(arr1Expected);
});
it('sort array 1-5 with null', () => {
const arr1 = [
{ timeStart: 1 },
{ timeStart: 5 },
{ timeStart: 3 },
{ timeStart: 2 },
{ timeStart: 4 },
{ timeStart: null },
];
const arr1Expected = [
{ timeStart: null },
{ timeStart: 1 },
{ timeStart: 2 },
{ timeStart: 3 },
{ timeStart: 4 },
{ timeStart: 5 },
];
const sorted = sortArrayByProperty(arr1, 'timeStart');
expect(sorted).toStrictEqual(arr1Expected);
});
});
// test getRollTimers()
describe('test that roll loads selection in right order', () => {
const eventlist: Partial<OntimeEvent>[] = [
{
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<OntimeEvent>[] = [
{
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<OntimeEvent>[] = [
{
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<OntimeEvent>[] = [
{
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 roll that goes over midnight', () => {
const singleEventList: Partial<OntimeEvent>[] = [
{
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);
});
});
// test getRollTimers()
describe('test that roll behaviour with overlapping times', () => {
const eventlist: Partial<OntimeEvent>[] = [
{
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);
});
});
// test getRollTimers() on issue #58
describe('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<OntimeEvent>[] = [
{
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<OntimeEvent>[] = [
{
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 getRollTimers() on issue #757
describe('it handles timeEnd over day', () => {
it('ignores events with timeEnd larger than a day', () => {
const testRundown = [
{
title: 'Setup',
subtitle: '',
presenter: '',
note: 'BMA, Nebelfluid, Shooter, Akkus, UHF11, Getränke, Kaffee, Strom, ELA, Text für Holger',
endAction: 'play-next',
timerType: 'count-down',
timeStart: 66600000,
timeEnd: 68400000,
duration: 1800000,
isPublic: false,
skip: false,
colour: '#2fa9e5',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: 'event',
revision: 0,
cue: 'PRE',
id: 'b2f8d',
},
{
title: 'Künstliche Intelligenz',
subtitle: ' -> Vorstellung Maske',
presenter: 'Engel und Teufel',
note: 'Melli Kleben! Sofa',
endAction: 'play-next',
timerType: 'count-down',
timeStart: 86100000,
// timeEnd: 1020000, <--- this would have been equivalent
timeEnd: 87420000,
duration: 1320000,
isPublic: true,
skip: false,
colour: '#a8ec31',
user0: 'UHF1 Melli (Korsett)',
user1: 'UHF2 Reinhold (unter Flügel)',
user2: 'UHF3 Oli (Sport-Unterhose Rechts)',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: 'event',
revision: 0,
cue: '16',
id: '8b970',
},
];
const timeNow = 64488675; // 17:55-something
const timers = getRollTimers(testRundown as OntimeEvent[], timeNow);
expect(timers.currentEvent).toBeNull();
});
});
// test normaliseEndTime() on issue #58
test('test typical scenarios', () => {
const t1 = {
start: 10,
end: 20,
};
const t1_expected = 20;
expect(normaliseEndTime(t1.start, t1.end)).toBe(t1_expected);
const t2 = {
start: 10 + dayInMs,
end: 20,
};
const t2_expected = 20 + dayInMs;
expect(normaliseEndTime(t2.start, t2.end)).toBe(t2_expected);
const t3 = {
start: 10,
end: 10,
};
const t3_expected = 10;
expect(normaliseEndTime(t3.start, t3.end)).toBe(t3_expected);
});
// test updateRoll()
describe('typical scenarios', () => {
it('it updates running events correctly', () => {
const timers = {
selectedEventId: '1',
current: 10,
_finishAt: 15,
clock: 11,
secondaryTimer: null,
secondaryTarget: null,
};
const expected = {
updatedTimer: timers._finishAt - timers.clock,
updatedSecondaryTimer: null,
doRollLoad: false,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
// test that it can jump time
timers._finishAt = 1000;
timers.clock = 600;
expected.updatedTimer = timers._finishAt - timers.clock;
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('it updates secondary timer', () => {
const timers = {
selectedEventId: null,
current: null,
_finishAt: null,
clock: 11,
secondaryTimer: 1,
secondaryTarget: 15,
};
const expected = {
updatedTimer: null,
updatedSecondaryTimer: timers.secondaryTarget - timers.clock,
doRollLoad: false,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('flags an event end', () => {
const timers = {
selectedEventId: '1',
current: 10,
_finishAt: 11,
clock: 12,
secondaryTimer: null,
secondaryTarget: null,
};
const expected = {
updatedTimer: -1,
updatedSecondaryTimer: null,
doRollLoad: true,
isFinished: true,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('secondary events do not trigger event ends', () => {
const timers = {
selectedEventId: null,
current: null,
_finishAt: null,
clock: 16,
secondaryTimer: 1,
secondaryTarget: 15,
};
const expected = {
updatedTimer: null,
updatedSecondaryTimer: timers.secondaryTarget - timers.clock,
doRollLoad: true,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('when a secondary timer is finished, it prompts for new event load', () => {
const timers = {
selectedEventId: null,
current: null,
_finishAt: null,
clock: 15,
secondaryTimer: 0,
secondaryTarget: 15,
};
const expected = {
updatedTimer: null,
updatedSecondaryTimer: timers.secondaryTarget - timers.clock,
doRollLoad: true,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('counts over midnight', () => {
const timers = {
selectedEventId: '1',
current: 25,
_finishAt: 10 + dayInMs,
clock: dayInMs - 10,
secondaryTimer: null,
secondaryTarget: null,
};
const expected = {
updatedTimer: 20,
updatedSecondaryTimer: null,
doRollLoad: false,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('rolls over midnight', () => {
const timers = {
selectedEventId: '1',
current: dayInMs,
_finishAt: 10 + dayInMs,
clock: 10,
secondaryTimer: null,
secondaryTarget: null,
};
const expected = {
updatedTimer: dayInMs,
updatedSecondaryTimer: null,
doRollLoad: false,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
import { Low } from 'lowdb';
import { JSONFile } from 'lowdb/node';
import { appStatePath, isTest } from '../../setup/index.js';
interface Config {
lastLoadedProject: string;
}
/**
* Service manages Ontime's runtime memory between boots
*/
class AppStateService {
private config: Low<Config>;
private pathToFile: string;
constructor(appStatePath: string) {
this.pathToFile = appStatePath;
const adapter = new JSONFile<Config>(this.pathToFile);
this.config = new Low<Config>(adapter, null);
this.init();
}
private async init() {
await this.config.read();
await this.config.write();
}
async get(): Promise<Config> {
await this.config.read();
return this.config.data;
}
async updateDatabaseConfig(filename: string): Promise<void> {
if (isTest) return;
this.config.data.lastLoadedProject = filename;
await this.config.write();
}
}
export const appStateService = new AppStateService(appStatePath);
-37
View File
@@ -1,37 +0,0 @@
import { isOntimeBlock, isOntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
import { deleteAtIndex } from '../utils/arrayUtils.js';
export function _applyDelay(eventId: string, rundown: OntimeRundown): OntimeRundown {
const delayIndex = rundown.findIndex((event) => event.id === eventId);
const delayEvent = rundown.at(delayIndex);
if (delayEvent.type !== SupportedEvent.Delay) {
throw new Error('Given event ID is not a delay');
}
const updatedRundown = [...rundown];
const delayValue = delayEvent.duration;
if (delayValue === 0 || delayIndex === rundown.length - 1) {
// nothing to apply
return updatedRundown;
}
for (let i = delayIndex + 1; i < rundown.length; i++) {
const currentEvent = updatedRundown[i];
if (isOntimeBlock(currentEvent)) {
break;
} else if (isOntimeEvent(currentEvent)) {
currentEvent.timeStart = Math.max(0, currentEvent.timeStart + delayValue);
currentEvent.timeEnd = Math.max(currentEvent.duration, currentEvent.timeEnd + delayValue);
if (currentEvent.delay) {
currentEvent.delay = currentEvent.delay - delayValue;
}
currentEvent.revision += 1;
}
}
return deleteAtIndex(delayIndex, updatedRundown);
}
@@ -0,0 +1,79 @@
import { SimpleDirection, SimpleTimerState } from 'ontime-types';
import { SimpleTimer } from '../../classes/simple-timer/SimpleTimer.js';
import { eventStore } from '../../stores/EventStore.js';
export type EmitFn = (state: SimpleTimerState) => void;
export type GetTimeFn = () => number;
export class ExtraTimerService {
private timer: SimpleTimer;
private interval: NodeJS.Timer | null = null;
private emit: EmitFn;
private getTime: GetTimeFn;
constructor(emit: EmitFn, getTime: GetTimeFn) {
this.timer = new SimpleTimer();
this.emit = emit;
this.getTime = getTime;
}
private startInterval() {
this.interval = setInterval(this.update.bind(this), 500);
}
private stopInterval() {
if (this.interval) {
clearInterval(this.interval);
}
}
@broadcastReturn
setDirection(direction: SimpleDirection) {
return this.timer.setDirection(direction);
}
@broadcastReturn
start() {
this.startInterval();
return this.timer.start(this.getTime());
}
@broadcastReturn
pause() {
return this.timer.pause(this.getTime());
}
@broadcastReturn
stop() {
this.stopInterval();
return this.timer.stop();
}
@broadcastReturn
setTime(duration: number) {
return this.timer.setTime(duration);
}
@broadcastReturn
private update() {
return this.timer.update(this.getTime());
}
}
function broadcastReturn(_target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
const result = originalMethod.apply(this, args);
this.emit(result);
return result;
};
return descriptor;
}
const emit = (state: SimpleTimerState) => eventStore.set('auxtimer1', state);
const timeNow = () => Date.now();
export const extraTimerService = new ExtraTimerService(emit, timeNow);
@@ -1,23 +1,22 @@
import got from 'got';
import { HttpSettings, HttpSubscription, HttpSubscriptionOptions, LogOrigin } from 'ontime-types';
import { HttpSettings, HttpSubscription, LogOrigin } from 'ontime-types';
import IIntegration, { TimerLifeCycleKey } from './IIntegration.js';
import { parseTemplateNested } from './integrationUtils.js';
import { dbModel } from '../../models/dataModel.js';
import { logger } from '../../classes/Logger.js';
import { validateHttpSubscriptionObject } from '../../utils/parserFunctions.js';
type Action = TimerLifeCycleKey | string;
/**
* @description Class contains logic towards outgoing HTTP communications
* @class
*/
export class HttpIntegration implements IIntegration<HttpSubscriptionOptions> {
subscriptions: HttpSubscription;
export class HttpIntegration implements IIntegration<HttpSubscription, HttpSettings> {
subscriptions: HttpSubscription[];
enabled: boolean;
constructor() {
this.subscriptions = dbModel.http.subscriptions;
this.subscriptions = [];
this.enabled = false;
}
/**
@@ -25,75 +24,40 @@ export class HttpIntegration implements IIntegration<HttpSubscriptionOptions> {
*/
init(config: HttpSettings) {
const { subscriptions, enabledOut } = config;
if (!enabledOut) {
return {
success: false,
message: 'HTTP output disabled',
};
}
this.initSubscriptions(subscriptions);
try {
return {
success: true,
message: `HTTP integration client ready`,
};
} catch (error) {
return {
success: false,
message: `Failed initialising HTTP integration: ${error}`,
};
}
this.enabled = enabledOut;
}
initSubscriptions(subscriptionOptions: HttpSubscription) {
if (validateHttpSubscriptionObject(subscriptionOptions)) {
this.subscriptions = { ...subscriptionOptions };
}
initSubscriptions(subscriptions: HttpSubscription[]) {
this.subscriptions = subscriptions;
}
dispatch(action: Action, state?: object) {
if (!action) {
return {
success: false,
message: 'HTTP called with no action',
};
dispatch(action: TimerLifeCycleKey, state?: object) {
// noop
if (!this.enabled) {
return;
}
// check subscriptions for action
const eventSubscriptions = this.subscriptions?.[action] || [];
eventSubscriptions.forEach((sub) => {
const { enabled, message } = sub;
if (enabled && message) {
const parsedMessage = parseTemplateNested(message, state || {});
try {
const parsedUrl = new URL(parsedMessage);
this.emit(parsedUrl);
} catch (err) {
logger.error(LogOrigin.Tx, `HTTP Integration: ${err}`);
return {
success: false,
message: `${err}`,
};
}
for (let i = 0; i < this.subscriptions.length; i++) {
const { cycle, message, enabled } = this.subscriptions[i];
if (cycle !== action || !enabled || !message) {
continue;
}
const parsedMessage = parseTemplateNested(message, state || {});
this.emit(parsedMessage);
}
}
emit(path: string) {
got.get(path, { retry: { limit: 0 } }).catch((err) => {
logger.error(LogOrigin.Tx, `HTTP Integration: ${err.code}`);
});
}
async emit(path: URL) {
try {
await got.get(path, {
retry: { limit: 0 },
});
} catch (err) {
logger.error(LogOrigin.Tx, `HTTP integration: ${err}`);
}
shutdown() {
/** shutdown is a no-op here*/
}
shutdown() {}
}
export const httpIntegration = new HttpIntegration();
@@ -1,24 +1,11 @@
import { TimerLifeCycle, Subscription } from 'ontime-types';
import { TimerLifeCycle } from 'ontime-types';
export type TimerLifeCycleKey = keyof typeof TimerLifeCycle;
export default interface IIntegration<T> {
subscriptions: Subscription<T>;
init: (config: unknown) => OperationReturn;
dispatch: (action: TimerLifeCycleKey, state?: object) => OperationReturn;
emit: (...args: unknown[]) => unknown;
export default interface IIntegration<T, C> {
subscriptions: T[];
init: (config: C) => void;
dispatch: (action: TimerLifeCycleKey, state?: object) => void;
emit: (...args: never[]) => unknown;
shutdown: () => void;
}
// either went well, or explain what failed
type OperationReturn = ReturnOnSuccess | ReturnOnError;
type ReturnOnSuccess = {
success: true;
message?: string;
};
type ReturnOnError = {
success: false;
message: string;
};
@@ -1,18 +1,21 @@
import { LogOrigin } from 'ontime-types';
import IIntegration, { TimerLifeCycleKey } from './IIntegration.js';
import { eventStore } from '../../stores/EventStore.js';
import { logger } from '../../classes/Logger.js';
class IntegrationService {
private integrations: IIntegration<unknown>[];
private integrations: IIntegration<unknown, unknown>[];
constructor() {
this.integrations = [];
}
register(integrationService: IIntegration<unknown>) {
register(integrationService: IIntegration<unknown, unknown>) {
this.integrations.push(integrationService);
}
unregister(integrationService: IIntegration<unknown>) {
unregister(integrationService: IIntegration<unknown, unknown>) {
this.integrations = this.integrations.filter((int) => int !== integrationService);
}
@@ -24,7 +27,7 @@ class IntegrationService {
}
shutdown() {
console.log('Shutdown integrations');
logger.info(LogOrigin.Tx, 'Shutdown Integrations');
this.integrations.forEach((integration) => {
integration.shutdown();
});
@@ -1,133 +1,144 @@
import { ArgumentType, Client, Message } from 'node-osc';
import { OSCSettings, OscSubscription, OscSubscriptionOptions } from 'ontime-types';
import { LogOrigin, MaybeNumber, MaybeString, OSCSettings, OscSubscription } from 'ontime-types';
import IIntegration, { TimerLifeCycleKey } from './IIntegration.js';
import { parseTemplateNested } from './integrationUtils.js';
import { isObject } from '../../utils/varUtils.js';
import { dbModel } from '../../models/dataModel.js';
import { validateOscSubscriptionObject } from '../../utils/parserFunctions.js';
type Action = TimerLifeCycleKey | string;
import { logger } from '../../classes/Logger.js';
import { OscServer } from '../../adapters/OscAdapter.js';
/**
* @description Class contains logic towards outgoing OSC communications
* @class
*/
export class OscIntegration implements IIntegration<OscSubscriptionOptions> {
export class OscIntegration implements IIntegration<OscSubscription, OSCSettings> {
protected oscClient: null | Client;
subscriptions: OscSubscription;
protected oscServer: OscServer | null = null;
subscriptions: OscSubscription[];
targetIP: MaybeString;
portOut: MaybeNumber;
portIn: MaybeNumber;
enabledOut: boolean;
enabledIn: boolean;
constructor() {
this.oscClient = null;
this.subscriptions = dbModel.osc.subscriptions;
this.subscriptions = [];
this.targetIP = null;
this.portOut = null;
this.portIn = null;
this.enabledOut = false;
this.enabledIn = false;
}
/**
* Initializes oscClient
*/
init(config: OSCSettings) {
const { targetIP, portOut, subscriptions, enabledOut } = config;
const { targetIP, portOut, subscriptions, enabledOut, enabledIn, portIn } = config;
if (!enabledOut) {
this.oscClient?.close();
return {
success: false,
message: 'OSC output disabled',
};
this.initTX(enabledOut, targetIP, portOut, subscriptions);
this.initRX(enabledIn, portIn);
// return `OSC integration client connected to ${targetIP}:${portOut}`;
}
private initSubscriptions(subscriptions: OscSubscription[]) {
this.subscriptions = subscriptions;
}
dispatch(action: TimerLifeCycleKey, state?: object) {
// noop
if (!this.oscClient) {
return;
}
for (let i = 0; i < this.subscriptions.length; i++) {
const { cycle, address, payload, enabled } = this.subscriptions[i];
if (cycle !== action || !enabled || !address) {
continue;
}
const parsedAddress = parseTemplateNested(address, state || {});
const parsedPayload = payload ? parseTemplateNested(payload, state || {}) : undefined;
try {
this.emit(parsedAddress, parsedPayload);
} catch (error) {
logger.error(LogOrigin.Tx, `OSC Integration: ${error}`);
}
}
}
emit(address: string, payload?: ArgumentType) {
if (!this.oscClient) {
return;
}
const message = new Message(address);
if (payload) {
if (isObject(payload)) {
message.append(JSON.stringify(payload));
} else {
message.append(payload);
}
}
this.oscClient.send(message);
}
private initTX(enabledOut: boolean, targetIP: string, portOut: number, subscriptions: OscSubscription[]) {
this.initSubscriptions(subscriptions);
// runtime validation
const validateType = typeof targetIP !== 'string' || typeof portOut !== 'number';
const validateNull = !targetIP || !portOut;
if (validateType || validateNull) {
return {
success: false,
message: 'Config options incorrect',
};
if (!enabledOut && this.enabledOut) {
this.targetIP = targetIP;
this.portOut = portOut;
this.enabledOut = enabledOut;
this.shutdownTX();
return;
}
if (this.oscClient && targetIP === this.targetIP && portOut === this.portOut) {
// nothing changed that would mean we need a new client
return;
}
this.targetIP = targetIP;
this.portOut = portOut;
this.enabledOut = enabledOut;
try {
// this allows re-calling the init function during runtime
this.oscClient?.close();
this.oscClient = new Client(targetIP, portOut);
return {
success: true,
message: `OSC integration client connected to ${targetIP}:${portOut}`,
};
} catch (error) {
this.oscClient = null;
return {
success: false,
message: `Failed initialising OSC Client: ${error}`,
};
throw new Error(`Failed initialising OSC client: ${error}`);
}
}
initSubscriptions(subscriptionOptions: OscSubscription) {
if (validateOscSubscriptionObject(subscriptionOptions)) {
this.subscriptions = { ...subscriptionOptions };
}
}
dispatch(action: Action, state?: object) {
if (!this.oscClient) {
return {
success: false,
message: 'Client not initialised',
};
private initRX(enabledIn: boolean, portIn: number) {
if (!enabledIn && this.enabledIn) {
this.shutdownRX();
return;
}
if (!action) {
return {
success: false,
message: 'OSC called with no action',
};
}
// check subscriptions for action
const eventSubscriptions = this.subscriptions?.[action] || [];
eventSubscriptions.forEach((sub) => {
const { enabled, message } = sub;
if (enabled && message) {
const parsedMessage = parseTemplateNested(message, state || {});
this.emit(parsedMessage);
}
});
}
emit(path: string, payload?: ArgumentType) {
const message = new Message(path);
if (payload) {
try {
if (isObject(payload)) {
message.append(JSON.stringify(payload));
} else {
message.append(payload);
}
} catch (error) {
console.log('OSC ERROR', error, payload);
}
}
this.oscClient.send(message, (error) => {
if (error) {
return {
success: false,
message: `Error sending message: ${JSON.stringify(error)}`,
};
}
return {
success: true,
message: 'OSC Message sent',
};
});
// Start OSC Server
logger.info(LogOrigin.Rx, `Starting OSC Server on port: ${portIn}`);
this.oscServer = new OscServer(portIn);
}
shutdown() {
console.log('Shutting down OSC integration');
this.shutdownTX();
this.shutdownRX();
}
private shutdownTX() {
logger.info(LogOrigin.Rx, 'Shutting down OSC integration');
if (this.oscServer) {
this.oscServer?.shutdown();
this.oscServer = null;
}
}
private shutdownRX() {
logger.info(LogOrigin.Tx, 'Shutting down OSC integration');
if (this.oscClient) {
this.oscClient?.close();
this.oscClient = null;
@@ -1,10 +1,11 @@
// any value inside double curly braces {{val}}
import { formatDisplay } from 'ontime-utils';
import { MaybeNumber } from 'ontime-types';
import { millisToString, removeLeadingZero } from 'ontime-utils';
// any value inside double curly braces {{val}}
const placeholderRegex = /{{(.*?)}}/g;
function formatDisplayFromString(value: string, hideZero = false): string {
let valueInNumber = null;
let valueInNumber: MaybeNumber = null;
if (value !== 'null') {
const parsedValue = Number(value);
@@ -12,12 +13,16 @@ function formatDisplayFromString(value: string, hideZero = false): string {
valueInNumber = parsedValue;
}
}
return formatDisplay(valueInNumber, hideZero);
let formatted = millisToString(valueInNumber, { fallback: hideZero ? '00:00' : '00:00:00' });
if (hideZero) {
formatted = removeLeadingZero(formatted);
}
return formatted;
}
type AliasesDefinition = Record<string, { key: string; cb: (value: unknown) => string }>;
type AliasesDefinition = Record<string, { key: string; cb: (value: string) => string }>;
const quickAliases: AliasesDefinition = {
clock: { key: 'timer.clock', cb: (value: string) => formatDisplayFromString(value) },
clock: { key: 'clock', cb: (value: string) => formatDisplayFromString(value) },
duration: { key: 'timer.duration', cb: (value: string) => formatDisplayFromString(value, true) },
expectedEnd: {
key: 'timer.expectedFinish',
@@ -44,7 +49,7 @@ export function parseTemplateNested(template: string, state: object, humanReadab
for (const match of matches) {
const variableName = match[1];
const variableParts = variableName.split('.');
let value = undefined;
let value: string | undefined = undefined;
if (variableParts[0] === 'human') {
const lookupKey = variableParts[1];
@@ -57,9 +62,9 @@ export function parseTemplateNested(template: string, state: object, humanReadab
}
} else {
// iterate through variable parts, and look for the property in the state object
value = variableParts.reduce((obj, key) => obj && obj[key], state);
value = variableParts.reduce((obj, key) => obj?.[key], state);
}
if (typeof value !== 'undefined') {
if (value !== undefined) {
parsedTemplate = parsedTemplate.replace(match[0], value);
}
}
@@ -1,18 +1,16 @@
import { Message } from 'ontime-types';
import { DeepPartial, Message, TimerMessage, MessageState } from 'ontime-types';
import { TimerMessage } from 'ontime-types/src/definitions/runtime/MessageControl.type.js';
import { throttle } from '../../utils/throttle.js';
import type { PublishFn } from '../../stores/EventStore.js';
let instance;
let instance: MessageService | null = null;
class MessageService {
timerMessage: TimerMessage;
publicMessage: Message;
lowerMessage: Message;
externalMessage: Message;
onAir: boolean;
timer: TimerMessage;
public: Message;
lower: Message;
external: Message;
private throttledSet: PublishFn;
private publish: PublishFn | null;
@@ -25,165 +23,62 @@ class MessageService {
// eslint-disable-next-line @typescript-eslint/no-this-alias -- this logic is used to ensure singleton
instance = this;
this.timerMessage = {
text: '',
visible: false,
timerBlink: false,
timerBlackout: false,
};
this.publicMessage = {
text: '',
visible: false,
};
this.lowerMessage = {
text: '',
visible: false,
};
this.externalMessage = {
text: '',
visible: false,
};
this.onAir = false;
this.throttledSet = () => {
throw new Error('Published called before initialisation');
};
this.clear();
}
clear() {
this.timer = {
text: '',
visible: false,
blink: false,
blackout: false,
};
this.public = {
text: '',
visible: false,
};
this.lower = {
text: '',
visible: false,
};
this.external = {
text: '',
visible: false,
};
}
init(publish: PublishFn) {
this.publish = publish;
this.throttledSet = throttle((key, value) => this.publish(key, value), 100);
this.throttledSet = throttle((key, value) => this.publish?.(key, value), 100);
}
/**
* @description sets message on stage timer screen
*/
setExternalText(payload: string) {
if (this.externalMessage.text !== payload) {
this.externalMessage.text = payload;
this.throttledSet('externalMessage', this.externalMessage);
}
return this.getAll();
}
/**
* @description sets message visibility on stage timer screen
*/
setExternalVisibility(status: boolean) {
this.externalMessage.visible = status;
this.throttledSet('externalMessage', this.externalMessage);
return this.getAll();
}
/**
* @description sets message on stage timer screen
*/
setTimerText(payload: string) {
this.timerMessage.text = payload;
this.throttledSet('timerMessage', this.timerMessage);
return this.getAll();
}
/**
* @description sets message visibility on stage timer screen
*/
setTimerVisibility(status: boolean) {
this.timerMessage.visible = status;
this.throttledSet('timerMessage', this.timerMessage);
return this.getAll();
}
/**
* @description sets message on public screen
*/
setPublicText(payload: string) {
this.publicMessage.text = payload;
this.throttledSet('publicMessage', this.publicMessage);
return this.getAll();
}
/**
* @description sets message visibility on public screen
*/
setPublicVisibility(status: boolean) {
this.publicMessage.visible = status;
this.throttledSet('publicMessage', this.publicMessage);
return this.getAll();
}
/**
* @description sets message on lower third screen
*/
setLowerText(payload: string) {
this.lowerMessage.text = payload;
this.throttledSet('lowerMessage', this.lowerMessage);
return this.getAll();
}
/**
* @description sets message visibility on lower third screen
*/
setLowerVisibility(status: boolean) {
this.lowerMessage.visible = status;
this.throttledSet('lowerMessage', this.lowerMessage);
return this.getAll();
}
/**
* @description set state of onAir, toggles if parameters are offered
*/
setOnAir(status?: boolean) {
if (typeof status === 'undefined') {
this.onAir = !this.onAir;
} else {
this.onAir = status;
}
this.throttledSet('onAir', this.onAir);
return this.getAll();
}
/**
* @description set state of timer blink, toggles if parameters are offered
*/
setTimerBlink(status?: boolean) {
if (typeof status === 'undefined') {
this.timerMessage.timerBlink = !this.timerMessage.timerBlink;
} else {
this.timerMessage.timerBlink = status;
}
this.throttledSet('timerMessage', this.timerMessage);
return this.getAll();
}
/**
* @description set state of timer blackout, toggles if parameters are offered
*/
setTimerBlackout(status?: boolean) {
if (typeof status === 'undefined') {
this.timerMessage.timerBlackout = !this.timerMessage.timerBlackout;
} else {
this.timerMessage.timerBlackout = status;
}
this.throttledSet('timerMessage', this.timerMessage);
return this.getAll();
}
/**
* @description Returns feature data
*/
getAll() {
getState(): MessageState {
return {
timerMessage: this.timerMessage,
publicMessage: this.publicMessage,
lowerMessage: this.lowerMessage,
onAir: this.onAir,
timer: this.timer,
public: this.public,
lower: this.lower,
external: this.external,
};
}
patch(message: DeepPartial<MessageState>) {
if (message.timer) this.timer = { ...this.timer, ...message.timer };
if (message.public) this.public = { ...this.public, ...message.public };
if (message.lower) this.lower = { ...this.lower, ...message.lower };
if (message.external) this.external = { ...this.external, ...message.external };
const newState = this.getState();
this.throttledSet('message', newState);
return newState;
}
}
export const messageService = new MessageService();
@@ -0,0 +1,59 @@
import { messageService } from '../MessageService.js';
describe('MessageService', () => {
const publishFunction = () => {};
beforeAll(() => {
messageService.init(publishFunction);
});
beforeEach(() => {
messageService.clear();
});
it('should patch the message state', () => {
const message = {
timer: { text: 'new text', visible: true },
public: { text: 'public text', visible: false },
lower: { text: 'lower text' },
external: { visible: true },
};
const newState = messageService.patch(message);
expect(newState).toEqual({
timer: { text: 'new text', visible: true, blackout: false, blink: false },
public: { text: 'public text', visible: false },
lower: {
text: 'lower text',
visible: false,
},
external: {
text: '',
visible: true,
},
});
});
it('should not affect other properties when patching', () => {
const initialMessage = {
timer: { text: 'initial text', visible: true },
public: { text: 'public text', visible: false },
};
const newState = messageService.patch(initialMessage);
expect(newState).toEqual({
timer: { text: 'initial text', visible: true, blackout: false, blink: false },
public: { text: 'public text', visible: false },
lower: {
text: '',
visible: false,
},
external: {
text: '',
visible: false,
},
});
});
});
@@ -0,0 +1,55 @@
import { validateMessage, validateTimerMessage } from '../messageUtils.js';
describe('validateMessage()', () => {
it('returns a valid Message object', () => {
const payload = {
text: '12312',
visible: 'true',
};
const expected = {
text: '12312',
visible: true,
};
expect(validateMessage(payload)).toEqual(expected);
});
it('skips keys not given', () => {
const payload = {
visible: 'true',
};
const expected = {
visible: true,
};
expect(validateMessage(payload)).toStrictEqual(expected);
});
});
describe('validateTimerMessage()', () => {
it('returns a valid Timer Message object', () => {
const payload = {
text: '12312',
visible: 'true',
blink: 'true',
blackout: 'true',
};
const expected = {
text: '12312',
visible: true,
blink: true,
blackout: true,
};
expect(validateTimerMessage(payload)).toEqual(expected);
});
it('skips keys not given', () => {
const payload = {
visible: 'true',
};
const expected = {
visible: true,
};
expect(validateTimerMessage(payload)).toStrictEqual(expected);
});
});
@@ -0,0 +1,35 @@
import { Message, TimerMessage } from 'ontime-types';
import * as assert from '../../utils/assert.js';
import { coerceBoolean, coerceString } from '../../utils/coerceType.js';
/**
* Creates a valid Message object from a payload
* @throws if the payload is not an object
*/
export function validateMessage(message: unknown): Partial<Message> {
assert.isObject(message);
const result: Partial<Message> = {};
if ('text' in message) result.text = coerceString(message.text);
if ('visible' in message) result.visible = coerceBoolean(message.visible);
return result;
}
/**
* Creates a valid Timer Message object from a payload
* @throws if the payload is not an object
*/
export function validateTimerMessage(message: unknown): Partial<TimerMessage> {
assert.isObject(message);
const result: Partial<TimerMessage> = {};
if ('text' in message) result.text = coerceString(message.text);
if ('visible' in message) result.visible = coerceBoolean(message.visible);
if ('blink' in message) result.blink = coerceBoolean(message.blink);
if ('blackout' in message) result.blackout = coerceBoolean(message.blackout);
return result;
}
@@ -0,0 +1,266 @@
import { DatabaseModel, GetInfo, ProjectData, ProjectFile, ProjectFileListResponse } from 'ontime-types';
import { copyFile, rename, stat, writeFile } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
import { initRundown } from '../rundown-service/RundownService.js';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { runtimeService } from '../runtime-service/RuntimeService.js';
import { getNetworkInterfaces } from '../../utils/networkInterfaces.js';
import { resolveProjectsDirectory, resolveStylesPath } from '../../setup/index.js';
import { filterProjectFiles, parseProjectFile } from './projectFileUtils.js';
import { appStateService } from '../app-state-service/AppStateService.js';
import { ensureDirectory, getFilesFromFolder, removeFileExtension } from '../../utils/fileManagement.js';
import { dbModel } from '../../models/dataModel.js';
import { deleteFile } from '../../utils/parserUtils.js';
import { switchDb } from '../../setup/loadDb.js';
// init dependencies
init();
/**
* Ensure services has its dependencies initialized
*/
function init() {
ensureDirectory(resolveProjectsDirectory);
}
type Options = {
onlyRundown?: 'true' | 'false';
};
/**
* Handles a file from the upload folder and applies its data
*/
export async function applyProjectFile(name: string, options?: Options) {
const filePath = join(resolveProjectsDirectory, name);
const data = parseProjectFile(filePath);
// change LowDB to point to new file
await switchDb(name);
// apply data model
await applyDataModel(data, options);
// persist the project selection
await appStateService.updateDatabaseConfig(name);
}
/**
* Copies a file from upload folder to the projects folder
* @param filePath
* @param name
* @returns
*/
export async function handleUploadedFile(filePath: string, name: string) {
const newFilePath = join(resolveProjectsDirectory, name);
await rename(filePath, newFilePath);
await deleteFile(filePath);
return name;
}
/**
* Asynchronously retrieves and returns an array of project files from the 'uploads' folder.
* Each file in the 'uploads' folder is checked, and only those with a '.json' extension are processed.
* For each qualifying file, its metadata is retrieved, including filename, creation time, and last modification time.
*
* @returns {Promise<Array<ProjectFile>>} A promise that resolves to an array of ProjectFile objects,
* each representing a file in the 'uploads' folder with its metadata.
* The metadata includes the filename, creation or overwriting time (updatedAt)
*
* @throws {Error} Throws an error if there is an issue in reading the directory or fetching file statistics.
*/
export async function getProjectFiles(): Promise<ProjectFile[]> {
const allFiles = await getFilesFromFolder(resolveProjectsDirectory);
const filteredFiles = filterProjectFiles(allFiles);
const projectFiles: ProjectFile[] = [];
for (const file of filteredFiles) {
const filePath = join(resolveProjectsDirectory, file);
const stats = await stat(filePath);
projectFiles.push({
filename: removeFileExtension(file),
updatedAt: stats.mtime.toISOString(),
});
}
return projectFiles;
}
/**
* Gathers data related to the project list
*/
export async function getProjectList(): Promise<ProjectFileListResponse> {
const files = await getProjectFiles();
const appState = await appStateService.get();
const lastLoadedProject = removeFileExtension(appState.lastLoadedProject);
return {
files,
lastLoadedProject,
};
}
/**
* Duplicates an existing project file
*/
export async function duplicateProjectFile(existingProjectFile: string, newProjectFile: string) {
const projectFilePath = join(resolveProjectsDirectory, existingProjectFile);
const duplicateProjectFilePath = join(resolveProjectsDirectory, newProjectFile);
return copyFile(projectFilePath, duplicateProjectFilePath);
}
/**
* Renames an existing project file
*/
export async function renameProjectFile(existingProjectFile: string, newName: string) {
const projectFilePath = join(resolveProjectsDirectory, existingProjectFile);
const newProjectFilePath = join(resolveProjectsDirectory, newName);
await rename(projectFilePath, newProjectFilePath);
// Update the last loaded project config if current loaded project is the one being renamed
const { lastLoadedProject } = await appStateService.get();
if (lastLoadedProject === existingProjectFile) {
await appStateService.updateDatabaseConfig(newName);
}
}
/**
* Creates a new project file and applies its result
*/
export async function createProjectFile(filename: string, projectData: ProjectData) {
const data = {
...dbModel,
project: {
...dbModel.project,
...projectData,
},
};
// create new file
const newFile = join(resolveProjectsDirectory, filename);
await writeFile(newFile, JSON.stringify(data));
// change LowDB to point to new file
await switchDb(filename);
// apply its data
await applyDataModel(data);
appStateService.updateDatabaseConfig(filename);
}
/**
* Deletes a project file
*/
export async function deleteProjectFile(filename: string) {
const projectFilePath = join(resolveProjectsDirectory, filename);
await deleteFile(projectFilePath);
}
/**
* Adds business logic to gathering data for the info endpoint
*/
export async function getInfo(): Promise<GetInfo> {
const { version, serverPort } = DataProvider.getSettings();
const osc = DataProvider.getOsc();
// get nif and inject localhost
const ni = getNetworkInterfaces();
ni.unshift({ name: 'localhost', address: '127.0.0.1' });
const cssOverride = resolveStylesPath;
return {
networkInterfaces: ni,
version,
serverPort,
osc,
cssOverride,
};
}
/**
* Business logic for resolving a string
*/
export function extractPin(value: string | undefined | null, fallback: string | null): string | null {
if (value === null) {
return value;
}
if (typeof value === 'undefined') {
return fallback;
}
if (value.length === 0) {
return null;
}
return value;
}
/**
* applies a partial database model
*/
export async function applyDataModel(data: Partial<DatabaseModel>, _options?: Options) {
runtimeService.stop();
// TODO: allow partial project merge from options
const { rundown, customFields, ...rest } = data;
const newData = await DataProvider.mergeIntoData(rest);
if (rundown != null) {
initRundown(rundown, customFields ?? {});
}
return newData;
}
/**
* Checks whether a project of a given name exists
* @param name
*/
export function doesProjectExist(name: string): boolean {
const projectFilePath = join(resolveProjectsDirectory, name);
return existsSync(projectFilePath);
}
/**
* @description Validates the existence of project files.
* @param {object} projectFiles
* @param {string} projectFiles.projectFilename
* @param {string} projectFiles.newFilename
*
* @returns {Promise<Array<string>>} Array of errors
*
*/
export const validateProjectFiles = (projectFiles: { filename?: string; newFilename?: string }): Array<string> => {
const errors: string[] = [];
if (projectFiles.filename) {
const projectFilePath = join(resolveProjectsDirectory, projectFiles.filename);
if (!existsSync(projectFilePath)) {
errors.push('Project file does not exist');
}
}
if (projectFiles.newFilename) {
const projectFilePath = join(resolveProjectsDirectory, projectFiles.newFilename);
if (existsSync(projectFilePath)) {
errors.push('New project file already exists');
}
}
return errors;
};
/**
* Get current project title or fallback
*/
export function getProjectTitle(): string {
const { title } = DataProvider.getProjectData();
return title || 'ontime data';
}
@@ -0,0 +1,33 @@
import { expect, vi } from 'vitest';
import { getProjectFiles } from '../ProjectService.js';
vi.mock('fs/promises', () => {
const mockFiles = ['file1.json', 'file2.json', 'file3.json', 'document.txt', 'image.png'];
const mockStats = {
birthtime: new Date('2020-01-01'),
mtime: new Date('2021-01-01'),
};
return {
readdir: vi.fn().mockResolvedValue(mockFiles),
stat: vi.fn().mockResolvedValue(mockStats),
};
});
describe('getProjectFiles test', () => {
it('should return a list of project .json files', async () => {
const { readdir, stat } = await import('fs/promises');
const result = await getProjectFiles();
const expectedFiles = ['file1', 'file2', 'file3'].map((file) => ({
filename: file,
updatedAt: new Date('2021-01-01').toISOString(),
}));
expect(result).toEqual(expectedFiles);
expect(readdir).toHaveBeenCalled();
expect(stat).toHaveBeenCalledTimes(expectedFiles.length);
});
});
@@ -0,0 +1,31 @@
import { readFileSync } from 'fs';
import { extname } from 'path';
/**
* Given an array of file names, filters out any files that do not have a '.json' extension.
* We assume these are project files
* @param files
* @returns
*/
export function filterProjectFiles(files: Array<string>): Array<string> {
return files.filter((file) => {
const ext = extname(file).toLowerCase();
return ext === '.json';
});
}
export function parseProjectFile(filePath: string): object {
if (!filePath.endsWith('.json')) {
throw new Error('Invalid file type');
}
const rawdata = readFileSync(filePath, 'utf-8');
const uploadedJson = JSON.parse(rawdata);
// at this point, we think this is a DatabaseModel
// verify by looking for the required fields
if (uploadedJson?.settings?.app !== 'ontime') {
throw new Error('Not a ontime project file');
}
return uploadedJson;
}
-203
View File
@@ -1,203 +0,0 @@
import { OntimeEvent } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
/**
* handle events that span over midnight
*/
export const normaliseEndTime = (start: number, end: number) => (end < start ? end + dayInMs : end);
/**
* @description Sorts an array of objects by given property
* @param {array} arr - array to be sorted
* @param {string} property - property to compare
* @returns {array} copy of array sorted in ascending order
*/
export const sortArrayByProperty = <T>(arr: T[], property: string): T[] => {
return [...arr].sort((a, b) => {
return a[property] - b[property];
});
};
/**
* Finds loading information given a current rundown and time
* @param {OntimeEvent[]} rundown - List of playable events
* @param {number} timeNow - time now in ms
* @returns {{}}
*/
export const getRollTimers = (rundown: OntimeEvent[], timeNow: number) => {
let nowIndex: number | null = null; // index of event now
let nowId: string | null = null; // id of event now
let publicIndex: number | null = null; // index of public event now
let nextIndex: number | null = null; // index of next event
let publicNextIndex: number | null = null; // index of next public event
let timeToNext: number | null = null; // counter: time for next event
let publicTimeToNext: number | null = null; // counter: time for next public event
const orderedEvents = sortArrayByProperty(rundown, 'timeStart');
const lastEvent = orderedEvents[orderedEvents.length - 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 = orderedEvents[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 orderedEvents) {
if (event.isPublic) {
nextPublicEvent = event;
// we need the index before this was sorted
publicNextIndex = rundown.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 orderedEvents) {
// When does the event end (handle midnight)
const normalEnd = normaliseEndTime(event.timeStart, event.timeEnd);
const hasNotEnded = normalEnd > timeNow;
// TODO: we will likely want a better solution than the modulus here
const isFromDayBefore = normalEnd > dayInMs && timeNow < event.timeEnd % dayInMs;
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 = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
} else if (hasNotEnded && hasStarted && !nowFound) {
// event is running
currentEvent = event;
nowIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
nowId = event.id;
nowFound = true;
// it could also be public
if (event.isPublic) {
publicTime = normalEnd;
currentPublicEvent = event;
publicIndex = rundown.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 = rundown.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 = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
}
}
}
}
return {
nowIndex,
nowId,
publicIndex,
nextIndex,
publicNextIndex,
timeToNext,
nextEvent,
nextPublicEvent,
currentEvent,
currentPublicEvent,
};
};
type CurrentTimers = {
selectedEventId: string | null;
current: number | null;
_finishAt: number | null;
clock: number | null;
secondaryTimer: number | null;
secondaryTarget: number | null;
};
/**
* @description Implements update functions for roll mode
* @param {CurrentTimers} currentTimers
* @returns {object} object with selection variables
*/
export const updateRoll = (currentTimers: CurrentTimers) => {
const { selectedEventId, current, _finishAt, clock, secondaryTimer, secondaryTarget } = currentTimers;
// timers
let updatedTimer = current;
let updatedSecondaryTimer = secondaryTimer;
// whether rollLoad should be called: force reload of events
let doRollLoad = false;
// whether finished event should trigger
let isPrimaryFinished = false;
if (selectedEventId && current !== null) {
// if we have something selected and a timer, we are running
updatedTimer = _finishAt - clock;
if (updatedTimer > dayInMs) {
updatedTimer -= dayInMs;
}
if (updatedTimer < 0) {
isPrimaryFinished = true;
// we need a new event
doRollLoad = true;
}
} else if (secondaryTimer >= 0) {
// if secondaryTimer is running we are in waiting to roll
updatedSecondaryTimer = secondaryTarget - clock;
if (updatedSecondaryTimer <= 0) {
// we need a new event
doRollLoad = true;
}
}
return { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished: isPrimaryFinished };
};
@@ -1,212 +1,87 @@
import {
CustomFields,
LogOrigin,
OntimeBaseEvent,
OntimeBlock,
OntimeDelay,
OntimeEvent,
Playback,
SupportedEvent,
OntimeRundownEntry,
isOntimeBlock,
isOntimeDelay,
isOntimeEvent,
OntimeRundown,
} from 'ontime-types';
import { generateId, getCueCandidate } from 'ontime-utils';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { getCueCandidate } from 'ontime-utils';
import { block as blockDef, delay as delayDef } from '../../models/eventsDefinition.js';
import { MAX_EVENTS } from '../../settings.js';
import { EventLoader, eventLoader } from '../../classes/event-loader/EventLoader.js';
import { eventTimer } from '../TimerService.js';
import { sendRefetch } from '../../adapters/websocketAux.js';
import { runtimeCacheStore } from '../../stores/cachingStore.js';
import {
cachedAdd,
cachedApplyDelay,
cachedClear,
cachedDelete,
cachedEdit,
cachedReorder,
cachedSwap,
delayedRundownCacheKey,
} from './delayedRundown.utils.js';
import { logger } from '../../classes/Logger.js';
import { validateEvent } from '../../utils/parser.js';
import { clock } from '../Clock.js';
import { createEvent } from '../../utils/parser.js';
import { updateRundownData } from '../../stores/runtimeState.js';
import { runtimeService } from '../runtime-service/RuntimeService.js';
/**
* Forces rundown to be recalculated
* To be used when we know the rundown has changed completely
*/
export function forceReset() {
eventLoader.reset();
runtimeCacheStore.invalidate(delayedRundownCacheKey);
}
import * as cache from './rundownCache.js';
import { getPlayableEvents } from './rundownUtils.js';
/**
* Checks if a list of IDs is in the current selection
*/
const affectedLoaded = (affectedIds: string[]) => {
const now = eventLoader.loaded.selectedEventId;
const nowPublic = eventLoader.loaded.selectedPublicEventId;
const next = eventLoader.loaded.nextEventId;
const nextPublic = eventLoader.loaded.nextPublicEventId;
return (
affectedIds.includes(now) ||
affectedIds.includes(nowPublic) ||
affectedIds.includes(next) ||
affectedIds.includes(nextPublic)
);
};
type PatchWithId = (Partial<OntimeEvent> | Partial<OntimeBlock> | Partial<OntimeDelay>) & { id: string };
/**
* Checks if timer replaces the loaded next
*/
const isNewNext = () => {
const timedEvents = EventLoader.getTimedEvents();
const now = eventLoader.loaded.selectedEventId;
const next = eventLoader.loaded.nextEventId;
type CompleteEntry<T> = T extends Partial<OntimeEvent>
? OntimeEvent
: T extends Partial<OntimeDelay>
? OntimeDelay
: T extends Partial<OntimeBlock>
? OntimeBlock
: never;
// check whether the index of now and next are consecutive
const indexNow = timedEvents.findIndex((event) => event.id === now);
const indexNext = timedEvents.findIndex((event) => event.id === next);
function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock>>(
eventData: T,
): CompleteEntry<T> {
// we discard any UI provided IDs and add our own
const id = cache.getUniqueId();
if (indexNext - indexNow !== 1) {
return true;
}
// iterate through timed events and see if there are public events between nowPublic and nextPublic
const nowPublic = eventLoader.loaded.selectedPublicEventId;
const nextPublic = eventLoader.loaded.nextPublicEventId;
let foundNew = false;
let isAfter = false;
for (const event of timedEvents) {
if (!isAfter) {
if (event.id === nowPublic) {
isAfter = true;
}
} else {
if (event.id === nextPublic) {
break;
}
if (event.isPublic) {
foundNew = true;
break;
}
}
if (isOntimeEvent(eventData)) {
return createEvent(eventData, getCueCandidate(cache.getPersistedRundown(), eventData?.after)) as CompleteEntry<T>;
}
return foundNew;
};
/**
* Updates timer service when a relevant piece of data changes
*/
export function updateTimer(affectedIds?: string[]) {
const runningEventId = eventLoader.loaded.selectedEventId;
const nextEventId = eventLoader.loaded.nextEventId;
if (runningEventId === null && nextEventId === null) {
return false;
if (isOntimeDelay(eventData)) {
return { ...delayDef, duration: eventData.duration ?? 0, id } as CompleteEntry<T>;
}
// we need to reload in a few scenarios:
// 1. we are not confident that changes do not affect running event
const safeOption = typeof affectedIds === 'undefined';
// 2. the edited event is in memory (now or next) running
const eventInMemory = safeOption ? false : affectedLoaded(affectedIds);
// 3. the edited event replaces next event
const isNext = isNewNext();
if (safeOption) {
eventLoader.reset();
const { eventNow } = eventLoader.loadById(runningEventId) || {};
eventTimer.hotReload(eventNow);
return true;
if (isOntimeBlock(eventData)) {
return { ...blockDef, title: eventData?.title ?? '', id } as CompleteEntry<T>;
}
if (eventInMemory) {
eventLoader.reset();
if (eventTimer.playback === Playback.Roll) {
const rollTimers = eventLoader.findRoll(clock.timeNow());
if (rollTimers === null) {
eventTimer.stop();
} else {
const { currentEvent, nextEvent } = rollTimers;
eventTimer.roll(currentEvent, nextEvent);
}
} else {
const { eventNow } = eventLoader.loadById(runningEventId) || {};
if (eventNow) {
eventTimer.hotReload(eventNow);
} else {
eventTimer.stop();
}
}
return true;
}
if (isNext) {
const { eventNow } = eventLoader.loadById(runningEventId) || {};
eventTimer.hotReload(eventNow);
return true;
}
return false;
throw new Error('Invalid event type');
}
/**
* @description creates a new event with given data
* @param {object} eventData
* @return {unknown[]}
* @return {OntimeRundownEntry}
*/
export async function addEvent(eventData: Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock>) {
const numEvents = DataProvider.getRundownLength();
if (numEvents > MAX_EVENTS) {
throw new Error(`Reached limit number of ${MAX_EVENTS} events`);
}
let newEvent: Partial<OntimeBaseEvent> = {};
const id = generateId();
let insertIndex = 0;
export async function addEvent(eventData: PatchWithId & { after?: string }): Promise<OntimeRundownEntry> {
// if the user didnt provide an index, we add the event to start
let atIndex = 0;
if (eventData?.after !== undefined) {
const index = DataProvider.getIndexOf(eventData.after);
if (index < 0) {
const previousIndex = cache.getIndexOf(eventData.after);
if (previousIndex < 0) {
logger.warning(LogOrigin.Server, `Could not find event with id ${eventData.after}`);
} else {
insertIndex = index + 1;
atIndex = previousIndex + 1;
}
}
switch (eventData.type) {
case SupportedEvent.Event: {
newEvent = validateEvent(eventData, getCueCandidate(DataProvider.getRundown(), eventData?.after)) as OntimeEvent;
break;
}
case SupportedEvent.Delay:
newEvent = { ...delayDef, duration: eventData.duration, id } as OntimeDelay;
break;
case SupportedEvent.Block:
newEvent = { ...blockDef, title: eventData.title, id } as OntimeBlock;
break;
}
delete eventData.after;
// generate a fully formed event from the patch
const eventToAdd = generateEvent(eventData);
// modify rundown
await cachedAdd(insertIndex, newEvent as OntimeEvent | OntimeDelay | OntimeBlock);
const scopedMutation = cache.mutateCache(cache.add);
const { newEvent } = await scopedMutation({ atIndex, event: eventToAdd });
notifyChanges({ timer: [id], external: true });
// notify runtime that rundown has changed
updateRuntimeOnChange();
// notify event loader that rundown size has changed
updateChangeNumEvents();
return newEvent;
}
export async function editEvent(eventData: Partial<OntimeEvent> | Partial<OntimeBlock> | Partial<OntimeDelay>) {
if (eventData.type === SupportedEvent.Event && eventData?.cue === '') {
throw new Error('Cue value invalid');
}
const newEvent = await cachedEdit(eventData.id, eventData);
notifyChanges({ timer: [newEvent.id], external: true });
// notify timer and external services of change
notifyChanges({ timer: [eventData.id], external: true });
return newEvent;
}
@@ -214,24 +89,76 @@ export async function editEvent(eventData: Partial<OntimeEvent> | Partial<Ontime
/**
* deletes event by its ID
* @param eventId
* @returns {Promise<void>}
*/
export async function deleteEvent(eventId) {
await cachedDelete(eventId);
export async function deleteEvent(eventId: string) {
const scopedMutation = cache.mutateCache(cache.remove);
const { didMutate } = await scopedMutation({ eventId });
if (didMutate === false) {
return;
}
// notify runtime that rundown has changed
updateRuntimeOnChange();
// notify timer and external services of change
notifyChanges({ timer: [eventId], external: true });
// notify event loader that rundown size has changed
updateChangeNumEvents();
}
/**
* deletes all events in database
* @returns {Promise<void>}
*/
export async function deleteAllEvents() {
await cachedClear();
const scopedMutation = cache.mutateCache(cache.removeAll);
await scopedMutation({});
notifyChanges({ timer: true, external: true, reset: true });
// notify event loader that rundown has changed
updateRuntimeOnChange();
// notify timer and external services of change
notifyChanges({ timer: true, external: true });
}
/**
* Apply patch to an element in rundown
* @param patch
*/
export async function editEvent(patch: PatchWithId) {
if (isOntimeEvent(patch) && patch?.cue === '') {
throw new Error('Cue value invalid');
}
const scopedMutation = cache.mutateCache(cache.edit);
const { newEvent, didMutate } = await scopedMutation({ patch, eventId: patch.id });
// short circuit if nothing changed
if (didMutate === false) {
return newEvent;
}
// notify runtime that rundown has changed
updateRuntimeOnChange();
// notify timer and external services of change
notifyChanges({ timer: [patch.id], external: true });
return newEvent;
}
/**
* Applies a patch to several elements in a rundown
* @param ids
* @param data
*/
export async function batchEditEvents(ids: string[], data: Partial<OntimeEvent>) {
const scopedMutation = cache.mutateCache(cache.batchEdit);
await scopedMutation({ patch: data, eventIds: ids });
// notify runtime that rundown has changed
updateRuntimeOnChange();
// notify timer and external services of change
notifyChanges({ timer: ids, external: true });
}
/**
@@ -239,19 +166,28 @@ export async function deleteAllEvents() {
* @param {string} eventId - ID of event from, for sanity check
* @param {number} from - index of event from
* @param {number} to - index of event to
* @returns {Promise<void>}
*/
export async function reorderEvent(eventId: string, from: number, to: number) {
const reorderedItem = await cachedReorder(eventId, from, to);
const scopedMutation = cache.mutateCache(cache.reorder);
const reorderedItem = await scopedMutation({ eventId, from, to });
// notify runtime that rundown has changed
updateRuntimeOnChange();
// notify timer and external services of change
notifyChanges({ timer: true, external: true });
return reorderedItem;
}
export async function applyDelay(eventId: string) {
await cachedApplyDelay(eventId);
const scopedMutation = cache.mutateCache(cache.applyDelay);
await scopedMutation({ eventId });
// notify runtime that rundown has changed
updateRuntimeOnChange();
// notify timer and external services of change
notifyChanges({ timer: true, external: true });
}
@@ -262,8 +198,13 @@ export async function applyDelay(eventId: string) {
* @returns {Promise<void>}
*/
export async function swapEvents(from: string, to: string) {
await cachedSwap(from, to);
const scopedMutation = cache.mutateCache(cache.swap);
await scopedMutation({ fromId: from, toId: to });
// notify runtime that rundown has changed
updateRuntimeOnChange();
// notify timer and external services of change
notifyChanges({ timer: true, external: true });
}
@@ -271,25 +212,35 @@ export async function swapEvents(from: string, to: string) {
* Forces update in the store
* Called when we make changes to the rundown object
*/
function updateChangeNumEvents() {
eventLoader.updateNumEvents();
function updateRuntimeOnChange() {
const playableEvents = getPlayableEvents();
const numEvents = playableEvents.length;
const metadata = cache.getMetadata();
// schedule an update for the end of the event loop
setImmediate(() =>
updateRundownData({
numEvents,
...metadata,
}),
);
}
/**
* Notify services of changes in the rundown
*/
export function notifyChanges(options: { timer?: boolean | string[]; external?: boolean; reset?: boolean }) {
function notifyChanges(options: { timer?: boolean | string[]; external?: boolean }) {
if (options.timer) {
// notify timer service of changed events
if (Array.isArray(options.timer)) {
updateTimer(options.timer);
}
updateTimer();
}
const playableEvents = getPlayableEvents();
if (options.reset) {
// force rundown to be recalculated
forceReset();
if (playableEvents.length === 0) {
runtimeService.stop();
} else {
// notify timer service of changed events
// timer can be true or an array of changed IDs
const affected = Array.isArray(options.timer) ? options.timer : undefined;
runtimeService.maybeUpdate(playableEvents, affected);
}
}
if (options.external) {
@@ -297,3 +248,17 @@ export function notifyChanges(options: { timer?: boolean | string[]; external?:
sendRefetch();
}
}
/**
* Overrides the rundown with the given
* @param rundown
*/
export async function initRundown(rundown: Readonly<OntimeRundown>, customFields: Readonly<CustomFields>) {
await cache.init(rundown, customFields);
// notify runtime that rundown has changed
updateRuntimeOnChange();
// notify timer of change
notifyChanges({ timer: true });
}
@@ -1,7 +1,7 @@
import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
import { _applyDelay } from '../delayUtils.js';
import { apply } from '../delayUtils.js';
describe('_applyDelay() ', () => {
describe('apply() ', () => {
describe('in a rundown without the delay field, persisted rundown', () => {
it('applies delays', () => {
const delayId = '1';
@@ -20,7 +20,7 @@ describe('_applyDelay() ', () => {
{ id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
];
const updatedRundown = _applyDelay(delayId, testRundown);
const updatedRundown = apply(delayId, testRundown);
expect(updatedRundown).toStrictEqual(expected);
});
it('applies negative delays', () => {
@@ -40,7 +40,7 @@ describe('_applyDelay() ', () => {
{ id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
];
const updatedRundown = _applyDelay(delayId, testRundown);
const updatedRundown = apply(delayId, testRundown);
expect(updatedRundown).toStrictEqual(expected);
});
it('maintains constant duration', () => {
@@ -56,7 +56,7 @@ describe('_applyDelay() ', () => {
{ id: '3', type: SupportedEvent.Event, timeStart: 0, timeEnd: 20, duration: 20, revision: 2 } as OntimeEvent,
];
const updatedRundown = _applyDelay(delayId, testRundown);
const updatedRundown = apply(delayId, testRundown);
expect(updatedRundown).toStrictEqual(expected);
});
});
@@ -126,7 +126,7 @@ describe('_applyDelay() ', () => {
} as OntimeEvent,
];
const updatedRundown = _applyDelay(delayId, testRundown);
const updatedRundown = apply(delayId, testRundown);
expect(updatedRundown).toStrictEqual(expected);
});
it('applies negative delays', () => {
@@ -194,7 +194,7 @@ describe('_applyDelay() ', () => {
} as OntimeEvent,
];
const updatedRundown = _applyDelay(delayId, testRundown);
const updatedRundown = apply(delayId, testRundown);
expect(updatedRundown).toStrictEqual(expected);
});
it('maintains constant duration', () => {
@@ -242,7 +242,7 @@ describe('_applyDelay() ', () => {
} as OntimeEvent,
];
const updatedRundown = _applyDelay(delayId, testRundown);
const updatedRundown = apply(delayId, testRundown);
expect(updatedRundown).toStrictEqual(expected);
});
});
@@ -1,457 +0,0 @@
import { EndAction, OntimeEvent, OntimeRundown, SupportedEvent, TimerType } from 'ontime-types';
import { calculateRuntimeDelays, calculateRuntimeDelaysFrom, getDelayAt } from '../delayedRundown.utils.js';
describe('calculateRuntimeDelays', () => {
it('calculates all delays in a given rundown', () => {
const rundown: OntimeRundown = [
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: '659e1',
cue: '1',
},
{
duration: 600000,
type: SupportedEvent.Delay,
revision: 0,
id: '07986',
},
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 1200000,
timeEnd: 1200000,
duration: 0,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: '1c48f',
cue: '2',
},
{
duration: 1200000,
type: SupportedEvent.Delay,
revision: 0,
id: '7db42',
},
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: 'd48c2',
cue: '3',
},
{
title: '',
type: SupportedEvent.Block,
id: '9870d',
},
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 1200000,
timeEnd: 1800000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: '2f185',
cue: '4',
},
];
const updatedRundown = calculateRuntimeDelays(rundown);
expect(rundown.length).toBe(updatedRundown.length);
expect((updatedRundown[0] as OntimeEvent).delay).toBe(0);
expect((updatedRundown[2] as OntimeEvent).delay).toBe(600000);
expect((updatedRundown[4] as OntimeEvent).delay).toBe(600000 + 1200000);
expect((updatedRundown[6] as OntimeEvent).delay).toBe(0);
});
});
describe('getDelayAt()', () => {
const delayedRundown: OntimeRundown = [
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: '659e1',
delay: 0,
cue: '1',
},
{
duration: 600000,
type: SupportedEvent.Delay,
revision: 0,
id: '07986',
},
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 1200000,
timeEnd: 1200000,
duration: 0,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: '1c48f',
delay: 600000,
cue: '2',
},
{
duration: 1200000,
type: SupportedEvent.Delay,
revision: 0,
id: '7db42',
},
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: 'd48c2',
delay: 1800000,
cue: '3',
},
{
title: '',
type: SupportedEvent.Block,
id: '9870d',
},
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 1200000,
timeEnd: 1800000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: '2f185',
delay: 0,
cue: '4',
},
];
it('calculates delay in a rundown', () => {
const delayAtStart = getDelayAt(0, delayedRundown);
const delayOnFirstEvent = getDelayAt(2, delayedRundown);
const delayOnSecondEvent = getDelayAt(4, delayedRundown);
const delayOnBlockedEvent = getDelayAt(0, delayedRundown);
expect(delayAtStart).toBe(0);
expect(delayOnFirstEvent).toBe(600000);
expect(delayOnSecondEvent).toBe(600000 + 1200000);
expect(delayOnBlockedEvent).toBe(0);
});
it('finds delay before a delay block', () => {
const valueOnFirstDelayBlock = getDelayAt(1, delayedRundown);
const valueOnSecondDelayBlock = getDelayAt(3, delayedRundown);
const valueAfterSecondDelayBlock = getDelayAt(4, delayedRundown);
expect(valueOnFirstDelayBlock).toBe(0);
expect(valueOnSecondDelayBlock).toBe(600000);
expect(valueAfterSecondDelayBlock).toBe(600000 + 1200000);
});
it('returns 0 after blocks', () => {
const valueOnBlock = getDelayAt(6, delayedRundown);
expect(valueOnBlock).toBe(0);
});
});
describe('calculateRuntimeDelaysFrom()', () => {
it('updates delays from given id', () => {
const delayedRundown: OntimeRundown = [
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: '659e1',
delay: 0,
cue: '1',
},
{
duration: 600000,
type: SupportedEvent.Delay,
revision: 0,
id: '07986',
},
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 1200000,
timeEnd: 1200000,
duration: 0,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: '1c48f',
delay: 0,
cue: '2',
},
{
duration: 1200000,
type: SupportedEvent.Delay,
revision: 0,
id: '7db42',
},
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: 'd48c2',
delay: 1800000,
cue: '3',
},
{
title: '',
type: SupportedEvent.Block,
id: '9870d',
},
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 1200000,
timeEnd: 1800000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: '2f185',
delay: 0,
cue: '4',
},
];
const updatedRundown = calculateRuntimeDelaysFrom('07986', delayedRundown);
// we only update from the 4th on
expect((updatedRundown[0] as OntimeEvent).delay).toBe(0);
// 1 + 3
expect((updatedRundown[4] as OntimeEvent).delay).toBe(600000 + 1200000);
});
});
@@ -0,0 +1,893 @@
import {
CustomFields,
EndAction,
EventCustomFields,
OntimeBlock,
OntimeDelay,
OntimeEvent,
OntimeRundown,
SupportedEvent,
TimeStrategy,
TimerType,
} from 'ontime-types';
import { MILLIS_PER_HOUR, dayInMs, millisToString } from 'ontime-utils';
import { calculateRuntimeDelays, getDelayAt, calculateRuntimeDelaysFrom } from '../delayUtils.js';
import {
add,
batchEdit,
edit,
generate,
remove,
reorder,
swap,
createCustomField,
editCustomField,
removeCustomField,
} from '../rundownCache.js';
describe('generate()', () => {
it('creates normalised versions of a given rundown', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1' } as OntimeEvent,
{ type: SupportedEvent.Block, id: '2' } as OntimeBlock,
{ type: SupportedEvent.Delay, id: '3' } as OntimeDelay,
];
const initResult = generate(testRundown);
expect(initResult.order.length).toBe(3);
expect(initResult.order).toStrictEqual(['1', '2', '3']);
expect(initResult.rundown['1'].type).toBe(SupportedEvent.Event);
expect(initResult.rundown['2'].type).toBe(SupportedEvent.Block);
expect(initResult.rundown['3'].type).toBe(SupportedEvent.Delay);
});
it('calculates delays versions of a given rundown', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Delay, id: '1', duration: 100 } as OntimeDelay,
{ type: SupportedEvent.Event, id: '2', timeStart: 1, timeEnd: 100 } as OntimeEvent,
];
const initResult = generate(testRundown);
expect(initResult.order.length).toBe(2);
expect((initResult.rundown['2'] as OntimeEvent).delay).toBe(100);
expect(initResult.totalDelay).toBe(100);
});
it('accounts for gaps in rundown when calculating delays', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200 } as OntimeEvent,
{ type: SupportedEvent.Delay, id: 'delay', duration: 200 } as OntimeDelay,
{ type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300 } as OntimeEvent,
{ type: SupportedEvent.Block, id: 'block', title: 'break' } as OntimeBlock,
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500 } as OntimeEvent,
{ type: SupportedEvent.Block, id: 'another-block', title: 'another-break' } as OntimeBlock,
{ type: SupportedEvent.Event, id: '4', timeStart: 600, timeEnd: 700 } as OntimeEvent,
];
const initResult = generate(testRundown);
expect(initResult.order.length).toBe(7);
expect((initResult.rundown['1'] as OntimeEvent).delay).toBe(0);
expect((initResult.rundown['2'] as OntimeEvent).delay).toBe(200);
expect((initResult.rundown['3'] as OntimeEvent).delay).toBe(100);
expect((initResult.rundown['4'] as OntimeEvent).delay).toBe(0);
expect(initResult.totalDelay).toBe(0);
expect(initResult.totalDuration).toBe(700 - 100);
});
it('handles negative delays', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200 } as OntimeEvent,
{ type: SupportedEvent.Delay, id: 'delay', duration: -200 } as OntimeDelay,
{ type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300 } as OntimeEvent,
{ type: SupportedEvent.Block, id: 'block', title: 'break' } as OntimeBlock,
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500 } as OntimeEvent,
{ type: SupportedEvent.Block, id: 'another-block', title: 'another-break' } as OntimeBlock,
{ type: SupportedEvent.Event, id: '4', timeStart: 600, timeEnd: 700 } as OntimeEvent,
];
const initResult = generate(testRundown);
expect(initResult.order.length).toBe(7);
expect((initResult.rundown['1'] as OntimeEvent).delay).toBe(0);
expect((initResult.rundown['2'] as OntimeEvent).delay).toBe(-200);
expect((initResult.rundown['3'] as OntimeEvent).delay).toBe(-200);
expect((initResult.rundown['4'] as OntimeEvent).delay).toBe(-200);
expect(initResult.totalDelay).toBe(-200);
expect(initResult.totalDuration).toBe(700 - 100);
});
it('links times across events', () => {
const testRundown: OntimeRundown = [
{
type: SupportedEvent.Event,
id: '1',
timeStart: 1,
duration: 1,
timeEnd: 2,
timeStrategy: TimeStrategy.LockEnd,
} as OntimeEvent,
{
type: SupportedEvent.Event,
id: '2',
timeStart: 11,
duration: 1,
timeEnd: 12,
linkStart: '1',
timeStrategy: TimeStrategy.LockEnd,
} as OntimeEvent,
{ type: SupportedEvent.Block, id: 'block' } as OntimeBlock,
{ type: SupportedEvent.Delay, id: 'delay' } as OntimeDelay,
{
type: SupportedEvent.Event,
id: '3',
timeStart: 21,
duration: 1,
timeEnd: 22,
linkStart: '2',
timeStrategy: TimeStrategy.LockEnd,
} as OntimeEvent,
];
const initResult = generate(testRundown);
expect(initResult.order.length).toBe(5);
expect((initResult.rundown['2'] as OntimeEvent).timeStart).toBe(2);
expect((initResult.rundown['2'] as OntimeEvent).timeEnd).toBe(12);
expect((initResult.rundown['2'] as OntimeEvent).duration).toBe(10);
expect((initResult.rundown['3'] as OntimeEvent).timeStart).toBe(12);
expect((initResult.rundown['3'] as OntimeEvent).timeEnd).toBe(22);
expect((initResult.rundown['3'] as OntimeEvent).duration).toBe(10);
expect(initResult.links['1']).toBe('2');
expect(initResult.links['2']).toBe('3');
});
it('links times across events, reordered', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1', timeStart: 1, timeEnd: 2 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '3', timeStart: 21, timeEnd: 22, linkStart: '2' } as OntimeEvent,
{ type: SupportedEvent.Event, id: '2', timeStart: 11, timeEnd: 12, linkStart: '1' } as OntimeEvent,
];
const initResult = generate(testRundown);
expect(initResult.order.length).toBe(3);
expect((initResult.rundown['3'] as OntimeEvent).timeStart).toBe(2);
expect(initResult.links['1']).toBe('3');
expect(initResult.links['3']).toBe('2');
});
it('calculates total duration', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '3', timeStart: 300, timeEnd: 400 } as OntimeEvent,
];
const initResult = generate(testRundown);
expect(initResult.order.length).toBe(3);
expect(initResult.totalDuration).toBe(400 - 100);
});
it('calculates total duration across days with gap', () => {
const testRundown: OntimeRundown = [
{
type: SupportedEvent.Event,
id: '1',
timeStart: new Date(0).setHours(9),
timeEnd: new Date(0).setHours(23),
} as OntimeEvent,
{
type: SupportedEvent.Event,
id: '2',
timeStart: new Date(0).setHours(9),
timeEnd: new Date(0).setHours(23),
} as OntimeEvent,
{
type: SupportedEvent.Event,
id: '3',
timeStart: new Date(0).setHours(9),
timeEnd: new Date(0).setHours(23),
} as OntimeEvent,
];
const initResult = generate(testRundown);
const expectedDuration = (23 - 9 + 48) * MILLIS_PER_HOUR;
expect(millisToString(initResult.totalDuration)).toBe('62:00:00');
expect(initResult.totalDuration).toBe(expectedDuration);
});
it('calculates total duration across days', () => {
const testRundown: OntimeRundown = [
{
type: SupportedEvent.Event,
id: '1',
timeStart: new Date(0).setHours(12),
timeEnd: new Date(0).setHours(22),
} as OntimeEvent,
{
type: SupportedEvent.Event,
id: '2',
timeStart: new Date(0).setHours(22),
timeEnd: new Date(0).setHours(8),
} as OntimeEvent,
];
const initResult = generate(testRundown);
const expectedDuration = 8 * MILLIS_PER_HOUR + (dayInMs - 12 * MILLIS_PER_HOUR);
expect(millisToString(initResult.totalDuration)).toBe('20:00:00');
expect(initResult.totalDuration).toBe(expectedDuration);
});
it('handles updating event sequence', () => {
const testRundown: OntimeRundown = [
{
type: SupportedEvent.Event,
id: '97cc3e',
timeStart: 0,
timeEnd: 600000,
duration: 600000,
timeStrategy: TimeStrategy.LockDuration,
linkStart: null,
} as OntimeEvent,
{
type: SupportedEvent.Event,
id: 'e01948',
timeStart: 600000,
timeEnd: 601000,
duration: 85801000, // <------------- value out of sync
timeStrategy: TimeStrategy.LockEnd,
linkStart: '97cc3e',
} as OntimeEvent,
{
type: SupportedEvent.Event,
id: '25c1af',
timeStart: 100, // <------------- value out of sync
timeEnd: 602000,
duration: 0,
timeStrategy: TimeStrategy.LockEnd,
linkStart: 'e01948',
} as OntimeEvent,
];
const initResult = generate(testRundown);
expect(initResult.rundown).toMatchObject({
'97cc3e': {
timeStart: 0,
timeEnd: 600000,
duration: 600000,
timeStrategy: 'lock-duration',
linkStart: null,
},
e01948: {
timeStart: 600000,
timeEnd: 601000,
duration: 1000,
timeStrategy: 'lock-end',
linkStart: '97cc3e',
},
'25c1af': {
timeStart: 601000,
timeEnd: 602000,
duration: 1000,
timeStrategy: 'lock-end',
linkStart: 'e01948',
},
});
});
it('deletes links if invalid', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1', timeStart: 1, linkStart: '10' } as OntimeEvent,
];
const initResult = generate(testRundown);
expect(initResult.order.length).toBe(1);
expect((initResult.rundown['1'] as OntimeEvent).timeStart).toBe(1);
expect(Object.keys(initResult.links).length).toBe(0);
});
describe('custom properties feature', () => {
it('creates a map of custom properties', () => {
const customProperties: CustomFields = {
lighting: {
label: 'lighting',
type: 'string',
colour: 'red',
},
sound: {
label: 'sound',
type: 'string',
colour: 'red',
},
};
const testRundown: OntimeRundown = [
{
type: SupportedEvent.Event,
id: '1',
custom: {
lighting: { value: 'event 1 lx' },
} as EventCustomFields,
} as OntimeEvent,
{
type: SupportedEvent.Event,
id: '2',
custom: {
lighting: { value: 'event 2 lx' },
sound: { value: 'event 2 sound' },
} as EventCustomFields,
} as OntimeEvent,
];
const initResult = generate(testRundown, customProperties);
expect(initResult.order.length).toBe(2);
expect(initResult.assignedCustomProperties).toMatchObject({
lighting: ['1', '2'],
sound: ['2'],
});
expect((initResult.rundown['1'] as OntimeEvent).custom).toMatchObject({ lighting: { value: 'event 1 lx' } });
expect((initResult.rundown['2'] as OntimeEvent).custom).toMatchObject({
lighting: { value: 'event 2 lx' },
sound: { value: 'event 2 sound' },
});
});
});
});
describe('add() mutation', () => {
test('adds an event to the rundown', () => {
const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent;
const testRundown: OntimeRundown = [];
const { newRundown } = add({ atIndex: 0, event: mockEvent, persistedRundown: testRundown });
expect(newRundown.length).toBe(1);
expect(newRundown[0]).toMatchObject(mockEvent);
});
});
describe('remove() mutation', () => {
test('deletes an event from the rundown', () => {
const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent;
const testRundown: OntimeRundown = [mockEvent];
const { newRundown } = remove({ eventId: mockEvent.id, persistedRundown: testRundown });
expect(newRundown.length).toBe(0);
});
});
describe('edit() mutation', () => {
test('edits an event in the rundown', () => {
const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent;
const mockEventPatch = { cue: 'patched' } as OntimeEvent;
const testRundown: OntimeRundown = [mockEvent];
const { newRundown, newEvent } = edit({
eventId: mockEvent.id,
patch: mockEventPatch,
persistedRundown: testRundown,
});
expect(newRundown.length).toBe(1);
expect(newEvent).toMatchObject({
id: 'mock',
cue: 'patched',
type: SupportedEvent.Event,
});
});
});
describe('batchEdit() mutation', () => {
it('should correctly apply the patch to the events with the given IDs', () => {
const persistedRundown: OntimeRundown = [
{ id: '1', type: SupportedEvent.Event, cue: 'data1' } as OntimeEvent,
{ id: '2', type: SupportedEvent.Event, cue: 'data2' } as OntimeEvent,
{ id: '3', type: SupportedEvent.Event, cue: 'data3' } as OntimeEvent,
];
const eventIds = ['1', '3'];
const patch = { cue: 'newData' };
const { newRundown } = batchEdit({ persistedRundown, eventIds, patch });
expect(newRundown).toMatchObject([
{ id: '1', type: SupportedEvent.Event, cue: 'newData' },
{ id: '2', type: SupportedEvent.Event, cue: 'data2' },
{ id: '3', type: SupportedEvent.Event, cue: 'newData' },
]);
});
});
describe('reorder() mutation', () => {
it('should correctly reorder two events', () => {
const persistedRundown: OntimeRundown = [
{ id: '1', type: SupportedEvent.Event, cue: 'data1', revision: 0 } as OntimeEvent,
{ id: '2', type: SupportedEvent.Event, cue: 'data2', revision: 0 } as OntimeEvent,
{ id: '3', type: SupportedEvent.Event, cue: 'data3', revision: 0 } as OntimeEvent,
];
const { newRundown } = reorder({
persistedRundown,
eventId: persistedRundown[0].id,
from: 0,
to: persistedRundown.length - 1,
});
expect(newRundown).toMatchObject([
{ id: '2', type: SupportedEvent.Event, cue: 'data2', revision: 1 },
{ id: '3', type: SupportedEvent.Event, cue: 'data3', revision: 1 },
{ id: '1', type: SupportedEvent.Event, cue: 'data1', revision: 1 },
]);
});
});
describe('swap() mutation', () => {
it('should correctly swap data between events', () => {
const persistedRundown: OntimeRundown = [
{ id: '1', type: SupportedEvent.Event, cue: 'data1', timeStart: 1, revision: 0 } as OntimeEvent,
{ id: '2', type: SupportedEvent.Event, cue: 'data2', timeStart: 2, revision: 0 } as OntimeEvent,
{ id: '3', type: SupportedEvent.Event, cue: 'data3', timeStart: 3, revision: 0 } as OntimeEvent,
];
const { newRundown } = swap({
persistedRundown,
fromId: persistedRundown[0].id,
toId: persistedRundown[1].id,
});
expect((newRundown[0] as OntimeEvent).id).toBe('1');
expect((newRundown[0] as OntimeEvent).cue).toBe('data2');
expect((newRundown[0] as OntimeEvent).timeStart).toBe(1);
expect((newRundown[0] as OntimeEvent).revision).toBe(1);
expect((newRundown[1] as OntimeEvent).id).toBe('2');
expect((newRundown[1] as OntimeEvent).cue).toBe('data1');
expect((newRundown[1] as OntimeEvent).timeStart).toBe(2);
expect((newRundown[1] as OntimeEvent).revision).toBe(1);
expect((newRundown[2] as OntimeEvent).id).toBe('3');
expect((newRundown[2] as OntimeEvent).cue).toBe('data3');
expect((newRundown[2] as OntimeEvent).timeStart).toBe(3);
expect((newRundown[2] as OntimeEvent).revision).toBe(0);
});
});
/**
*
*
*
*
*
*
*
*
*
*
*
*/
describe('calculateRuntimeDelays', () => {
it('calculates all delays in a given rundown', () => {
const rundown: OntimeRundown = [
{
title: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
type: SupportedEvent.Event,
revision: 0,
timeWarning: 120000,
timeDanger: 60000,
id: '659e1',
cue: '1',
custom: {},
},
{
duration: 600000,
type: SupportedEvent.Delay,
id: '07986',
},
{
title: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
timeStart: 1200000,
timeEnd: 1200000,
duration: 0,
isPublic: true,
skip: false,
colour: '',
type: SupportedEvent.Event,
revision: 0,
timeWarning: 120000,
timeDanger: 60000,
id: '1c48f',
cue: '2',
custom: {},
},
{
duration: 1200000,
type: SupportedEvent.Delay,
id: '7db42',
},
{
title: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
type: SupportedEvent.Event,
revision: 0,
timeWarning: 120000,
timeDanger: 60000,
id: 'd48c2',
cue: '3',
custom: {},
},
{
title: '',
type: SupportedEvent.Block,
id: '9870d',
},
{
title: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
timeStart: 1200000,
timeEnd: 1800000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
type: SupportedEvent.Event,
revision: 0,
timeWarning: 120000,
timeDanger: 60000,
id: '2f185',
cue: '4',
custom: {},
},
];
const updatedRundown = calculateRuntimeDelays(rundown);
expect(rundown.length).toBe(updatedRundown.length);
expect((updatedRundown[0] as OntimeEvent).delay).toBe(0);
expect((updatedRundown[2] as OntimeEvent).delay).toBe(600000);
expect((updatedRundown[4] as OntimeEvent).delay).toBe(600000 + 1200000);
expect((updatedRundown[6] as OntimeEvent).delay).toBe(0);
});
});
describe('getDelayAt()', () => {
const delayedRundown: OntimeRundown = [
{
title: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
type: SupportedEvent.Event,
revision: 0,
timeWarning: 120000,
timeDanger: 60000,
id: '659e1',
delay: 0,
cue: '1',
custom: {},
},
{
duration: 600000,
type: SupportedEvent.Delay,
id: '07986',
},
{
title: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
timeStart: 1200000,
timeEnd: 1200000,
duration: 0,
isPublic: true,
skip: false,
colour: '',
type: SupportedEvent.Event,
revision: 0,
timeWarning: 120000,
timeDanger: 60000,
id: '1c48f',
delay: 600000,
cue: '2',
custom: {},
},
{
duration: 1200000,
type: SupportedEvent.Delay,
id: '7db42',
},
{
title: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
type: SupportedEvent.Event,
revision: 0,
timeWarning: 120000,
timeDanger: 60000,
id: 'd48c2',
delay: 1800000,
cue: '3',
custom: {},
},
{
title: '',
type: SupportedEvent.Block,
id: '9870d',
},
{
title: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
timeStart: 1200000,
timeEnd: 1800000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
type: SupportedEvent.Event,
revision: 0,
timeWarning: 120000,
timeDanger: 60000,
id: '2f185',
delay: 0,
cue: '4',
custom: {},
},
];
it('calculates delay in a rundown', () => {
const delayAtStart = getDelayAt(0, delayedRundown);
const delayOnFirstEvent = getDelayAt(2, delayedRundown);
const delayOnSecondEvent = getDelayAt(4, delayedRundown);
const delayOnBlockedEvent = getDelayAt(0, delayedRundown);
expect(delayAtStart).toBe(0);
expect(delayOnFirstEvent).toBe(600000);
expect(delayOnSecondEvent).toBe(600000 + 1200000);
expect(delayOnBlockedEvent).toBe(0);
});
it('finds delay before a delay block', () => {
const valueOnFirstDelayBlock = getDelayAt(1, delayedRundown);
const valueOnSecondDelayBlock = getDelayAt(3, delayedRundown);
const valueAfterSecondDelayBlock = getDelayAt(4, delayedRundown);
expect(valueOnFirstDelayBlock).toBe(0);
expect(valueOnSecondDelayBlock).toBe(600000);
expect(valueAfterSecondDelayBlock).toBe(600000 + 1200000);
});
it('returns 0 after blocks', () => {
const valueOnBlock = getDelayAt(6, delayedRundown);
expect(valueOnBlock).toBe(0);
});
});
describe('calculateRuntimeDelaysFrom()', () => {
it('updates delays from given id', () => {
const delayedRundown: OntimeRundown = [
{
title: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
type: SupportedEvent.Event,
revision: 0,
timeWarning: 120000,
timeDanger: 60000,
id: '659e1',
delay: 0,
cue: '1',
custom: {},
},
{
duration: 600000,
type: SupportedEvent.Delay,
id: '07986',
},
{
title: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
timeStart: 1200000,
timeEnd: 1200000,
duration: 0,
isPublic: true,
skip: false,
colour: '',
type: SupportedEvent.Event,
revision: 0,
timeWarning: 120000,
timeDanger: 60000,
id: '1c48f',
delay: 0,
cue: '2',
custom: {},
},
{
duration: 1200000,
type: SupportedEvent.Delay,
id: '7db42',
},
{
title: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
type: SupportedEvent.Event,
revision: 0,
timeWarning: 120000,
timeDanger: 60000,
id: 'd48c2',
delay: 1800000,
cue: '3',
custom: {},
},
{
title: '',
type: SupportedEvent.Block,
id: '9870d',
},
{
title: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
timeStart: 1200000,
timeEnd: 1800000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
type: SupportedEvent.Event,
revision: 0,
timeWarning: 120000,
timeDanger: 60000,
id: '2f185',
delay: 0,
cue: '4',
custom: {},
},
];
const updatedRundown = calculateRuntimeDelaysFrom('07986', delayedRundown);
// we only update from the 4th on
expect((updatedRundown[0] as OntimeEvent).delay).toBe(0);
// 1 + 3
expect((updatedRundown[4] as OntimeEvent).delay).toBe(600000 + 1200000);
});
});
describe('custom fields', () => {
describe('createCustomField()', () => {
beforeEach(() => {
vi.mock('../../classes/data-provider/DataProvider.js', () => {
return {
DataProvider: {
...vi.fn().mockImplementation(() => {
return {};
}),
getCustomFields: vi.fn().mockReturnValue({}),
setCustomFields: vi.fn().mockImplementation((newData) => {
return newData;
}),
persist: vi.fn().mockReturnValue({}),
},
};
});
});
it('creates a field from given parameters', async () => {
const expected = {
lighting: {
label: 'Lighting',
type: 'string',
colour: 'blue',
},
};
const customField = await createCustomField({ label: 'Lighting', type: 'string', colour: 'blue' });
expect(customField).toStrictEqual(expected);
});
});
describe('editCustomField()', () => {
it('edits a field with a given label', async () => {
await createCustomField({ label: 'Sound', type: 'string', colour: 'blue' });
const expected = {
lighting: {
label: 'Lighting',
type: 'string',
colour: 'blue',
},
sound: {
label: 'Sound',
type: 'string',
colour: 'green',
},
};
const customField = await editCustomField('sound', { label: 'Sound', type: 'string', colour: 'green' });
expect(customField).toStrictEqual(expected);
});
});
describe('removeCustomField()', () => {
it('deletes a field with a given label', async () => {
const expected = {
lighting: {
label: 'Lighting',
type: 'string',
colour: 'blue',
},
};
const customField = await removeCustomField('sound');
expect(customField).toStrictEqual(expected);
});
});
});
@@ -0,0 +1,253 @@
import {
CustomFields,
EndAction,
OntimeEvent,
OntimeRundown,
SupportedEvent,
TimeStrategy,
TimerType,
} from 'ontime-types';
import {
addToCustomAssignment,
getLink,
handleCustomField,
handleLink,
hasChanges,
isDataStale,
} from '../rundownCacheUtils.js';
describe('getLink()', () => {
it('should return null if there is no link', () => {
const rundown = [
{ type: SupportedEvent.Block, id: 'block' },
{ type: SupportedEvent.Event, id: '1' },
] as OntimeRundown;
const result = getLink(1, rundown);
expect(result).toBeNull();
});
it('returns previous event', () => {
const rundown = [
{ type: SupportedEvent.Event, id: '1', timeEnd: 100 },
{ type: SupportedEvent.Event, id: '2', timeStart: 0, linkStart: '1' },
] as OntimeRundown;
const result = getLink(1, rundown);
expect(result.id).toBe('1');
});
});
describe('handleLink()', () => {
it('populates data in object and updates link map', () => {
const rundown = [
{ type: SupportedEvent.Event, id: '1', timeEnd: 100 },
{ type: SupportedEvent.Event, id: '2', timeStart: 0, linkStart: '1' },
] as OntimeRundown;
const mutableEvent = { ...rundown[1] } as OntimeEvent;
const links = {};
const result = handleLink(1, rundown, mutableEvent, links);
expect(result).toBeUndefined();
expect(mutableEvent.timeStart).toBe(100);
expect(mutableEvent.linkStart).toBe('1');
expect(links).toStrictEqual({ '1': '2' });
});
it('removes link if linked event is not found', () => {
const rundown = [
{ type: SupportedEvent.Block, id: '1' },
{ type: SupportedEvent.Event, id: '2', timeStart: 0, linkStart: '1' },
] as OntimeRundown;
const mutableEvent = { ...rundown[1] } as OntimeEvent;
const links = {};
const result = handleLink(1, rundown, mutableEvent, links);
expect(result).toBeUndefined();
expect(mutableEvent.timeStart).toBe(0);
expect(mutableEvent.linkStart).toBe(null);
expect(links).toStrictEqual({});
});
});
describe('addToCustomAssignment()', () => {
it('adds given entry to assignedCustomFields', () => {
const assignedCustomFields = {};
addToCustomAssignment('label1', 'eventId 1', assignedCustomFields);
expect(assignedCustomFields).toStrictEqual({ label1: ['eventId 1'] });
addToCustomAssignment('label1', 'eventId 2', assignedCustomFields);
expect(assignedCustomFields).toStrictEqual({ label1: ['eventId 1', 'eventId 2'] });
});
});
describe('handleCustomField()', () => {
it('creates a map of where custom fields are used', () => {
const customFields = {
lighting: {
type: 'string',
colour: 'red',
label: 'lighting',
},
sound: {
type: 'string',
colour: 'red',
label: 'sound',
},
} as CustomFields;
const customFieldChangelog = {};
// @ts-expect-error -- partial event for testing
const event: OntimeEvent = {
type: SupportedEvent.Event,
id: '2',
timeStart: 0,
linkStart: '1',
custom: {
lighting: { value: 'on' },
},
};
const assignedCustomFields = {};
const result = handleCustomField(customFields, customFieldChangelog, event, assignedCustomFields);
expect(result).toBeUndefined();
expect(assignedCustomFields).toStrictEqual({ lighting: ['2'] });
expect(event.custom).toStrictEqual({
lighting: { value: 'on' },
});
});
it('renames a field if in changelog', () => {
const customFields = {
lighting: {
type: 'string',
colour: 'red',
label: 'lighting',
},
video: {
type: 'string',
colour: 'red',
label: 'video',
},
} as CustomFields;
const customFieldChangelog = {
sound: 'video',
};
// @ts-expect-error -- partial event for testing
const event: OntimeEvent = {
type: SupportedEvent.Event,
id: '2',
timeStart: 0,
linkStart: '1',
custom: {
sound: { value: 'on' },
},
};
const assignedCustomFields = {};
const result = handleCustomField(customFields, customFieldChangelog, event, assignedCustomFields);
expect(result).toBeUndefined();
expect(assignedCustomFields).toStrictEqual({ video: ['2'] });
expect(event.custom).toStrictEqual({
video: { value: 'on' },
});
});
it('processes all fields', () => {
const customFields = {
field1: {
type: 'string',
colour: 'red',
label: 'field1',
},
field2: {
type: 'string',
colour: 'red',
label: 'field2',
},
} as CustomFields;
const customFieldChangelog = {
field1: 'newField1',
};
// @ts-expect-error -- partial event for testing
const mutableEvent: OntimeEvent = {
type: SupportedEvent.Event,
id: 'event1',
custom: {
field1: { value: 'value1' },
field2: { value: 'value2' },
},
};
const assignedCustomFields = {};
handleCustomField(customFields, customFieldChangelog, mutableEvent, assignedCustomFields);
// Check that field1 has been renamed to newField1 and the value reassigned
expect(mutableEvent.custom['newField1']).toStrictEqual({ value: 'value1' });
expect(mutableEvent.custom['field1']).toBeUndefined();
// Check that field2 has been processed
expect(mutableEvent.custom['field2']).toStrictEqual({ value: 'value2' });
// Check that assignedCustomFields has been updated correctly
expect(assignedCustomFields).toStrictEqual({
newField1: ['event1'],
field2: ['event1'],
});
});
});
describe('isDataStale()', () => {
it('is stale if data contains timers', () => {
const needsRecompute = [
{ timeStart: 10 },
{ timeEnd: 10 },
{ duration: 10 },
{ linkStart: '1' },
{ timerStrategy: TimeStrategy.LockDuration },
];
for (const testCase of needsRecompute) {
expect(isDataStale(testCase)).toBe(true);
}
expect.assertions(needsRecompute.length);
});
it('is not stale if data contains auxiliary dataset', () => {
expect(
isDataStale({
cue: 'cue',
title: 'title',
note: 'note',
endAction: EndAction.LoadNext,
timerType: TimerType.Clock,
isPublic: false,
colour: 'colour',
timeWarning: 1,
timeDanger: 2,
custom: {
lighting: { value: '3' },
},
}),
).toBe(false);
});
});
describe('hasChanges()', () => {
it('identifies objects with new values', () => {
const newEvent = { id: '1', title: 'new-title' } as OntimeEvent;
const existing = { id: '1', cue: 'cue', title: 'title' } as OntimeEvent;
expect(hasChanges(existing, newEvent)).toBe(true);
});
it('identifies objects with all same values', () => {
const newEvent = { id: '1', title: 'title' } as OntimeEvent;
const existing = { id: '1', cue: 'cue', title: 'title' } as OntimeEvent;
expect(hasChanges(existing, newEvent)).toBe(false);
});
});
@@ -0,0 +1,136 @@
import { OntimeRundown, isOntimeDelay, isOntimeBlock, isOntimeEvent } from 'ontime-types';
import { deleteAtIndex } from '../../../../../packages/utils/src/array-utils/arrayUtils.js';
/**
* Calculates all delays in a given rundown
* @param rundown
*/
export function calculateRuntimeDelays(rundown: OntimeRundown) {
let accumulatedDelay = 0;
const updatedRundown = [...rundown];
for (const [index, event] of updatedRundown.entries()) {
if (isOntimeDelay(event)) {
accumulatedDelay += event.duration;
} else if (isOntimeBlock(event)) {
accumulatedDelay = 0;
} else if (isOntimeEvent(event)) {
updatedRundown[index] = {
...event,
delay: accumulatedDelay,
};
}
}
return updatedRundown;
}
/**
* Calculate delays in rundown from a given index
* @param eventIndex
* @param rundown
*/
export function calculateRuntimeDelaysFromIndex(eventIndex: number, rundown: OntimeRundown) {
if (eventIndex === -1) {
throw new Error('ID not found at index');
}
let accumulatedDelay = getDelayAt(eventIndex, rundown);
const updatedRundown = [...rundown];
for (let i = eventIndex; i < rundown.length; i++) {
const event = rundown[i];
if (isOntimeDelay(event)) {
accumulatedDelay += event.duration;
} else if (isOntimeBlock(event)) {
if (i === eventIndex) {
accumulatedDelay = 0;
} else {
break;
}
} else if (isOntimeEvent(event)) {
updatedRundown[i] = {
...event,
delay: accumulatedDelay,
};
}
}
return updatedRundown;
}
/**
* Calculate delays in rundown from an event with given id
* @param eventId
* @param rundown
*/
export function calculateRuntimeDelaysFrom(eventId: string, rundown: OntimeRundown) {
const index = rundown.findIndex((event) => event.id === eventId);
return calculateRuntimeDelaysFromIndex(index, rundown);
}
/**
* Calculates delay to an event at a given index
* @param eventIndex
* @param rundown
*/
export function getDelayAt(eventIndex: number, rundown: OntimeRundown): number {
if (eventIndex < 1) {
return 0;
}
// we need to check the event before
const event = rundown[eventIndex - 1];
if (isOntimeDelay(event)) {
return event.duration + getDelayAt(eventIndex - 1, rundown);
} else if (isOntimeBlock(event)) {
return 0;
} else if (isOntimeEvent(event)) {
return event.delay ?? 0;
}
return 0;
}
/**
* Applies delay from given event ID, deletes the delay event after
* @param eventId
* @param rundown
* @throws {Error} if event ID not found or is not a delay
* @returns
*/
export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown {
const delayIndex = rundown.findIndex((event) => event.id === eventId);
const delayEvent = rundown.at(delayIndex);
if (!delayEvent) {
throw new Error('Given event ID not found');
}
if (!isOntimeDelay(delayEvent)) {
throw new Error('Given event ID is not a delay');
}
const updatedRundown = [...rundown];
const delayValue = delayEvent.duration;
if (delayValue === 0 || delayIndex === rundown.length - 1) {
// nothing to apply
return updatedRundown;
}
for (let i = delayIndex + 1; i < rundown.length; i++) {
const currentEvent = updatedRundown[i];
if (isOntimeBlock(currentEvent)) {
break;
} else if (isOntimeEvent(currentEvent)) {
currentEvent.timeStart = Math.max(0, currentEvent.timeStart + delayValue);
currentEvent.timeEnd = Math.max(currentEvent.duration, currentEvent.timeEnd + delayValue);
if (currentEvent.delay) {
currentEvent.delay = currentEvent.delay - delayValue;
}
currentEvent.revision += 1;
}
}
return deleteAtIndex(delayIndex, updatedRundown);
}
@@ -1,353 +0,0 @@
import {
GetRundownCached,
isOntimeBlock,
isOntimeDelay,
isOntimeEvent,
OntimeBlock,
OntimeDelay,
OntimeEvent,
OntimeRundown,
OntimeRundownEntry,
} from 'ontime-types';
import { swapOntimeEvents } from 'ontime-utils';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { getCached, runtimeCacheStore } from '../../stores/cachingStore.js';
import { isProduction } from '../../setup.js';
import { deleteAtIndex, insertAtIndex, reorderArray } from '../../utils/arrayUtils.js';
import { _applyDelay } from '../delayUtils.js';
/**
* Keep incremental revision number of rundown for runtime
*/
let rundownRevision = 0;
/**
* Key of rundown in cache
*/
export const delayedRundownCacheKey = 'delayed-rundown';
/**
* Invalidates the cached rundown when an inconsistency is found
* will throw when not in production
* @param errorMessage
*/
export function invalidateFromError(errorMessage = 'Found mismatch between store and cache') {
if (isProduction) {
runtimeCacheStore.invalidate(delayedRundownCacheKey);
} else {
throw new Error(errorMessage);
}
}
/**
* Returns rundown with calculated delays
* Ensures request goes through the caching layer
*/
export function getRundownCache(): GetRundownCached {
function calculateRundown() {
const rundown = DataProvider.getRundown();
return calculateRuntimeDelays(rundown);
}
const cached = getCached(delayedRundownCacheKey, calculateRundown);
return {
rundown: cached,
revision: rundownRevision,
};
}
/**
* Returns rundown with calculated delays
* Ensures request goes through the caching layer
*/
export function getDelayedRundown() {
function calculateRundown() {
const rundown = DataProvider.getRundown();
return calculateRuntimeDelays(rundown);
}
return getCached(delayedRundownCacheKey, calculateRundown);
}
/**
* Adds an event in the rundown at given index, ensuring replication to delayed rundown cache
* @param eventIndex
* @param event
*/
export async function cachedAdd(eventIndex: number, event: OntimeEvent | OntimeDelay | OntimeBlock) {
// TODO: create wrapper function
const rundown = DataProvider.getRundown();
const newRundown = insertAtIndex(eventIndex, event, rundown);
const delayedRundown = getDelayedRundown();
let newDelayedRundown = insertAtIndex(eventIndex, event, delayedRundown);
// update delay cache
if (isOntimeEvent(event)) {
// if it is an event, we need its delay
(newDelayedRundown[eventIndex] as OntimeEvent).delay = getDelayAt(eventIndex, newDelayedRundown);
} else {
// if it is a block or delay, we invalidate from here
newDelayedRundown = calculateRuntimeDelaysFromIndex(eventIndex, newDelayedRundown);
}
runtimeCacheStore.setCached(delayedRundownCacheKey, newDelayedRundown);
// we need to delay updating this to ensure add operation happens on same dataset
await DataProvider.setRundown(newRundown);
rundownRevision++;
}
/**
* Edits an event in rundown, ensuring replication to delayed rundown cache
* @param eventId
* @param patchObject
*/
export async function cachedEdit(
eventId: string,
patchObject: Partial<OntimeEvent> | Partial<OntimeBlock> | Partial<OntimeDelay>,
) {
const indexInMemory = DataProvider.getIndexOf(eventId);
if (indexInMemory < 0) {
throw new Error('No event with ID found');
}
const updatedRundown = DataProvider.getRundown();
const newEvent = { ...updatedRundown[indexInMemory], ...patchObject } as OntimeRundownEntry;
if (isOntimeEvent(newEvent)) {
newEvent.revision++;
}
updatedRundown[indexInMemory] = newEvent;
let newDelayedRundown = getDelayedRundown();
if (newDelayedRundown?.[indexInMemory].id !== newEvent.id) {
invalidateFromError();
} else {
newDelayedRundown[indexInMemory] = newEvent;
if (isOntimeEvent(newEvent)) {
(newDelayedRundown[indexInMemory] as OntimeEvent).delay = getDelayAt(indexInMemory, newDelayedRundown);
} else if (isOntimeDelay(newEvent)) {
// blocks have no reason to change the rundown, from delays we need to recalculate
newDelayedRundown = calculateRuntimeDelaysFromIndex(indexInMemory, newDelayedRundown);
}
runtimeCacheStore.setCached(delayedRundownCacheKey, newDelayedRundown);
}
// we need to delay updating this to ensure edit operation happens on same dataset
await DataProvider.setRundown(updatedRundown);
rundownRevision++;
return newEvent;
}
/**
* Deletes an event with given id from rundown, ensuring replication to delayed rundown cache
* @param eventId
*/
export async function cachedDelete(eventId: string) {
const eventIndex = DataProvider.getIndexOf(eventId);
let delayedRundown = getDelayedRundown();
if (eventIndex < 0) {
if (delayedRundown.findIndex((event) => event.id === eventId) >= 0) {
invalidateFromError();
}
throw new Error(`Event with id ${eventId} not found`);
}
let updatedRundown = DataProvider.getRundown();
const eventBack = { ...updatedRundown[eventIndex] };
updatedRundown = deleteAtIndex(eventIndex, updatedRundown);
if (eventId !== delayedRundown[eventIndex].id) {
invalidateFromError();
} else {
delayedRundown = deleteAtIndex(eventIndex, delayedRundown);
if (isOntimeDelay(eventBack) || isOntimeBlock(eventBack)) {
// for events, we do not have to worry
// the following event, would have taken the place of the deleted event by now
delayedRundown = calculateRuntimeDelaysFromIndex(eventIndex, delayedRundown);
}
runtimeCacheStore.setCached(delayedRundownCacheKey, delayedRundown);
}
// we need to delay updating this to ensure edit operation happens on same dataset
await DataProvider.setRundown(updatedRundown);
rundownRevision++;
}
/**
* Reorders an event in the rundown, ensuring replication to delayed rundown cache
* @param eventId
* @param from
* @param to
*/
export async function cachedReorder(eventId: string, from: number, to: number) {
const indexCheck = DataProvider.getIndexOf(eventId);
if (indexCheck !== from) {
invalidateFromError();
throw new Error('ID not found at index');
}
let updatedRundown = DataProvider.getRundown();
const reorderedEvent = updatedRundown[from];
updatedRundown = reorderArray(updatedRundown, from, to);
const delayedRundown = getDelayedRundown();
if (eventId !== delayedRundown[from].id) {
invalidateFromError();
} else {
// TODO: could we be more granular about updates
// I fear we need to update both from and to, which could signify more iterations
runtimeCacheStore.invalidate(delayedRundownCacheKey);
}
// we need to delay updating this to ensure edit operation happens on same dataset
await DataProvider.setRundown(updatedRundown);
rundownRevision++;
return reorderedEvent;
}
export async function cachedClear() {
await DataProvider.clearRundown();
runtimeCacheStore.setCached(delayedRundownCacheKey, []);
rundownRevision++;
}
/**
* Swaps two events
* @param {string} fromEventId
* @param {string} toEventId
*/
export async function cachedSwap(fromEventId: string, toEventId: string) {
const fromEventIndex = DataProvider.getIndexOf(fromEventId);
const toEventIndex = DataProvider.getIndexOf(toEventId);
const rundown = DataProvider.getRundown();
const rundownToUpdate = swapOntimeEvents(rundown, fromEventIndex, toEventIndex);
const delayedRundown = getDelayedRundown();
const fromCachedEvent = delayedRundown.at(fromEventIndex);
const toCachedEvent = delayedRundown.at(toEventIndex);
if (fromCachedEvent.id !== fromEventId || toCachedEvent.id !== toEventId) {
// something went wrong, we invalidate the cache
runtimeCacheStore.invalidate(delayedRundownCacheKey);
} else {
const delayedRundownToUpdate = swapOntimeEvents(delayedRundown, fromEventIndex, toEventIndex);
runtimeCacheStore.setCached(delayedRundownCacheKey, delayedRundownToUpdate);
}
await DataProvider.setRundown(rundownToUpdate);
rundownRevision++;
}
export async function cachedApplyDelay(eventId: string) {
// update persisted rundown
const rundown: OntimeRundown = DataProvider.getRundown();
const persistedRundown = _applyDelay(eventId, rundown);
const delayedRundown = getDelayedRundown();
const cachedRundown = _applyDelay(eventId, delayedRundown);
// update
runtimeCacheStore.setCached(delayedRundownCacheKey, cachedRundown);
await DataProvider.setRundown(persistedRundown);
rundownRevision++;
}
/**
* Calculates all delays in a given rundown
* @param rundown
*/
export function calculateRuntimeDelays(rundown: OntimeRundown) {
let accumulatedDelay = 0;
const updatedRundown = [...rundown];
for (const [index, event] of updatedRundown.entries()) {
if (isOntimeDelay(event)) {
accumulatedDelay += event.duration;
} else if (isOntimeBlock(event)) {
accumulatedDelay = 0;
} else if (isOntimeEvent(event)) {
updatedRundown[index] = {
...event,
delay: accumulatedDelay,
};
}
}
return updatedRundown;
}
/**
* Calculate delays in rundown from a given index
* @param eventIndex
* @param rundown
*/
export function calculateRuntimeDelaysFromIndex(eventIndex: number, rundown: OntimeRundown) {
if (eventIndex === -1) {
throw new Error('ID not found at index');
}
let accumulatedDelay = getDelayAt(eventIndex, rundown);
const updatedRundown = [...rundown];
for (let i = eventIndex; i < rundown.length; i++) {
const event = rundown[i];
if (isOntimeDelay(event)) {
accumulatedDelay += event.duration;
} else if (isOntimeBlock(event)) {
if (i === eventIndex) {
accumulatedDelay = 0;
} else {
break;
}
} else if (isOntimeEvent(event)) {
updatedRundown[i] = {
...event,
delay: accumulatedDelay,
};
}
}
return updatedRundown;
}
/**
* Calculate delays in rundown from an event with given id
* @param eventId
* @param rundown
*/
export function calculateRuntimeDelaysFrom(eventId: string, rundown: OntimeRundown) {
const index = rundown.findIndex((event) => event.id === eventId);
return calculateRuntimeDelaysFromIndex(index, rundown);
}
/**
* Calculates delay to an event at a given index
* @param eventIndex
* @param rundown
*/
export function getDelayAt(eventIndex: number, rundown: OntimeRundown): number {
if (eventIndex < 1) {
return 0;
}
// we need to check the event before
const event = rundown[eventIndex - 1];
if (isOntimeDelay(event)) {
return event.duration + getDelayAt(eventIndex - 1, rundown);
} else if (isOntimeBlock(event)) {
return 0;
} else if (isOntimeEvent(event)) {
return event.delay ?? 0;
}
return 0;
}
@@ -0,0 +1,495 @@
import {
CustomField,
CustomFieldLabel,
CustomFields,
isOntimeDelay,
isOntimeEvent,
MaybeNumber,
OntimeEvent,
OntimeRundown,
OntimeRundownEntry,
} from 'ontime-types';
import { generateId, deleteAtIndex, insertAtIndex, reorderArray, swapEventData } from 'ontime-utils';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { createPatch } from '../../utils/parser.js';
import { getTotalDuration } from '../timerUtils.js';
import { apply } from './delayUtils.js';
import { handleCustomField, handleLink, hasChanges, isDataStale } from './rundownCacheUtils.js';
type EventID = string;
type NormalisedRundown = Record<EventID, OntimeRundownEntry>;
let persistedRundown: OntimeRundown = [];
let persistedCustomFields: CustomFields = {};
/** Utility function gets to expose data */
export const getPersistedRundown = (): OntimeRundown => persistedRundown;
export const getCustomFields = (): CustomFields => persistedCustomFields;
let rundown: NormalisedRundown = {};
let order: EventID[] = [];
let revision = 0;
let isStale = true;
let totalDelay = 0;
let totalDuration = 0;
let firstStart: MaybeNumber = null;
let lastEnd: MaybeNumber = null;
let links: Record<EventID, EventID> = {};
/**
* Object that contains renamings to custom fields
* Used to rename the custom fields in the events
* @example
* {
* oldLabel: newLabel
* lighting: lx
* }
*/
const customFieldChangelog = {};
/**
* Keep track of which custom fields are used.
* This will be handy for when we delete custom fields
*/
let assignedCustomFields: Record<CustomFieldLabel, EventID[]> = {};
export async function init(initialRundown: Readonly<OntimeRundown>, customFields: Readonly<CustomFields>) {
persistedRundown = structuredClone(initialRundown) as OntimeRundown;
persistedCustomFields = structuredClone(customFields);
generate();
await DataProvider.setRundown(persistedRundown);
await DataProvider.setCustomFields(customFields);
}
/**
* Utility initialises cache
* @param rundown
*/
export function generate(
initialRundown: OntimeRundown = persistedRundown,
customFields: CustomFields = persistedCustomFields,
) {
// we decided to re-write this dataset for every change
// instead of maintaining logic to update it
assignedCustomFields = {};
rundown = {};
order = [];
links = {};
firstStart = null;
lastEnd = null;
let accumulatedDelay = 0;
let daySpan = 0;
let previousEnd: MaybeNumber = null;
for (let i = 0; i < initialRundown.length; i++) {
const currentEvent = initialRundown[i];
const updatedEvent = { ...currentEvent };
if (isOntimeEvent(updatedEvent)) {
// 1. handle links
handleLink(i, initialRundown, updatedEvent, links);
// 2. handle custom fields
handleCustomField(customFields, customFieldChangelog, updatedEvent, assignedCustomFields);
// update the persisted event
initialRundown[i] = updatedEvent;
// update rundown duration
if (firstStart === null) {
firstStart = updatedEvent.timeStart;
}
lastEnd = updatedEvent.timeEnd;
// check if we go over midnight, account for eventual gaps
const gapOverMidnight = previousEnd !== null && previousEnd > updatedEvent.timeStart;
const durationOverMidnight = updatedEvent.timeStart > updatedEvent.timeEnd;
if (gapOverMidnight || durationOverMidnight) {
daySpan++;
}
}
// calculate delays
// !!! this must happen after handling the links
if (isOntimeDelay(updatedEvent)) {
accumulatedDelay += updatedEvent.duration;
} else if (isOntimeEvent(updatedEvent)) {
const eventStart = updatedEvent.timeStart;
// we only affect positive delays (time forwards)
if (accumulatedDelay > 0 && previousEnd) {
const gap = Math.max(eventStart - previousEnd, 0);
accumulatedDelay = Math.max(accumulatedDelay - gap, 0);
}
updatedEvent.delay = accumulatedDelay;
previousEnd = updatedEvent.timeEnd;
}
order.push(updatedEvent.id);
rundown[updatedEvent.id] = { ...updatedEvent };
}
isStale = false;
totalDelay = accumulatedDelay;
if (lastEnd !== null && firstStart !== null) {
totalDuration = getTotalDuration(firstStart, lastEnd, daySpan);
}
return { rundown, order, links, totalDelay, totalDuration, assignedCustomProperties: assignedCustomFields };
}
/** Returns an ID guaranteed to be unique */
export function getUniqueId(): string {
if (isStale) {
generate();
}
let id = '';
do {
id = generateId();
} while (Object.hasOwn(rundown, id));
return id;
}
/** Returns index of an event with a given id */
export function getIndexOf(eventId: string) {
if (isStale) {
generate();
}
return order.indexOf(eventId);
}
type RundownCache = {
rundown: NormalisedRundown;
order: string[];
revision: number;
totalDelay: number;
totalDuration: number;
};
/**
* Returns cached data
* @returns {RundownCache}
*/
export function get(): Readonly<RundownCache> {
if (isStale) {
console.time('rundownCache__init');
generate();
console.timeEnd('rundownCache__init');
}
return {
rundown,
order,
revision,
totalDelay,
totalDuration,
};
}
/**
* Returns calculated metadata from rundown
*/
export function getMetadata() {
if (isStale) {
console.time('rundownCache__init');
generate();
console.timeEnd('rundownCache__init');
}
return {
firstStart,
lastEnd,
totalDelay,
totalDuration,
};
}
type CommonParams = { persistedRundown: OntimeRundown };
type MutationParams<T> = T & CommonParams;
type MutatingReturn = {
newRundown: OntimeRundown;
newEvent?: OntimeRundownEntry;
didMutate: boolean;
};
type MutatingFn<T extends object> = (params: MutationParams<T>) => MutatingReturn;
/**
* Decorators injects data into mutation
* @param mutation
* @returns
*/
export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
async function scopedMutation(params: T) {
/**
* Marking the data set as stale
* doing it before calling the mutation, gives the function a chance
* to prevent recalculation by setting stale = false
*/
isStale = true;
const { newEvent, newRundown, didMutate } = mutation({ ...params, persistedRundown });
revision = revision + 1;
persistedRundown = newRundown;
// schedule a non priority cache update
setImmediate(() => {
console.time('rundownCache__init');
get();
console.timeEnd('rundownCache__init');
});
// defer writing to the database
setImmediate(() => {
DataProvider.setRundown(persistedRundown);
});
return { newEvent, newRundown, didMutate };
}
return scopedMutation;
}
type AddArgs = MutationParams<{ atIndex: number; event: OntimeRundownEntry }>;
export function add({ persistedRundown, atIndex, event }: AddArgs): Required<MutatingReturn> {
const newEvent: OntimeRundownEntry = { ...event };
const newRundown = insertAtIndex(atIndex, newEvent, persistedRundown);
return { newRundown, newEvent, didMutate: true };
}
type RemoveArgs = MutationParams<{ eventId: string }>;
export function remove({ persistedRundown, eventId }: RemoveArgs): MutatingReturn {
const atIndex = persistedRundown.findIndex((event) => event.id === eventId);
const newRundown = deleteAtIndex(atIndex, persistedRundown);
return { newRundown, didMutate: atIndex !== -1 };
}
export function removeAll(): MutatingReturn {
return { newRundown: [], didMutate: true };
}
/**
* Utility function for patching events
* @param eventFromRundown
* @param patch
* @returns
*/
function makeEvent(eventFromRundown: OntimeRundownEntry, patch: Partial<OntimeRundownEntry>): OntimeRundownEntry {
if (isOntimeEvent(eventFromRundown)) {
const newEvent = createPatch(eventFromRundown, patch as OntimeEvent);
newEvent.revision++;
return newEvent;
}
// TODO: exhaustive check
return { ...eventFromRundown, ...patch } as OntimeRundownEntry;
}
type EditArgs = MutationParams<{ eventId: string; patch: Partial<OntimeRundownEntry> }>;
export function edit({ persistedRundown, eventId, patch }: EditArgs): Required<MutatingReturn> {
const indexAt = persistedRundown.findIndex((event) => event.id === eventId);
if (indexAt < 0) {
throw new Error('Event not found');
}
if (patch?.type && persistedRundown[indexAt].type !== patch.type) {
throw new Error('Invalid event type');
}
const eventInMemory = persistedRundown[indexAt];
if (!hasChanges(eventInMemory, patch)) {
isStale = false;
return { newRundown: persistedRundown, newEvent: eventInMemory, didMutate: false };
}
const newEvent = makeEvent(eventInMemory, patch);
const newRundown = [...persistedRundown];
newRundown[indexAt] = newEvent;
// check whether the data warrants recalculation of cache
const makeStale = isDataStale(patch);
if (!makeStale) {
rundown[newEvent.id] = newEvent;
}
isStale = makeStale;
return { newRundown, newEvent, didMutate: true };
}
type BatchEditArgs = MutationParams<{ eventIds: string[]; patch: Partial<OntimeRundownEntry> }>;
export function batchEdit({ persistedRundown, eventIds, patch }: BatchEditArgs): MutatingReturn {
const ids = new Set(eventIds);
const newRundown = [];
for (let i = 0; i < persistedRundown.length; i++) {
if (ids.has(persistedRundown[i].id)) {
if (patch?.type && persistedRundown[i].type !== patch.type) {
continue;
}
const newEvent = makeEvent(persistedRundown[i], patch);
newRundown.push(newEvent);
} else {
newRundown.push(persistedRundown[i]);
}
}
return { newRundown, didMutate: true };
}
type ReorderArgs = MutationParams<{ eventId: string; from: number; to: number }>;
export function reorder({ persistedRundown, eventId, from, to }: ReorderArgs): Required<MutatingReturn> {
const event = persistedRundown[from];
if (!event || eventId !== event.id) {
throw new Error('Event not found');
}
const newRundown = reorderArray(persistedRundown, from, to);
for (let i = from; i <= to; i++) {
const event = newRundown.at(i);
if (isOntimeEvent(event)) {
event.revision += 1;
}
}
return { newRundown, newEvent: newRundown.at(from) as OntimeRundownEntry, didMutate: true };
}
type ApplyDelayArgs = MutationParams<{ eventId: string }>;
export function applyDelay({ persistedRundown, eventId }: ApplyDelayArgs): MutatingReturn {
const newRundown = apply(eventId, persistedRundown);
return { newRundown, didMutate: true };
}
type SwapArgs = MutationParams<{ fromId: string; toId: string }>;
export function swap({ persistedRundown, fromId, toId }: SwapArgs): MutatingReturn {
const indexA = persistedRundown.findIndex((event) => event.id === fromId);
const eventA = persistedRundown.at(indexA);
const indexB = persistedRundown.findIndex((event) => event.id === toId);
const eventB = persistedRundown.at(indexB);
if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) {
throw new Error('Swap only available for OntimeEvents');
}
const { newA, newB } = swapEventData(eventA, eventB);
const newRundown = [...persistedRundown];
newRundown[indexA] = newA;
(newRundown[indexA] as OntimeEvent).revision += 1;
newRundown[indexB] = newB;
(newRundown[indexB] as OntimeEvent).revision += 1;
return { newRundown, didMutate: true };
}
/**
* Invalidates service cache if a custom field is used
* @param label
*/
function invalidateIfUsed(label: CustomFieldLabel) {
if (label in assignedCustomFields) {
isStale = true;
}
// if the field was in use, we mark the cache as stale
if (label in assignedCustomFields) {
isStale = true;
}
// ... and schedule a cache update
// schedule a non priority cache update
setImmediate(() => {
console.time('rundownCache__init');
generate();
console.timeEnd('rundownCache__init');
});
}
/**
* Scheduløes a non priority custom field persist
* @param persistedCustomFields
*/
function scheduleCustomFieldPersist(persistedCustomFields: CustomFields) {
setImmediate(() => {
DataProvider.setCustomFields(persistedCustomFields);
});
}
/**
* Sanitises and creates a custom field in the database
* @param field
* @returns
*/
export const createCustomField = async (field: CustomField) => {
const { label, type, colour } = field;
const key = label.toLowerCase();
// check if label already exists
const alreadyExists = Object.hasOwn(persistedCustomFields, key);
if (alreadyExists) {
throw new Error('Label already exists');
}
// update object and persist
persistedCustomFields[key] = { label, type, colour };
scheduleCustomFieldPersist(persistedCustomFields);
return persistedCustomFields;
};
/**
* Edits an existing custom field in the database
* @param key
* @param newField
* @returns
*/
export const editCustomField = async (key: string, newField: Partial<CustomField>) => {
if (!(key in persistedCustomFields)) {
throw new Error('Could not find label');
}
const existingField = persistedCustomFields[key];
if (existingField.type !== newField.type) {
throw new Error('Change of field type is not allowed');
}
const newKey = newField.label.toLowerCase();
persistedCustomFields[newKey] = { ...existingField, ...newField };
if (key !== newKey) {
delete persistedCustomFields[key];
customFieldChangelog[key] = newKey;
}
scheduleCustomFieldPersist(persistedCustomFields);
invalidateIfUsed(key);
return persistedCustomFields;
};
/**
* Deletes a custom field from the database
* @param label
*/
export const removeCustomField = async (label: string) => {
if (label in persistedCustomFields) {
delete persistedCustomFields[label];
}
scheduleCustomFieldPersist(persistedCustomFields);
invalidateIfUsed(label);
return persistedCustomFields;
};
@@ -0,0 +1,135 @@
import {
OntimeEvent,
isOntimeEvent,
OntimeRundown,
CustomFieldLabel,
CustomFields,
OntimeRundownEntry,
OntimeBaseEvent,
} from 'ontime-types';
import { getLinkedTimes } from 'ontime-utils';
/**
* Get linked event
*/
export function getLink(currentIndex: number, rundown: OntimeRundown): OntimeEvent | null {
// currently the link is the previous event
for (let i = currentIndex - 1; i >= 0; i--) {
const event = rundown[i];
if (isOntimeEvent(event) && !event.skip) {
return event;
}
}
return null;
}
/**
* Populates data from link, if necessary
* Mutates in place mutableEvent
* Mutates in place links
*/
export function handleLink(
currentIndex: number,
rundown: OntimeRundown,
mutableEvent: OntimeEvent,
links: Record<string, string>,
): void {
if (!mutableEvent.linkStart) {
return;
}
const linkedEvent = getLink(currentIndex, rundown);
if (!linkedEvent) {
mutableEvent.linkStart = null;
return;
}
links[linkedEvent.id] = mutableEvent.id;
const timePatch = getLinkedTimes(mutableEvent, linkedEvent);
// use object.assign to force mutation
Object.assign(mutableEvent, timePatch);
}
/**
* Utility function to add an entry, mutates given assignedCustomFields in place
* @param label
* @param eventId
*/
export function addToCustomAssignment(
label: CustomFieldLabel,
eventId: string,
assignedCustomFields: Record<string, string[]>,
) {
if (!Array.isArray(assignedCustomFields[label])) {
assignedCustomFields[label] = [];
}
assignedCustomFields[label].push(eventId);
}
/**
* Sanitises custom fields and updates values if necessary
* Mudates in place mutableEvent and assignedCustomFields
*/
export function handleCustomField(
customFields: CustomFields,
customFieldChangelog: Record<string, string>,
mutableEvent: OntimeEvent,
assignedCustomFields: Record<string, string[]>,
) {
for (const field in mutableEvent.custom) {
// rename the property if it is in the changelog
if (field in customFieldChangelog) {
const oldData = mutableEvent.custom[field];
const newLabel = customFieldChangelog[field];
mutableEvent.custom[newLabel] = { ...oldData };
delete mutableEvent.custom[field];
addToCustomAssignment(newLabel, mutableEvent.id, assignedCustomFields);
continue;
}
if (field in customFields) {
// add field to assignment map
addToCustomAssignment(field, mutableEvent.id, assignedCustomFields);
} else {
// delete data if it is not declared in project level custom fields
delete mutableEvent.custom[field];
}
}
}
/** List of event properties which do not need the rundown to be regenerated */
enum regenerateWhitelist {
'id',
'cue',
'title',
'note',
'endAction',
'timerType',
'isPublic',
'colour',
'timeWarning',
'timeDanger',
'custom',
}
/**
* given a patch, returns whether all keys are whitelisted
* @param path
*/
export function isDataStale(patch: Partial<OntimeRundownEntry>): boolean {
return Object.keys(patch).some((key) => !(key in regenerateWhitelist));
}
/**
* Given an event and a patch to that event checks whether there are actual changes to the dataset
* @param existingEvent
* @param newEvent
* @returns
*/
export function hasChanges<T extends OntimeBaseEvent>(existingEvent: T, newEvent: Partial<T>): boolean {
return Object.keys(newEvent).some(
(key) => !Object.hasOwn(existingEvent, key) || existingEvent[key] !== newEvent[key],
);
}
@@ -0,0 +1,119 @@
import { OntimeEvent, OntimeRundown, isOntimeEvent, RundownCached } from 'ontime-types';
import * as cache from './rundownCache.js';
export function getNormalisedRundown(): RundownCached {
return cache.get();
}
/**
* returns entire unfiltered rundown
* @return {array}
*/
export function getRundown(): OntimeRundown {
return cache.getPersistedRundown();
}
/**
* returns all events of type OntimeEvent
* @return {array}
*/
export function getTimedEvents(): OntimeEvent[] {
return getRundown().filter((event) => isOntimeEvent(event)) as OntimeEvent[];
}
/**
* 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;
}
/**
* 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();
return timedEvents.at(eventIndex);
}
/**
* returns first event that matches a given ID
* @param {string} eventId
* @return {object | undefined}
*/
export function getEventWithId(eventId: string): OntimeEvent | undefined {
const timedEvents = getTimedEvents();
return timedEvents.find((event) => event.id === eventId);
}
/**
* 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 lowerCaseCue = targetCue.toLowerCase();
for (let i = currentEventIndex; i < timedEvents.length; i++) {
const event = timedEvents.at(i);
if (event && event.cue.toLowerCase() === lowerCaseCue) {
return event;
}
}
}
/**
* finds the previous event
* @return {object | undefined}
*/
export function findPrevious(currentEventId?: string): OntimeEvent | null {
const timedEvents = getPlayableEvents();
if (!timedEvents || !timedEvents.length) {
return null;
}
// if there is no event running, go to first
if (!currentEventId) {
return timedEvents.at(0) ?? null;
}
const currentIndex = timedEvents.findIndex((event) => event.id === currentEventId);
const newIndex = Math.max(currentIndex - 1, 0);
const previousEvent = timedEvents.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) {
return null;
}
// if there is no event running, go to first
if (!currentEventId) {
return timedEvents.at(0) ?? null;
}
const currentIndex = timedEvents.findIndex((event) => event.id === currentEventId);
const newIndex = currentIndex + 1;
const nextEvent = timedEvents.at(newIndex);
return nextEvent ?? null;
}
@@ -0,0 +1,470 @@
import { EndAction, LogOrigin, OntimeEvent, Playback, TimerLifeCycle } from 'ontime-types';
import { millisToString, validatePlayback } from 'ontime-utils';
import { TimerService } from '../TimerService.js';
import { logger } from '../../classes/Logger.js';
import { RestorePoint } from '../RestoreService.js';
import * as runtimeState from '../../stores/runtimeState.js';
import {
findNext,
findPrevious,
getEventAtIndex,
getNextEventWithCue,
getEventWithId,
getPlayableEvents,
} from '../rundown-service/rundownUtils.js';
import { integrationService } from '../integration-service/IntegrationService.js';
import { timerConfig } from '../../config/config.js';
/**
* Service manages runtime status of app
* Coordinating with necessary services
*/
class RuntimeService {
private eventTimer: TimerService | null = null;
private lastOnUpdate = -1;
/** Checks result of an update and notifies integrations as needed */
checkTimerUpdate({ shouldCallRoll, hasTimerFinished }: runtimeState.UpdateResult) {
const newState = runtimeState.getState();
if (hasTimerFinished) {
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.endAction === EndAction.Stop) {
setTimeout(this.stop.bind(this), 0);
} else if (newState.eventNow.endAction === EndAction.LoadNext) {
setTimeout(this.loadNext.bind(this), 0);
} else if (newState.eventNow.endAction === EndAction.PlayNext) {
setTimeout(this.startNext.bind(this), 0);
}
}
}
// update normal cycle
if (newState.clock - this.lastOnUpdate >= timerConfig.notificationRate) {
const hasRunningTimer = Boolean(newState.eventNow) && newState.timer.playback === Playback.Play;
if (hasRunningTimer) {
integrationService.dispatch(TimerLifeCycle.onUpdate);
}
integrationService.dispatch(TimerLifeCycle.onClock);
this.lastOnUpdate = newState.clock;
}
if (shouldCallRoll) {
// we dont call this.roll because we need to bypass the checks
const rundown = getPlayableEvents();
this.eventTimer.roll(rundown);
}
}
/** delay initialisation until we have a restore point */
init(resumable: RestorePoint | null) {
logger.info(LogOrigin.Server, 'Runtime service started');
// calculate at 30fps, refresh at 1fps
this.eventTimer = new TimerService({
refresh: timerConfig.updateRate,
updateInterval: timerConfig.notificationRate,
onUpdateCallback: (updateResult) => this.checkTimerUpdate(updateResult),
});
if (resumable) {
this.resume(resumable);
}
}
shutdown() {
if (this.eventTimer) {
logger.info(LogOrigin.Server, 'Runtime service shutting down');
this.eventTimer.shutdown();
}
}
/**
* Checks if a list of IDs is in the current selection
*/
private affectsLoaded(affectedIds: string[]): boolean {
const state = runtimeState.getState();
const now = state.eventNow?.id;
const nowPublic = state.publicEventNow?.id;
const next = state.eventNext?.id;
const nextPublic = state.publicEventNext?.id;
return (
affectedIds.includes(now) ||
affectedIds.includes(nowPublic) ||
affectedIds.includes(next) ||
affectedIds.includes(nextPublic)
);
}
private isNewNext() {
const timedEvents = getPlayableEvents();
const state = runtimeState.getState();
const now = state.eventNow?.id;
const next = state.eventNext?.id;
// check whether the index of now and next are consecutive
const indexNow = timedEvents.findIndex((event) => event.id === now);
const indexNext = timedEvents.findIndex((event) => event.id === next);
if (indexNext - indexNow !== 1) {
return true;
}
// iterate through timed events and see if there are public events between nowPublic and nextPublic
const nowPublic = state.publicEventNow?.id;
const nextPublic = state.publicEventNext?.id;
let foundNew = false;
let isAfter = false;
for (const event of timedEvents) {
if (!isAfter) {
if (event.id === nowPublic) {
isAfter = true;
}
} else {
if (event.id === nextPublic) {
break;
}
if (event.isPublic) {
foundNew = true;
break;
}
}
}
return foundNew;
}
/**
* Called when the underlying data has changed,
* we check if the change affects the runtime
*/
maybeUpdate(playableEvents: OntimeEvent[], affectedIds?: string[]) {
const state = runtimeState.getState();
const hasLoadedElements = state.eventNow !== null || state.eventNext !== null;
if (!hasLoadedElements) {
return;
}
// we need to reload in a few scenarios:
// 1. we are not confident that changes do not affect running event (eg. all events where changed)
const safeOption = typeof affectedIds === 'undefined';
// 2. the edited event is in memory (now or next) running
const eventInMemory = safeOption ? false : this.affectsLoaded(affectedIds);
// 3. the edited event replaces next event
let isNext = false;
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);
const onlyChangedNow = affectedIds?.length === 1 && affectedIds.at(0) === eventNow.id;
if (onlyChangedNow) {
runtimeState.reload(eventNow);
} else {
runtimeState.reloadAll(eventNow, playableEvents);
}
return;
}
// Maybe the event will become the next
isNext = this.isNewNext();
if (isNext) {
runtimeState.loadNext(playableEvents);
}
}
/**
* makes calls for loading and starting given event
* @param {OntimeEvent} event
* @return {boolean} success - whether an event was loaded
*/
loadEvent(event: OntimeEvent): boolean {
if (event.skip) {
logger.warning(LogOrigin.Playback, `Refused skipped event with ID ${event.id}`);
return false;
}
const timedEvents = getPlayableEvents();
const success = runtimeState.load(event, timedEvents);
if (success) {
integrationService.dispatch(TimerLifeCycle.onLoad);
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
}
return success;
}
/**
* starts event matching given ID
* @param {string} eventId
* @return {boolean} success - whether an event was started
*/
startById(eventId: string): boolean {
const event = getEventWithId(eventId);
if (!event) {
return false;
}
const loaded = this.loadEvent(event);
if (!loaded) {
return false;
}
return this.start();
}
/**
* starts an event at index
* @param {number} eventIndex
* @return {boolean} success - whether an event was started
*/
startByIndex(eventIndex: number): boolean {
const event = getEventAtIndex(eventIndex);
if (!event) {
return false;
}
const loaded = this.loadEvent(event);
if (!loaded) {
return false;
}
return this.start();
}
/**
* starts first event matching given cue
* @param {string} cue
* @return {boolean} success - whether an event was started
*/
startByCue(cue: string): boolean {
const event = getNextEventWithCue(cue); //TODO: add index
if (!event) {
return false;
}
const loaded = this.loadEvent(event);
if (!loaded) {
return false;
}
return this.start();
}
/**
* loads event matching given ID
* @param {string} eventId
* @return {boolean} success - whether an event was loaded
*/
loadById(eventId: string): boolean {
const event = getEventWithId(eventId);
if (!event) {
return false;
}
return this.loadEvent(event);
}
/**
* loads event matching given ID
* @param {number} eventIndex
* @return {boolean} success - whether an event was loaded
*/
loadByIndex(eventIndex: number): boolean {
const event = getEventAtIndex(eventIndex);
if (!event) {
return false;
}
return this.loadEvent(event);
}
/**
* loads first event matching given cue
* @param {string} cue
* @return {boolean} success - whether an event was loaded
*/
loadByCue(cue: string): boolean {
const event = getNextEventWithCue(cue); //TODO: add index
if (!event) {
return false;
}
return this.loadEvent(event);
}
/**
* Loads event before currently selected
* @return {boolean} success - whether an event was loaded
*/
loadPrevious(): boolean {
const state = runtimeState.getState();
const previousEvent = findPrevious(state.eventNow?.id);
if (previousEvent) {
return this.loadEvent(previousEvent);
}
return false;
}
/**
* Loads event after currently selected
* @return {boolean} success
*/
loadNext(): boolean {
const state = runtimeState.getState();
const nextEvent = findNext(state.eventNow?.id);
if (nextEvent) {
return this.loadEvent(nextEvent);
}
logger.info(LogOrigin.Playback, 'No next event found! Continuing playback');
return false;
}
/**
* Starts playback on selected event
*/
start(): boolean {
const state = runtimeState.getState();
const canStart = validatePlayback(state.timer.playback).start;
if (!canStart) {
return false;
}
const didStart = this.eventTimer?.start() ?? false;
logger.info(LogOrigin.Playback, `Play Mode ${state.timer.playback.toUpperCase()}`);
if (didStart) {
integrationService.dispatch(TimerLifeCycle.onStart);
}
return didStart;
}
/**
* Starts playback on previous event
*/
startPrevious(): boolean {
const hasPrevious = this.loadPrevious();
if (!hasPrevious) {
return false;
}
return this.start();
}
/**
* Starts playback on next event
*/
startNext(): boolean {
const hasNext = this.loadNext();
if (!hasNext) {
return false;
}
return this.start();
}
/**
* Pauses playback on selected event
*/
pause() {
const state = runtimeState.getState();
const canPause = validatePlayback(state.timer.playback).pause;
if (!canPause) {
return;
}
this.eventTimer?.pause();
const newState = state.timer.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
integrationService.dispatch(TimerLifeCycle.onPause);
}
/**
* Stops timer and unloads any events
*/
stop(): boolean {
const state = runtimeState.getState();
const canStop = validatePlayback(state.timer.playback).stop;
if (!canStop) {
return false;
}
const didStop = this.eventTimer?.stop();
if (didStop) {
const newState = state.timer.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
integrationService.dispatch(TimerLifeCycle.onStop);
return true;
}
return false;
}
/**
* Reloads current event
*/
reload() {
const state = runtimeState.getState();
if (state.eventNow) {
runtimeState.reload();
}
}
/**
* Sets playback to roll
*/
roll() {
const beforeState = runtimeState.getState();
const canRoll = validatePlayback(beforeState.timer.playback).roll;
if (!canRoll) {
return;
}
const playableEvents = getPlayableEvents();
if (playableEvents.length === 0) {
logger.warning(LogOrigin.Server, 'Roll: no events found');
return;
}
this.eventTimer.roll(playableEvents);
const state = runtimeState.getState();
const newState = state.timer.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
}
/**
* @description resume playback state given a restore point
* @param restorePoint
*/
resume(restorePoint: RestorePoint) {
const { selectedEventId, playback } = restorePoint;
if (playback === Playback.Roll) {
this.roll();
return;
}
if (!selectedEventId) {
return;
}
// the db would have to change for the event not to exist
// we do not kow the reason for the crash, so we check anyway
const event = getEventWithId(selectedEventId);
if (!event) {
return;
}
const timedEvents = getPlayableEvents();
runtimeState.resume(restorePoint, event, timedEvents);
logger.info(LogOrigin.Playback, 'Resuming playback');
}
/**
* Adds time to current event
* @param {number} time - time to add in milliseconds
*/
addTime(time: number) {
if (this.eventTimer.addTime(time)) {
logger.info(LogOrigin.Playback, `${time > 0 ? 'Added' : 'Removed'} ${millisToString(time)}`);
}
}
}
export const runtimeService = new RuntimeService();
@@ -0,0 +1,374 @@
/**
* Service aggregates business logic related
* to integration with Google Sheets API
* @link https://developers.google.com/identity/protocols/oauth2/limited-input-device
*/
import { AuthenticationStatus, CustomFields, LogOrigin, MaybeString, OntimeRundown } from 'ontime-types';
import { sheets, sheets_v4 } from '@googleapis/sheets';
import { Credentials, OAuth2Client } from 'google-auth-library';
import got from 'got';
import { resolveSheetsDirectory } from '../../setup/index.js';
import { ensureDirectory } from '../../utils/fileManagement.js';
import { cellRequestFromEvent, type ClientSecret, getA1Notation, validateClientSecret } from './sheetUtils.js';
import { ImportMap } from 'ontime-utils';
import { parseExcel } from '../../utils/parser.js';
import { logger } from '../../classes/Logger.js';
import { parseCustomFields, parseRundown } from '../../utils/parserFunctions.js';
import { getRundown } from '../rundown-service/rundownUtils.js';
const sheetScope = 'https://www.googleapis.com/auth/spreadsheets';
const codesUrl = 'https://oauth2.googleapis.com/device/code';
const tokenUrl = 'https://oauth2.googleapis.com/token';
const grantType = 'urn:ietf:params:oauth:grant-type:device_code';
let currentAuthClient: OAuth2Client | null = null;
let currentClientSecret: ClientSecret | null = null;
let currentAuthUrl: MaybeString = null;
let currentAuthCode: MaybeString = null;
let currentSheetId: MaybeString = null;
let pollInterval: NodeJS.Timer | null = null;
let cleanupTimeout: NodeJS.Timeout | null = null;
function reset() {
currentAuthClient = null;
currentClientSecret = null;
currentAuthUrl = null;
currentAuthCode = null;
currentSheetId = null;
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
if (cleanupTimeout) {
clearTimeout(cleanupTimeout);
cleanupTimeout = null;
}
}
/**
* Initialise module
*/
export function init() {
reset();
ensureDirectory(resolveSheetsDirectory);
}
/**
* Resets all state related to an eventual connection
*/
export function revoke(): ReturnType<typeof hasAuth> {
reset();
return hasAuth();
}
/**
* Parses and validates a client secret string
* @param clientSecret
* @returns
*/
export function handleClientSecret(clientSecret: string): ClientSecret {
const clientSecretObject = JSON.parse(clientSecret);
const isValid = validateClientSecret(clientSecretObject);
if (!isValid) {
throw new Error('Client secret invalid');
}
return clientSecretObject;
}
// https://developers.google.com/identity/protocols/oauth2/limited-input-device#success-response
type CodesResponse = {
device_code: string;
expires_in: number;
interval: number;
user_code: string;
verification_url: string;
};
/**
* Establishes connection with Google Auth server
* and retrieves device codes
* @param clientSecret
* @returns
*/
async function getDeviceCodes(clientSecret: ClientSecret): Promise<CodesResponse> {
const deviceCodes: CodesResponse = await got
.post(codesUrl, {
json: {
client_id: clientSecret.installed.client_id,
scope: sheetScope,
},
})
.json();
return deviceCodes;
}
/**
* Gets credentials from Google Auth server
* @param clientSecret
* @param device_code
* @param interval
* @param expires_in
* @param postAction
*/
function verifyConnection(
clientSecret: ClientSecret,
device_code: string,
interval: number,
expires_in: number,
postAction: () => void,
) {
// create poller to check for auth
pollInterval = setInterval(pollForAuth, interval * 1000);
// schedule to clear the poller when we know the token is no longer valid
if (cleanupTimeout) {
clearTimeout(cleanupTimeout);
cleanupTimeout = null;
}
cleanupTimeout = setTimeout(() => {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
}, expires_in * 1000);
async function pollForAuth() {
// server returns 428 if user hasnt yet completed the auth process
try {
logger.info(LogOrigin.Server, 'Polling for auth...');
const auth: Credentials = await got
.post(tokenUrl, {
json: {
client_id: clientSecret.installed.client_id,
client_secret: clientSecret.installed.client_secret,
device_code,
grant_type: grantType,
},
})
.json();
logger.info(LogOrigin.Server, 'Successfully Authenticated');
const client = new OAuth2Client({
clientId: clientSecret.installed.client_id,
clientSecret: clientSecret.installed.client_secret,
});
client.setCredentials({
refresh_token: auth.refresh_token,
access_token: auth.access_token,
scope: auth.scope,
token_type: auth.token_type,
});
// save client and cancel tasks
currentAuthClient = client;
if (cleanupTimeout) {
clearTimeout(cleanupTimeout);
cleanupTimeout = null;
}
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
await postAction();
} catch (_error) {
/** we do not handle failure */
}
}
}
export function hasAuth(): { authenticated: AuthenticationStatus; sheetId: string } {
if (cleanupTimeout) {
return { authenticated: 'pending', sheetId: currentSheetId };
}
return { authenticated: currentAuthClient ? 'authenticated' : 'not_authenticated', sheetId: currentSheetId };
}
async function verifySheet(
sheetId = currentSheetId,
authClient = currentAuthClient,
): Promise<{ worksheetOptions: string[] }> {
try {
const spreadsheets = await sheets({ version: 'v4', auth: authClient }).spreadsheets.get({
spreadsheetId: sheetId,
includeGridData: false,
});
return { worksheetOptions: spreadsheets.data.sheets.map((i) => i.properties.title) };
} catch (error) {
throw new Error(`Failed to verify sheet: ${error.message}`);
}
}
export async function handleInitialConnection(
clientSecret: ClientSecret,
sheetId: string,
): Promise<{ verification_url: string; user_code: string }> {
// TODO: check if the clientSecret has changed
currentClientSecret = clientSecret;
// we know there is an ongoing process if there is a timeout for cleanup
// if there is an ongoing process, we return its data
if (cleanupTimeout) {
return { verification_url: currentAuthUrl, user_code: currentAuthCode };
}
const { device_code, expires_in, interval, user_code, verification_url } = await getDeviceCodes(currentClientSecret);
currentAuthUrl = verification_url;
currentAuthCode = user_code;
currentSheetId = sheetId;
// schedule verifying token and the existence of the sheetID
verifyConnection(currentClientSecret, device_code, interval, expires_in, verifySheet);
return { verification_url, user_code };
}
/**
* Allow calling verification for sheetId
* @returns
*/
export async function getWorksheetOptions(sheetId: string): ReturnType<typeof verifySheet> {
if (!currentAuthClient) {
throw new Error('Not authenticated');
}
currentSheetId = sheetId;
return verifySheet(sheetId);
}
async function verifyWorksheet(sheetId: string, worksheet: string): Promise<{ worksheetId: number; range: string }> {
const spreadsheets = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.get({
spreadsheetId: sheetId,
});
if (spreadsheets.status !== 200) {
throw new Error(`Request failed: ${spreadsheets.status} ${spreadsheets.statusText}`);
}
const selectedWorksheet = spreadsheets.data.sheets.find(
(n) => n.properties.title.toLowerCase() === worksheet.toLowerCase(),
);
if (!selectedWorksheet) {
throw new Error('Could not find worksheet');
}
const endCell = getA1Notation(
selectedWorksheet.properties.gridProperties.rowCount,
selectedWorksheet.properties.gridProperties.columnCount,
);
return { worksheetId: selectedWorksheet.properties.sheetId, range: `${worksheet}!A1:${endCell}` };
}
export async function upload(sheetId: string, options: ImportMap) {
const { worksheetId, range } = await verifyWorksheet(sheetId, options.worksheet);
const readResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.values.get({
spreadsheetId: sheetId,
valueRenderOption: 'FORMATTED_VALUE',
majorDimension: 'ROWS',
range,
});
if (readResponse.status !== 200) {
throw new Error(`Sheet read failed: ${readResponse.statusText}`);
}
const { rundownMetadata } = parseExcel(readResponse.data.values, options);
const rundown = getRundown();
const titleRow = Object.values(rundownMetadata)[0]['row'];
const updateRundown = Array<sheets_v4.Schema$Request>();
// we can't delete the last unfrozen row so we create an empty one
updateRundown.push({
insertDimension: {
inheritFromBefore: false,
range: {
dimension: 'ROWS',
startIndex: titleRow + 1,
endIndex: titleRow + 2,
sheetId: worksheetId,
},
},
});
// ... and delete the rest
updateRundown.push({
deleteDimension: { range: { dimension: 'ROWS', startIndex: titleRow + 2, sheetId: worksheetId } },
});
// insert the length of the rundown
updateRundown.push({
insertDimension: {
inheritFromBefore: false,
range: {
dimension: 'ROWS',
startIndex: titleRow + 1,
endIndex: titleRow + rundown.length,
sheetId: worksheetId,
},
},
});
// update the corresponding row with event data
rundown.forEach((entry, index) =>
updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, rundownMetadata)),
);
const writeResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.batchUpdate({
spreadsheetId: sheetId,
requestBody: {
includeSpreadsheetInResponse: false,
responseRanges: [range],
requests: updateRundown,
},
});
if (writeResponse.status === 200) {
logger.info(LogOrigin.Server, `Sheet write ${writeResponse.statusText}`);
} else {
throw new Error(`Sheet write failed: ${writeResponse.statusText}`);
}
}
export async function download(
sheetId: string,
options: ImportMap,
): Promise<{
rundown: OntimeRundown;
customFields: CustomFields;
}> {
const { range } = await verifyWorksheet(sheetId, options.worksheet);
const googleResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.values.get({
spreadsheetId: sheetId,
valueRenderOption: 'FORMATTED_VALUE',
majorDimension: 'ROWS',
range,
});
if (googleResponse.status !== 200) {
throw new Error(`Sheet read failed: ${googleResponse.statusText}`);
}
const dataFromSheet = parseExcel(googleResponse.data.values, options);
const rundown = parseRundown(dataFromSheet);
if (rundown.length < 1) {
throw new Error('Sheet: Could not find data to import in the worksheet');
}
const customFields = parseCustomFields(dataFromSheet);
return { rundown, customFields };
}
@@ -0,0 +1,252 @@
import { EndAction, OntimeEvent, SupportedEvent, TimeStrategy, TimerType } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { getA1Notation, cellRequestFromEvent } from '../sheetUtils.js';
describe('getA1Notation()', () => {
test('A1', () => {
expect(getA1Notation(0, 0)).toStrictEqual('A1');
});
test('E3', () => {
expect(getA1Notation(2, 4)).toStrictEqual('E3');
});
test('AA100', () => {
expect(getA1Notation(99, 26)).toStrictEqual('AA100');
});
test('can not be negative', () => {
expect(() => getA1Notation(-1, 1)).toThrowError('Index can not be less than 0');
});
});
describe('cellRequestFromEvent()', () => {
test('string to string', () => {
const event: OntimeEvent = {
type: SupportedEvent.Event,
cue: '1',
title: 'Fancy',
note: 'Blue button on the right',
timeStart: 46800000,
timeEnd: 57600000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
endAction: EndAction.None,
timerType: TimerType.CountDown,
duration: 10800000,
isPublic: false,
skip: false,
colour: 'red',
revision: 0,
id: '1358',
timeWarning: 0,
timeDanger: 0,
custom: {},
};
const metadata = {
type: { row: 1, col: 14 },
cue: { row: 1, col: 15 },
title: { row: 1, col: 16 },
note: { row: 1, col: 19 },
timeStart: { row: 1, col: 20 },
timeEnd: { row: 1, col: 21 },
endAction: { row: 1, col: 22 },
timerType: { row: 1, col: 23 },
duration: { row: 1, col: 24 },
isPublic: { row: 1, col: 25 },
skip: { row: 1, col: 26 },
colour: { row: 1, col: 27 },
revision: { row: 1, col: 38 },
id: { row: 1, col: 39 },
timeWarning: { row: 1, col: 40 },
timeDanger: { row: 1, col: 41 },
};
const result = cellRequestFromEvent(event, 1, 1234, metadata);
expect(result.updateCells.rows[0].values[5].userEnteredValue.stringValue).toStrictEqual(event.note);
});
test('number to timer', () => {
const event: OntimeEvent = {
type: SupportedEvent.Event,
cue: '1',
title: 'Fancy',
note: 'Blue button on the right',
timeStart: 46800000,
timeEnd: 57600000,
endAction: EndAction.None,
timerType: TimerType.CountDown,
duration: 10800000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
isPublic: false,
skip: false,
colour: 'red',
revision: 0,
id: '1358',
timeWarning: 0,
timeDanger: 0,
custom: {},
};
const metadata = {
type: { row: 1, col: 14 },
cue: { row: 1, col: 15 },
title: { row: 1, col: 16 },
note: { row: 1, col: 19 },
timeStart: { row: 1, col: 20 },
timeEnd: { row: 1, col: 21 },
endAction: { row: 1, col: 22 },
timerType: { row: 1, col: 23 },
duration: { row: 1, col: 24 },
isPublic: { row: 1, col: 25 },
skip: { row: 1, col: 26 },
colour: { row: 1, col: 27 },
revision: { row: 1, col: 38 },
id: { row: 1, col: 39 },
timeWarning: { row: 1, col: 40 },
timeDanger: { row: 1, col: 41 },
};
const result = cellRequestFromEvent(event, 1, 1234, metadata).updateCells.rows[0].values[10].userEnteredValue
.stringValue;
expect(result).toStrictEqual(millisToString(event.duration));
});
test('boolean to TRUE', () => {
const event: OntimeEvent = {
type: SupportedEvent.Event,
cue: '1',
title: 'Fancy',
note: 'Blue button on the right',
timeStart: 46800000,
timeEnd: 57600000,
endAction: EndAction.None,
timerType: TimerType.CountDown,
duration: 10800000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
isPublic: true,
skip: false,
colour: 'red',
revision: 0,
id: '1358',
timeWarning: 0,
timeDanger: 0,
custom: {},
};
const metadata = {
type: { row: 1, col: 14 },
cue: { row: 1, col: 15 },
title: { row: 1, col: 16 },
note: { row: 1, col: 19 },
timeStart: { row: 1, col: 20 },
timeEnd: { row: 1, col: 21 },
endAction: { row: 1, col: 22 },
timerType: { row: 1, col: 23 },
duration: { row: 1, col: 24 },
isPublic: { row: 1, col: 25 },
skip: { row: 1, col: 26 },
colour: { row: 1, col: 27 },
revision: { row: 1, col: 38 },
id: { row: 1, col: 39 },
timeWarning: { row: 1, col: 40 },
timeDanger: { row: 1, col: 41 },
};
const result = cellRequestFromEvent(event, 1, 1234, metadata);
expect(result.updateCells.rows[0].values[11].userEnteredValue.boolValue).toStrictEqual(true);
expect(result.updateCells.rows[0].values[12].userEnteredValue.boolValue).toStrictEqual(false);
});
test('spacing in metadata', () => {
const event: OntimeEvent = {
type: SupportedEvent.Event,
cue: '1',
title: 'Fancy',
note: 'Blue button on the right',
timeStart: 46800000,
timeEnd: 57600000,
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
duration: 10800000,
isPublic: true,
skip: false,
colour: 'red',
revision: 0,
id: '1358',
timeWarning: 0,
timeDanger: 0,
custom: {},
};
const metadata = {
cue: { row: 1, col: 0 },
title: { row: 1, col: 6 },
};
const result = cellRequestFromEvent(event, 1, 1234, metadata);
expect(result.updateCells.rows[0].values[0].userEnteredValue.stringValue).toStrictEqual(event.cue);
expect(result.updateCells.rows[0].values[6].userEnteredValue.stringValue).toStrictEqual(event.title);
});
test('metadata offset from zero', () => {
const event: OntimeEvent = {
type: SupportedEvent.Event,
cue: '1',
title: 'Fancy',
note: 'Blue button on the right',
timeStart: 46800000,
timeEnd: 57600000,
endAction: EndAction.None,
timerType: TimerType.CountDown,
duration: 10800000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
isPublic: true,
skip: false,
colour: 'red',
revision: 0,
id: '1358',
timeWarning: 0,
timeDanger: 0,
custom: {},
};
const metadata = {
cue: { row: 1, col: 5 },
title: { row: 1, col: 6 },
user0: { row: 1, col: 16 },
};
const result = cellRequestFromEvent(event, 1, 1234, metadata);
expect(result.updateCells.rows[0].values[0].userEnteredValue.stringValue).toStrictEqual(event.cue);
expect(result.updateCells.rows[0].values[1].userEnteredValue.stringValue).toStrictEqual(event.title);
});
test('sheet setup', () => {
const event: OntimeEvent = {
type: SupportedEvent.Event,
cue: '1',
title: 'Fancy',
note: 'Blue button on the right',
timeStart: 46800000,
timeEnd: 57600000,
endAction: EndAction.None,
timerType: TimerType.CountDown,
duration: 10800000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
isPublic: true,
skip: false,
colour: 'red',
revision: 0,
id: '1358',
timeWarning: 0,
timeDanger: 0,
custom: {},
};
const metadata = {
cue: { row: 10, col: 5 },
title: { row: 10, col: 6 },
};
const result1 = cellRequestFromEvent(event, 1, 1234, metadata);
expect(result1.updateCells.start.sheetId).toStrictEqual(1234);
const result2 = cellRequestFromEvent(event, 10, 1234, metadata);
expect(result2.updateCells.start.rowIndex).toStrictEqual(21);
expect(result2.updateCells.start.columnIndex).toStrictEqual(5);
expect(result2.updateCells.fields).toStrictEqual('userEnteredValue');
});
});
@@ -0,0 +1,147 @@
import { isOntimeBlock, isOntimeEvent, OntimeRundownEntry } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { sheets_v4 } from '@googleapis/sheets';
// we expect client secret file to contain the following keys
const requiredClientKeys = [
'client_id',
'auth_uri',
'token_uri',
'token_uri',
'auth_provider_x509_cert_url',
'client_secret',
];
export type ClientSecret = {
installed: {
client_id: string;
auth_uri: string;
token_uri: string;
auth_provider_x509_cert_url: string;
client_secret: string;
};
};
/**
* Guard validates a given client secrets file
* @param clientSecret
* @returns
*/
export function validateClientSecret(clientSecret: object): clientSecret is ClientSecret {
return requiredClientKeys.every((key) => Object.keys(clientSecret['installed']).includes(key));
}
/**
*
* @param {number} row - The row number of the cell reference. Row 1 is row number 0.
* @param {number} column - The column number of the cell reference. A is column number 0.
* @returns {string} - Returns a cell reference as a string using A1 Notation
* @author https://www.labnol.org/convert-column-a1-notation-210601
* @example
*
* getA1Notation(2, 4) returns "E3"
* getA1Notation(99, 26) returns "AA100"
*
*/
export function getA1Notation(row: number, column: number): string {
if (row < 0 || column < 0) {
throw new Error('Index can not be less than 0');
}
const a1Notation = [`${row + 1}`];
const totalAlphabets = 'Z'.charCodeAt(0) - 'A'.charCodeAt(0) + 1;
let block = column;
while (block >= 0) {
a1Notation.unshift(String.fromCharCode((block % totalAlphabets) + 'A'.charCodeAt(0)));
block = Math.floor(block / totalAlphabets) - 1;
}
return a1Notation.join('');
}
/**
* @description - creates updateCells request from ontime event
* @param {OntimeRundownEntry} event
* @param {number} index - index of the event
* @param {number} worksheetId
* @param {any} metadata - object with all the cell positions of the title of each attribute
* @returns {sheets_v4.Schema} - list of update requests
*/
export function cellRequestFromEvent(
event: OntimeRundownEntry,
index: number,
worksheetId: number,
metadata,
): sheets_v4.Schema$Request {
const rowData = Object.entries(metadata)
.filter(([_, value]) => value !== undefined)
.sort(([_a, a], [_b, b]) => a['col'] - b['col']) as [string, { col: number; row: number }][];
const titleCol = rowData[0][1].col;
for (const [index, e] of rowData.entries()) {
if (index !== 0) {
const prevCol = rowData[index - 1][1].col;
const thisCol = e[1].col;
const diff = thisCol - prevCol;
if (diff > 1) {
const fillArr = new Array<(typeof rowData)[0]>(1).fill(['blank', { row: e[1].row, col: prevCol + 1 }]);
rowData.splice(index, 0, ...fillArr);
}
}
}
const returnRows: sheets_v4.Schema$CellData[] = rowData.map(([key, _]) => {
return getCellData(key, event);
});
return {
updateCells: {
start: {
sheetId: worksheetId,
rowIndex: index + rowData[0][1]['row'] + 1,
columnIndex: titleCol,
},
fields: 'userEnteredValue',
rows: [
{
values: returnRows,
},
],
},
};
}
function getCellData(key: string, event: OntimeRundownEntry) {
if (isOntimeEvent(event)) {
if (key === 'blank') {
return {};
}
if (key === 'colour') {
return { userEnteredValue: { stringValue: event[key] } };
}
const dataType = typeof event[key];
if (dataType === 'number') {
return { userEnteredValue: { stringValue: millisToString(event[key]) } };
}
if (dataType === 'string') {
return { userEnteredValue: { stringValue: event[key] } };
}
if (dataType === 'boolean') {
return { userEnteredValue: { boolValue: event[key] } };
}
}
if (isOntimeBlock(event)) {
if (key === 'title') {
return { userEnteredValue: { stringValue: event[key] } };
}
if (key === 'timerType') {
return { userEnteredValue: { stringValue: 'block' } };
}
}
return {};
}
+313 -39
View File
@@ -1,18 +1,29 @@
import { MaybeNumber, TimerType } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import { MaybeNumber, MaybeString, OntimeEvent, TimerType } from 'ontime-types';
import { dayInMs, sortArrayByProperty } from 'ontime-utils';
import { RuntimeState } from '../stores/runtimeState.js';
import { timerConfig } from '../config/config.js';
/**
* handle events that span over midnight
*/
export const normaliseEndTime = (start: number, end: number) => (end < start ? end + dayInMs : end);
/**
* Calculates expected finish time of a running timer
* @param {RuntimeState} state runtime state
* @returns {number | null} new current time or null if nothing is running
*/
export function getExpectedFinish(
startedAt: MaybeNumber,
finishedAt: MaybeNumber,
duration: number,
pausedTime: number,
addedTime: number,
timeEnd: number,
timerType: TimerType,
) {
export function getExpectedFinish(state: RuntimeState): MaybeNumber {
const { startedAt, finishedAt, duration, addedTime } = state.timer;
if (state.eventNow === null) {
return null;
}
const { timerType, timeEnd } = state.eventNow;
const { pausedAt } = state._timer;
const { clock } = state;
if (startedAt === null) {
return null;
}
@@ -21,12 +32,15 @@ export function getExpectedFinish(
return finishedAt;
}
const pausedTime = pausedAt != null ? clock - pausedAt : 0;
if (timerType === TimerType.TimeToEnd) {
return timeEnd + addedTime + pausedTime;
}
// handle events that finish the day after
const expectedFinish = startedAt + duration + pausedTime + addedTime;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- duration exists if ther eis a timer
const expectedFinish = startedAt + duration! + addedTime + pausedTime;
if (expectedFinish > dayInMs) {
return expectedFinish - dayInMs;
}
@@ -37,41 +51,46 @@ export function getExpectedFinish(
/**
* Calculates running countdown
* @param {RuntimeState} state runtime state
* @returns {number} current time for timer
*/
export function getCurrent(
startedAt: MaybeNumber,
duration: number,
addedTime: number,
pausedTime: number,
clock: number,
timeEnd: number,
timerType: TimerType,
) {
if (startedAt === null) {
return null;
}
export function getCurrent(state: RuntimeState): number {
const { startedAt, duration, addedTime } = state.timer;
const { timerType, timeStart, timeEnd } = state.eventNow;
const { pausedAt } = state._timer;
const { clock } = state;
if (timerType === TimerType.TimeToEnd) {
if (startedAt > timeEnd) {
return timeEnd + addedTime + pausedTime + dayInMs - clock;
}
return timeEnd + addedTime + pausedTime - clock;
const isEventOverMidnight = timeStart > timeEnd;
const correctDay = isEventOverMidnight ? dayInMs : 0;
return correctDay - clock + timeEnd + addedTime;
}
if (startedAt > clock) {
// we are the day after the event was started
return startedAt + duration + addedTime + pausedTime - clock - dayInMs;
if (startedAt === null) {
return duration;
}
return startedAt + duration + addedTime + pausedTime - clock;
if (pausedAt != null) {
return startedAt + duration + addedTime - pausedAt;
}
const hasPassedMidnight = startedAt > clock;
const correctDay = hasPassedMidnight ? dayInMs : 0;
return startedAt + duration + addedTime - clock - correctDay;
}
export function skippedOutOfEvent(
previousTime: number,
clock: number,
startedAt: number,
expectedFinish: number,
skipLimit: number,
): boolean {
/**
* Checks whether we have skipped out of the event
* @param {RuntimeState} state runtime state
* @param {number} previousTime previous clock
* @param {number} skipLimit how much time can we skip
* @returns {boolean}
*/
export function skippedOutOfEvent(state: RuntimeState, previousTime: number, skipLimit: number): boolean {
const { startedAt, expectedFinish } = state.timer;
const { clock } = state;
const hasPassedMidnight = previousTime > dayInMs - skipLimit && clock < skipLimit;
const adjustedClock = hasPassedMidnight ? clock + dayInMs : clock;
@@ -81,3 +100,258 @@ export function skippedOutOfEvent(
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[]} rundown - List of playable events
* @param {number} timeNow - time now in ms
* @returns {{}}
*/
export const getRollTimers = (rundown: OntimeEvent[], timeNow: number): RollTimers => {
let nowIndex: number | null = null; // index of event now
let nowId: string | null = null; // id of event now
let publicIndex: number | null = null; // index of public event now
let nextIndex: number | null = null; // index of next event
let publicNextIndex: number | null = null; // index of next public event
let timeToNext: number | null = null; // counter: time for next event
let publicTimeToNext: number | null = null; // counter: time for next public event
const orderedEvents = sortArrayByProperty(rundown, 'timeStart');
const lastEvent = orderedEvents[orderedEvents.length - 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 = orderedEvents[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 orderedEvents) {
if (event.isPublic) {
nextPublicEvent = event;
// we need the index before this was sorted
publicNextIndex = rundown.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 orderedEvents) {
// 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 = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
} else if (hasNotEnded && hasStarted && !nowFound) {
// event is running
currentEvent = event;
nowIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
nowId = event.id;
nowFound = true;
// it could also be public
if (event.isPublic) {
publicTime = normalEnd;
currentPublicEvent = event;
publicIndex = rundown.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 = rundown.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 = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
}
}
}
}
return {
nowIndex,
nowId,
publicIndex,
nextIndex,
publicNextIndex,
timeToNext,
nextEvent,
nextPublicEvent,
currentEvent,
currentPublicEvent,
};
};
/**
* @description Implements update functions for roll mode
* @param {RuntimeState}
* @returns object with selection variables
*/
export const updateRoll = (state: RuntimeState) => {
const { current, expectedFinish, startedAt, secondaryTimer } = state.timer;
const { secondaryTarget } = state._timer;
const { clock } = state;
const selectedEventId = state.eventNow?.id ?? null;
// timers
let updatedTimer = current;
let updatedSecondaryTimer = secondaryTimer;
// whether rollLoad should be called: force reload of events
let doRollLoad = false;
// whether finished event should trigger
let isPrimaryFinished = false;
if (selectedEventId && current !== null) {
// if we have something selected and a timer, we are running
const finishAt = expectedFinish >= startedAt ? expectedFinish : expectedFinish + dayInMs;
updatedTimer = finishAt - clock;
if (updatedTimer > dayInMs) {
updatedTimer -= dayInMs;
}
if (updatedTimer <= timerConfig.triggerAhead) {
isPrimaryFinished = true;
// we need a new event
doRollLoad = true;
}
} else if (secondaryTimer >= 0) {
// if secondaryTimer is running we are in waiting to roll
updatedSecondaryTimer = secondaryTarget - clock;
if (updatedSecondaryTimer <= 0) {
// we need a new event
doRollLoad = true;
}
}
return { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished: isPrimaryFinished };
};
/**
* Calculates difference between the runtime and the schedule of an event
* Positive offset is a delay
* Negative offset is time ahead
* @param state
* @returns
*/
export function getRuntimeOffset(state: RuntimeState): MaybeNumber {
if (state.eventNow === null || state.runtime.actualStart === null) {
return null;
}
const { clock } = state;
const { timeStart, timerType } = state.eventNow;
const { addedTime, current, startedAt } = state.timer;
// if we havent started, the offset is the difference to the schedule
if (startedAt === null) {
return clock - timeStart;
}
const overtime = Math.abs(Math.min(current, 0));
// in time-to-end, offset is overtime
if (timerType === TimerType.TimeToEnd) {
return overtime;
}
const startOffset = startedAt - timeStart;
const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt;
return startOffset + addedTime + pausedTime + overtime;
}
/**
* Calculates total duration of a time span
* @param firstStart
* @param lastEnd
* @param daySpan
* @returns
*/
export function getTotalDuration(firstStart: number, lastEnd: number, daySpan: number): number {
if (!lastEnd) {
return 0;
}
let correctDay = 0;
if (lastEnd < firstStart) {
correctDay = dayInMs;
daySpan -= 1;
}
// eslint-disable-next-line prettier/prettier -- we like the clarity
return lastEnd + correctDay + daySpan * dayInMs - firstStart;
}
/**
* Calculates the expected end of the rundown
*/
export function getExpectedEnd(state: RuntimeState): MaybeNumber {
// there is no expected end if we havent started
if (state.runtime.actualStart === null) {
return null;
}
return state.runtime.plannedEnd + state.runtime.offset + state._timer.totalDelay;
}