refactor: cuesheet v2 (#435)

* refactor: typescript migration

* chore: remove prop-types package

* refactor: file structure

* refactor: migrate ontime table to tanstack table 8

* refactor: rundown controller uses service as data source

* refactor: convert to typescript

* feat: caching store

* refactor: add delay values to rundown

* feat: toggle past visibility

* chore: update tests

* refactor: add extra fields to CSV

* style: show skipped events

* chore: add route to navigation menu

* style: allow jumping to bottom

* chore: add tests
This commit is contained in:
Carlos Valente
2023-07-22 09:32:40 +02:00
committed by GitHub
parent bee7a8dcb4
commit 3603b836f6
80 changed files with 3188 additions and 2434 deletions
+20 -20
View File
@@ -1,4 +1,4 @@
import { OntimeEvent } from 'ontime-types';
import { LogOrigin, OntimeEvent, Playback } from 'ontime-types';
import { validatePlayback } from 'ontime-utils';
import { eventLoader, EventLoader } from '../classes/event-loader/EventLoader.js';
@@ -20,9 +20,9 @@ export class PlaybackService {
static loadEvent(event: OntimeEvent): boolean {
let success = false;
if (!event) {
logger.error('PLAYBACK', 'No event found');
logger.error(LogOrigin.Playback, 'No event found');
} else if (event.skip) {
logger.warning('PLAYBACK', `Refused playback of skipped event ID ${event.id}`);
logger.warning(LogOrigin.Playback, `Refused playback of skipped event ID ${event.id}`);
} else {
eventLoader.loadEvent(event);
eventTimer.load(event);
@@ -41,7 +41,7 @@ export class PlaybackService {
const event = EventLoader.getEventWithId(eventId);
const success = PlaybackService.loadEvent(event);
if (success) {
logger.info('PLAYBACK', `Loaded event with ID ${event.id}`);
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
PlaybackService.start();
}
return success;
@@ -56,7 +56,7 @@ export class PlaybackService {
const event = EventLoader.getEventAtIndex(eventIndex);
const success = PlaybackService.loadEvent(event);
if (success) {
logger.info('PLAYBACK', `Loaded event with ID ${event.id}`);
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
PlaybackService.start();
}
return success;
@@ -71,7 +71,7 @@ export class PlaybackService {
const event = EventLoader.getEventWithId(eventId);
const success = PlaybackService.loadEvent(event);
if (success) {
logger.info('PLAYBACK', `Loaded event with ID ${event.id}`);
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
}
return success;
}
@@ -85,7 +85,7 @@ export class PlaybackService {
const event = EventLoader.getEventAtIndex(eventIndex);
const success = PlaybackService.loadEvent(event);
if (success) {
logger.info('PLAYBACK', `Loaded event with ID ${event.id}`);
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
}
return success;
}
@@ -98,7 +98,7 @@ export class PlaybackService {
if (previousEvent) {
const success = PlaybackService.loadEvent(previousEvent);
if (success) {
logger.info('PLAYBACK', `Loaded event with ID ${previousEvent.id}`);
logger.info(LogOrigin.Playback, `Loaded event with ID ${previousEvent.id}`);
}
}
}
@@ -113,19 +113,19 @@ export class PlaybackService {
if (nextEvent) {
const success = PlaybackService.loadEvent(nextEvent);
if (success) {
logger.info('PLAYBACK', `Loaded event with ID ${nextEvent.id}`);
logger.info(LogOrigin.Playback, `Loaded event with ID ${nextEvent.id}`);
return true;
}
} else if (fallbackAction === 'stop') {
logger.info('PLAYBACK', 'No next event found! Stopping playback');
logger.info(LogOrigin.Playback, 'No next event found! Stopping playback');
PlaybackService.stop();
return false;
} else if (fallbackAction === 'pause') {
logger.info('PLAYBACK', 'No next event found! Pausing playback');
logger.info(LogOrigin.Playback, 'No next event found! Pausing playback');
PlaybackService.pause();
return false;
} else {
logger.info('PLAYBACK', 'No next event found! Continuing playback');
logger.info(LogOrigin.Playback, 'No next event found! Continuing playback');
return false;
}
}
@@ -137,7 +137,7 @@ export class PlaybackService {
if (validatePlayback(eventTimer.playback).start) {
eventTimer.start();
const newState = eventTimer.playback;
logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
}
}
@@ -159,7 +159,7 @@ export class PlaybackService {
if (validatePlayback(eventTimer.playback).pause) {
eventTimer.pause();
const newState = eventTimer.playback;
logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
}
}
@@ -171,7 +171,7 @@ export class PlaybackService {
eventLoader.reset();
eventTimer.stop();
const newState = eventTimer.playback;
logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
}
}
@@ -193,14 +193,14 @@ export class PlaybackService {
// nothing to play
if (rollTimers === null) {
logger.warning('SERVER', 'Roll: no events found');
logger.warning(LogOrigin.Server, 'Roll: no events found');
PlaybackService.stop();
return;
}
const { currentEvent, nextEvent } = rollTimers;
if (!currentEvent && !nextEvent) {
logger.warning('SERVER', 'Roll: no events found');
logger.warning(LogOrigin.Server, 'Roll: no events found');
PlaybackService.stop();
return;
}
@@ -208,7 +208,7 @@ export class PlaybackService {
eventTimer.roll(currentEvent, nextEvent);
const newState = eventTimer.playback;
logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
}
}
@@ -221,8 +221,8 @@ export class PlaybackService {
const delayInMs = delayTime * 1000 * 60;
eventTimer.delay(delayInMs);
delayInMs > 0
? logger.info('PLAYBACK', `Added ${delayTime} min delay`)
: logger.info('PLAYBACK', `Removed ${delayTime} min delay`);
? logger.info(LogOrigin.Playback, `Added ${delayTime} min delay`)
: logger.info(LogOrigin.Playback, `Removed ${delayTime} min delay`);
}
}
}
@@ -1,11 +1,30 @@
import { OntimeBaseEvent, OntimeBlock, OntimeDelay, OntimeEvent, SupportedEvent } from 'ontime-types';
import {
LogOrigin,
OntimeBaseEvent,
OntimeBlock,
OntimeDelay,
OntimeEvent,
OntimeRundown,
SupportedEvent,
} from 'ontime-types';
import { generateId } from 'ontime-utils';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
import { block as blockDef, delay as delayDef, event as eventDef } 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 { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { block as blockDef, delay, delay as delayDef, event as eventDef } 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,
cachedDelete,
cachedEdit,
cachedReorder,
calculateRuntimeDelaysFrom,
delayedRundownCacheKey,
getDelayedRundown,
} from './delayedRundown.utils.js';
import { logger } from '../../classes/Logger.js';
/**
* Forces rundown to be recalculated
@@ -14,6 +33,7 @@ import { sendRefetch } from '../adapters/websocketAux.js';
export function forceReset() {
eventLoader.reset();
sendRefetch();
runtimeCacheStore.invalidate(delayedRundownCacheKey);
}
/**
@@ -73,7 +93,7 @@ const isNewNext = () => {
};
/**
* Updates timer object
* Updates timer service when a relevant piece of data changes
*/
export function updateTimer(affectedIds?: string[]) {
const runningEventId = eventLoader.loaded.selectedEventId;
@@ -130,44 +150,54 @@ export async function addEvent(eventData: Partial<OntimeEvent> | Partial<OntimeD
let newEvent: Partial<OntimeBaseEvent> = {};
const id = generateId();
// TODO: filter the parameters that exist in the event, use the parserUtils
switch (eventData.type) {
case 'event':
newEvent = { ...eventDef, ...eventData, id } as Partial<OntimeEvent>;
case SupportedEvent.Event:
newEvent = { ...eventDef, ...eventData, id };
break;
case 'delay':
newEvent = { ...delayDef, ...eventData, id } as Partial<OntimeDelay>;
case SupportedEvent.Delay:
newEvent = { ...delayDef, ...eventData, id };
break;
case 'block':
newEvent = { ...blockDef, ...eventData, id } as Partial<OntimeBlock>;
case SupportedEvent.Block:
newEvent = { ...blockDef, ...eventData, id };
break;
}
try {
const afterId = newEvent?.after;
if (typeof afterId === 'undefined') {
await DataProvider.insertEventAt(newEvent, 0);
let insertIndex = 0;
if (typeof newEvent?.after !== 'undefined') {
const index = DataProvider.getIndexOf(newEvent.after);
if (index < 0) {
logger.warning(LogOrigin.Server, `Could not find event with id ${newEvent.after}`);
} else {
delete newEvent.after;
await DataProvider.insertEventAfterId(newEvent, afterId);
insertIndex = index + 1;
}
} catch (error) {
throw new Error(error);
delete newEvent.after;
}
// modify rundown
await cachedAdd(insertIndex, newEvent as OntimeEvent | OntimeDelay | OntimeBlock);
// notify timer service of changed events
updateTimer([id]);
// notify event loader that rundown size has changed
updateChangeNumEvents();
// advice socket subscribers of change
sendRefetch();
return newEvent;
}
export async function editEvent(eventData) {
const eventId = eventData.id;
const eventInMemory = DataProvider.getEventById(eventId);
if (typeof eventInMemory === 'undefined') {
throw new Error('No event with ID found');
}
const newEvent = await DataProvider.updateEventById(eventId, eventData);
updateTimer([eventId]);
export async function editEvent(eventData: Partial<OntimeEvent> | Partial<OntimeBlock> | Partial<OntimeDelay>) {
const newEvent = await cachedEdit(eventData.id, eventData);
// notify timer service of changed events
updateTimer([newEvent.id]);
// advice socket subscribers of change
sendRefetch();
return newEvent;
}
@@ -177,9 +207,18 @@ export async function editEvent(eventData) {
* @returns {Promise<void>}
*/
export async function deleteEvent(eventId) {
await DataProvider.deleteEvent(eventId);
await cachedDelete(eventId);
// notify timer service of changed events
updateTimer([eventId]);
// notify event loader that rundown size has changed
updateChangeNumEvents();
// invalidate cache
runtimeCacheStore.invalidate(delayedRundownCacheKey);
// advice socket subscribers of change
sendRefetch();
}
@@ -190,78 +229,83 @@ export async function deleteEvent(eventId) {
export async function deleteAllEvents() {
await DataProvider.clearRundown();
updateTimer();
updateChangeNumEvents();
sendRefetch();
forceReset();
}
/**
* reorders a given event
* @param {string} eventId
* @param {number} from
* @param {number} to
* @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, from, to) {
const rundown = DataProvider.getRundown();
const index = rundown.findIndex((event) => event.id === eventId);
export async function reorderEvent(eventId: string, from: number, to: number) {
const reorderedItem = await cachedReorder(eventId, from, to);
if (index !== from) {
throw new Error('ID not found at index');
}
const [reorderedItem] = rundown.splice(from, 1);
// reinsert item at to
rundown.splice(to, 0, reorderedItem);
// save rundown
await DataProvider.setRundown(rundown);
// notify timer service of changed events
updateTimer();
// advice socket subscribers of change
sendRefetch();
return reorderedItem;
}
export function _applyDelay(
eventId: string,
rundown: OntimeRundown,
): {
delayIndex: number | null;
updatedRundown: OntimeRundown;
} {
const updatedRundown = [...rundown];
let delayIndex = null;
let delayValue = 0;
for (const [index, event] of updatedRundown.entries()) {
// look for delay
if (delayIndex === null) {
if (event.type === SupportedEvent.Delay && event.id === eventId) {
delayValue = event.duration;
delayIndex = index;
if (delayValue === 0) {
// nothing to apply
break;
}
}
continue;
}
// once delay is found, apply delay value to all items until block or end
if (event.type === SupportedEvent.Event) {
updatedRundown[index] = {
...event,
timeStart: Math.max(0, event.timeStart + delayValue),
timeEnd: Math.max(event.duration, event.timeEnd + delayValue),
revision: event.revision + 1,
};
} else if (event.type === SupportedEvent.Block) {
break;
}
}
return { delayIndex, updatedRundown };
}
/**
* applies delay value for given event
* @param eventId
* @returns {Promise<void>}
*/
export async function applyDelay(eventId: string) {
const rundown = DataProvider.getRundown();
let delayIndex = null;
let delayValue = 0;
for (const [index, event] of rundown.entries()) {
// look for delay
if (delayIndex === null) {
if (event.id === eventId && event.type === SupportedEvent.Delay) {
delayValue = event.duration;
delayIndex = index;
}
}
// apply delay value to all items until block or end
else {
if (event.type === SupportedEvent.Event) {
event.timeStart = Math.max(0, event.timeStart + delayValue);
event.timeEnd = Math.max(event.duration, event.timeStart + delayValue);
event.revision += 1;
} else if (event.type === SupportedEvent.Block) {
break;
}
}
}
const rundown: OntimeRundown = DataProvider.getRundown();
const { delayIndex, updatedRundown } = _applyDelay(eventId, rundown);
if (delayIndex === null) {
throw new Error(`Delay event with ID ${eventId} not found`);
}
// delete delay
rundown.splice(delayIndex, 1);
// update rundown
await DataProvider.setRundown(rundown);
updateTimer();
sendRefetch();
await DataProvider.setRundown(updatedRundown);
await deleteEvent(eventId);
}
/**
@@ -0,0 +1,319 @@
import { EndAction, OntimeRundown, SupportedEvent, TimerType } from 'ontime-types';
import { _applyDelay } from '../RundownService.js';
describe('applyDelay()', () => {
it('applies its duration to following events', () => {
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: 4,
id: '659e1',
},
{
duration: 600000,
type: SupportedEvent.Delay,
revision: 0,
id: '07986',
},
{
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: 4,
id: 'd48c2',
},
];
const eventId = rundown[1].id;
const { delayIndex, updatedRundown } = _applyDelay(eventId, rundown);
expect(delayIndex).toBe(1);
// we do not delay delays anymore
expect(updatedRundown.length).toBe(3);
expect(rundown.length).toBe(3);
expect(updatedRundown[0].timeStart).toBe(rundown[0].timeStart);
expect(updatedRundown[2].timeStart).toBe(rundown[1].duration + rundown[2].timeStart);
expect(updatedRundown[2].timeEnd).toBe(rundown[1].duration + rundown[2].timeEnd);
});
it('stops propagating on blocks', () => {
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',
},
{
duration: 600000,
type: SupportedEvent.Delay,
revision: 0,
id: '07986',
},
{
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',
},
{
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: 2,
id: '2f185',
},
];
const eventId = rundown[1].id;
const { updatedRundown } = _applyDelay(eventId, rundown);
expect(updatedRundown[0].timeStart).toBe(rundown[0].timeStart);
expect(updatedRundown[2].timeStart).toBe(rundown[1].duration + rundown[2].timeStart);
expect(updatedRundown[4].timeStart).toBe(rundown[4].timeStart);
});
it('only applies given delay', () => {
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',
},
{
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',
},
{
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',
},
{
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',
},
];
const eventId = rundown[1].id;
const { updatedRundown } = _applyDelay(eventId, rundown);
expect(updatedRundown[0].timeStart).toBe(rundown[0].timeStart);
expect(updatedRundown[2].timeStart).toBe(rundown[1].duration + rundown[2].timeStart);
expect(updatedRundown[4].timeStart).toBe(rundown[1].duration + rundown[4].timeStart);
});
});
@@ -0,0 +1,445 @@
import { EndAction, 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',
},
{
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',
},
{
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',
},
{
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',
},
];
const updatedRundown = calculateRuntimeDelays(rundown);
expect(rundown.length).toBe(updatedRundown.length);
expect(updatedRundown[0].delay).toBe(0);
expect(updatedRundown[2].delay).toBe(600000);
expect(updatedRundown[4].delay).toBe(600000 + 1200000);
expect(updatedRundown[6].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,
},
{
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,
},
{
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,
},
{
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,
},
];
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,
},
{
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,
},
{
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,
},
{
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,
},
];
const updatedRundown = calculateRuntimeDelaysFrom('07986', delayedRundown);
// we only update from the 4th on
expect(updatedRundown[0].delay).toBe(0);
// 1 + 3
expect(updatedRundown[4].delay).toBe(600000 + 1200000);
});
});
@@ -0,0 +1,261 @@
import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
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';
/**
* 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 getDelayedRundown(): OntimeRundown {
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 (event.type === SupportedEvent.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);
}
/**
* 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 };
if (newEvent.type === SupportedEvent.Event) {
newEvent.revision++;
}
// @ts-expect-error -- this merge is safe
updatedRundown[indexInMemory] = newEvent;
let newDelayedRundown = getDelayedRundown();
if (newDelayedRundown?.[indexInMemory].id !== newEvent.id) {
invalidateFromError();
} else {
// @ts-expect-error -- this merge is safe
newDelayedRundown[indexInMemory] = newEvent;
if (newEvent.type === SupportedEvent.Event) {
(newDelayedRundown[indexInMemory] as OntimeEvent).delay = getDelayAt(indexInMemory, newDelayedRundown);
} else if (newEvent.type === SupportedEvent.Delay) {
// 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);
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();
}
return;
}
let updatedRundown = DataProvider.getRundown();
const eventType = updatedRundown[eventIndex].type;
updatedRundown = deleteAtIndex(eventIndex, updatedRundown);
if (eventId !== delayedRundown[eventIndex].id) {
invalidateFromError();
} else {
delayedRundown = deleteAtIndex(eventIndex, delayedRundown);
if (eventType === SupportedEvent.Delay || eventType === SupportedEvent.Block) {
// 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);
}
/**
* 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);
return reorderedEvent;
}
/**
* 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 (event.type === SupportedEvent.Delay) {
accumulatedDelay += event.duration;
} else if (event.type === SupportedEvent.Block) {
accumulatedDelay = 0;
} else if (event.type === SupportedEvent.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 (event.type === SupportedEvent.Delay) {
accumulatedDelay += event.duration;
} else if (event.type === SupportedEvent.Block) {
if (i === eventIndex) {
accumulatedDelay = 0;
} else {
break;
}
} else if (event.type === SupportedEvent.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 (event.type === SupportedEvent.Delay) {
return event.duration + getDelayAt(eventIndex - 1, rundown);
} else if (event.type === SupportedEvent.Block) {
return 0;
} else if (event.type === SupportedEvent.Event) {
return event.delay ?? 0;
}
return 0;
}