diff --git a/apps/server/src/api-integration/integration.controller.ts b/apps/server/src/api-integration/integration.controller.ts index 662f1c35d..b51551eab 100644 --- a/apps/server/src/api-integration/integration.controller.ts +++ b/apps/server/src/api-integration/integration.controller.ts @@ -10,6 +10,10 @@ import { eventStore } from '../stores/EventStore.js'; import * as assert from '../utils/assert.js'; import { isEmptyObject } from '../utils/parserUtils.js'; import { parseProperty, updateEvent } from './integration.utils.js'; +import { throttle } from '../utils/throttle.js'; +import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js'; + +const throttledUpdateEvent = throttle(updateEvent, 20); export function dispatchFromAdapter(type: string, payload: unknown, _source?: 'osc' | 'ws' | 'http') { const action = type.toLowerCase(); @@ -43,6 +47,8 @@ const actionHandlers: Record = { const data = payload[id as keyof typeof payload]; const patchEvent: Partial & { id: string } = { id }; + let shouldThrottle = false; + Object.entries(data).forEach(([property, value]) => { if (typeof property !== 'string' || value === undefined) { throw new Error('Invalid property or value'); @@ -50,6 +56,9 @@ const actionHandlers: Record = { const newObjectProperty = parseProperty(property, value); + const key = Object.keys(newObjectProperty)[0] as keyof OntimeEvent; + shouldThrottle = willCauseRegeneration(key) || shouldThrottle; + if (patchEvent.custom && newObjectProperty.custom) { Object.assign(patchEvent.custom, newObjectProperty.custom); } else { @@ -57,8 +66,13 @@ const actionHandlers: Record = { } }); - updateEvent(patchEvent); - + if (shouldThrottle) { + if (throttledUpdateEvent(patchEvent)) { + return { payload: 'throttled' }; + } + } else { + updateEvent(patchEvent); + } return { payload: 'success' }; }, /* Message Service */ diff --git a/apps/server/src/api-integration/integration.utils.ts b/apps/server/src/api-integration/integration.utils.ts index 8e0ae55fc..bc0bbbf05 100644 --- a/apps/server/src/api-integration/integration.utils.ts +++ b/apps/server/src/api-integration/integration.utils.ts @@ -1,24 +1,45 @@ -import { OntimeEvent, isKeyOfType, isOntimeEvent } from 'ontime-types'; +import { EndAction, OntimeEvent, TimerType, isKeyOfType, isOntimeEvent } from 'ontime-types'; import { MILLIS_PER_SECOND, maxDuration } from 'ontime-utils'; import { DataProvider } from '../classes/data-provider/DataProvider.js'; import { editEvent } from '../services/rundown-service/RundownService.js'; import { getEventWithId } from '../services/rundown-service/rundownUtils.js'; -import { coerceBoolean, coerceColour, coerceNumber, coerceString } from '../utils/coerceType.js'; +import { coerceBoolean, coerceColour, coerceEnum, coerceNumber, coerceString } from '../utils/coerceType.js'; -const whitelistedPayload = { +/** + * + * @param {number} value time amount in seconds + * @returns {number} time in milliseconds clamped to 0 and max duration + */ +function clampDuration(value: number) { + const valueInMillis = value * MILLIS_PER_SECOND; + if (valueInMillis > maxDuration || valueInMillis < 0) { + throw new Error('Times should be from 0 to 23:59:59'); + } + return valueInMillis; +} + +const propertyConversion = { title: coerceString, note: coerceString, cue: coerceString, - duration: (value: unknown) => Math.max(coerceNumber(value) * MILLIS_PER_SECOND, maxDuration), - isPublic: coerceBoolean, skip: coerceBoolean, colour: coerceColour, custom: coerceString, + + timeWarning: (value: unknown) => clampDuration(coerceNumber(value)), + timeDanger: (value: unknown) => clampDuration(coerceNumber(value)), + + endAction: (value: unknown) => coerceEnum(value, EndAction), + timerType: (value: unknown) => coerceEnum(value, TimerType), + + duration: (value: unknown) => clampDuration(coerceNumber(value)), + timeStart: (value: unknown) => clampDuration(coerceNumber(value)), + timeEnd: (value: unknown) => clampDuration(coerceNumber(value)), }; export function parseProperty(property: string, value: unknown) { @@ -27,13 +48,13 @@ export function parseProperty(property: string, value: unknown) { if (!(customKey in DataProvider.getCustomFields())) { throw new Error(`Custom field ${customKey} not found`); } - const parserFn = whitelistedPayload.custom; + const parserFn = propertyConversion.custom; return { custom: { [customKey]: parserFn(value) } }; } - if (!isKeyOfType(property, whitelistedPayload)) { + if (!isKeyOfType(property, propertyConversion)) { throw new Error(`Property ${property} not permitted`); } - const parserFn = whitelistedPayload[property]; + const parserFn = propertyConversion[property]; return { [property]: parserFn(value) }; } @@ -50,6 +71,5 @@ export function updateEvent(patchEvent: Partial & { id: string }) { if (!isOntimeEvent(event)) { throw new Error('Can only update events'); } - editEvent(patchEvent); } diff --git a/apps/server/src/services/rundown-service/rundownCacheUtils.ts b/apps/server/src/services/rundown-service/rundownCacheUtils.ts index f9dbe4a6a..ed12d6f3d 100644 --- a/apps/server/src/services/rundown-service/rundownCacheUtils.ts +++ b/apps/server/src/services/rundown-service/rundownCacheUtils.ts @@ -100,7 +100,7 @@ export function handleCustomField( } /** List of event properties which do not need the rundown to be regenerated */ -enum regenerateWhitelist { +export enum regenerateWhitelist { 'id', 'cue', 'title', @@ -122,6 +122,14 @@ export function isDataStale(patch: Partial): boolean { return Object.keys(patch).some((key) => !(key in regenerateWhitelist)); } +/** + * given a key, returns whether it is whitelisted + * @param path + */ +export function willCauseRegeneration(key: keyof OntimeEvent): boolean { + return !(key in regenerateWhitelist); +} + /** * Given an event and a patch to that event checks whether there are actual changes to the dataset * @param existingEvent diff --git a/apps/server/src/utils/__tests__/coerceType.test.ts b/apps/server/src/utils/__tests__/coerceType.test.ts index eeaa43b37..65699d443 100644 --- a/apps/server/src/utils/__tests__/coerceType.test.ts +++ b/apps/server/src/utils/__tests__/coerceType.test.ts @@ -1,4 +1,4 @@ -import { coerceColour } from '../coerceType.js'; +import { coerceColour, coerceEnum } from '../coerceType.js'; describe('parses a colour string that is', () => { it('valid hex', () => { @@ -19,3 +19,21 @@ describe('parses a colour string that is', () => { expect(() => coerceColour(5)).toThrowError(Error('Invalid colour value received')); }); }); + +describe('match a string to an enum that is', () => { + enum testEnum { + 'abc', + 'def', + 'ghi', + } + it('valid key', () => { + const key = coerceEnum('abc', testEnum); + expect(key).toBe('abc'); + }); + it('invalid key', () => { + expect(() => coerceEnum('123', testEnum)).toThrowError(Error('Invalid value received')); + }); + it('invalid type', () => { + expect(() => coerceEnum(123, testEnum)).toThrowError(Error('Invalid value received')); + }); +}); diff --git a/apps/server/src/utils/coerceType.ts b/apps/server/src/utils/coerceType.ts index 8360f3db2..ea9094fab 100644 --- a/apps/server/src/utils/coerceType.ts +++ b/apps/server/src/utils/coerceType.ts @@ -1,5 +1,18 @@ import { isColourHex } from 'ontime-utils'; +/** + * @description Converts a value to an item in the provided enume. + * @param {unknown} value - Value to be converted. + * @returns {T} - The converted value as key of the enum. + * @throws {Error} Throws an error value is not found in the enum. + */ +export function coerceEnum(value: unknown, list: object): T { + if (typeof value !== 'string' || !(value in list)) { + throw new Error('Invalid value received'); + } + return value as T; +} + //TODO: write tests /** * @description Converts a value to a string if possible, throws otherwise diff --git a/apps/server/src/utils/throttle.ts b/apps/server/src/utils/throttle.ts index c72853a6c..b75dde271 100644 --- a/apps/server/src/utils/throttle.ts +++ b/apps/server/src/utils/throttle.ts @@ -22,11 +22,12 @@ export function throttle(cb: (...args: T) => U, delay: numbe return (...args: T) => { if (shouldWait) { waitingArgs = args; - return; + return true; } cb(...args); shouldWait = true; setTimeout(timeoutFunc, delay); + return false; }; }