From f19d38a606aaa63d435e6ac7f2f7fe39bf4ec343 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Wed, 16 Oct 2024 08:55:36 +0200 Subject: [PATCH 1/3] chore: improve error message --- packages/connector/src/MosConnection.ts | 4 +--- packages/model/src/mosTypes/__tests__/mosDuration.spec.ts | 2 ++ packages/model/src/mosTypes/mosDuration.ts | 2 +- packages/model/src/mosTypes/mosTime.ts | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/connector/src/MosConnection.ts b/packages/connector/src/MosConnection.ts index f2028ec6..0c4219fc 100644 --- a/packages/connector/src/MosConnection.ts +++ b/packages/connector/src/MosConnection.ts @@ -544,9 +544,7 @@ export class MosConnection extends EventEmitter implements { ID: this.mosTypes.mosString128.create('0'), Revision: 0, - Description: this.mosTypes.mosString128.create( - `MosDevice "${ncsID + '_' + mosID}" not found` - ), + Description: this.mosTypes.mosString128.create(`Internal error: ${err}`), Status: IMOSAckStatus.NACK, }, this.mosTypes.strict diff --git a/packages/model/src/mosTypes/__tests__/mosDuration.spec.ts b/packages/model/src/mosTypes/__tests__/mosDuration.spec.ts index fc33ebe1..0443502c 100644 --- a/packages/model/src/mosTypes/__tests__/mosDuration.spec.ts +++ b/packages/model/src/mosTypes/__tests__/mosDuration.spec.ts @@ -29,6 +29,8 @@ describe('MosDuration', () => { expect(mosTypes.mosDuration.is(null)).toBe(false) expect(mosTypes.mosDuration.is('abc')).toBe(false) expect(mosTypes.mosDuration.is(123)).toBe(false) + + expect(mosTypes.mosDuration.is({ _mosDuration: 1234 })).toBe(true) }) test('stringify', () => { const mosTypes = getMosTypes(true) diff --git a/packages/model/src/mosTypes/mosDuration.ts b/packages/model/src/mosTypes/mosDuration.ts index 22441842..1ce044be 100644 --- a/packages/model/src/mosTypes/mosDuration.ts +++ b/packages/model/src/mosTypes/mosDuration.ts @@ -9,7 +9,7 @@ export interface IMOSDuration { export function create(anyValue: AnyValue, strict: boolean): IMOSDuration { let value: number if (typeof anyValue === 'number') { - value = anyValue + value = anyValue // seconds } else if (typeof anyValue === 'string') { const m = /(\d+):(\d+):(\d+)/.exec(anyValue) if (!m) throw new Error(`MosDuration: Invalid input format: "${anyValue}"!`) diff --git a/packages/model/src/mosTypes/mosTime.ts b/packages/model/src/mosTypes/mosTime.ts index d960adff..348eb93e 100644 --- a/packages/model/src/mosTypes/mosTime.ts +++ b/packages/model/src/mosTypes/mosTime.ts @@ -61,14 +61,14 @@ export function create(timestamp: AnyValue, strict: boolean): IMOSTime { } else if (!strict) { time = new Date() } else { - throw new Error(`MosTime: Invalid input: "${timestamp}"`) + throw new Error(`MosTime: Invalid input: ${JSON.stringify(timestamp)}`) } } else { throw new Error(`MosTime: Invalid input: "${timestamp}"`) } if (isNaN(time.getTime())) { - throw new Error(`MosTime: Invalid timestamp: "${timestamp}"`) + throw new Error(`MosTime: Invalid timestamp: ${JSON.stringify(timestamp)}`) } const iMosTime: IMOSTime = { From ab2d17e01b2459aacce283387602c5ab86bbc02d Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Wed, 16 Oct 2024 08:57:58 +0200 Subject: [PATCH 2/3] fix: Quick-MOS: Add support for Sofie Snapshots --- packages/quick-mos/input/config.ts | 23 +++- .../quick-mos/input/runningorders/README.md | 9 ++ packages/quick-mos/package.json | 2 +- .../quick-mos/src/convertFromSofieSnapshot.ts | 82 ++++++++++++++ packages/quick-mos/src/index.ts | 104 ++++++++++-------- 5 files changed, 168 insertions(+), 52 deletions(-) create mode 100644 packages/quick-mos/input/runningorders/README.md create mode 100644 packages/quick-mos/src/convertFromSofieSnapshot.ts diff --git a/packages/quick-mos/input/config.ts b/packages/quick-mos/input/config.ts index f4b6f230..dec8e437 100644 --- a/packages/quick-mos/input/config.ts +++ b/packages/quick-mos/input/config.ts @@ -5,7 +5,18 @@ export const config: Config = { // This is the NCS-id, you might need to specify it in your mos-client that connects to Quick-MOS. mosID: 'quick.mos', acceptsConnections: true, - openRelay: true, + openRelay: { + options: { + id: 'testid', + host: 'testhost', + // ports: { + // Set these if you have a mos-client running on other ports than standard: + // lower: 11540, + // upper: 11541, + // query: 11542, + // }, + }, + }, profiles: { '0': true, '1': true, @@ -13,11 +24,11 @@ export const config: Config = { '3': true, }, // Set these if you want quick-mos to run on other ports than standard: - // ports: { - // lower: 11540, - // upper: 11541, - // query: 11542, - // }, + ports: { + lower: 11540, + upper: 11541, + query: 11542, + }, // Set to true to turn on debug-logging: debug: false, diff --git a/packages/quick-mos/input/runningorders/README.md b/packages/quick-mos/input/runningorders/README.md new file mode 100644 index 00000000..673ea1b7 --- /dev/null +++ b/packages/quick-mos/input/runningorders/README.md @@ -0,0 +1,9 @@ +Quick-MOS will monitor the contents of this folder. + +Put any RunningOrders that you want the Quick-MOS server to expose in here. + +Quick-MOS supports + +- ts files (see examples in the folder) +- json files (see examples in the folder) +- Sofie Playlist/Rundown Snapshots diff --git a/packages/quick-mos/package.json b/packages/quick-mos/package.json index 6d5850a6..65c9209f 100644 --- a/packages/quick-mos/package.json +++ b/packages/quick-mos/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "description": "Read rundowns from files, use mos-connection and send mos commands", - "main": "dist/index.js", + "main": "src/index.ts", "license": "MIT", "repository": { "type": "git", diff --git a/packages/quick-mos/src/convertFromSofieSnapshot.ts b/packages/quick-mos/src/convertFromSofieSnapshot.ts new file mode 100644 index 00000000..fdadf4a7 --- /dev/null +++ b/packages/quick-mos/src/convertFromSofieSnapshot.ts @@ -0,0 +1,82 @@ +import { getMosTypes, IMOSROFullStory, IMOSROStory, IMOSRunningOrder } from '@mos-connection/model' + +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ + +export function convertFromSofieSnapshot( + filePath: string, + snapShotData: any +): { ro: IMOSRunningOrder; stories: IMOSROFullStory[]; readyToAir: boolean }[] { + const output: { ro: IMOSRunningOrder; stories: IMOSROFullStory[]; readyToAir: boolean }[] = [] + const mosTypes = getMosTypes(true) + + const snapshot = snapShotData.ingestData + + const rundownData = snapshot.filter((e: any) => e.type === 'rundown') + const segmentData = snapshot.filter((e: any) => e.type === 'segment') + const partData = snapshot.filter((e: any) => e.type === 'part') + + if (rundownData.length === 0) throw new Error(`Got ${rundownData.length} rundown ingest data. Can't continue`) + + for (const seg of segmentData) { + let parts = partData.filter((e: any) => e.segmentId === seg.segmentId) + parts = parts.map((e: any) => e.data) + parts = parts.sort((a: any, b: any) => b.rank - a.rank) + + seg.data.parts = parts + } + + rundownData.forEach((rundown: any, rundownIndex: number) => { + const segments0 = segmentData.filter((e: any) => e.rundownId === rundown.rundownId) + + let segments = segments0.map((s: any) => s.data) + segments = segments.sort((a: any, b: any) => b.rank - a.rank) + + const fullStories: IMOSROFullStory[] = [] + const stories: IMOSROStory[] = [] + + segments.sort((a: any, b: any) => (a.rank || 0) - (b.rank || 0)) + + for (const segment of segments) { + segment.parts.sort((a: any, b: any) => (a.rank || 0) - (b.rank || 0)) + + for (const part of segment.parts) { + fullStories.push(part.payload) + stories.push({ + ID: part.payload.ID, + Slug: part.name, + Items: [], + }) + } + } + + const runningOrder: IMOSRunningOrder = { + ...rundown.data.payload, + ID: mosTypes.mosString128.create(filePath.replace(/\W/g, '_') + `_${rundownIndex}`), + Stories: stories, + EditorialStart: mosTypes.mosTime.create(rundown.data.payload.EditorialStart), + EditorialDuration: mosTypes.mosDuration.create(rundown.data.payload.EditorialDuration), + } + + output.push({ + ro: runningOrder, + stories: fixStoryBody(fullStories), + readyToAir: rundown.data.readyToAir || false, + }) + }) + return output +} + +function fixStoryBody(stories: any[]) { + for (const story of stories) { + for (const item of story.Body) { + if (item.Type === 'p' && item.Content) { + if (item.Content['@type'] === 'element') { + delete item.Content + } else if (item.Content['@type'] === 'text') { + item.Content = item.Content['text'] + } + } + } + } + return stories +} diff --git a/packages/quick-mos/src/index.ts b/packages/quick-mos/src/index.ts index 83bc1c54..ac15eed8 100644 --- a/packages/quick-mos/src/index.ts +++ b/packages/quick-mos/src/index.ts @@ -17,13 +17,12 @@ import { } from '@mos-connection/connector' import { diffLists, ListEntry, OperationType } from './mosDiff' import * as crypto from 'crypto' +import { convertFromSofieSnapshot } from './convertFromSofieSnapshot' console.log('Starting Quick-MOS') const DELAY_TIME = 300 // ms -// const tsr = new TSRHandler(console.log) - const watcher = chokidar.watch('input/**', { ignored: /^\./, persistent: true }) const simulateFrequentEditing = false @@ -80,31 +79,15 @@ function triggerReload() { } }, DELAY_TIME) } -function loadFile(requirePath: string) { +function loadFile(requirePath: string): any { delete require.cache[require.resolve(requirePath)] // eslint-disable-next-line @typescript-eslint/no-var-requires - const mosData = require(requirePath) - if (mosData.runningOrder?.EditorialStart && !mosTypes.mosTime.is(mosData.runningOrder.EditorialStart)) { - mosData.runningOrder.EditorialStart = mosTypes.mosTime.create(mosData.runningOrder.EditorialStart._time) - } - - if (mosData.runningOrder?.EditorialDuration && !mosTypes.mosDuration.is(mosData.runningOrder.EditorialDuration)) { - let s = mosData.runningOrder.EditorialDuration._duration - const hh = Math.floor(s / 3600) - s -= hh * 3600 - - const mm = Math.floor(s / 60) - s -= mm * 60 - - const ss = Math.floor(s) - - mosData.runningOrder.EditorialDuration = mosTypes.mosDuration.create(hh + ':' + mm + ':' + ss) - } + const content = require(requirePath) - return mosData + return content } const monitors: { [id: string]: MOSMonitor } = {} -const runningOrderIds: { [id: string]: number } = {} +const runningOrderIds: { [id: string]: string } = {} async function reloadInner() { const newConfig: Config = loadFile('../input/config.ts').config @@ -133,7 +116,7 @@ async function reloadInner() { mos.mosConnection.onConnection((mosDevice: MosDevice) => { console.log('new mos connection', mosDevice.ID) - mosDevice.onGetMachineInfo(async () => { + mosDevice.onRequestMachineInfo(async () => { const machineInfo: IMOSListMachInfo = { manufacturer: mosTypes.mosString128.create('<<>>'), model: mosTypes.mosString128.create('<<>>'), @@ -196,13 +179,14 @@ async function reloadInner() { // mosDevice.onMosReqSearchableSchema((username: string) => Promise): void; // mosDevice.onMosReqObjectList((objList: IMosRequestObjectList) => Promise): void; // mosDevice.onMosReqObjectAction((action: string, obj: IMOSObject) => Promise): void; - mosDevice.onROReqAll(async () => { + mosDevice.onRequestAllRunningOrders(async () => { const ros = fetchRunningOrders() - return Promise.resolve(ros.map((r) => r.ro)) + if (!ros) return [] + return ros.map((r) => r.ro) }) mosDevice.onRequestRunningOrder(async (roId) => { - const ro = monitors[mosId].resendRunningOrder(roId as any as string) - return Promise.resolve(ro) + const ro = monitors[mosId].resendRunningOrder(mosTypes.mosString128.stringify(roId)) + return ro }) // mosDevice.onROStory((story: IMOSROFullStory) => Promise): void; setTimeout(() => { @@ -222,14 +206,14 @@ async function reloadInner() { } function refreshFiles() { // Check data - const t = Date.now() - _.each(fetchRunningOrders(), (r) => { + const timestamp = `${Date.now()}` + for (const r of fetchRunningOrders() || []) { const runningOrder = r.ro const stories = r.stories const readyToAir = r.readyToAir const id = mosTypes.mosString128.stringify(runningOrder.ID) - runningOrderIds[id] = t + runningOrderIds[id] = timestamp if (_.isEmpty(monitors)) { fakeOnUpdatedRunningOrder(runningOrder, stories) } else { @@ -237,42 +221,69 @@ function refreshFiles() { monitor.onUpdatedRunningOrder(runningOrder, stories, readyToAir) }) } - }) - _.each(runningOrderIds, (oldT, id) => { - if (oldT !== t) { + } + for (const [oldT, id] of Object.entries(runningOrderIds)) { + if (oldT !== timestamp) { _.each(monitors, (monitor) => { monitor.onDeletedRunningOrder(id) }) } - }) + } } function fetchRunningOrders() { const runningOrders: { ro: IMOSRunningOrder; stories: IMOSROFullStory[]; readyToAir: boolean }[] = [] - _.each(getAllFilesInDirectory('input/runningorders'), (filePath) => { + for (const filePath of getAllFilesInDirectory('input/runningorders')) { const requirePath = '../' + filePath.replace(/\\/g, '/') try { if ( requirePath.match(/[/\\]_/) || // ignore and folders files that begin with "_" requirePath.match(/[/\\]lib\.ts/) // ignore lib files ) { - return + continue } if (filePath.match(/(\.ts|.json)$/)) { const fileContents = loadFile(requirePath) - const ro: IMOSRunningOrder = fileContents.runningOrder - ro.ID = mosTypes.mosString128.create(filePath.replace(/\W/g, '_')) - runningOrders.push({ - ro, - stories: fileContents.fullStories, - readyToAir: fileContents.READY_TO_AIR, - }) + if (fileContents.runningOrder) { + const ro = fileContents.runningOrder + ro.ID = mosTypes.mosString128.create(filePath.replace(/\W/g, '_')) + + if (ro.EditorialStart && !mosTypes.mosTime.is(ro.EditorialStart)) { + ro.EditorialStart = mosTypes.mosTime.create(ro.EditorialStart._time) + } + + if ( + ro.EditorialDuration && + !mosTypes.mosDuration.is(ro.EditorialDuration) && + typeof ro.EditorialDuration._duration === 'number' + ) { + ro.EditorialDuration = mosTypes.mosDuration.create(ro.EditorialDuration._duration) + } + + runningOrders.push({ + ro, + stories: fileContents.stories, + readyToAir: fileContents.READY_TO_AIR, + }) + } else if (fileContents.snapshot && fileContents.snapshot.type === 'rundownplaylist') { + // Is a Sofie snapshot + convertFromSofieSnapshot(filePath, fileContents).forEach(({ ro, stories, readyToAir }) => { + runningOrders.push({ + ro, + stories, + readyToAir, + }) + }) + } else { + throw new Error('Unsupported file') + } } } catch (err) { console.log(`Error when parsing file "${requirePath}"`) throw err } - }) + } + return runningOrders } function getAllFilesInDirectory(dir: string): string[] { @@ -372,7 +383,10 @@ class MOSMonitor { this.triggerCheckQueue() }, 100) return local.ro - } else throw new Error(`ro ${roId} not found`) + } else { + console.log('ros', Object.keys(this.ros)) + throw new Error(`ro ${roId} not found`) + } } onUpdatedRunningOrder(ro: IMOSRunningOrder, fullStories: IMOSROFullStory[], readyToAir: boolean | undefined): void { // compare with From a4e410e7ae648d3d88315b066ff1eedf4098b3ce Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Wed, 16 Oct 2024 09:46:26 +0200 Subject: [PATCH 3/3] chore: lint fix --- packages/quick-mos/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/quick-mos/src/index.ts b/packages/quick-mos/src/index.ts index ac15eed8..3023a433 100644 --- a/packages/quick-mos/src/index.ts +++ b/packages/quick-mos/src/index.ts @@ -1,3 +1,4 @@ +/* eslint-disable node/no-unpublished-import */ import * as chokidar from 'chokidar' import * as fs from 'fs'