mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-15 04:13:47 +00:00
V3 (#657)
* 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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
+8
-8
@@ -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 {};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user