diff --git a/packages/timeline-state-resolver-types/src/__tests__/__snapshots__/index.spec.ts.snap b/packages/timeline-state-resolver-types/src/__tests__/__snapshots__/index.spec.ts.snap index e8fc96baa..ae311d6a3 100644 --- a/packages/timeline-state-resolver-types/src/__tests__/__snapshots__/index.spec.ts.snap +++ b/packages/timeline-state-resolver-types/src/__tests__/__snapshots__/index.spec.ts.snap @@ -12,6 +12,8 @@ exports[`index imports 1`] = ` "AtemTransitionStyle", "BlendMode", "BorderBevel", + "CasparCGActionErrorCode", + "CasparCGActionErrorMessages", "CasparCGActions", "CasparCGScaleMode", "CasparCGStatusCode", @@ -28,6 +30,8 @@ exports[`index imports 1`] = ` "HTTPWatcherStatusCode", "HTTPWatcherStatusMessages", "HttpMethod", + "HttpSendActionErrorCode", + "HttpSendActionErrorMessages", "HttpSendActions", "HyperdeckActions", "HyperdeckStatusCode", diff --git a/packages/timeline-state-resolver-types/src/actions.ts b/packages/timeline-state-resolver-types/src/actions.ts index 8de3b7844..513631894 100644 --- a/packages/timeline-state-resolver-types/src/actions.ts +++ b/packages/timeline-state-resolver-types/src/actions.ts @@ -1,11 +1,42 @@ import type { ITranslatableMessage } from './translations.js' +/** + * The result of executing a device action. + * + * On error, `response` is the pre-rendered human-readable fallback message. Consumers who want + * to customise the message can use `code` and `context` to look up and interpolate a custom + * template (e.g. from a blueprint's `deviceActionErrorMessages` map). + * + * Action error codes follow the pattern: ACTION_{DEVICETYPE}_{REASON} + * + * @example + * // Device returns a structured error: + * { + * result: ActionExecutionResultCode.Error, + * response: { key: 'CasparCG launcher host not configured' }, + * code: 'ACTION_CASPARCG_LAUNCHER_HOST_NOT_SET', + * context: { deviceName: 'CasparCG 1', host: '192.168.1.10' }, + * } + * + * // Consumer applies a custom template: + * interpolateTemplateString('{{deviceName}}: launcher host not set ({{host}})', result.context) + */ export interface ActionExecutionResult { result: ActionExecutionResultCode - /** Response message, intended to be displayed to a user */ + /** Pre-rendered human-readable response message, intended to be displayed to a user */ response?: ITranslatableMessage /** Response data */ resultData?: ResultData + /** + * Structured error code for customisable messages - typically ACTION_{DEVICETYPE}_{REASON}. + * Present on structured errors alongside `context`. + */ + code?: string + /** + * Context for custom message interpolation via interpolateTemplateString(). + * Present on structured errors alongside `code`. + */ + context?: Record } export enum ActionExecutionResultCode { diff --git a/packages/timeline-state-resolver-types/src/integrations/casparcg/timeline.ts b/packages/timeline-state-resolver-types/src/integrations/casparcg/timeline.ts index 68dda31f1..fff0b299a 100644 --- a/packages/timeline-state-resolver-types/src/integrations/casparcg/timeline.ts +++ b/packages/timeline-state-resolver-types/src/integrations/casparcg/timeline.ts @@ -43,6 +43,89 @@ export const CasparCGStatusMessages: Record = { [CasparCGStatusCode.QUEUE_OVERFLOW]: 'CasparCG command queue overflow', } +/** + * Action error codes for CasparCG device actions. + * These codes can be customized in blueprints via deviceActionErrorMessages. + * + * Error codes follow the pattern: ACTION_CASPARCG_{ACTION}_{REASON} + */ +export const CasparCGActionErrorCode = { + // ClearAllChannels errors + /** ClearAllChannels: no connection to CasparCG */ + CLEAR_NO_CONNECTION: 'ACTION_CASPARCG_CLEAR_NO_CONNECTION', + /** ClearAllChannels: failed to execute INFO command */ + CLEAR_INFO_FAILED: 'ACTION_CASPARCG_CLEAR_INFO_FAILED', + /** ClearAllChannels: no channel data returned from INFO command */ + CLEAR_NO_CHANNELS: 'ACTION_CASPARCG_CLEAR_NO_CHANNELS', + + // RestartServer errors + /** RestartServer: device not initialized (no connection options) */ + RESTART_NOT_INITIALIZED: 'ACTION_CASPARCG_RESTART_NOT_INITIALIZED', + /** RestartServer: launcher host not configured */ + RESTART_LAUNCHER_HOST_NOT_SET: 'ACTION_CASPARCG_RESTART_LAUNCHER_HOST_NOT_SET', + /** RestartServer: launcher port not configured */ + RESTART_LAUNCHER_PORT_NOT_SET: 'ACTION_CASPARCG_RESTART_LAUNCHER_PORT_NOT_SET', + /** RestartServer: launcher process not configured */ + RESTART_LAUNCHER_PROCESS_NOT_SET: 'ACTION_CASPARCG_RESTART_LAUNCHER_PROCESS_NOT_SET', + /** RestartServer: launcher returned a non-200 HTTP status */ + RESTART_BAD_REPLY: 'ACTION_CASPARCG_RESTART_BAD_REPLY', + /** RestartServer: network request to launcher failed */ + RESTART_REQUEST_FAILED: 'ACTION_CASPARCG_RESTART_REQUEST_FAILED', + + // ListMedia errors + /** ListMedia: device not initialized */ + LIST_NOT_INITIALIZED: 'ACTION_CASPARCG_LIST_NOT_INITIALIZED', + /** ListMedia: CLS command returned an error */ + LIST_CLS_ERROR: 'ACTION_CASPARCG_LIST_CLS_ERROR', + /** ListMedia: CLS command returned a non-200 response code */ + LIST_BAD_RESPONSE: 'ACTION_CASPARCG_LIST_BAD_RESPONSE', +} as const + +export type CasparCGActionErrorCode = (typeof CasparCGActionErrorCode)[keyof typeof CasparCGActionErrorCode] + +/** + * Default human-readable messages for each CasparCG action error code. + * Used as fallback when no blueprint customization is present. + */ +export const CasparCGActionErrorMessages: Record = { + [CasparCGActionErrorCode.CLEAR_NO_CONNECTION]: 'Cannot clear CasparCG channels: no connection', + [CasparCGActionErrorCode.CLEAR_INFO_FAILED]: 'Cannot clear CasparCG channels: failed to retrieve channel info', + [CasparCGActionErrorCode.CLEAR_NO_CHANNELS]: 'Cannot clear CasparCG channels: no channels found', + + [CasparCGActionErrorCode.RESTART_NOT_INITIALIZED]: 'Cannot restart CasparCG: device not initialized', + [CasparCGActionErrorCode.RESTART_LAUNCHER_HOST_NOT_SET]: 'Cannot restart CasparCG: launcher host not configured', + [CasparCGActionErrorCode.RESTART_LAUNCHER_PORT_NOT_SET]: 'Cannot restart CasparCG: launcher port not configured', + [CasparCGActionErrorCode.RESTART_LAUNCHER_PROCESS_NOT_SET]: + 'Cannot restart CasparCG: launcher process not configured', + [CasparCGActionErrorCode.RESTART_BAD_REPLY]: 'CasparCG restart failed: launcher returned {{statusCode}} {{body}}', + [CasparCGActionErrorCode.RESTART_REQUEST_FAILED]: 'CasparCG restart failed: {{errorMessage}}', + + [CasparCGActionErrorCode.LIST_NOT_INITIALIZED]: 'Cannot list CasparCG media: device not initialized', + [CasparCGActionErrorCode.LIST_CLS_ERROR]: 'CasparCG media list failed: {{errorMessage}}', + [CasparCGActionErrorCode.LIST_BAD_RESPONSE]: 'CasparCG media list failed: server returned error {{responseCode}}', +} + +/** + * Context data for each CasparCG action error code. + * These fields are available for message template interpolation. + */ +export interface CasparCGActionErrorContextMap { + [CasparCGActionErrorCode.CLEAR_NO_CONNECTION]: Record + [CasparCGActionErrorCode.CLEAR_INFO_FAILED]: Record + [CasparCGActionErrorCode.CLEAR_NO_CHANNELS]: Record + + [CasparCGActionErrorCode.RESTART_NOT_INITIALIZED]: Record + [CasparCGActionErrorCode.RESTART_LAUNCHER_HOST_NOT_SET]: Record + [CasparCGActionErrorCode.RESTART_LAUNCHER_PORT_NOT_SET]: Record + [CasparCGActionErrorCode.RESTART_LAUNCHER_PROCESS_NOT_SET]: Record + [CasparCGActionErrorCode.RESTART_BAD_REPLY]: { statusCode: number; body: string } + [CasparCGActionErrorCode.RESTART_REQUEST_FAILED]: { errorMessage: string } + + [CasparCGActionErrorCode.LIST_NOT_INITIALIZED]: Record + [CasparCGActionErrorCode.LIST_CLS_ERROR]: { errorMessage: string } + [CasparCGActionErrorCode.LIST_BAD_RESPONSE]: { responseCode: number } +} + export enum TimelineContentTypeCasparCg { // CasparCG-state MEDIA = 'media', diff --git a/packages/timeline-state-resolver-types/src/integrations/httpSend/timeline.ts b/packages/timeline-state-resolver-types/src/integrations/httpSend/timeline.ts index 8013dddcd..67c9c8def 100644 --- a/packages/timeline-state-resolver-types/src/integrations/httpSend/timeline.ts +++ b/packages/timeline-state-resolver-types/src/integrations/httpSend/timeline.ts @@ -10,3 +10,52 @@ export interface HTTPSendCommandContentExt extends Omit = { + [HttpSendActionErrorCode.MISSING_PAYLOAD]: 'Failed to send HTTP command: missing payload', + [HttpSendActionErrorCode.MISSING_URL]: 'Failed to send HTTP command: missing URL', + [HttpSendActionErrorCode.INVALID_TYPE]: 'Failed to send HTTP command: invalid HTTP method type', + [HttpSendActionErrorCode.MISSING_PARAMS]: 'Failed to send HTTP command: missing params', + [HttpSendActionErrorCode.INVALID_PARAMS_TYPE]: 'Failed to send HTTP command: invalid params type', + [HttpSendActionErrorCode.REQUEST_FAILED]: 'HTTP request to {{url}} failed: {{errorMessage}}', +} + +/** + * Context data for each HTTP Send action error code. + * These fields are available for message template interpolation. + */ +export interface HttpSendActionErrorContextMap { + [HttpSendActionErrorCode.MISSING_PAYLOAD]: Record + [HttpSendActionErrorCode.MISSING_URL]: Record + [HttpSendActionErrorCode.INVALID_TYPE]: { type: string } + [HttpSendActionErrorCode.MISSING_PARAMS]: { url: string } + [HttpSendActionErrorCode.INVALID_PARAMS_TYPE]: { paramsType: string } + [HttpSendActionErrorCode.REQUEST_FAILED]: { url: string; errorMessage: string; errorCode?: string } +} diff --git a/packages/timeline-state-resolver/src/__tests__/__snapshots__/index.spec.ts.snap b/packages/timeline-state-resolver/src/__tests__/__snapshots__/index.spec.ts.snap index a4865e1fd..437dcad20 100644 --- a/packages/timeline-state-resolver/src/__tests__/__snapshots__/index.spec.ts.snap +++ b/packages/timeline-state-resolver/src/__tests__/__snapshots__/index.spec.ts.snap @@ -13,6 +13,8 @@ exports[`index imports 1`] = ` "AtemTransitionStyle", "BlendMode", "BorderBevel", + "CasparCGActionErrorCode", + "CasparCGActionErrorMessages", "CasparCGActions", "CasparCGDevice", "CasparCGScaleMode", @@ -35,6 +37,8 @@ exports[`index imports 1`] = ` "HTTPWatcherStatusCode", "HTTPWatcherStatusMessages", "HttpMethod", + "HttpSendActionErrorCode", + "HttpSendActionErrorMessages", "HttpSendActions", "HyperdeckActions", "HyperdeckDevice", diff --git a/packages/timeline-state-resolver/src/integrations/casparCG/index.ts b/packages/timeline-state-resolver/src/integrations/casparCG/index.ts index a92690d70..aed81ede5 100644 --- a/packages/timeline-state-resolver/src/integrations/casparCG/index.ts +++ b/packages/timeline-state-resolver/src/integrations/casparCG/index.ts @@ -34,6 +34,7 @@ import { CasparCGStatusCode, DeviceStatusDetail, DeviceStatusInput, + CasparCGActionErrorCode, } from 'timeline-state-resolver-types' import { createCasparCGStatusDetail } from './messages.js' @@ -633,17 +634,29 @@ export class CasparCGDevice extends DeviceWithState { if (!this.initOptions) { - return { result: ActionExecutionResultCode.Error, response: t('CasparCGDevice._connectionOptions is not set!') } + return { + result: ActionExecutionResultCode.Error, + code: CasparCGActionErrorCode.RESTART_NOT_INITIALIZED, + context: {}, + response: t('Cannot restart CasparCG: device not initialized'), + } } if (!this.initOptions.launcherHost) { - return { result: ActionExecutionResultCode.Error, response: t('CasparCGDevice: config.launcherHost is not set!') } + return { + result: ActionExecutionResultCode.Error, + code: CasparCGActionErrorCode.RESTART_LAUNCHER_HOST_NOT_SET, + context: {}, + response: t('Cannot restart CasparCG: launcher host not configured'), + } } if (!this.initOptions.launcherPort) { - return { result: ActionExecutionResultCode.Error, response: t('CasparCGDevice: config.launcherPort is not set!') } + return { + result: ActionExecutionResultCode.Error, + code: CasparCGActionErrorCode.RESTART_LAUNCHER_PORT_NOT_SET, + context: {}, + response: t('Cannot restart CasparCG: launcher port not configured'), + } } if (!this.initOptions.launcherProcess) { return { result: ActionExecutionResultCode.Error, - response: t('CasparCGDevice: config.launcherProcess is not set!'), + code: CasparCGActionErrorCode.RESTART_LAUNCHER_PROCESS_NOT_SET, + context: {}, + response: t('Cannot restart CasparCG: launcher process not configured'), } } @@ -725,7 +755,9 @@ export class CasparCGDevice extends DeviceWithState { return { result: ActionExecutionResultCode.Error, - response: t('{{message}}', { - message: error.toString(), + code: CasparCGActionErrorCode.RESTART_REQUEST_FAILED, + context: { errorMessage: error.toString() }, + response: t('CasparCG restart failed: {{errorMessage}}', { + errorMessage: error.toString(), }), } }) @@ -745,7 +779,9 @@ export class CasparCGDevice extends DeviceWithState(TimelineContentTypeHTTP).includes(cmd.type)) { return { result: ActionExecutionResultCode.Error, response: t('Failed to send command: type is invalid'), + code: HttpSendActionErrorCode.INVALID_TYPE, + context: { type: cmd.type }, } } if (!cmd.params) { return { result: ActionExecutionResultCode.Error, response: t('Failed to send command: Missing params'), + code: HttpSendActionErrorCode.MISSING_PARAMS, + context: { url: interpolateTemplateStringIfNeeded(cmd.url) }, } } - if (cmd.paramsType && !(cmd.type in TimelineContentTypeHTTPParamType)) { + if ( + cmd.paramsType && + !Object.values(TimelineContentTypeHTTPParamType).includes(cmd.paramsType) + ) { return { result: ActionExecutionResultCode.Error, response: t('Failed to send command: params type is invalid'), + code: HttpSendActionErrorCode.INVALID_PARAMS_TYPE, + context: { paramsType: cmd.paramsType }, } } @@ -220,9 +234,7 @@ export class HTTPSendDevice implements Device { this.sendCommand({ timelineObjId, @@ -329,6 +341,16 @@ export class HTTPSendDevice implements Device