Skip to content

Commit 8e1e763

Browse files
authored
Merge pull request #1503 from SuperFlyTV/feat/tsr-plugins
2 parents ef3241f + 592110d commit 8e1e763

File tree

10 files changed

+209
-84
lines changed

10 files changed

+209
-84
lines changed

meteor/yarn.lock

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1244,7 +1244,7 @@ __metadata:
12441244
resolution: "@sofie-automation/shared-lib@portal:../packages/shared-lib::locator=automation-core%40workspace%3A."
12451245
dependencies:
12461246
"@mos-connection/model": "npm:^4.2.2"
1247-
timeline-state-resolver-types: "npm:9.3.0"
1247+
timeline-state-resolver-types: "npm:9.4.0-nightly-release53-20250730-145840-ce6dce9c1.0"
12481248
tslib: "npm:^2.8.1"
12491249
type-fest: "npm:^4.33.0"
12501250
languageName: node
@@ -9983,12 +9983,12 @@ __metadata:
99839983
languageName: node
99849984
linkType: hard
99859985

9986-
"timeline-state-resolver-types@npm:9.3.0":
9987-
version: 9.3.0
9988-
resolution: "timeline-state-resolver-types@npm:9.3.0"
9986+
"timeline-state-resolver-types@npm:9.4.0-nightly-release53-20250730-145840-ce6dce9c1.0":
9987+
version: 9.4.0-nightly-release53-20250730-145840-ce6dce9c1.0
9988+
resolution: "timeline-state-resolver-types@npm:9.4.0-nightly-release53-20250730-145840-ce6dce9c1.0"
99899989
dependencies:
99909990
tslib: "npm:^2.6.3"
9991-
checksum: 10/2193715a9a3acd89134b6dd102aa8a0adc3386a286744255788d44343e9e820a4cec293609397139a1506d326f0689599e8cc0ca50868c05ad4e78b90f5324ec
9991+
checksum: 10/15c09f1d9ff815506471a1d1ee0405d39c6565d6fd8cf8478a9dac92c34004360d22f77b3d06371fa69b283b14a75d15c31b65b91185c9df6c5ca82ca285eebe
99929992
languageName: node
99939993
linkType: hard
99949994

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# TSR Plugins
2+
3+
As of 1.53, it is possible to load additional device integrations into TSR as 'plugins'. This is intended to be an escape hatch when you need to make an integration for an internal system or for when an NDA with a device vendor does not allow for opensourcing. We still encourage anything which can be made opensource to be contributed back.
4+
5+
## Creating a plugin
6+
7+
It is expected that each plugin should be its own self-contained folder, including any npm dependencies.
8+
9+
You can see a complete and working (at time of writing) example of this at [sofie-tsr-plugin-example](https://github.com/SuperFlyTV/sofie-tsr-plugin-example). This example is based upon a copy of the builtin atem integration.
10+
11+
There are a few npm libraries which will be useful to you
12+
13+
- `timeline-state-resolver-types` - Some common types from TSR are defined in here
14+
- `timeline-state-resolver-api` - This defines the api and other types that your device integrations should implement.
15+
- `timeline-state-resolver-tools` - This contains various tooling for building your plugin
16+
17+
Some useful npm scripts you may wish to copy are:
18+
19+
```js
20+
{
21+
"translations:extract": "tsr-extract-translations tsr-plugin-example ./src/main.ts",
22+
"translations:bundle": "tsr-bundle-translations tsr-plugin-example ./translations.json",
23+
"schema:deref": "tsr-schema-deref ./src ./src/\\$schemas/generated",
24+
"schema:types": "tsr-schema-types ./src/\\$schemas/generated ./src/generated"
25+
}
26+
```
27+
28+
There are a few key properties that your plugin must conform to, the rest of the structure and how it gets generated is up to you.
29+
30+
1. It must be possible to `require(...)` your plugin folder. The resuling js must contain an export of the format `export const Devices: Record<string, DeviceEntry> = {}`
31+
This is how the TSR process finds the entrypoint for your code, and allows you to define multiple device types.
32+
33+
2. There must be a `manifest.json` file at the root of your plugin folder. This should contain json in the form `Record<string, TSRDevicesManifestEntry>`
34+
This is a composite of various json schemas, we recommend generating this file with a script and using the same source schemas to generate relevant typescript types.
35+
36+
3. There must be a `translations.json` file at the root of your plugin folder. This should contain json in the form `TranslationsBundle[]`.
37+
This should contain any translation strings that should be used when displaying various things about your device in a UI. Populating this with translations is optional, you only need to do so if this is useful to your users.
38+
39+
:::info
40+
If running some of the `timeline-state-resolver-tools` scripts fails with an error relating to `cheerio`, you should add a yarn resolution (or equivalent for your package manager) to pin the version to `"cheerio": "1.0.0-rc.12"` which is compatible with our tooling.
41+
:::
42+
43+
## Using with the TSR API
44+
45+
If you are using TSR in a non-sofie project, to load plugins you should:
46+
47+
- construct a `DevicesRegistry`
48+
- using the methods on this registry, load the needed plugins
49+
- pass this registry into the `Conductor` constructor, inside the options object.
50+
51+
You can mutate the contents of the `DevicesRegistry` after passing to the `Conductor`, and it will be used when spawning or restarting devices.
52+
53+
## Using with Sofie
54+
55+
In Sofie playout-gateway, plugins can be loaded by setting the `TSR_PLUGIN_PATHS` environment variable to any folders containing plugins.
56+
57+
It is possible to extend the docker images to add in your own plugins.
58+
You can use a dockerfile in your plugin git repository along the lines of:
59+
60+
```Dockerfile
61+
# BUILD IMAGE
62+
FROM node:22
63+
WORKDIR /opt/tsr-plugin-example
64+
65+
COPY . .
66+
67+
RUN corepack enable
68+
RUN yarn install
69+
RUN yarn build
70+
RUN yarn install --production
71+
72+
# cleanup stuff we don't want in the final image
73+
RUN rm -rf .git src
74+
75+
# DEPLOY IMAGE
76+
FROM sofietv/tv-automation-playout-gateway:release53
77+
78+
ENV TSR_PLUGIN_PATHS=/opt/tsr-plugin-example
79+
COPY --from=0 /opt/tsr-plugin-example /opt/tsr-plugin-example
80+
```

packages/playout-gateway/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
"@sofie-automation/shared-lib": "1.53.0-in-development",
6161
"debug": "^4.4.0",
6262
"influx": "^5.9.7",
63-
"timeline-state-resolver": "9.3.0",
63+
"timeline-state-resolver": "9.4.0-nightly-release53-20250730-145840-ce6dce9c1.0",
6464
"tslib": "^2.8.1",
6565
"underscore": "^1.13.7",
6666
"winston": "^3.17.0"

packages/playout-gateway/src/configManifest.ts

Lines changed: 29 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,38 +5,41 @@ import {
55
JSONSchema,
66
SubdeviceManifest,
77
} from '@sofie-automation/server-core-integration'
8-
import { manifest as TSRManifest, TSRDevicesManifestEntry } from 'timeline-state-resolver'
9-
10-
import Translations = require('timeline-state-resolver/dist/translations.json')
8+
import type { TSRDevicesManifestEntry } from 'timeline-state-resolver'
9+
import { TSRDeviceRegistry } from './tsrDeviceRegistry.js'
1110

1211
import ConfigSchema = require('./$schemas/options.json')
1312

14-
const subdeviceManifest: SubdeviceManifest = Object.fromEntries(
15-
Object.entries<TSRDevicesManifestEntry>(TSRManifest.subdevices).map(([id, dev]) => {
16-
return [
17-
id,
18-
{
19-
displayName: dev.displayName,
20-
configSchema: stringToJsonBlob(dev.configSchema),
21-
playoutMappings: Object.fromEntries<JSONBlob<JSONSchema>>(
22-
Object.entries<string>(dev.mappingsSchemas).map(([id, str]) => [id, stringToJsonBlob(str)])
23-
),
24-
actions: dev.actions?.map((action) => ({
25-
...action,
26-
payload: action.payload ? stringToJsonBlob(action.payload) : undefined,
27-
})),
28-
},
29-
]
30-
})
31-
)
13+
export function compilePlayoutGatewayConfigManifest(): DeviceConfigManifest {
14+
const tsrManifest = TSRDeviceRegistry.manifest
15+
16+
const subdeviceManifest: SubdeviceManifest = Object.fromEntries(
17+
Object.entries<TSRDevicesManifestEntry>(tsrManifest.subdevices).map(([id, dev]) => {
18+
return [
19+
id,
20+
{
21+
displayName: dev.displayName,
22+
configSchema: stringToJsonBlob(dev.configSchema),
23+
playoutMappings: Object.fromEntries<JSONBlob<JSONSchema>>(
24+
Object.entries<string>(dev.mappingsSchemas).map(([id, str]) => [id, stringToJsonBlob(str)])
25+
),
26+
actions: dev.actions?.map((action) => ({
27+
...action,
28+
payload: action.payload ? stringToJsonBlob(action.payload) : undefined,
29+
})),
30+
},
31+
]
32+
})
33+
)
3234

33-
export const PLAYOUT_DEVICE_CONFIG: DeviceConfigManifest = {
34-
deviceConfigSchema: JSONBlobStringify<JSONSchema>(ConfigSchema as any),
35+
return {
36+
deviceConfigSchema: JSONBlobStringify<JSONSchema>(ConfigSchema as any),
3537

36-
subdeviceConfigSchema: stringToJsonBlob(TSRManifest.commonOptions),
37-
subdeviceManifest,
38+
subdeviceConfigSchema: stringToJsonBlob(tsrManifest.commonOptions),
39+
subdeviceManifest,
3840

39-
translations: Translations as any,
41+
translations: TSRDeviceRegistry.translations,
42+
}
4043
}
4144

4245
function stringToJsonBlob(str: string): JSONBlob<JSONSchema> {

packages/playout-gateway/src/coreHandler.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { TSRHandler } from './tsrHandler.js'
1919
import { Logger } from 'winston'
2020
// eslint-disable-next-line n/no-extraneous-import
2121
import { MemUsageReport as ThreadMemUsageReport } from 'threadedclass'
22-
import { PLAYOUT_DEVICE_CONFIG } from './configManifest.js'
22+
import { compilePlayoutGatewayConfigManifest } from './configManifest.js'
2323
import { BaseRemoteDeviceIntegration } from 'timeline-state-resolver/dist/service/remoteDeviceInstance'
2424
import { getVersions } from './versions.js'
2525
import { CoreConnectionChild } from '@sofie-automation/server-core-integration/dist/lib/CoreConnectionChild'
@@ -156,7 +156,7 @@ export class CoreHandler {
156156
deviceName: 'Playout gateway',
157157
watchDog: this._coreConfig ? this._coreConfig.watchdog : true,
158158

159-
configManifest: PLAYOUT_DEVICE_CONFIG,
159+
configManifest: compilePlayoutGatewayConfigManifest(),
160160

161161
versions: getVersions(this.logger),
162162

@@ -557,6 +557,6 @@ export class CoreTSRDeviceHandler {
557557
this._coreParentHandler.logger.info(`Exec ${actionId} on ${this._deviceId}`)
558558
const device = this._device.device
559559

560-
return device.executeAction(actionId, payload)
560+
return device.executeAction(actionId, payload || {})
561561
}
562562
}

packages/playout-gateway/src/index.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Connector } from './connector.js'
22
import { config, logPath, disableWatchdog, logLevel } from './config.js'
3+
import { loadTSRPlugins } from './tsrDeviceRegistry.js'
34

45
import * as Winston from 'winston'
56
import { stringifyError } from '@sofie-automation/server-core-integration'
@@ -89,8 +90,14 @@ logger.info('Starting Playout Gateway')
8990
if (disableWatchdog) logger.info('Watchdog is disabled!')
9091
const connector = new Connector(logger)
9192

92-
logger.info('Core: ' + config.core.host + ':' + config.core.port)
93-
logger.info('------------------------------------------------------------------')
94-
connector.init(config).catch((e) => {
95-
logger.error(e)
96-
})
93+
Promise.resolve()
94+
.then(async () => {
95+
await loadTSRPlugins(logger)
96+
97+
logger.info('Core: ' + config.core.host + ':' + config.core.port)
98+
logger.info('------------------------------------------------------------------')
99+
await connector.init(config)
100+
})
101+
.catch((e) => {
102+
logger.error(e)
103+
})
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { stringifyError } from '@sofie-automation/server-core-integration'
2+
import { DevicesRegistry } from 'timeline-state-resolver'
3+
import type * as Winston from 'winston'
4+
5+
export const TSRDeviceRegistry = new DevicesRegistry()
6+
7+
/**
8+
* Load TSR plugins from the paths specified in the TSR_PLUGIN_PATHS environment variable.
9+
*/
10+
export async function loadTSRPlugins(logger: Winston.Logger): Promise<void> {
11+
const paths = process.env.TSR_PLUGIN_PATHS
12+
if (!paths) {
13+
logger.debug('No TSR_PLUGIN_PATHS set, skipping loading of plugins')
14+
return
15+
}
16+
17+
const pathsArray = paths.split(';').filter((p) => !!p)
18+
logger.info(`Loading TSR plugins from ${pathsArray.length} paths`)
19+
20+
for (const pluginPath of pathsArray) {
21+
try {
22+
const deviceTypes = await TSRDeviceRegistry.loadDeviceIntegrationsFromPath(pluginPath)
23+
24+
logger.info(`Loaded TSR plugins from path "${pluginPath}": ${deviceTypes.join(', ')}`)
25+
} catch (e) {
26+
logger.error(`Failed to load TSR plugins from "${pluginPath}": ${stringifyError(e)}`)
27+
}
28+
}
29+
}

packages/playout-gateway/src/tsrHandler.ts

Lines changed: 18 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,18 @@ import {
3636
RoutedTimeline,
3737
TimelineObjGeneric,
3838
} from '@sofie-automation/shared-lib/dist/core/model/Timeline'
39-
import { PLAYOUT_DEVICE_CONFIG } from './configManifest.js'
4039
import { PlayoutGatewayConfig } from '@sofie-automation/shared-lib/dist/generated/PlayoutGatewayConfigTypes'
4140
import {
4241
assertNever,
4342
getSchemaDefaultValues,
44-
JSONBlobParse,
4543
PeripheralDeviceAPI,
4644
PeripheralDeviceForDevice,
4745
protectString,
48-
SubdeviceManifest,
4946
unprotectObject,
5047
unprotectString,
5148
} from '@sofie-automation/server-core-integration'
5249
import { BaseRemoteDeviceIntegration } from 'timeline-state-resolver/dist/service/remoteDeviceInstance'
50+
import { TSRDeviceRegistry } from './tsrDeviceRegistry.js'
5351

5452
const debug = Debug('playout-gateway')
5553

@@ -85,7 +83,6 @@ export class TSRHandler {
8583
private _triggerUpdateDevicesCheckAgain = false
8684
private _triggerUpdateDevicesTimeout: NodeJS.Timeout | undefined
8785

88-
private defaultDeviceOptions: { [deviceType: string]: Record<string, any> } = {}
8986
private _debugStates: Map<string, object> = new Map()
9087

9188
constructor(logger: Logger) {
@@ -112,9 +109,9 @@ export class TSRHandler {
112109
multiThreadedResolver: settings.multiThreadedResolver === true,
113110
useCacheWhenResolving: settings.useCacheWhenResolving === true,
114111
proActiveResolve: true,
115-
}
116112

117-
this.defaultDeviceOptions = this.loadSubdeviceConfigurations()
113+
devicesRegistry: TSRDeviceRegistry,
114+
}
118115

119116
this.tsr = new Conductor(c)
120117
this._triggerupdateTimelineAndMappings('TSRHandler.init()')
@@ -394,19 +391,6 @@ export class TSRHandler {
394391
})
395392
}
396393

397-
private loadSubdeviceConfigurations(): { [deviceType: string]: Record<string, any> } {
398-
const defaultDeviceOptions: { [deviceType: string]: Record<string, any> } = {}
399-
400-
for (const [deviceType, deviceManifest] of Object.entries<SubdeviceManifest[0]>(
401-
PLAYOUT_DEVICE_CONFIG.subdeviceManifest
402-
)) {
403-
const schema = JSONBlobParse(deviceManifest.configSchema)
404-
defaultDeviceOptions[deviceType] = getSchemaDefaultValues(schema)
405-
}
406-
407-
return defaultDeviceOptions
408-
}
409-
410394
private setupObservers(): void {
411395
if (this._observers.length) {
412396
this.logger.debug('Clearing observers..')
@@ -689,11 +673,21 @@ export class TSRHandler {
689673
}
690674

691675
private populateDefaultValuesIfMissing(deviceOptions: DeviceOptionsAny): DeviceOptionsAny {
692-
const options = Object.fromEntries<any>(
693-
Object.entries<any>({ ...deviceOptions.options }).filter(([_key, value]) => value !== '')
694-
)
695-
deviceOptions.options = { ...this.defaultDeviceOptions[deviceOptions.type], ...options }
696-
return deviceOptions
676+
const schema = TSRDeviceRegistry.manifest.subdevices[deviceOptions.type]?.configSchema
677+
if (!schema) return deviceOptions
678+
679+
try {
680+
const defaultValues = getSchemaDefaultValues(JSON.parse(schema))
681+
682+
const options = Object.fromEntries<any>(
683+
Object.entries<any>({ ...deviceOptions.options }).filter(([_key, value]) => value !== '')
684+
)
685+
deviceOptions.options = { ...defaultValues, ...options }
686+
return deviceOptions
687+
} catch (e) {
688+
this.logger.warn(`Failed to populate default values for device ${deviceOptions.type}: ${stringifyError(e)}`)
689+
return deviceOptions
690+
}
697691
}
698692
/**
699693
* This function is a quick and dirty solution to load a still to the atem mixers.

packages/shared-lib/package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@
77
"license": "MIT",
88
"repository": {
99
"type": "git",
10-
"url": "git+https://github.com/Sofie-Automation/sofie-core.git",
10+
"url": "git+https://github.com/nrkno/sofie-core.git",
1111
"directory": "packages/shared-lib"
1212
},
1313
"bugs": {
14-
"url": "https://github.com/Sofie-Automation/sofie-core/issues"
14+
"url": "https://github.com/nrkno/sofie-core/issues"
1515
},
16-
"homepage": "https://github.com/Sofie-Automation/sofie-core/blob/main/packages/shared-lib#readme",
16+
"homepage": "https://github.com/nrkno/sofie-core/blob/master/packages/shared-lib#readme",
1717
"scripts": {
1818
"build": "run -T rimraf dist && run build:main",
1919
"build:main": "run -T tsc -p tsconfig.build.json",
@@ -39,7 +39,7 @@
3939
],
4040
"dependencies": {
4141
"@mos-connection/model": "^4.2.2",
42-
"timeline-state-resolver-types": "9.3.0",
42+
"timeline-state-resolver-types": "9.4.0-nightly-release53-20250730-145840-ce6dce9c1.0",
4343
"tslib": "^2.8.1",
4444
"type-fest": "^4.33.0"
4545
},

0 commit comments

Comments
 (0)