mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-12 17:49:37 +00:00
feat(automation): define validated automation recipes
Provide tested recipe definitions, lifecycle labels, and input validation for common automation setups.
This commit is contained in:
+193
@@ -0,0 +1,193 @@
|
|||||||
|
import { isHTTPOutput, isOSCOutput, isOntimeAction, timerLifecycleValues } from 'ontime-types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
automationRecipes,
|
||||||
|
defaultValues,
|
||||||
|
getAvailableRecipes,
|
||||||
|
recipeCategoryOrder,
|
||||||
|
validateRecipeValues,
|
||||||
|
} from '../automationRecipes';
|
||||||
|
import { operators } from '../automationUtils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recipes are shipped as constants but created through the same endpoint as a hand written
|
||||||
|
* automation. These assertions stand in for the server side validation, so a recipe cannot
|
||||||
|
* silently rot into something that 400s when the user presses create.
|
||||||
|
*/
|
||||||
|
describe('automationRecipes', () => {
|
||||||
|
const built = automationRecipes.map((recipe) => ({ recipe, automation: recipe.build(defaultValues(recipe)) }));
|
||||||
|
|
||||||
|
it('has unique ids', () => {
|
||||||
|
const ids = automationRecipes.map(({ id }) => id);
|
||||||
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only uses categories the picker knows how to render', () => {
|
||||||
|
for (const { recipe } of built) {
|
||||||
|
expect(recipeCategoryOrder).toContain(recipe.category);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('binds every recipe to at least one valid lifecycle', () => {
|
||||||
|
for (const { recipe } of built) {
|
||||||
|
expect(recipe.triggers.length).toBeGreaterThan(0);
|
||||||
|
for (const cycle of recipe.triggers) {
|
||||||
|
expect(timerLifecycleValues).toContain(cycle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a titled automation with something to send, from its own defaults', () => {
|
||||||
|
for (const { automation } of built) {
|
||||||
|
expect(automation.title).not.toBe('');
|
||||||
|
expect(automation.outputs.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const output of automation.outputs) {
|
||||||
|
expect(isOSCOutput(output) || isHTTPOutput(output) || isOntimeAction(output)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives every choice parameter options, and a default that is one of them', () => {
|
||||||
|
const choices = automationRecipes.flatMap(({ params }) => params.filter(({ type }) => type === 'choice'));
|
||||||
|
expect(choices.filter(({ options }) => !options?.length)).toEqual([]);
|
||||||
|
expect(choices.filter(({ options, defaultValue }) => !options?.some((o) => o.value === defaultValue))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads every parameter it declares', () => {
|
||||||
|
// a param the builder ignores is a field the user fills in for nothing, and a typo in
|
||||||
|
// either half would put the literal 'undefined' inside a URL
|
||||||
|
for (const { recipe } of built) {
|
||||||
|
for (const param of recipe.params) {
|
||||||
|
// a choice can only take one of its own options, so probe with the last one
|
||||||
|
if (param.type === 'choice') {
|
||||||
|
const last = param.options?.at(-1)?.value ?? '';
|
||||||
|
expect(JSON.stringify(recipe.build({ ...defaultValues(recipe), [param.name]: last }))).toContain(last);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const marker =
|
||||||
|
param.type === 'number' ? '4242' : param.validation?.kind === 'url' ? 'http://ontime-probe' : 'ontime-probe';
|
||||||
|
const probed = { ...defaultValues(recipe), [param.name]: marker };
|
||||||
|
expect(JSON.stringify(recipe.build(probed))).toContain(marker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only uses filter operators the server accepts', () => {
|
||||||
|
const allowed = operators.map(({ value }) => value);
|
||||||
|
for (const { automation } of built) {
|
||||||
|
for (const filter of automation.filters) {
|
||||||
|
expect(allowed).toContain(filter.operator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults every external target to this machine', () => {
|
||||||
|
const outputs = built.flatMap(({ automation }) => automation.outputs);
|
||||||
|
const osc = outputs.filter(isOSCOutput);
|
||||||
|
const http = outputs.filter(isHTTPOutput);
|
||||||
|
|
||||||
|
// filtering rather than asserting in a branch, so a failure names the offending recipe
|
||||||
|
expect(osc.filter(({ targetIP }) => targetIP !== '127.0.0.1')).toEqual([]);
|
||||||
|
expect(osc.filter(({ targetPort }) => !Number.isFinite(targetPort))).toEqual([]);
|
||||||
|
expect(http.filter(({ url }) => !url.startsWith('http://127.0.0.1'))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides local-network recipes in Ontime Cloud', () => {
|
||||||
|
expect(getAvailableRecipes(true).map(({ id }) => id)).toEqual([
|
||||||
|
'ontime-aux-timer',
|
||||||
|
'ontime-aux-stop',
|
||||||
|
'ontime-warn-stage',
|
||||||
|
'ontime-clear-message',
|
||||||
|
'ontime-secondary-message',
|
||||||
|
'webhook-event-title',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates recipe addresses and numeric bounds', () => {
|
||||||
|
const qlab = automationRecipes.find(({ id }) => id === 'qlab-go');
|
||||||
|
const companion = automationRecipes.find(({ id }) => id === 'companion-press');
|
||||||
|
const vmix = automationRecipes.find(({ id }) => id === 'vmix-overlay-warning');
|
||||||
|
const webhook = automationRecipes.find(({ id }) => id === 'webhook-event-title');
|
||||||
|
|
||||||
|
expect(qlab && validateRecipeValues(qlab, { ip: 'not a host', port: '70000' })).toEqual({
|
||||||
|
ip: 'Enter an IP address or hostname',
|
||||||
|
port: 'Enter a whole number from 1024 to 65535',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
companion && validateRecipeValues(companion, { host: 'localhost:8888', page: '1.5', row: '-1', column: '0' }),
|
||||||
|
).toEqual({
|
||||||
|
host: 'Enter a URL starting with http:// or https://',
|
||||||
|
page: 'Enter a whole number of 1 or more',
|
||||||
|
row: 'Enter a whole number of 0 or more',
|
||||||
|
});
|
||||||
|
expect(vmix && validateRecipeValues(vmix, { host: 'http://127.0.0.1:8088', overlay: '5' })).toEqual({
|
||||||
|
overlay: 'Enter a whole number from 1 to 4',
|
||||||
|
});
|
||||||
|
expect(webhook && validateRecipeValues(webhook, { url: 'ftp://example.com' })).toEqual({
|
||||||
|
url: 'Enter a URL starting with http:// or https://',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts every recipe default', () => {
|
||||||
|
for (const recipe of automationRecipes) {
|
||||||
|
expect(validateRecipeValues(recipe, defaultValues(recipe))).toEqual({});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** the outputs a recipe builds from the given answers, as plain JSON to assert against */
|
||||||
|
function buildWith(id: string, values: Record<string, string>) {
|
||||||
|
const recipe = automationRecipes.find((candidate) => candidate.id === id);
|
||||||
|
return JSON.stringify(recipe?.build(values).outputs);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('tolerates a URL that already carries a query', () => {
|
||||||
|
expect(buildWith('webhook-event-title', { url: 'http://127.0.0.1:3000/now?source=ontime' })).toContain(
|
||||||
|
'/now?source=ontime&title=',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the event title for URL-safe substitution', () => {
|
||||||
|
expect(buildWith('webhook-event-title', { url: 'http://127.0.0.1:3000/now' })).toContain(
|
||||||
|
'title={{url:eventNow.title}}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds webhook query parameters before a URL fragment', () => {
|
||||||
|
expect(buildWith('webhook-event-title', { url: 'http://127.0.0.1:3000/now#details' })).toContain(
|
||||||
|
'/now?title={{url:eventNow.title}}#details',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid aux timer durations', () => {
|
||||||
|
const auxTimer = automationRecipes.find(({ id }) => id === 'ontime-aux-timer');
|
||||||
|
|
||||||
|
expect(auxTimer && validateRecipeValues(auxTimer, { aux: '1', duration: 'abc' })).toEqual({
|
||||||
|
duration: 'Enter a valid duration',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsupported OSC targets', () => {
|
||||||
|
const qlab = automationRecipes.find(({ id }) => id === 'qlab-go');
|
||||||
|
|
||||||
|
expect(qlab && validateRecipeValues(qlab, { ip: '::1', port: '53000' })).toEqual({
|
||||||
|
ip: 'Enter an IP address or hostname',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps recipe previews available while an address is invalid', () => {
|
||||||
|
expect(() => buildWith('companion-press', { host: 'http:', page: '1', row: '0', column: '0' })).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates an address pasted with a trailing slash', () => {
|
||||||
|
expect(
|
||||||
|
buildWith('companion-press', { host: 'http://127.0.0.1:8888/', page: '1', row: '0', column: '0' }),
|
||||||
|
).toContain('http://127.0.0.1:8888/api/location/1/0/0/press');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a pasted query before adding a Companion path', () => {
|
||||||
|
expect(
|
||||||
|
buildWith('companion-press', { host: 'http://127.0.0.1:8888?token=value', page: '1', row: '0', column: '0' }),
|
||||||
|
).toContain('http://127.0.0.1:8888/api/location/1/0/0/press');
|
||||||
|
});
|
||||||
|
});
|
||||||
+41
-1
@@ -1,6 +1,6 @@
|
|||||||
import { TimerLifeCycle, Trigger } from 'ontime-types';
|
import { TimerLifeCycle, Trigger } from 'ontime-types';
|
||||||
|
|
||||||
import { checkDuplicates } from '../automationUtils';
|
import { checkDuplicates, cycles, groupTriggersByAutomation, operators } from '../automationUtils';
|
||||||
|
|
||||||
describe('checkDuplicates', () => {
|
describe('checkDuplicates', () => {
|
||||||
it('should return undefined if there are no duplicates', () => {
|
it('should return undefined if there are no duplicates', () => {
|
||||||
@@ -22,3 +22,43 @@ describe('checkDuplicates', () => {
|
|||||||
expect(checkDuplicates(triggers)).toStrictEqual([2]);
|
expect(checkDuplicates(triggers)).toStrictEqual([2]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('groupTriggersByAutomation', () => {
|
||||||
|
it('returns an empty object when there are no triggers', () => {
|
||||||
|
expect(groupTriggersByAutomation([])).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collects the lifecycles each automation is bound to', () => {
|
||||||
|
const triggers: Trigger[] = [
|
||||||
|
{ id: '1', title: 'First', trigger: TimerLifeCycle.onStart, automationId: 'a' },
|
||||||
|
{ id: '2', title: 'Second', trigger: TimerLifeCycle.onFinish, automationId: 'a' },
|
||||||
|
{ id: '3', title: 'Third', trigger: TimerLifeCycle.onLoad, automationId: 'b' },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(groupTriggersByAutomation(triggers)).toEqual({
|
||||||
|
a: [TimerLifeCycle.onStart, TimerLifeCycle.onFinish],
|
||||||
|
b: [TimerLifeCycle.onLoad],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses duplicates, the runtime only fires an automation once per lifecycle', () => {
|
||||||
|
const triggers: Trigger[] = [
|
||||||
|
{ id: '1', title: 'First', trigger: TimerLifeCycle.onStart, automationId: 'a' },
|
||||||
|
{ id: '2', title: 'Second', trigger: TimerLifeCycle.onStart, automationId: 'a' },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(groupTriggersByAutomation(triggers)).toEqual({ a: [TimerLifeCycle.onStart] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('operators', () => {
|
||||||
|
it('does not offer not_contains, which the server validation rejects', () => {
|
||||||
|
expect(operators.map(({ value }) => value)).not.toContain('not_contains');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cycles', () => {
|
||||||
|
it('uses the shared user facing labels', () => {
|
||||||
|
expect(cycles.find(({ value }) => value === 'onStart')?.label).toBe('On Start');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
import type { AutomationDTO, AutomationOutput, TimerLifeCycle } from 'ontime-types';
|
||||||
|
import { TimerLifeCycle as Cycle } from 'ontime-types';
|
||||||
|
import { parseUserTime } from 'ontime-utils';
|
||||||
|
|
||||||
|
export type RecipeCategory = 'ontime' | 'playback' | 'video' | 'messaging';
|
||||||
|
|
||||||
|
export const recipeCategoryLabels: Record<RecipeCategory, string> = {
|
||||||
|
ontime: 'Ontime automations',
|
||||||
|
playback: 'Playback and cue systems',
|
||||||
|
video: 'Video and streaming',
|
||||||
|
messaging: 'Webhooks and messaging',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const recipeCategoryOrder: RecipeCategory[] = ['ontime', 'playback', 'video', 'messaging'];
|
||||||
|
|
||||||
|
export type RecipeParam = {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
type?: 'text' | 'number' | 'choice';
|
||||||
|
options?: { value: string; label: string }[];
|
||||||
|
wide?: boolean;
|
||||||
|
defaultValue: string;
|
||||||
|
validation?:
|
||||||
|
| { kind: 'duration' }
|
||||||
|
| { kind: 'host' }
|
||||||
|
| { kind: 'url' }
|
||||||
|
| { kind: 'integer'; min: number; max?: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecipeValues = Record<string, string>;
|
||||||
|
|
||||||
|
export type AutomationRecipe = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
category: RecipeCategory;
|
||||||
|
localOnly?: boolean;
|
||||||
|
keywords?: string[];
|
||||||
|
params: RecipeParam[];
|
||||||
|
triggers: TimerLifeCycle[];
|
||||||
|
build: (values: RecipeValues) => AutomationDTO;
|
||||||
|
};
|
||||||
|
|
||||||
|
const auxTimers = [
|
||||||
|
{ value: '1', label: 'Aux timer 1' },
|
||||||
|
{ value: '2', label: 'Aux timer 2' },
|
||||||
|
{ value: '3', label: 'Aux timer 3' },
|
||||||
|
];
|
||||||
|
|
||||||
|
type AuxNumber = '1' | '2' | '3';
|
||||||
|
|
||||||
|
function toAux(value: string): AuxNumber {
|
||||||
|
return value === '2' || value === '3' ? value : '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
const auxSet = { 1: 'aux1-set', 2: 'aux2-set', 3: 'aux3-set' } as const;
|
||||||
|
const auxStart = { 1: 'aux1-start', 2: 'aux2-start', 3: 'aux3-start' } as const;
|
||||||
|
const auxStop = { 1: 'aux1-stop', 2: 'aux2-stop', 3: 'aux3-stop' } as const;
|
||||||
|
const auxSource = { 1: 'aux1', 2: 'aux2', 3: 'aux3' } as const;
|
||||||
|
|
||||||
|
function origin(value: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(value.trim()).origin;
|
||||||
|
} catch {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withQuery(url: string, query: string): string {
|
||||||
|
const trimmed = url.trim();
|
||||||
|
const fragmentIndex = trimmed.indexOf('#');
|
||||||
|
const base = fragmentIndex === -1 ? trimmed : trimmed.slice(0, fragmentIndex);
|
||||||
|
const fragment = fragmentIndex === -1 ? '' : trimmed.slice(fragmentIndex);
|
||||||
|
return `${base}${base.includes('?') ? '&' : '?'}${query}${fragment}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUnfilteredAutomation(title: string, outputs: AutomationOutput[]): AutomationDTO {
|
||||||
|
return { title, filterRule: 'all', filters: [], outputs };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const automationRecipes: AutomationRecipe[] = [
|
||||||
|
{
|
||||||
|
id: 'ontime-aux-timer',
|
||||||
|
title: 'Run an aux timer with the event',
|
||||||
|
description: 'Sets an aux timer and starts it whenever an event starts.',
|
||||||
|
category: 'ontime',
|
||||||
|
keywords: ['countdown', 'stage timer', 'speaker'],
|
||||||
|
params: [
|
||||||
|
{ name: 'aux', label: 'Which timer', type: 'choice', options: auxTimers, defaultValue: '1' },
|
||||||
|
{
|
||||||
|
name: 'duration',
|
||||||
|
label: 'Duration',
|
||||||
|
hint: 'hh:mm:ss',
|
||||||
|
defaultValue: '00:05:00',
|
||||||
|
validation: { kind: 'duration' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
triggers: [Cycle.onStart],
|
||||||
|
build: ({ aux, duration }) =>
|
||||||
|
buildUnfilteredAutomation(`Run Aux Timer ${toAux(aux)} with the event`, [
|
||||||
|
{ type: 'ontime', action: auxSet[toAux(aux)], time: duration.trim() },
|
||||||
|
{ type: 'ontime', action: auxStart[toAux(aux)] },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ontime-aux-stop',
|
||||||
|
title: 'Stop the aux timer when the event ends',
|
||||||
|
description: 'Stops an aux timer on finish, so it does not keep running into the next event.',
|
||||||
|
category: 'ontime',
|
||||||
|
keywords: ['countdown', 'stage timer', 'reset'],
|
||||||
|
params: [{ name: 'aux', label: 'Which timer', type: 'choice', options: auxTimers, defaultValue: '1' }],
|
||||||
|
triggers: [Cycle.onFinish],
|
||||||
|
build: ({ aux }) =>
|
||||||
|
buildUnfilteredAutomation(`Stop Aux Timer ${toAux(aux)} on finish`, [
|
||||||
|
{ type: 'ontime', action: auxStop[toAux(aux)] },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ontime-warn-stage',
|
||||||
|
title: 'Warn the stage when time runs low',
|
||||||
|
description: 'Shows a message on the stage timer as the running event enters its danger window.',
|
||||||
|
category: 'ontime',
|
||||||
|
keywords: ['message', 'danger', 'wrap up', 'presenter'],
|
||||||
|
params: [{ name: 'message', label: 'Message', wide: true, defaultValue: 'Please wrap up' }],
|
||||||
|
triggers: [Cycle.onDanger],
|
||||||
|
build: ({ message }) =>
|
||||||
|
buildUnfilteredAutomation('Warn the stage at danger', [
|
||||||
|
{ type: 'ontime', action: 'message-set', text: message, visible: true },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ontime-clear-message',
|
||||||
|
title: 'Hide the stage message on finish',
|
||||||
|
description: 'Clears the stage message once the event finishes. Pairs with the warning above.',
|
||||||
|
category: 'ontime',
|
||||||
|
keywords: ['message', 'clear', 'presenter'],
|
||||||
|
params: [],
|
||||||
|
triggers: [Cycle.onFinish],
|
||||||
|
build: () =>
|
||||||
|
buildUnfilteredAutomation('Hide the stage message on finish', [
|
||||||
|
{ type: 'ontime', action: 'message-set', text: '', visible: false },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ontime-secondary-message',
|
||||||
|
title: 'Show an aux timer beside the stage message',
|
||||||
|
description: 'Points the secondary field on the stage timer at an aux timer when an event loads.',
|
||||||
|
category: 'ontime',
|
||||||
|
keywords: ['message', 'secondary', 'stage', 'countdown'],
|
||||||
|
params: [{ name: 'aux', label: 'Which timer', type: 'choice', options: auxTimers, defaultValue: '1' }],
|
||||||
|
triggers: [Cycle.onLoad],
|
||||||
|
build: ({ aux }) =>
|
||||||
|
buildUnfilteredAutomation(`Show Aux Timer ${toAux(aux)} as the secondary message`, [
|
||||||
|
{ type: 'ontime', action: 'message-secondary', secondarySource: auxSource[toAux(aux)] },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'qlab-go',
|
||||||
|
title: 'QLab — fire the matching cue',
|
||||||
|
description: "Starts the QLab cue whose number matches the Ontime event's cue.",
|
||||||
|
category: 'playback',
|
||||||
|
localOnly: true,
|
||||||
|
keywords: ['osc', 'sound', 'audio', 'mac', 'figure 53'],
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
name: 'ip',
|
||||||
|
label: 'QLab computer',
|
||||||
|
hint: 'IP address of the machine running QLab',
|
||||||
|
wide: true,
|
||||||
|
defaultValue: '127.0.0.1',
|
||||||
|
validation: { kind: 'host' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'port',
|
||||||
|
label: 'OSC port',
|
||||||
|
type: 'number',
|
||||||
|
hint: "QLab's default is 53000",
|
||||||
|
defaultValue: '53000',
|
||||||
|
validation: { kind: 'integer', min: 1024, max: 65535 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
triggers: [Cycle.onStart],
|
||||||
|
build: ({ ip, port }) =>
|
||||||
|
buildUnfilteredAutomation('QLab GO on event start', [
|
||||||
|
{
|
||||||
|
type: 'osc',
|
||||||
|
targetIP: ip.trim(),
|
||||||
|
targetPort: Number(port),
|
||||||
|
address: '/cue/{{eventNow.cue}}/start',
|
||||||
|
args: '',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'companion-press',
|
||||||
|
title: 'Companion — press a button',
|
||||||
|
description: 'Presses a Stream Deck button through the Companion HTTP API when an event starts.',
|
||||||
|
category: 'playback',
|
||||||
|
localOnly: true,
|
||||||
|
keywords: ['stream deck', 'bitfocus', 'obs', 'http', 'elgato'],
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
name: 'host',
|
||||||
|
label: 'Companion address',
|
||||||
|
hint: 'Where the Companion HTTP API is listening',
|
||||||
|
wide: true,
|
||||||
|
defaultValue: 'http://127.0.0.1:8888',
|
||||||
|
validation: { kind: 'url' },
|
||||||
|
},
|
||||||
|
{ name: 'page', label: 'Page', type: 'number', defaultValue: '1', validation: { kind: 'integer', min: 1 } },
|
||||||
|
{ name: 'row', label: 'Row', type: 'number', defaultValue: '0', validation: { kind: 'integer', min: 0 } },
|
||||||
|
{ name: 'column', label: 'Column', type: 'number', defaultValue: '0', validation: { kind: 'integer', min: 0 } },
|
||||||
|
],
|
||||||
|
triggers: [Cycle.onStart],
|
||||||
|
build: ({ host, page, row, column }) =>
|
||||||
|
buildUnfilteredAutomation('Companion button press', [
|
||||||
|
{ type: 'http', url: `${origin(host)}/api/location/${page}/${row}/${column}/press` },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vmix-overlay-warning',
|
||||||
|
title: 'vMix — show an overlay on warning',
|
||||||
|
description: 'Triggers a vMix overlay through the web controller when the timer enters its warning window.',
|
||||||
|
category: 'video',
|
||||||
|
localOnly: true,
|
||||||
|
keywords: ['streaming', 'http', 'lower third', 'graphics'],
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
name: 'host',
|
||||||
|
label: 'vMix address',
|
||||||
|
hint: 'The vMix web controller',
|
||||||
|
wide: true,
|
||||||
|
defaultValue: 'http://127.0.0.1:8088',
|
||||||
|
validation: { kind: 'url' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'overlay',
|
||||||
|
label: 'Overlay number',
|
||||||
|
type: 'number',
|
||||||
|
defaultValue: '1',
|
||||||
|
validation: { kind: 'integer', min: 1, max: 4 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
triggers: [Cycle.onWarning],
|
||||||
|
build: ({ host, overlay }) =>
|
||||||
|
buildUnfilteredAutomation('vMix overlay on warning', [
|
||||||
|
{ type: 'http', url: `${origin(host)}/api/?Function=OverlayInput${overlay}In` },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'webhook-event-title',
|
||||||
|
title: 'Webhook — send the running event title',
|
||||||
|
description: 'Calls any URL with the running event title, as a template string you can edit afterwards.',
|
||||||
|
category: 'messaging',
|
||||||
|
keywords: ['http', 'rest', 'api', 'integration', 'slack'],
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
name: 'url',
|
||||||
|
label: 'URL',
|
||||||
|
hint: 'The event title is added as a title parameter',
|
||||||
|
wide: true,
|
||||||
|
defaultValue: 'http://127.0.0.1:3000/now',
|
||||||
|
validation: { kind: 'url' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
triggers: [Cycle.onStart],
|
||||||
|
build: ({ url }) =>
|
||||||
|
buildUnfilteredAutomation('Webhook with the current event', [
|
||||||
|
{ type: 'http', url: withQuery(url, 'title={{url:eventNow.title}}') },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function defaultValues(recipe: AutomationRecipe): RecipeValues {
|
||||||
|
return Object.fromEntries(recipe.params.map(({ name, defaultValue }) => [name, defaultValue]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvailableRecipes(isCloud: boolean): AutomationRecipe[] {
|
||||||
|
return isCloud ? automationRecipes.filter((recipe) => !recipe.localOnly) : automationRecipes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateRecipeValues(recipe: AutomationRecipe, values: RecipeValues): Record<string, string> {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const param of recipe.params) {
|
||||||
|
const value = values[param.name]?.trim() ?? '';
|
||||||
|
if (!value) {
|
||||||
|
errors[param.name] = 'Required field';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (param.validation?.kind === 'url' && !isHttpUrl(value)) {
|
||||||
|
errors[param.name] = 'Enter a URL starting with http:// or https://';
|
||||||
|
} else if (param.validation?.kind === 'duration' && !isDuration(value)) {
|
||||||
|
errors[param.name] = 'Enter a valid duration';
|
||||||
|
} else if (param.validation?.kind === 'host' && !isHost(value)) {
|
||||||
|
errors[param.name] = 'Enter an IP address or hostname';
|
||||||
|
} else if (param.validation?.kind === 'integer') {
|
||||||
|
const number = Number(value);
|
||||||
|
const { min, max } = param.validation;
|
||||||
|
if (!Number.isInteger(number) || number < min || (max !== undefined && number > max)) {
|
||||||
|
errors[param.name] =
|
||||||
|
max === undefined ? `Enter a whole number of ${min} or more` : `Enter a whole number from ${min} to ${max}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDuration(value: string): boolean {
|
||||||
|
return /^\d+(?::\d{1,2}){0,2}$/.test(value) && (parseUserTime(value) > 0 || /^0+(?::0+){0,2}$/.test(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHttpUrl(value: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return (url.protocol === 'http:' || url.protocol === 'https:') && Boolean(url.hostname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHost(value: string): boolean {
|
||||||
|
if (value === 'localhost') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipv4 = /^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)$/;
|
||||||
|
const hostname = /^(?=.{1,253}$)[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?(?:\.[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?)*$/i;
|
||||||
|
if (ipv4.test(value) || hostname.test(value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -1,21 +1,50 @@
|
|||||||
import { Automation, AutomationDTO, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types';
|
import { Automation, AutomationDTO, AutomationFilter, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types';
|
||||||
|
|
||||||
|
import { getLifecycleLabel, lifecycleLabels } from '../../../../common/constants/timerLifecycle';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names a trigger created from an automation's lifecycle picker.
|
||||||
|
* Shared so a trigger made by the form and one made by a recipe read the same in the list.
|
||||||
|
*/
|
||||||
|
export function makeTriggerTitle(automationTitle: string, cycle: TimerLifeCycle): string {
|
||||||
|
return `${automationTitle} — ${getLifecycleLabel(cycle)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outputs are a union, so react-hook-form cannot resolve a field's error by name.
|
||||||
|
* Every output card knows which fields it registered, this just makes them reachable.
|
||||||
|
*/
|
||||||
|
export type OutputErrors = Partial<Record<string, { message?: string }>>;
|
||||||
|
|
||||||
type CycleLabel = {
|
type CycleLabel = {
|
||||||
id: number;
|
|
||||||
label: string;
|
label: string;
|
||||||
value: keyof typeof TimerLifeCycle;
|
value: TimerLifeCycle;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cycles: CycleLabel[] = [
|
export const cycles: CycleLabel[] = [
|
||||||
{ id: 1, label: 'On Load', value: 'onLoad' },
|
{ label: lifecycleLabels.onLoad, value: TimerLifeCycle.onLoad },
|
||||||
{ id: 2, label: 'On Start', value: 'onStart' },
|
{ label: lifecycleLabels.onStart, value: TimerLifeCycle.onStart },
|
||||||
{ id: 3, label: 'On Pause', value: 'onPause' },
|
{ label: lifecycleLabels.onPause, value: TimerLifeCycle.onPause },
|
||||||
{ id: 4, label: 'On Stop', value: 'onStop' },
|
{ label: lifecycleLabels.onStop, value: TimerLifeCycle.onStop },
|
||||||
{ id: 5, label: 'Every second', value: 'onClock' },
|
{ label: lifecycleLabels.onClock, value: TimerLifeCycle.onClock },
|
||||||
{ id: 6, label: 'On Timer Update', value: 'onUpdate' },
|
{ label: lifecycleLabels.onUpdate, value: TimerLifeCycle.onUpdate },
|
||||||
{ id: 7, label: 'On Finish', value: 'onFinish' },
|
{ label: lifecycleLabels.onFinish, value: TimerLifeCycle.onFinish },
|
||||||
{ id: 8, label: 'On Warning', value: 'onWarning' },
|
{ label: lifecycleLabels.onWarning, value: TimerLifeCycle.onWarning },
|
||||||
{ id: 9, label: 'On Danger', value: 'onDanger' },
|
{ label: lifecycleLabels.onDanger, value: TimerLifeCycle.onDanger },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter operators offered in the automation form
|
||||||
|
* NOTE: not_contains is supported by the type and by the runtime, but the server
|
||||||
|
* validation list omits it, so an automation using it cannot be saved.
|
||||||
|
* It stays out of the UI until the server accepts it.
|
||||||
|
*/
|
||||||
|
export const operators: Array<{ value: AutomationFilter['operator']; label: string }> = [
|
||||||
|
{ value: 'equals', label: 'equals' },
|
||||||
|
{ value: 'not_equals', label: 'does not equal' },
|
||||||
|
{ value: 'contains', label: 'contains' },
|
||||||
|
{ value: 'greater_than', label: 'is greater than' },
|
||||||
|
{ value: 'less_than', label: 'is less than' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,20 +95,38 @@ export function makeFieldList(customFields: CustomFields): SelectableField[] {
|
|||||||
* We warn the user if they have created multiple links between the same automation and a trigger
|
* We warn the user if they have created multiple links between the same automation and a trigger
|
||||||
*/
|
*/
|
||||||
export function checkDuplicates(triggers: Trigger[]) {
|
export function checkDuplicates(triggers: Trigger[]) {
|
||||||
const triggersMap: Record<string, string[]> = {};
|
const seen = new Set<string>();
|
||||||
const duplicates = [];
|
const duplicates: number[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < triggers.length; i++) {
|
for (const [index, trigger] of triggers.entries()) {
|
||||||
const trigger = triggers[i];
|
const key = `${trigger.trigger}:${trigger.automationId}`;
|
||||||
if (!Object.hasOwn(triggersMap, trigger.trigger)) {
|
|
||||||
triggersMap[trigger.trigger] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (triggersMap[trigger.trigger].includes(trigger.automationId)) {
|
if (seen.has(key)) {
|
||||||
duplicates.push(i);
|
duplicates.push(index);
|
||||||
} else {
|
} else {
|
||||||
triggersMap[trigger.trigger].push(trigger.automationId);
|
seen.add(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return duplicates.length > 0 ? duplicates : undefined;
|
return duplicates.length > 0 ? duplicates : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups the lifecycles each automation is bound to
|
||||||
|
* Used to show when an automation runs, and to highlight the ones that never will
|
||||||
|
*/
|
||||||
|
export function groupTriggersByAutomation(triggers: Trigger[]): Record<string, TimerLifeCycle[]> {
|
||||||
|
const grouped: Record<string, TimerLifeCycle[]> = {};
|
||||||
|
|
||||||
|
for (const trigger of triggers) {
|
||||||
|
if (!Object.hasOwn(grouped, trigger.automationId)) {
|
||||||
|
grouped[trigger.automationId] = [];
|
||||||
|
}
|
||||||
|
// the runtime fires an automation once per lifecycle, duplicates would be noise here
|
||||||
|
if (!grouped[trigger.automationId].includes(trigger.trigger)) {
|
||||||
|
grouped[trigger.automationId].push(trigger.trigger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user