import { CustomFields, OntimeBlock, OntimeDelay, OntimeEvent, OntimeEntry, isOntimeBlock, isOntimeDelay, isOntimeEvent, PatchWithId, EventPostPayload, Rundown, EntryId, } from 'ontime-types'; import { getCueCandidate } from 'ontime-utils'; import { block as blockDef, delay as delayDef } from '../../models/eventsDefinition.js'; import { sendRefetch } from '../../adapters/websocketAux.js'; import { createEvent } from '../../api-data/rundown/rundown.utils.js'; import { updateRundownData } from '../../stores/runtimeState.js'; import { runtimeService } from '../runtime-service/RuntimeService.js'; import * as cache from './rundownCache.js'; import { getInsertionPosition } from './rundownUtils.js'; type CompleteEntry = T extends Partial ? OntimeEvent : T extends Partial ? OntimeDelay : T extends Partial ? OntimeBlock : never; /** * Generates a fully formed RundownEntry of the patch type */ function generateEvent | Partial | Partial>( eventData: T, afterId?: string, ): CompleteEntry { if (isOntimeEvent(eventData)) { const currentRundown = cache.getCurrentRundown(); return createEvent( eventData, getCueCandidate(currentRundown.entries, currentRundown.order, afterId), ) as CompleteEntry; } const id = eventData.id || cache.getUniqueId(); if (isOntimeDelay(eventData)) { return { ...delayDef, duration: eventData.duration ?? 0, id } as CompleteEntry; } // TODO(v4): allow user to provide a larger patch of the block entry if (isOntimeBlock(eventData)) { return { ...blockDef, title: eventData?.title ?? '', id } as CompleteEntry; } throw new Error('Invalid event type'); } /** * creates a new event with given data */ export async function addEvent(eventData: EventPostPayload): Promise { // 1. we allow the user to provide an ID, but make sure it is unique if (eventData?.id && cache.hasId(eventData.id)) { throw new Error(`Event with ID ${eventData.id} already exists`); } // 2. if the user provides a parent (inside a group), we make sure it exists let parent: EntryId | null = null; if ('parent' in eventData && eventData.parent != null) { if (!cache.hasId(eventData.parent)) { throw new Error(`Parent event with ID ${eventData.parent} not found`); } parent = eventData.parent; } // 3. if the user provides an after or before ID, we make sure it exists if (eventData?.after !== undefined) { if (!cache.hasId(eventData.after)) { throw new Error(`Event with ID ${eventData.after} not found`); } } if (eventData?.before !== undefined) { if (!cache.hasId(eventData.before)) { throw new Error(`Event with ID ${eventData.before} not found`); } } const { afterId, atIndex } = getInsertionPosition(parent, eventData?.after, eventData?.before); // generate a fully formed entry from the patch const sanitisedEntry = generateEvent(eventData, afterId); // modify rundown const scopedMutation = cache.mutateCache(cache.add); const { newEvent } = await scopedMutation({ atIndex, parent, entry: sanitisedEntry }); // notify runtime that rundown has changed updateRuntimeOnChange(); // notify timer and external services of change notifyChanges({ timer: [sanitisedEntry.id], external: true }); // we know this mutation returns an OntimeEntry return newEvent as OntimeEntry; } /** * deletes event by its ID */ export async function deleteEvent(eventIds: string[]) { const scopedMutation = cache.mutateCache(cache.remove); const { didMutate } = await scopedMutation({ eventIds }); if (!didMutate) { return; } // notify runtime that rundown has changed updateRuntimeOnChange(); // notify timer and external services of change notifyChanges({ timer: eventIds, external: true }); } /** * deletes all entries in database */ export async function deleteAllEntries() { const scopedMutation = cache.mutateCache(cache.removeAll); await scopedMutation({}); // 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) { 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) { 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 }); } /** * reorders a given entry * @param {string} eventId - ID of event from, for sanity check * @param {number} from - index of event from * @param {number} to - index of event to */ export async function reorderEntry(eventId: EntryId, from: number, to: number): Promise { const scopedMutation = cache.mutateCache(cache.reorder); const { changeList, newRundown } = await scopedMutation({ eventId, from, to }); // notify runtime that rundown has changed updateRuntimeOnChange(); // notify timer and external services of change notifyChanges({ timer: changeList, external: true }); return newRundown; } export async function applyDelay(delayId: EntryId) { const scopedMutation = cache.mutateCache(cache.applyDelay); await scopedMutation({ delayId }); // notify runtime that rundown has changed updateRuntimeOnChange(); // notify timer and external services of change notifyChanges({ timer: true, external: true }); } /** * Deletes a block from the rundown and moves all its children to the top level */ export async function dissolveBlock(blockId: EntryId) { const scopedMutation = cache.mutateCache(cache.dissolveBlock); const { newRundown } = await scopedMutation({ blockId }); // notify runtime that rundown has changed updateRuntimeOnChange(); // we dont need to modify the timer since the grouping does not affect the runtime notifyChanges({ external: true }); return newRundown; } /** * swaps two events * @param {string} from - id of event from * @param {string} to - id of event to * @returns {Promise} */ export async function swapEvents(from: string, to: string) { 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 }); } /** * Forces update in the store * Called when we make changes to the rundown object */ function updateRuntimeOnChange() { const { timedEventsOrder } = cache.getEventOrder(); const numEvents = timedEventsOrder.length; const metadata = cache.getMetadata(); // schedule an update for the end of the event loop setImmediate(() => updateRundownData({ numEvents, ...metadata, }), ); } type NotifyChangesOptions = { timer?: boolean | string[]; // whether to notify the timer, could be a yes / no or an array of affected IDs external?: boolean; // whether to notify external services reload?: boolean; // major change, clients should consider refetching everything }; /** * Notify services of changes in the rundown */ function notifyChanges(options: NotifyChangesOptions) { if (options.timer) { const { playableEventsOrder } = cache.getEventOrder(); if (playableEventsOrder.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.notifyOfChangedEvents(affected); } } if (options.external) { // advice socket subscribers of change const payload = { target: 'RUNDOWN', changes: Array.isArray(options.timer) ? options.timer : undefined, reload: options.reload, revision: cache.getMetadata().revision, }; sendRefetch(payload); } } /** * Sets a new rundown in the cache * and marks it as the currently loaded one */ export async function initRundown(rundown: Readonly, customFields: Readonly) { await cache.init(rundown, customFields); // notify runtime that rundown has changed updateRuntimeOnChange(); // notify timer of change notifyChanges({ timer: true, external: true, reload: true }); }