Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Quick-MOS: Add support for Sofie Snapshots (SOFIE-3554) #107

Merged
merged 3 commits into from
Oct 16, 2024
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
4 changes: 1 addition & 3 deletions packages/connector/src/MosConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,9 +544,7 @@ export class MosConnection extends EventEmitter<MosConnectionEvents> 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
Expand Down
2 changes: 2 additions & 0 deletions packages/model/src/mosTypes/__tests__/mosDuration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion packages/model/src/mosTypes/mosDuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}"!`)
Expand Down
4 changes: 2 additions & 2 deletions packages/model/src/mosTypes/mosTime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
23 changes: 17 additions & 6 deletions packages/quick-mos/input/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,30 @@ 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,
'2': true,
'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,
Expand Down
9 changes: 9 additions & 0 deletions packages/quick-mos/input/runningorders/README.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion packages/quick-mos/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
82 changes: 82 additions & 0 deletions packages/quick-mos/src/convertFromSofieSnapshot.ts
Original file line number Diff line number Diff line change
@@ -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
}
105 changes: 60 additions & 45 deletions packages/quick-mos/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable node/no-unpublished-import */
import * as chokidar from 'chokidar'

import * as fs from 'fs'
Expand All @@ -17,13 +18,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
Expand Down Expand Up @@ -80,31 +80,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
Expand Down Expand Up @@ -133,7 +117,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('<<<Mock Manufacturer>>>'),
model: mosTypes.mosString128.create('<<<Mock model>>>'),
Expand Down Expand Up @@ -196,13 +180,14 @@ async function reloadInner() {
// mosDevice.onMosReqSearchableSchema((username: string) => Promise<IMOSSearchableSchema>): void;
// mosDevice.onMosReqObjectList((objList: IMosRequestObjectList) => Promise<IMosObjectList>): void;
// mosDevice.onMosReqObjectAction((action: string, obj: IMOSObject) => Promise<IMOSAck>): 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<IMOSROAck>): void;
setTimeout(() => {
Expand All @@ -222,57 +207,84 @@ 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 {
_.each(monitors, (monitor) => {
monitor.onUpdatedRunningOrder(runningOrder, stories, readyToAir)
})
}
})
_.each(runningOrderIds, (oldT, id) => {
if (oldT !== t) {
}
for (const [oldT, id] of Object.entries<string>(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[] {
Expand Down Expand Up @@ -372,7 +384,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
Expand Down