Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ exports[`index imports 1`] = `
"AtemTransitionStyle",
"BlendMode",
"BorderBevel",
"CasparCGActionErrorCode",
"CasparCGActionErrorMessages",
"CasparCGActions",
"CasparCGScaleMode",
"CasparCGStatusCode",
Expand All @@ -28,6 +30,8 @@ exports[`index imports 1`] = `
"HTTPWatcherStatusCode",
"HTTPWatcherStatusMessages",
"HttpMethod",
"HttpSendActionErrorCode",
"HttpSendActionErrorMessages",
"HttpSendActions",
"HyperdeckActions",
"HyperdeckStatusCode",
Expand Down
33 changes: 32 additions & 1 deletion packages/timeline-state-resolver-types/src/actions.ts
Original file line number Diff line number Diff line change
@@ -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<ResultData = void> {
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<string, unknown>
}

export enum ActionExecutionResultCode {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,89 @@ export const CasparCGStatusMessages: Record<CasparCGStatusCode, string> = {
[CasparCGStatusCode.QUEUE_OVERFLOW]: 'CasparCG command queue overflow',
}

/**
* Action error codes for CasparCG device actions.
* These codes can be customized in blueprints via deviceActionErrorMessages.
*
Comment thread
coderabbitai[bot] marked this conversation as resolved.
* 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, string> = {
[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<string, never>
[CasparCGActionErrorCode.CLEAR_INFO_FAILED]: Record<string, never>
[CasparCGActionErrorCode.CLEAR_NO_CHANNELS]: Record<string, never>

[CasparCGActionErrorCode.RESTART_NOT_INITIALIZED]: Record<string, never>
[CasparCGActionErrorCode.RESTART_LAUNCHER_HOST_NOT_SET]: Record<string, never>
[CasparCGActionErrorCode.RESTART_LAUNCHER_PORT_NOT_SET]: Record<string, never>
[CasparCGActionErrorCode.RESTART_LAUNCHER_PROCESS_NOT_SET]: Record<string, never>
[CasparCGActionErrorCode.RESTART_BAD_REPLY]: { statusCode: number; body: string }
[CasparCGActionErrorCode.RESTART_REQUEST_FAILED]: { errorMessage: string }

[CasparCGActionErrorCode.LIST_NOT_INITIALIZED]: Record<string, never>
[CasparCGActionErrorCode.LIST_CLS_ERROR]: { errorMessage: string }
[CasparCGActionErrorCode.LIST_BAD_RESPONSE]: { responseCode: number }
}

export enum TimelineContentTypeCasparCg {
// CasparCG-state
MEDIA = 'media',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,52 @@ export interface HTTPSendCommandContentExt extends Omit<HTTPSendCommandContent,
}

export type TimelineContentHTTPRequest = TimelineContentHTTPSendBase & HTTPSendCommandContentExt

/**
* Action error codes for HTTP Send device actions.
* These codes can be customized in blueprints via deviceActionErrorMessages.
*
* Error codes follow the pattern: ACTION_HTTPSEND_{REASON}
*/
export const HttpSendActionErrorCode = {
/** SendCommand action was called without a payload */
MISSING_PAYLOAD: 'ACTION_HTTPSEND_MISSING_PAYLOAD',
/** SendCommand action payload is missing a URL */
MISSING_URL: 'ACTION_HTTPSEND_MISSING_URL',
/** SendCommand action payload has an invalid HTTP method type */
INVALID_TYPE: 'ACTION_HTTPSEND_INVALID_TYPE',
/** SendCommand action payload is missing params */
MISSING_PARAMS: 'ACTION_HTTPSEND_MISSING_PARAMS',
/** SendCommand action payload has an invalid params type */
INVALID_PARAMS_TYPE: 'ACTION_HTTPSEND_INVALID_PARAMS_TYPE',
/** HTTP request failed with a network error */
REQUEST_FAILED: 'ACTION_HTTPSEND_REQUEST_FAILED',
} as const

export type HttpSendActionErrorCode = (typeof HttpSendActionErrorCode)[keyof typeof HttpSendActionErrorCode]

/**
* Default human-readable messages for each HTTP Send action error code.
* Used as fallback when no blueprint customization is present.
*/
export const HttpSendActionErrorMessages: Record<HttpSendActionErrorCode, string> = {
[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<string, never>
[HttpSendActionErrorCode.MISSING_URL]: Record<string, never>
[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 }
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ exports[`index imports 1`] = `
"AtemTransitionStyle",
"BlendMode",
"BorderBevel",
"CasparCGActionErrorCode",
"CasparCGActionErrorMessages",
"CasparCGActions",
"CasparCGDevice",
"CasparCGScaleMode",
Expand All @@ -35,6 +37,8 @@ exports[`index imports 1`] = `
"HTTPWatcherStatusCode",
"HTTPWatcherStatusMessages",
"HttpMethod",
"HttpSendActionErrorCode",
"HttpSendActionErrorMessages",
"HttpSendActions",
"HyperdeckActions",
"HyperdeckDevice",
Expand Down
68 changes: 55 additions & 13 deletions packages/timeline-state-resolver/src/integrations/casparCG/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
CasparCGStatusCode,
DeviceStatusDetail,
DeviceStatusInput,
CasparCGActionErrorCode,
} from 'timeline-state-resolver-types'
import { createCasparCGStatusDetail } from './messages.js'

Expand Down Expand Up @@ -633,17 +634,29 @@ export class CasparCGDevice extends DeviceWithState<State, CasparCGDeviceTypes,
if (!this._ccg?.connected) {
return {
result: ActionExecutionResultCode.Error,
response: t('Cannot restart CasparCG without a connection'),
code: CasparCGActionErrorCode.CLEAR_NO_CONNECTION,
context: {},
response: t('Cannot clear CasparCG channels: no connection'),
}
}

const { error, request } = await this._ccg.executeCommand({ command: Commands.Info, params: {} })
if (error) {
return { result: ActionExecutionResultCode.Error }
return {
result: ActionExecutionResultCode.Error,
code: CasparCGActionErrorCode.CLEAR_INFO_FAILED,
context: {},
response: t('Cannot clear CasparCG channels: failed to get channel info'),
}
}
const response = await request
if (!response?.data[0]) {
return { result: ActionExecutionResultCode.Error }
return {
result: ActionExecutionResultCode.Error,
code: CasparCGActionErrorCode.CLEAR_NO_CHANNELS,
context: {},
response: t('Cannot clear CasparCG channels: no channels found'),
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

await Promise.all(
Expand Down Expand Up @@ -697,18 +710,35 @@ export class CasparCGDevice extends DeviceWithState<State, CasparCGDeviceTypes,
*/
private async restartCasparCG(): Promise<ActionExecutionResult> {
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'),
}
}

Expand All @@ -725,7 +755,9 @@ export class CasparCGDevice extends DeviceWithState<State, CasparCGDeviceTypes,
} else {
return {
result: ActionExecutionResultCode.Error,
response: t('Bad reply: [{{statusCode}}] {{body}}', {
code: CasparCGActionErrorCode.RESTART_BAD_REPLY,
context: { statusCode: response.statusCode, body: response.body },
response: t('CasparCG restart failed: launcher returned {{statusCode}} {{body}}', {
statusCode: response.statusCode,
body: response.body,
}),
Expand All @@ -735,8 +767,10 @@ export class CasparCGDevice extends DeviceWithState<State, CasparCGDeviceTypes,
.catch((error) => {
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(),
}),
}
})
Expand All @@ -745,7 +779,9 @@ export class CasparCGDevice extends DeviceWithState<State, CasparCGDeviceTypes,
if (!this._ccg) {
return {
result: ActionExecutionResultCode.Error,
response: t('CasparCG device not initialized'),
code: CasparCGActionErrorCode.LIST_NOT_INITIALIZED,
context: {},
response: t('Cannot list CasparCG media: device not initialized'),
}
}
const result = await this._ccg.executeCommand(
Expand All @@ -757,7 +793,9 @@ export class CasparCGDevice extends DeviceWithState<State, CasparCGDeviceTypes,
if (result.error)
return {
result: ActionExecutionResultCode.Error,
response: t(`Error message from CasparCG: {{message}}`, { message: `${result.error}` }),
code: CasparCGActionErrorCode.LIST_CLS_ERROR,
context: { errorMessage: `${result.error}` },
response: t(`CasparCG media list failed: {{errorMessage}}`, { errorMessage: `${result.error}` }),
}

const request = await result.request
Expand All @@ -770,7 +808,11 @@ export class CasparCGDevice extends DeviceWithState<State, CasparCGDeviceTypes,
} else {
return {
result: ActionExecutionResultCode.Error,
response: t(`Error code {{code}} from CasparCG`, { code: request.responseCode }),
code: CasparCGActionErrorCode.LIST_BAD_RESPONSE,
context: { responseCode: request.responseCode },
response: t(`CasparCG media list failed: server returned error {{responseCode}}`, {
responseCode: request.responseCode,
}),
}
}
}
Expand Down
Loading
Loading