diff --git a/packages/live-status-gateway/.eslintrc.json b/packages/live-status-gateway/.eslintrc.json index 82be0193db..1b89104e9a 100644 --- a/packages/live-status-gateway/.eslintrc.json +++ b/packages/live-status-gateway/.eslintrc.json @@ -13,7 +13,7 @@ "rules": { "prettier/prettier": ["error", { "endOfLine": "auto" }], "node/no-unpublished-import": ["error", { - "allowModules": ["jest-mock-extended"] + "allowModules": ["jest-mock-extended", "type-fest"] }] }, "parserOptions": { diff --git a/packages/live-status-gateway/README.md b/packages/live-status-gateway/README.md index 25a2ecc297..2d93c8ffe6 100644 --- a/packages/live-status-gateway/README.md +++ b/packages/live-status-gateway/README.md @@ -49,3 +49,9 @@ ws.addEventListener('error', (error) => { console.log('socket error', error); }); ``` + +### Timing accuracy + +The Live Status Gateway provides certain values in the form of timestamps, referencing both past and future events. These timestamps are particularly useful, for instance, in creating countdown timers. It's important to note that these values are relative to the system clock of the machine hosting Sofie Core. + +For optimal accuracy, we strongly recommend that external systems and applications leveraging these timestamps implement a method for time synchronization. This synchronization should align with the same time source used by Sofie Core — whether at the operating system level (e.g., utilizing a system-wide NTP client) or at the application level. diff --git a/packages/live-status-gateway/api/schemas/activePlaylist.yaml b/packages/live-status-gateway/api/schemas/activePlaylist.yaml index b0788f903a..59f5615b95 100644 --- a/packages/live-status-gateway/api/schemas/activePlaylist.yaml +++ b/packages/live-status-gateway/api/schemas/activePlaylist.yaml @@ -20,7 +20,10 @@ $defs: type: string currentPart: description: The current Part - if empty, no part in the active playlist is live - $ref: '#/$defs/part' + $ref: '#/$defs/currentPart' + currentSegment: + description: The Segment of the current Part - if empty, no part in the active playlist is live + $ref: '#/$defs/currentSegment' nextPart: description: The next Part - if empty, no part will follow live part $ref: '#/$defs/part' @@ -34,7 +37,7 @@ $defs: type: array items: $ref: '#/$defs/adLib' - required: [event, id, name, rundownIds, currentPart, nextPart, adLibs, globalAdLibs] + required: [event, id, name, rundownIds, currentPart, currentSegment, nextPart, adLibs, globalAdLibs] additionalProperties: false examples: - event: activePlaylist @@ -42,38 +45,78 @@ $defs: name: 'Playlist 0' rundownIds: ['y9HauyWkcxQS3XaAOsW40BRLLsI_'] currentPart: - $ref: '#/$defs/part/examples' + $ref: '#/$defs/currentPart/examples/0' + currentSegment: + $ref: '#/$defs/currentSegment/examples/0' nextPart: - $ref: '#/$defs/part/examples' + $ref: '#/$defs/part/examples/0' adLibs: $ref: '#/$defs/adLib/examples' globals: $ref: '#/$defs/adLib/examples' + partBase: + type: object + properties: + id: + description: Unique id of the part + type: string + name: + description: User name of the part + type: string + segmentId: + description: Unique id of the segment this part belongs to + type: string + autoNext: + description: If this part will progress to the next automatically + type: boolean + default: false + required: [id, name, segmentId] + additionalProperties: false + examples: + - id: 'H5CBGYjThrMSmaYvRaa5FVKJIzk_' + name: 'Intro' + segmentId: 'n1mOVd5_K5tt4sfk6HYfTuwumGQ_' + autoNext: false part: oneOf: - - type: object - properties: - id: - description: Unique id of the part - type: string - name: - description: User name of the part - type: string - segmentId: - description: Unique id of the segment this part belongs to - type: string - autoNext: - description: Should this part progress to the next automatically - type: boolean - default: false - required: [id, name, segmentId] - additionalProperties: false + - $ref: '#/$defs/partBase' - type: 'null' examples: - id: 'H5CBGYjThrMSmaYvRaa5FVKJIzk_' name: 'Intro' segmentId: 'n1mOVd5_K5tt4sfk6HYfTuwumGQ_' autoNext: false + currentPart: + oneOf: + - allOf: + - $ref: '#/$defs/partBase' + - type: object + properties: + timing: + description: Timing information about the current part + type: object + properties: + startTime: + description: Unix timestamp of when the part started (milliseconds) + type: number + expectedDurationMs: + description: Expected duration of the part (milliseconds) + type: number + projectedEndTime: + description: Unix timestamp of when the part is projected to end (milliseconds). A sum of `startTime` and `expectedDurationMs`. + type: number + required: [startTime, expectedDurationMs, projectedEndTime] + required: [timing] + - type: 'null' + examples: + - id: 'H5CBGYjThrMSmaYvRaa5FVKJIzk_' + name: 'Intro' + segmentId: 'n1mOVd5_K5tt4sfk6HYfTuwumGQ_' + autoNext: false + timing: + startTime: 1600000060000 + expectedDurationMs: 15000 + projectedEndTime: 1600000075000 adLib: type: object properties: @@ -118,3 +161,31 @@ $defs: - name: pvw label: Preview tags: ['music_video'] + currentSegment: + type: object + properties: + id: + description: Unique id of the segment + type: string + timing: + description: Timing information about the current segment + type: object + properties: + expectedDurationMs: + description: Expected duration of the segment + type: number + budgetDurationMs: + description: Budget duration of the segment + type: number + projectedEndTime: + description: Unix timestamp of when the segment is projected to end (milliseconds). The time this segment started, offset by its budget duration, if the segment has a defined budget duration. Otherwise, the time the current part started, offset by the difference between expected durations of all parts in this segment and the as-played durations of the parts that already stopped. + type: number + required: [expectedDurationMs, projectedEndTime] + required: [id, timing] + additionalProperties: false + examples: + - id: 'H5CBGYjThrMSmaYvRaa5FVKJIzk_' + timing: + expectedDurationMs: 15000 + budgetDurationMs: 20000 + projectedEndTime: 1600000075000 diff --git a/packages/live-status-gateway/api/schemas/segments.yaml b/packages/live-status-gateway/api/schemas/segments.yaml index 4ba041d195..0949e0b3c1 100644 --- a/packages/live-status-gateway/api/schemas/segments.yaml +++ b/packages/live-status-gateway/api/schemas/segments.yaml @@ -17,7 +17,7 @@ $defs: type: array items: $ref: '#/$defs/segment' - required: [event, id, segments] + required: [event, rundownPlaylistId, segments] additionalProperties: false examples: - event: segments @@ -39,9 +39,22 @@ $defs: name: description: Name of the segment type: string - required: [id, rundownId, name] + timing: + type: object + properties: + expectedDurationMs: + description: Expected duration of the segment (milliseconds) + type: number + budgetDurationMs: + description: Budget duration of the segment (milliseconds) + type: number + required: [expectedDurationMs] + required: [id, rundownId, name, timing] additionalProperties: false examples: - id: 'OKAgZmZ0Buc99lE_2uPPSKVbMrQ_' rundownId: 'y9HauyWkcxQS3XaAOsW40BRLLsI_' name: 'Segment 0' + timing: + expectedDurationMs: 15000 + budgetDurationMs: 20000 diff --git a/packages/live-status-gateway/package.json b/packages/live-status-gateway/package.json index 75deac341e..aa4b10f05b 100644 --- a/packages/live-status-gateway/package.json +++ b/packages/live-status-gateway/package.json @@ -69,7 +69,8 @@ "@asyncapi/generator": "1.9.13", "@asyncapi/html-template": "0.26.0", "@asyncapi/nodejs-ws-template": "0.9.25", - "jest-mock-extended": "^3.0.5" + "jest-mock-extended": "^3.0.5", + "type-fest": "^4.5.0" }, "lint-staged": { "*.{css,json,md,scss}": [ diff --git a/packages/live-status-gateway/sample-client/.eslintrc.json b/packages/live-status-gateway/sample-client/.eslintrc.json new file mode 100644 index 0000000000..bb7dd23ebd --- /dev/null +++ b/packages/live-status-gateway/sample-client/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "env": { + "browser": true + } +} diff --git a/packages/live-status-gateway/sample-client/index.html b/packages/live-status-gateway/sample-client/index.html new file mode 100644 index 0000000000..94eb84896f --- /dev/null +++ b/packages/live-status-gateway/sample-client/index.html @@ -0,0 +1,16 @@ + + + + Live Status Gateway client + + + +
+ + + + diff --git a/packages/live-status-gateway/sample-client/script.js b/packages/live-status-gateway/sample-client/script.js new file mode 100644 index 0000000000..137291ef02 --- /dev/null +++ b/packages/live-status-gateway/sample-client/script.js @@ -0,0 +1,146 @@ +const ws = new WebSocket(`ws://localhost:8080`) +ws.addEventListener('message', (message) => { + const data = JSON.parse(message.data) + switch (data.event) { + case 'pong': + handlePong(data) + break + case 'heartbeat': + handleHeartbeat(data) + break + case 'subscriptionStatus': + handleSubscriptionStatus(data) + break + case 'studio': + handleStudio(data) + break + case 'activePlaylist': + handleActivePlaylist(data) + break + case 'segments': + handleSegments(data) + break + } +}) + +ws.addEventListener('open', () => { + console.log('socket open') + + ws.send(JSON.stringify({ event: 'subscribe', subscription: { name: 'activePlaylist' }, reqid: 1 })) + + ws.send(JSON.stringify({ event: 'subscribe', subscription: { name: 'segments' }, reqid: 2 })) +}) + +ws.addEventListener('close', () => { + console.log('socket close') +}) + +ws.addEventListener('error', (error) => { + console.log('socket error', error) +}) + +function handlePong() { + // +} + +function handleHeartbeat() { + // +} + +function handleSubscriptionStatus() { + // +} + +function handleStudio() { + // +} + +const TIME_OF_DAY_SPAN_ID = 'time-of-day' +const SEGMENT_DURATION_SPAN_CLASS = 'segment-duration' +const SEGMENT_REMAINIG_SPAN_ID = 'segment-remaining' +const PART_REMAINIG_SPAN_ID = 'part-remaining' +const SEGMENTS_DIV_ID = 'segments' +const ENABLE_SYNCED_TICKS = true + +let activePlaylist = {} + +function handleActivePlaylist(data) { + activePlaylist = data +} + +setInterval(() => { + const segmentRemainingEl = document.getElementById(SEGMENT_REMAINIG_SPAN_ID) + const partRemainingEl = document.getElementById(PART_REMAINIG_SPAN_ID) + const segmentEndTime = activePlaylist.currentSegment && activePlaylist.currentSegment.timing.projectedEndTime + const partEndTime = activePlaylist.currentPart && activePlaylist.currentPart.timing.projectedEndTime + + const currentSegmentId = activePlaylist.currentPart && activePlaylist.currentPart.segmentId + const now = ENABLE_SYNCED_TICKS ? Math.floor(Date.now() / 1000) * 1000 : Date.now() + if (currentSegmentId && activePlaylist.currentPart) { + const currentSegmentEl = document.getElementById(activePlaylist.currentPart.segmentId) + if (currentSegmentEl) { + const durationEl = currentSegmentEl.querySelector('.' + SEGMENT_DURATION_SPAN_CLASS) + durationEl.textContent = formatMillisecondsToTime(segmentEndTime - now) + } + } + if (segmentEndTime) segmentRemainingEl.textContent = formatMillisecondsToTime(segmentEndTime - now) + if (partEndTime) partRemainingEl.textContent = formatMillisecondsToTime(Math.ceil(partEndTime / 1000) * 1000 - now) + updateClock() +}, 100) + +function updateClock() { + const now = new Date() + const hours = now.getHours() + const minutes = now.getMinutes() + const seconds = now.getSeconds() + const formattedTime = formatMillisecondsToTime(hours * 3600000 + minutes * 60000 + seconds * 1000) + + const clockElement = document.getElementById(TIME_OF_DAY_SPAN_ID) + if (clockElement) { + clockElement.textContent = formattedTime + } +} + +function handleSegments(data) { + const targetDiv = document.getElementById(SEGMENTS_DIV_ID) + + if (targetDiv) { + const existingUl = targetDiv.querySelector('ul') + if (existingUl) { + targetDiv.removeChild(existingUl) + } + + const ul = document.createElement('ul') + + data.segments.forEach((segment) => { + const li = document.createElement('li') + li.id = segment.id + const spanElement = document.createElement('span') + spanElement.classList = [SEGMENT_DURATION_SPAN_CLASS] + spanElement.textContent = formatMillisecondsToTime( + segment.timing.budgetDurationMs || segment.timing.expectedDurationMs + ) + const textNodeAfter = document.createTextNode(' ' + segment.name) + li.appendChild(spanElement) + li.appendChild(textNodeAfter) + ul.appendChild(li) + }) + + targetDiv.appendChild(ul) + } +} + +function formatMillisecondsToTime(milliseconds) { + const isNegative = milliseconds < 0 + milliseconds = Math.abs(milliseconds) + + const totalSeconds = Math.round(milliseconds / 1000) + const totalMinutes = Math.floor(totalSeconds / 60) + const totalHours = Math.floor(totalMinutes / 60) + + const formattedHours = String(totalHours).padStart(2, '0') + const formattedMinutes = String(totalMinutes % 60).padStart(2, '0') + const formattedSeconds = String(totalSeconds % 60).padStart(2, '0') + + return `${isNegative ? '+' : ''}${formattedHours}:${formattedMinutes}:${formattedSeconds}` +} diff --git a/packages/live-status-gateway/src/collections/adLibActions.ts b/packages/live-status-gateway/src/collections/adLibActionsHandler.ts similarity index 89% rename from packages/live-status-gateway/src/collections/adLibActions.ts rename to packages/live-status-gateway/src/collections/adLibActionsHandler.ts index f710687dcc..7148ba2b18 100644 --- a/packages/live-status-gateway/src/collections/adLibActions.ts +++ b/packages/live-status-gateway/src/collections/adLibActionsHandler.ts @@ -5,13 +5,13 @@ import { CoreConnection } from '@sofie-automation/server-core-integration' import { AdLibAction } from '@sofie-automation/corelib/dist/dataModel/AdlibAction' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { unprotectString } from '@sofie-automation/shared-lib/dist/lib/protectedString' -import { PartInstanceName } from './partInstances' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' import _ = require('underscore') +import { SelectedPartInstances } from './partInstancesHandler' export class AdLibActionsHandler extends CollectionBase - implements Collection, CollectionObserver> + implements Collection, CollectionObserver { public observerName: string private _core: CoreConnection @@ -33,11 +33,11 @@ export class AdLibActionsHandler await this.notify(this._collectionData) } - async update(source: string, data: Map | undefined): Promise { + async update(source: string, data: SelectedPartInstances | undefined): Promise { this._logger.info(`${this._name} received partInstances update from ${source}`) const prevRundownId = this._curRundownId const prevCurPartInstance = this._curPartInstance - this._curPartInstance = data ? data.get(PartInstanceName.current) ?? data.get(PartInstanceName.next) : undefined + this._curPartInstance = data ? data.current ?? data.next : undefined this._curRundownId = this._curPartInstance ? unprotectString(this._curPartInstance.rundownId) : undefined await new Promise(process.nextTick.bind(this)) diff --git a/packages/live-status-gateway/src/collections/adLibs.ts b/packages/live-status-gateway/src/collections/adLibsHandler.ts similarity index 70% rename from packages/live-status-gateway/src/collections/adLibs.ts rename to packages/live-status-gateway/src/collections/adLibsHandler.ts index dd2595c599..689a36d977 100644 --- a/packages/live-status-gateway/src/collections/adLibs.ts +++ b/packages/live-status-gateway/src/collections/adLibsHandler.ts @@ -5,18 +5,18 @@ import { CoreConnection } from '@sofie-automation/server-core-integration' import { AdLibPiece } from '@sofie-automation/corelib/dist/dataModel/AdLibPiece' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { unprotectString } from '@sofie-automation/shared-lib/dist/lib/protectedString' -import { PartInstanceName } from './partInstances' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' import _ = require('underscore') +import { SelectedPartInstances } from './partInstancesHandler' export class AdLibsHandler extends CollectionBase - implements Collection, CollectionObserver> + implements Collection, CollectionObserver { public observerName: string private _core: CoreConnection - private _curRundownId: string | undefined - private _curPartInstance: DBPartInstance | undefined + private _currentRundownId: string | undefined + private _currentPartInstance: DBPartInstance | undefined constructor(logger: Logger, coreHandler: CoreHandler) { super(AdLibsHandler.name, CollectionName.AdLibPieces, 'adLibPieces', logger, coreHandler) @@ -29,26 +29,28 @@ export class AdLibsHandler if (!this._collectionName) return const col = this._core.getCollection(this._collectionName) if (!col) throw new Error(`collection '${this._collectionName}' not found!`) - this._collectionData = col.find({ rundownId: this._curRundownId }) + this._collectionData = col.find({ rundownId: this._currentRundownId }) await this.notify(this._collectionData) } - async update(source: string, data: Map | undefined): Promise { + async update(source: string, data: SelectedPartInstances | undefined): Promise { this._logger.info(`${this._name} received adLibs update from ${source}`) - const prevRundownId = this._curRundownId - const prevCurPartInstance = this._curPartInstance - this._curPartInstance = data ? data.get(PartInstanceName.current) ?? data.get(PartInstanceName.next) : undefined - this._curRundownId = this._curPartInstance ? unprotectString(this._curPartInstance.rundownId) : undefined + const prevRundownId = this._currentRundownId + const prevCurPartInstance = this._currentPartInstance + this._currentPartInstance = data ? data.current ?? data.next : undefined + this._currentRundownId = this._currentPartInstance + ? unprotectString(this._currentPartInstance.rundownId) + : undefined await new Promise(process.nextTick.bind(this)) if (!this._collectionName) return if (!this._publicationName) return - if (prevRundownId !== this._curRundownId || !_.isEqual(prevCurPartInstance, this._curPartInstance)) { + if (prevRundownId !== this._currentRundownId || !_.isEqual(prevCurPartInstance, this._currentPartInstance)) { if (this._subscriptionId) this._coreHandler.unsubscribe(this._subscriptionId) if (this._dbObserver) this._dbObserver.stop() - if (this._curRundownId && this._curPartInstance) { + if (this._currentRundownId && this._currentPartInstance) { this._subscriptionId = await this._coreHandler.setupSubscription(this._publicationName, { - rundownId: this._curRundownId, + rundownId: this._currentRundownId, }) this._dbObserver = this._coreHandler.setupObserver(this._collectionName) this._dbObserver.added = (id: string) => { @@ -57,12 +59,15 @@ export class AdLibsHandler this._dbObserver.changed = (id: string) => { void this.changed(id, 'changed').catch(this._logger.error) } + this._dbObserver.removed = (id: string) => { + void this.changed(id, 'removed').catch(this._logger.error) + } const collection = this._core.getCollection(this._collectionName) if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) this._collectionData = collection.find({ - rundownId: this._curRundownId, - partId: this._curPartInstance.part._id, + rundownId: this._currentRundownId, + partId: this._currentPartInstance.part._id, }) await this.notify(this._collectionData) } diff --git a/packages/live-status-gateway/src/collections/globalAdLibActions.ts b/packages/live-status-gateway/src/collections/globalAdLibActionsHandler.ts similarity index 76% rename from packages/live-status-gateway/src/collections/globalAdLibActions.ts rename to packages/live-status-gateway/src/collections/globalAdLibActionsHandler.ts index 24a919bb89..68b951628e 100644 --- a/packages/live-status-gateway/src/collections/globalAdLibActions.ts +++ b/packages/live-status-gateway/src/collections/globalAdLibActionsHandler.ts @@ -3,20 +3,17 @@ import { CoreHandler } from '../coreHandler' import { CollectionBase, Collection, CollectionObserver } from '../wsHandler' import { CoreConnection } from '@sofie-automation/server-core-integration' import { RundownBaselineAdLibAction } from '@sofie-automation/corelib/dist/dataModel/RundownBaselineAdLibAction' -import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { unprotectString } from '@sofie-automation/shared-lib/dist/lib/protectedString' -import { PartInstanceName } from './partInstances' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' +import { SelectedPartInstances } from './partInstancesHandler' export class GlobalAdLibActionsHandler extends CollectionBase - implements - Collection, - CollectionObserver> + implements Collection, CollectionObserver { public observerName: string private _core: CoreConnection - private _curRundownId: string | undefined + private _currentRundownId: string | undefined constructor(logger: Logger, coreHandler: CoreHandler) { super( @@ -35,25 +32,25 @@ export class GlobalAdLibActionsHandler if (!this._collectionName) return const col = this._core.getCollection(this._collectionName) if (!col) throw new Error(`collection '${this._collectionName}' not found!`) - this._collectionData = col.find({ rundownId: this._curRundownId }) + this._collectionData = col.find({ rundownId: this._currentRundownId }) await this.notify(this._collectionData) } - async update(source: string, data: Map | undefined): Promise { + async update(source: string, data: SelectedPartInstances | undefined): Promise { this._logger.info(`${this._name} received partInstances update from ${source}`) - const prevRundownId = this._curRundownId - const partInstance = data ? data.get(PartInstanceName.current) ?? data.get(PartInstanceName.next) : undefined - this._curRundownId = partInstance ? unprotectString(partInstance.rundownId) : undefined + const prevRundownId = this._currentRundownId + const partInstance = data ? data.current ?? data.next : undefined + this._currentRundownId = partInstance ? unprotectString(partInstance.rundownId) : undefined await new Promise(process.nextTick.bind(this)) if (!this._collectionName) return if (!this._publicationName) return - if (prevRundownId !== this._curRundownId) { + if (prevRundownId !== this._currentRundownId) { if (this._subscriptionId) this._coreHandler.unsubscribe(this._subscriptionId) if (this._dbObserver) this._dbObserver.stop() - if (this._curRundownId) { + if (this._currentRundownId) { this._subscriptionId = await this._coreHandler.setupSubscription(this._publicationName, { - rundownId: this._curRundownId, + rundownId: this._currentRundownId, }) this._dbObserver = this._coreHandler.setupObserver(this._collectionName) this._dbObserver.added = (id: string) => { @@ -65,7 +62,7 @@ export class GlobalAdLibActionsHandler const collection = this._core.getCollection(this._collectionName) if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) - this._collectionData = collection.find({ rundownId: this._curRundownId }) + this._collectionData = collection.find({ rundownId: this._currentRundownId }) await this.notify(this._collectionData) } } diff --git a/packages/live-status-gateway/src/collections/globalAdLibs.ts b/packages/live-status-gateway/src/collections/globalAdLibsHandler.ts similarity index 75% rename from packages/live-status-gateway/src/collections/globalAdLibs.ts rename to packages/live-status-gateway/src/collections/globalAdLibsHandler.ts index d67d96d7e5..83b8249572 100644 --- a/packages/live-status-gateway/src/collections/globalAdLibs.ts +++ b/packages/live-status-gateway/src/collections/globalAdLibsHandler.ts @@ -3,20 +3,17 @@ import { CoreHandler } from '../coreHandler' import { CollectionBase, Collection, CollectionObserver } from '../wsHandler' import { CoreConnection } from '@sofie-automation/server-core-integration' import { RundownBaselineAdLibItem } from '@sofie-automation/corelib/dist/dataModel/RundownBaselineAdLibPiece' -import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { unprotectString } from '@sofie-automation/shared-lib/dist/lib/protectedString' -import { PartInstanceName } from './partInstances' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' +import { SelectedPartInstances } from './partInstancesHandler' export class GlobalAdLibsHandler extends CollectionBase - implements - Collection, - CollectionObserver> + implements Collection, CollectionObserver { public observerName: string private _core: CoreConnection - private _curRundownId: string | undefined + private _currentRundownId: string | undefined constructor(logger: Logger, coreHandler: CoreHandler) { super( @@ -35,25 +32,25 @@ export class GlobalAdLibsHandler if (!this._collectionName) return const collection = this._core.getCollection(this._collectionName) if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) - this._collectionData = collection.find({ rundownId: this._curRundownId }) + this._collectionData = collection.find({ rundownId: this._currentRundownId }) await this.notify(this._collectionData) } - async update(source: string, data: Map | undefined): Promise { + async update(source: string, data: SelectedPartInstances | undefined): Promise { this._logger.info(`${this._name} received globalAdLibs update from ${source}`) - const prevRundownId = this._curRundownId - const partInstance = data ? data.get(PartInstanceName.current) ?? data.get(PartInstanceName.next) : undefined - this._curRundownId = partInstance ? unprotectString(partInstance.rundownId) : undefined + const prevRundownId = this._currentRundownId + const partInstance = data ? data.current ?? data.next : undefined + this._currentRundownId = partInstance ? unprotectString(partInstance.rundownId) : undefined await new Promise(process.nextTick.bind(this)) if (!this._collectionName) return if (!this._publicationName) return - if (prevRundownId !== this._curRundownId) { + if (prevRundownId !== this._currentRundownId) { if (this._subscriptionId) this._coreHandler.unsubscribe(this._subscriptionId) if (this._dbObserver) this._dbObserver.stop() - if (this._curRundownId) { + if (this._currentRundownId) { this._subscriptionId = await this._coreHandler.setupSubscription(this._publicationName, { - rundownId: this._curRundownId, + rundownId: this._currentRundownId, }) this._dbObserver = this._coreHandler.setupObserver(this._collectionName) this._dbObserver.added = (id: string) => { @@ -65,7 +62,7 @@ export class GlobalAdLibsHandler const collection = this._core.getCollection(this._collectionName) if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) - this._collectionData = collection.find({ rundownId: this._curRundownId }) + this._collectionData = collection.find({ rundownId: this._currentRundownId }) await this.notify(this._collectionData) } } diff --git a/packages/live-status-gateway/src/collections/part.ts b/packages/live-status-gateway/src/collections/partHandler.ts similarity index 58% rename from packages/live-status-gateway/src/collections/part.ts rename to packages/live-status-gateway/src/collections/partHandler.ts index d4a19f7a4f..5886f1991e 100644 --- a/packages/live-status-gateway/src/collections/part.ts +++ b/packages/live-status-gateway/src/collections/partHandler.ts @@ -6,20 +6,22 @@ import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/Rund import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import { unprotectString } from '@sofie-automation/shared-lib/dist/lib/protectedString' -import { PartInstanceName, PartInstancesHandler } from './partInstances' +import { PartInstancesHandler, SelectedPartInstances } from './partInstancesHandler' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' -import { PlaylistHandler } from './playlist' +import { PlaylistHandler } from './playlistHandler' +import areElementsShallowEqual from '@sofie-automation/shared-lib/dist/lib/isShallowEqual' +import { PartsHandler } from './partsHandler' export class PartHandler extends CollectionBase - implements Collection, CollectionObserver> + implements Collection, CollectionObserver, CollectionObserver { public observerName: string private _core: CoreConnection private _activePlaylist: DBRundownPlaylist | undefined - private _curPartInstance: DBPartInstance | undefined + private _currentPartInstance: DBPartInstance | undefined - constructor(logger: Logger, coreHandler: CoreHandler) { + constructor(logger: Logger, coreHandler: CoreHandler, private _partsHandler: PartsHandler) { super(PartHandler.name, CollectionName.Parts, 'parts', logger, coreHandler) this._core = coreHandler.coreConnection this.observerName = this._name @@ -28,23 +30,22 @@ export class PartHandler async changed(id: string, changeType: string): Promise { this._logger.info(`${this._name} ${changeType} ${id}`) if (!this._collectionName) return - const col = this._core.getCollection(this._collectionName) - if (!col) throw new Error(`collection '${this._collectionName}' not found!`) + const collection = this._core.getCollection(this._collectionName) + if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) + const allParts = collection.find(undefined) + await this._partsHandler.setParts(allParts) if (this._collectionData) { - this._collectionData = col.findOne(this._collectionData._id) + this._collectionData = collection.findOne(this._collectionData._id) await this.notify(this._collectionData) } } - async update( - source: string, - data: DBRundownPlaylist | Map | undefined - ): Promise { - const prevPlaylist = this._activePlaylist - const prevCurPartInstance = this._curPartInstance + async update(source: string, data: DBRundownPlaylist | SelectedPartInstances | undefined): Promise { + const prevRundownIds = this._activePlaylist?.rundownIdsInOrder ?? [] + const prevCurPartInstance = this._currentPartInstance const rundownPlaylist = data ? (data as DBRundownPlaylist) : undefined - const partInstances = data as Map + const partInstances = data as SelectedPartInstances switch (source) { case PlaylistHandler.name: this._logger.info(`${this._name} received playlist update ${rundownPlaylist?._id}`) @@ -52,7 +53,7 @@ export class PartHandler break case PartInstancesHandler.name: this._logger.info(`${this._name} received partInstances update from ${source}`) - this._curPartInstance = partInstances.get(PartInstanceName.current) + this._currentPartInstance = partInstances.current break default: throw new Error(`${this._name} received unsupported update from ${source}}`) @@ -61,11 +62,12 @@ export class PartHandler await new Promise(process.nextTick.bind(this)) if (!this._collectionName) return if (!this._publicationName) return - if (prevPlaylist?.rundownIdsInOrder !== this._activePlaylist?.rundownIdsInOrder) { + const rundownsChanged = !areElementsShallowEqual(this._activePlaylist?.rundownIdsInOrder ?? [], prevRundownIds) + if (rundownsChanged) { if (this._subscriptionId) this._coreHandler.unsubscribe(this._subscriptionId) if (this._dbObserver) this._dbObserver.stop() if (this._activePlaylist) { - const rundownIds = this._activePlaylist.rundownIdsInOrder.map((r) => unprotectString(r)) + const rundownIds = this._activePlaylist?.rundownIdsInOrder.map((r) => unprotectString(r)) this._subscriptionId = await this._coreHandler.setupSubscription( this._publicationName, rundownIds, @@ -78,17 +80,23 @@ export class PartHandler this._dbObserver.changed = (id: string) => { void this.changed(id, 'changed').catch(this._logger.error) } + this._dbObserver.removed = (id: string) => { + void this.changed(id, 'removed').catch(this._logger.error) + } } } - - if (prevCurPartInstance !== this._curPartInstance) { + const collection = this._core.getCollection(this._collectionName) + if (rundownsChanged) { + const allParts = collection.find(undefined) + await this._partsHandler.setParts(allParts) + } + if (prevCurPartInstance !== this._currentPartInstance) { this._logger.info( `${this._name} found updated partInstances with current part ${this._activePlaylist?.currentPartInfo?.partInstanceId}` ) - const collection = this._core.getCollection(this._collectionName) if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) - if (this._curPartInstance) { - this._collectionData = collection.findOne(this._curPartInstance.part._id) + if (this._currentPartInstance) { + this._collectionData = collection.findOne(this._currentPartInstance.part._id) await this.notify(this._collectionData) } } diff --git a/packages/live-status-gateway/src/collections/partInstances.ts b/packages/live-status-gateway/src/collections/partInstances.ts deleted file mode 100644 index 605a30c2b6..0000000000 --- a/packages/live-status-gateway/src/collections/partInstances.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { Logger } from 'winston' -import { CoreHandler } from '../coreHandler' -import { CollectionBase, Collection, CollectionObserver } from '../wsHandler' -import { CoreConnection } from '@sofie-automation/server-core-integration' -import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' -import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' -import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' -import isShallowEqual from '@sofie-automation/shared-lib/dist/lib/isShallowEqual' - -export enum PartInstanceName { - current = 'current', - next = 'next', -} - -export class PartInstancesHandler - extends CollectionBase> - implements Collection>, CollectionObserver -{ - public observerName: string - private _core: CoreConnection - private _curPlaylist: DBRundownPlaylist | undefined - private _rundownIds: string[] = [] - private _activationId: string | undefined - - constructor(logger: Logger, coreHandler: CoreHandler) { - super(PartInstancesHandler.name, CollectionName.PartInstances, 'partInstances', logger, coreHandler) - this._core = coreHandler.coreConnection - this.observerName = this._name - this._collectionData = new Map() - this._collectionData.set(PartInstanceName.current, undefined) - this._collectionData.set(PartInstanceName.next, undefined) - } - - async changed(id: string, changeType: string): Promise { - this._logger.info(`${this._name} ${changeType} ${id}`) - if (!this._collectionName) return - const collection = this._core.getCollection(this._collectionName) - if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) - const curPartInstance = this._curPlaylist?.currentPartInfo?.partInstanceId - ? collection.findOne(this._curPlaylist.currentPartInfo.partInstanceId) - : undefined - const nextPartInstance = this._curPlaylist?.nextPartInfo?.partInstanceId - ? collection.findOne(this._curPlaylist.nextPartInfo.partInstanceId) - : undefined - this._collectionData?.forEach((_pi, key) => { - if (PartInstanceName.current === key) this._collectionData?.set(key, curPartInstance) - else if (PartInstanceName.next === key) this._collectionData?.set(key, nextPartInstance) - }) - - await this.notify(this._collectionData) - } - - async update(source: string, data: DBRundownPlaylist | undefined): Promise { - const prevRundownIds = this._rundownIds.map((rid) => rid) - const prevActivationId = this._activationId - - this._logger.info( - `${this._name} received playlist update ${data?._id}, active ${ - data?.activationId ? true : false - } from ${source}` - ) - this._curPlaylist = data - if (!this._collectionName) return - - this._rundownIds = this._curPlaylist ? this._curPlaylist.rundownIdsInOrder.map((r) => unprotectString(r)) : [] - this._activationId = unprotectString(this._curPlaylist?.activationId) - if (this._curPlaylist && this._rundownIds.length && this._activationId) { - const sameSubscription = - isShallowEqual(this._rundownIds, prevRundownIds) && prevActivationId === this._activationId - if (!sameSubscription) { - await new Promise(process.nextTick.bind(this)) - if (!this._collectionName) return - if (!this._publicationName) return - if (!this._curPlaylist) return - if (this._subscriptionId) this._coreHandler.unsubscribe(this._subscriptionId) - this._subscriptionId = await this._coreHandler.setupSubscription( - this._publicationName, - this._rundownIds, - this._activationId - ) - this._dbObserver = this._coreHandler.setupObserver(this._collectionName) - this._dbObserver.added = (id: string) => { - void this.changed(id, 'added').catch(this._logger.error) - } - this._dbObserver.changed = (id: string) => { - void this.changed(id, 'changed').catch(this._logger.error) - } - - const collection = this._core.getCollection(this._collectionName) - if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) - const curPartInstance = this._curPlaylist?.currentPartInfo?.partInstanceId - ? collection.findOne(this._curPlaylist.currentPartInfo.partInstanceId) - : undefined - const nextPartInstance = this._curPlaylist?.nextPartInfo?.partInstanceId - ? collection.findOne(this._curPlaylist.nextPartInfo.partInstanceId) - : undefined - this._collectionData?.forEach((_pi, key) => { - if (PartInstanceName.current === key) this._collectionData?.set(key, curPartInstance) - else if (PartInstanceName.next === key) this._collectionData?.set(key, nextPartInstance) - }) - await this.notify(this._collectionData) - } else if (this._subscriptionId) { - const collection = this._core.getCollection(this._collectionName) - if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) - const curPartInstance = this._curPlaylist?.currentPartInfo?.partInstanceId - ? collection.findOne(this._curPlaylist.currentPartInfo.partInstanceId) - : undefined - const nextPartInstance = this._curPlaylist.nextPartInfo?.partInstanceId - ? collection.findOne(this._curPlaylist.nextPartInfo.partInstanceId) - : undefined - this._collectionData?.forEach((_pi, key) => { - if (PartInstanceName.current === key) this._collectionData?.set(key, curPartInstance) - else if (PartInstanceName.next === key) this._collectionData?.set(key, nextPartInstance) - }) - await this.notify(this._collectionData) - } else { - this._collectionData?.forEach((_pi, key) => this._collectionData?.set(key, undefined)) - await this.notify(this._collectionData) - } - } else { - this._collectionData?.forEach((_pi, key) => this._collectionData?.set(key, undefined)) - await this.notify(this._collectionData) - } - } -} diff --git a/packages/live-status-gateway/src/collections/partInstancesHandler.ts b/packages/live-status-gateway/src/collections/partInstancesHandler.ts new file mode 100644 index 0000000000..c361b2ea03 --- /dev/null +++ b/packages/live-status-gateway/src/collections/partInstancesHandler.ts @@ -0,0 +1,155 @@ +import { Logger } from 'winston' +import { CoreHandler } from '../coreHandler' +import { CollectionBase, Collection, CollectionObserver } from '../wsHandler' +import { CoreConnection } from '@sofie-automation/server-core-integration' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' +import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' +import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' +import areElementsShallowEqual from '@sofie-automation/shared-lib/dist/lib/isShallowEqual' +import _ = require('underscore') + +export interface SelectedPartInstances { + current: DBPartInstance | undefined + next: DBPartInstance | undefined + firstInSegmentPlayout: DBPartInstance | undefined + inCurrentSegment: DBPartInstance[] +} + +export class PartInstancesHandler + extends CollectionBase + implements Collection, CollectionObserver +{ + public observerName: string + private _core: CoreConnection + private _currentPlaylist: DBRundownPlaylist | undefined + private _rundownIds: string[] = [] + private _activationId: string | undefined + + constructor(logger: Logger, coreHandler: CoreHandler) { + super(PartInstancesHandler.name, CollectionName.PartInstances, 'partInstances', logger, coreHandler) + this._core = coreHandler.coreConnection + this.observerName = this._name + this._collectionData = { + current: undefined, + next: undefined, + firstInSegmentPlayout: undefined, + inCurrentSegment: [], + } + } + + async changed(id: string, changeType: string): Promise { + this._logger.info(`${this._name} ${changeType} ${id}`) + if (!this._collectionName) return + this.updateCollectionData() + + await this.notify(this._collectionData) + } + + private updateCollectionData(): boolean { + if (!this._collectionName || !this._collectionData) return false + const collection = this._core.getCollection(this._collectionName) + if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) + const currentPartInstance = this._currentPlaylist?.currentPartInfo?.partInstanceId + ? collection.findOne(this._currentPlaylist.currentPartInfo.partInstanceId) + : undefined + const nextPartInstance = this._currentPlaylist?.nextPartInfo?.partInstanceId + ? collection.findOne(this._currentPlaylist.nextPartInfo.partInstanceId) + : undefined + const partInstancesInSegmentPlayout = currentPartInstance + ? collection.find({ segmentPlayoutId: currentPartInstance.segmentPlayoutId }) + : [] + + const firstPartInstanceInSegmentPlayout = _.min( + partInstancesInSegmentPlayout, + (partInstance) => partInstance.takeCount + ) as DBPartInstance + + let hasAnythingChanged = false + if (currentPartInstance !== this._collectionData.current) { + this._collectionData.current = currentPartInstance + hasAnythingChanged = true + } + if (this._collectionData.next !== nextPartInstance) { + this._collectionData.next = nextPartInstance + hasAnythingChanged = true + } + if (this._collectionData.firstInSegmentPlayout !== firstPartInstanceInSegmentPlayout) { + this._collectionData.firstInSegmentPlayout = firstPartInstanceInSegmentPlayout + hasAnythingChanged = true + } + if (!areElementsShallowEqual(this._collectionData.inCurrentSegment, partInstancesInSegmentPlayout)) { + this._collectionData.inCurrentSegment = partInstancesInSegmentPlayout + hasAnythingChanged = true + } + return hasAnythingChanged + } + + private clearCollectionData() { + if (!this._collectionName || !this._collectionData) return + this._collectionData.current = undefined + this._collectionData.next = undefined + this._collectionData.firstInSegmentPlayout = undefined + this._collectionData.inCurrentSegment = [] + } + + async update(source: string, data: DBRundownPlaylist | undefined): Promise { + const prevRundownIds = this._rundownIds.map((rid) => rid) + const prevActivationId = this._activationId + + this._logger.info( + `${this._name} received playlist update ${data?._id}, active ${ + data?.activationId ? true : false + } from ${source}` + ) + this._currentPlaylist = data + if (!this._collectionName) return + + this._rundownIds = this._currentPlaylist + ? this._currentPlaylist.rundownIdsInOrder.map((r) => unprotectString(r)) + : [] + this._activationId = unprotectString(this._currentPlaylist?.activationId) + if (this._currentPlaylist && this._rundownIds.length && this._activationId) { + const sameSubscription = + areElementsShallowEqual(this._rundownIds, prevRundownIds) && prevActivationId === this._activationId + if (!sameSubscription) { + await new Promise(process.nextTick.bind(this)) + if (!this._collectionName) return + if (!this._publicationName) return + if (!this._currentPlaylist) return + if (this._subscriptionId) this._coreHandler.unsubscribe(this._subscriptionId) + this._subscriptionId = await this._coreHandler.setupSubscription( + this._publicationName, + this._rundownIds, + this._activationId + ) + this._dbObserver = this._coreHandler.setupObserver(this._collectionName) + this._dbObserver.added = (id: string) => { + void this.changed(id, 'added').catch(this._logger.error) + } + this._dbObserver.changed = (id: string) => { + void this.changed(id, 'changed').catch(this._logger.error) + } + this._dbObserver.removed = (id: string) => { + void this.changed(id, 'removed').catch(this._logger.error) + } + + const hasAnythingChanged = this.updateCollectionData() + if (hasAnythingChanged) { + await this.notify(this._collectionData) + } + } else if (this._subscriptionId) { + const hasAnythingChanged = this.updateCollectionData() + if (hasAnythingChanged) { + await this.notify(this._collectionData) + } + } else { + this.clearCollectionData() + await this.notify(this._collectionData) + } + } else { + this.clearCollectionData() + await this.notify(this._collectionData) + } + } +} diff --git a/packages/live-status-gateway/src/collections/partsHandler.ts b/packages/live-status-gateway/src/collections/partsHandler.ts new file mode 100644 index 0000000000..7a30013282 --- /dev/null +++ b/packages/live-status-gateway/src/collections/partsHandler.ts @@ -0,0 +1,35 @@ +import { Logger } from 'winston' +import { CoreHandler } from '../coreHandler' +import { CollectionBase, Collection } from '../wsHandler' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import _ = require('underscore') + +const THROTTLE_PERIOD_MS = 200 + +export class PartsHandler extends CollectionBase implements Collection { + public observerName: string + private throttledNotify: (data: DBPart[]) => Promise + + constructor(logger: Logger, coreHandler: CoreHandler) { + super(PartsHandler.name, undefined, undefined, logger, coreHandler) + this.observerName = this._name + this.throttledNotify = _.throttle(this.notify.bind(this), THROTTLE_PERIOD_MS, { leading: true, trailing: true }) + } + + async setParts(parts: DBPart[]): Promise { + this._logger.info(`'${this._name}' handler received parts update with ${parts.length} parts`) + this._collectionData = parts + await this.throttledNotify(this._collectionData) + } + + async notify(data: DBPart[] | undefined): Promise { + this._logger.info( + `${this._name} notifying all observers of an update with ${this._collectionData?.length} parts` + ) + if (data !== undefined) { + for (const observer of this._observers) { + await observer.update(this._name, data) + } + } + } +} diff --git a/packages/live-status-gateway/src/collections/playlist.ts b/packages/live-status-gateway/src/collections/playlistHandler.ts similarity index 100% rename from packages/live-status-gateway/src/collections/playlist.ts rename to packages/live-status-gateway/src/collections/playlistHandler.ts diff --git a/packages/live-status-gateway/src/collections/rundownHandler.ts b/packages/live-status-gateway/src/collections/rundownHandler.ts index 7505131336..6922de60e3 100644 --- a/packages/live-status-gateway/src/collections/rundownHandler.ts +++ b/packages/live-status-gateway/src/collections/rundownHandler.ts @@ -3,26 +3,22 @@ import { CoreHandler } from '../coreHandler' import { CollectionBase, Collection, CollectionObserver } from '../wsHandler' import { CoreConnection } from '@sofie-automation/server-core-integration' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { protectString } from '@sofie-automation/shared-lib/dist/lib/protectedString' -import { PartInstanceName, PartInstancesHandler } from './partInstances' +import { PartInstancesHandler, SelectedPartInstances } from './partInstancesHandler' import { RundownId, RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' -import { PlaylistHandler } from './playlist' +import { PlaylistHandler } from './playlistHandler' import { RundownsHandler } from './rundownsHandler' export class RundownHandler extends CollectionBase - implements - Collection, - CollectionObserver, - CollectionObserver> + implements Collection, CollectionObserver, CollectionObserver { public observerName: string private _core: CoreConnection - private _curPlaylistId: RundownPlaylistId | undefined - private _curRundownId: RundownId | undefined + private _currentPlaylistId: RundownPlaylistId | undefined + private _currentRundownId: RundownId | undefined constructor(logger: Logger, coreHandler: CoreHandler, private _rundownsHandler?: RundownsHandler) { super(RundownHandler.name, CollectionName.Rundowns, 'rundowns', logger, coreHandler) @@ -32,8 +28,8 @@ export class RundownHandler async changed(id: string, changeType: string): Promise { this._logger.info(`${this._name} ${changeType} ${id}`) - if (protectString(id) !== this._curRundownId) - throw new Error(`${this._name} received change with unexpected id ${id} !== ${this._curRundownId}`) + if (protectString(id) !== this._currentRundownId) + throw new Error(`${this._name} received change with unexpected id ${id} !== ${this._currentRundownId}`) if (!this._collectionName) return const collection = this._core.getCollection(this._collectionName) if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) @@ -42,22 +38,19 @@ export class RundownHandler await this.notify(this._collectionData) } - async update( - source: string, - data: DBRundownPlaylist | Map | undefined - ): Promise { - const prevPlaylistId = this._curPlaylistId - const prevCurRundownId = this._curRundownId + async update(source: string, data: DBRundownPlaylist | SelectedPartInstances | undefined): Promise { + const prevPlaylistId = this._currentPlaylistId + const prevCurRundownId = this._currentRundownId const rundownPlaylist = data ? (data as DBRundownPlaylist) : undefined - const partInstances = data as Map + const partInstances = data as SelectedPartInstances switch (source) { case PlaylistHandler.name: this._logger.info(`${this._name} received playlist update ${rundownPlaylist?._id}`) - this._curPlaylistId = rundownPlaylist?._id + this._currentPlaylistId = rundownPlaylist?._id break case PartInstancesHandler.name: this._logger.info(`${this._name} received partInstances update from ${source}`) - this._curRundownId = partInstances.get(PartInstanceName.current)?.rundownId + this._currentRundownId = partInstances.current?.rundownId break default: throw new Error(`${this._name} received unsupported update from ${source}}`) @@ -66,13 +59,13 @@ export class RundownHandler await new Promise(process.nextTick.bind(this)) if (!this._collectionName) return if (!this._publicationName) return - if (prevPlaylistId !== this._curPlaylistId) { + if (prevPlaylistId !== this._currentPlaylistId) { if (this._subscriptionId) this._coreHandler.unsubscribe(this._subscriptionId) if (this._dbObserver) this._dbObserver.stop() - if (this._curPlaylistId) { + if (this._currentPlaylistId) { this._subscriptionId = await this._coreHandler.setupSubscription( this._publicationName, - [this._curPlaylistId], + [this._currentPlaylistId], undefined ) this._dbObserver = this._coreHandler.setupObserver(this._collectionName) @@ -85,12 +78,12 @@ export class RundownHandler } } - if (prevCurRundownId !== this._curRundownId) { - if (this._curRundownId) { + if (prevCurRundownId !== this._currentRundownId) { + if (this._currentRundownId) { const collection = this._core.getCollection(this._collectionName) if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) - const rundown = collection.findOne(this._curRundownId) - if (!rundown) throw new Error(`rundown '${this._curRundownId}' not found!`) + const rundown = collection.findOne(this._currentRundownId) + if (!rundown) throw new Error(`rundown '${this._currentRundownId}' not found!`) this._collectionData = rundown } else this._collectionData = undefined await this.notify(this._collectionData) diff --git a/packages/live-status-gateway/src/collections/segmentHandler.ts b/packages/live-status-gateway/src/collections/segmentHandler.ts index d70ab16885..ede4eefddb 100644 --- a/packages/live-status-gateway/src/collections/segmentHandler.ts +++ b/packages/live-status-gateway/src/collections/segmentHandler.ts @@ -3,25 +3,21 @@ import { CoreHandler } from '../coreHandler' import { CollectionBase, Collection, CollectionObserver } from '../wsHandler' import { CoreConnection } from '@sofie-automation/server-core-integration' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' -import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' -import { PartInstanceName, PartInstancesHandler } from './partInstances' +import { PartInstancesHandler, SelectedPartInstances } from './partInstancesHandler' import { RundownId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' -import isShallowEqual from '@sofie-automation/shared-lib/dist/lib/isShallowEqual' +import areElementsShallowEqual from '@sofie-automation/shared-lib/dist/lib/isShallowEqual' import { SegmentsHandler } from './segmentsHandler' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { PlaylistHandler } from './playlist' +import { PlaylistHandler } from './playlistHandler' export class SegmentHandler extends CollectionBase - implements - Collection, - CollectionObserver>, - CollectionObserver + implements Collection, CollectionObserver, CollectionObserver { public observerName: string private _core: CoreConnection - private _curSegmentId: SegmentId | undefined + private _currentSegmentId: SegmentId | undefined private _rundownIds: RundownId[] = [] constructor(logger: Logger, coreHandler: CoreHandler, private _segmentsHandler: SegmentsHandler) { @@ -37,24 +33,21 @@ export class SegmentHandler if (!collection) throw new Error(`collection '${this._collectionName}' not found!`) const allSegments = collection.find(undefined) await this._segmentsHandler.setSegments(allSegments) - if (this._curSegmentId) { - this._collectionData = collection.findOne(this._curSegmentId) + if (this._currentSegmentId) { + this._collectionData = collection.findOne(this._currentSegmentId) await this.notify(this._collectionData) } } - async update( - source: string, - data: Map | DBRundownPlaylist | undefined - ): Promise { - const prevSegmentId = this._curSegmentId - const prevRundownIds = this._rundownIds + async update(source: string, data: SelectedPartInstances | DBRundownPlaylist | undefined): Promise { + const previousSegmentId = this._currentSegmentId + const previousRundownIds = this._rundownIds switch (source) { case PartInstancesHandler.name: { this._logger.info(`${this._name} received update from ${source}`) - const partInstanceMap = data as Map - this._curSegmentId = data ? partInstanceMap.get(PartInstanceName.current)?.segmentId : undefined + const partInstanceMap = data as SelectedPartInstances + this._currentSegmentId = data ? partInstanceMap.current?.segmentId : undefined break } case PlaylistHandler.name: { @@ -69,7 +62,7 @@ export class SegmentHandler if (!this._collectionName) return if (!this._publicationName) return - const rundownsChanged = !isShallowEqual(this._rundownIds, prevRundownIds) + const rundownsChanged = !areElementsShallowEqual(this._rundownIds, previousRundownIds) if (rundownsChanged) { if (this._subscriptionId) this._coreHandler.unsubscribe(this._subscriptionId) if (this._dbObserver) this._dbObserver.stop() @@ -85,6 +78,9 @@ export class SegmentHandler this._dbObserver.changed = (id: string) => { void this.changed(id, 'changed').catch(this._logger.error) } + this._dbObserver.removed = (id: string) => { + void this.changed(id, 'removed').catch(this._logger.error) + } } } @@ -94,9 +90,9 @@ export class SegmentHandler const allSegments = collection.find(undefined) await this._segmentsHandler.setSegments(allSegments) } - if (prevSegmentId !== this._curSegmentId) { - if (this._curSegmentId) { - this._collectionData = collection.findOne(this._curSegmentId) + if (previousSegmentId !== this._currentSegmentId) { + if (this._currentSegmentId) { + this._collectionData = collection.findOne(this._currentSegmentId) await this.notify(this._collectionData) } } diff --git a/packages/live-status-gateway/src/collections/segmentsHandler.ts b/packages/live-status-gateway/src/collections/segmentsHandler.ts index 318dea81c4..6309cd42aa 100644 --- a/packages/live-status-gateway/src/collections/segmentsHandler.ts +++ b/packages/live-status-gateway/src/collections/segmentsHandler.ts @@ -16,9 +16,9 @@ export class SegmentsHandler extends CollectionBase implements Coll this.throttledNotify = _.throttle(this.notify.bind(this), THROTTLE_PERIOD_MS, { leading: true, trailing: true }) } - async setSegments(rundowns: DBSegment[]): Promise { - this._logger.info(`'${this._name}' handler received segments update with ${rundowns.length} segments`) - this._collectionData = rundowns + async setSegments(segments: DBSegment[]): Promise { + this._logger.info(`'${this._name}' handler received segments update with ${segments.length} segments`) + this._collectionData = segments await this.throttledNotify(this._collectionData) } diff --git a/packages/live-status-gateway/src/collections/showStyleBase.ts b/packages/live-status-gateway/src/collections/showStyleBaseHandler.ts similarity index 100% rename from packages/live-status-gateway/src/collections/showStyleBase.ts rename to packages/live-status-gateway/src/collections/showStyleBaseHandler.ts diff --git a/packages/live-status-gateway/src/collections/studio.ts b/packages/live-status-gateway/src/collections/studioHandler.ts similarity index 100% rename from packages/live-status-gateway/src/collections/studio.ts rename to packages/live-status-gateway/src/collections/studioHandler.ts diff --git a/packages/live-status-gateway/src/liveStatusServer.ts b/packages/live-status-gateway/src/liveStatusServer.ts index 80accc4e7d..6949271a7c 100644 --- a/packages/live-status-gateway/src/liveStatusServer.ts +++ b/packages/live-status-gateway/src/liveStatusServer.ts @@ -1,23 +1,25 @@ import { Logger } from 'winston' import { CoreHandler } from './coreHandler' import { WebSocket, WebSocketServer } from 'ws' -import { StudioHandler } from './collections/studio' -import { ShowStyleBaseHandler } from './collections/showStyleBase' -import { PlaylistHandler } from './collections/playlist' +import { StudioHandler } from './collections/studioHandler' +import { ShowStyleBaseHandler } from './collections/showStyleBaseHandler' +import { PlaylistHandler } from './collections/playlistHandler' import { RundownHandler } from './collections/rundownHandler' // import { RundownsHandler } from './collections/rundownsHandler' import { SegmentHandler } from './collections/segmentHandler' // import { PartHandler } from './collections/part' -import { PartInstancesHandler } from './collections/partInstances' -import { AdLibActionsHandler } from './collections/adLibActions' -import { GlobalAdLibActionsHandler } from './collections/globalAdLibActions' +import { PartInstancesHandler } from './collections/partInstancesHandler' +import { AdLibActionsHandler } from './collections/adLibActionsHandler' +import { GlobalAdLibActionsHandler } from './collections/globalAdLibActionsHandler' import { RootChannel } from './topics/root' -import { StudioTopic } from './topics/studio' -import { ActivePlaylistTopic } from './topics/activePlaylist' -import { AdLibsHandler } from './collections/adLibs' -import { GlobalAdLibsHandler } from './collections/globalAdLibs' +import { StudioTopic } from './topics/studioTopic' +import { ActivePlaylistTopic } from './topics/activePlaylistTopic' +import { AdLibsHandler } from './collections/adLibsHandler' +import { GlobalAdLibsHandler } from './collections/globalAdLibsHandler' import { SegmentsTopic } from './topics/segmentsTopic' import { SegmentsHandler } from './collections/segmentsHandler' +import { PartHandler } from './collections/partHandler' +import { PartsHandler } from './collections/partsHandler' export class LiveStatusServer { _logger: Logger @@ -56,8 +58,10 @@ export class LiveStatusServer { await segmentsHandler.init() const segmentHandler = new SegmentHandler(this._logger, this._coreHandler, segmentsHandler) await segmentHandler.init() - // const partHandler = new PartHandler(this._logger, this._coreHandler) - // await partHandler.init() + const partsHandler = new PartsHandler(this._logger, this._coreHandler) + await partsHandler.init() + const partHandler = new PartHandler(this._logger, this._coreHandler, partsHandler) + await partHandler.init() const partInstancesHandler = new PartInstancesHandler(this._logger, this._coreHandler) await partInstancesHandler.init() const adLibActionsHandler = new AdLibActionsHandler(this._logger, this._coreHandler) @@ -72,7 +76,7 @@ export class LiveStatusServer { // add observers for collection subscription updates await playlistHandler.subscribe(rundownHandler) await playlistHandler.subscribe(segmentHandler) - // playlistHandler.subscribe(partHandler) + await playlistHandler.subscribe(partHandler) await playlistHandler.subscribe(partInstancesHandler) await rundownHandler.subscribe(showStyleBaseHandler) await partInstancesHandler.subscribe(rundownHandler) @@ -93,8 +97,11 @@ export class LiveStatusServer { await adLibsHandler.subscribe(activePlaylistTopic) await globalAdLibActionsHandler.subscribe(activePlaylistTopic) await globalAdLibsHandler.subscribe(activePlaylistTopic) + await partsHandler.subscribe(activePlaylistTopic) + await playlistHandler.subscribe(segmentsTopic) await segmentsHandler.subscribe(segmentsTopic) + await partsHandler.subscribe(segmentsTopic) const wss = new WebSocketServer({ port: 8080 }) wss.on('connection', (ws, request) => { diff --git a/packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts b/packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts index c55bdafbb5..0ecbd3c81d 100644 --- a/packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts +++ b/packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts @@ -1,16 +1,20 @@ -import { ActivePlaylistStatus, ActivePlaylistTopic } from '../activePlaylist' +import { ActivePlaylistStatus, ActivePlaylistTopic } from '../activePlaylistTopic' import { makeMockLogger, makeMockSubscriber, makeTestPlaylist } from './utils' -import { PlaylistHandler } from '../../collections/playlist' -import { ShowStyleBaseHandler } from '../../collections/showStyleBase' +import { PlaylistHandler } from '../../collections/playlistHandler' +import { ShowStyleBaseHandler } from '../../collections/showStyleBaseHandler' import { DBShowStyleBase } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' import { SourceLayerType } from '@sofie-automation/blueprints-integration/dist' -import { PartInstanceName, PartInstancesHandler } from '../../collections/partInstances' -import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' +import { PartInstancesHandler, SelectedPartInstances } from '../../collections/partInstancesHandler' import { AdLibAction } from '@sofie-automation/corelib/dist/dataModel/AdlibAction' import { protectString, unprotectString, unprotectStringArray } from '@sofie-automation/server-core-integration/dist' import { RundownBaselineAdLibAction } from '@sofie-automation/corelib/dist/dataModel/RundownBaselineAdLibAction' -import { AdLibActionsHandler } from '../../collections/adLibActions' -import { GlobalAdLibActionsHandler } from '../../collections/globalAdLibActions' +import { AdLibActionsHandler } from '../../collections/adLibActionsHandler' +import { GlobalAdLibActionsHandler } from '../../collections/globalAdLibActionsHandler' +import { PartialDeep } from 'type-fest' +import { literal } from '@sofie-automation/corelib/dist/lib' +import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' +import { PartsHandler } from '../../collections/partsHandler' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' function makeTestShowStyleBase(): Pick { return { @@ -25,8 +29,13 @@ function makeTestShowStyleBase(): Pick { - return new Map([]) // TODO +function makeTestPartInstanceMap(): SelectedPartInstances { + return { + current: undefined, + firstInSegmentPlayout: undefined, + inCurrentSegment: [], + next: undefined, + } } function makeTestAdLibActions(): AdLibAction[] { @@ -111,6 +120,7 @@ describe('ActivePlaylistTopic', () => { ], currentPart: null, nextPart: null, + currentSegment: null, globalAdLibs: [ { actionType: [], @@ -128,4 +138,78 @@ describe('ActivePlaylistTopic', () => { expect(mockSubscriber.send).toHaveBeenCalledTimes(1) expect(JSON.parse(mockSubscriber.send.mock.calls[0][0] as string)).toMatchObject(expectedStatus) }) + + it('provides segment and part', async () => { + const topic = new ActivePlaylistTopic(makeMockLogger()) + const mockSubscriber = makeMockSubscriber() + + const currentPartInstanceId = 'CURRENT_PART_INSTANCE_ID' + + const playlist = makeTestPlaylist() + playlist.activationId = protectString('somethingRandom') + playlist.currentPartInfo = { + consumesQueuedSegmentId: false, + manuallySelected: false, + partInstanceId: protectString(currentPartInstanceId), + rundownId: playlist.rundownIdsInOrder[0], + } + await topic.update(PlaylistHandler.name, playlist) + + const testShowStyleBase = makeTestShowStyleBase() + await topic.update(ShowStyleBaseHandler.name, testShowStyleBase as DBShowStyleBase) + const part1: Partial = { + _id: protectString('PART_1'), + title: 'Test Part', + segmentId: protectString('SEGMENT_1'), + expectedDurationWithPreroll: 10000, + expectedDuration: 10000, + } + const testPartInstances: PartialDeep = { + current: { + _id: currentPartInstanceId, + part: part1, + timings: { plannedStartedPlayback: 1600000060000 }, + }, + firstInSegmentPlayout: {}, + inCurrentSegment: [ + literal>({ + _id: protectString(currentPartInstanceId), + part: part1, + timings: { plannedStartedPlayback: 1600000060000 }, + }), + ] as DBPartInstance[], + } + await topic.update(PartInstancesHandler.name, testPartInstances as SelectedPartInstances) + + await topic.update(PartsHandler.name, [part1] as DBPart[]) + + topic.addSubscriber(mockSubscriber) + + const expectedStatus: ActivePlaylistStatus = { + event: 'activePlaylist', + name: playlist.name, + id: unprotectString(playlist._id), + adLibs: [], + currentPart: { + id: 'PART_1', + name: 'Test Part', + segmentId: 'SEGMENT_1', + timing: { startTime: 1600000060000, expectedDurationMs: 10000, projectedEndTime: 1600000070000 }, + }, + nextPart: null, + currentSegment: { + id: 'SEGMENT_1', + timing: { + expectedDurationMs: 10000, + projectedEndTime: 1600000070000, + }, + }, + globalAdLibs: [], + rundownIds: unprotectStringArray(playlist.rundownIdsInOrder), + } + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockSubscriber.send).toHaveBeenCalledTimes(1) + expect(JSON.parse(mockSubscriber.send.mock.calls[0][0] as string)).toMatchObject(expectedStatus) + }) }) diff --git a/packages/live-status-gateway/src/topics/__tests__/segmentsTopic.spec.ts b/packages/live-status-gateway/src/topics/__tests__/segmentsTopic.spec.ts index 1e2ebc1a97..fc5851702f 100644 --- a/packages/live-status-gateway/src/topics/__tests__/segmentsTopic.spec.ts +++ b/packages/live-status-gateway/src/topics/__tests__/segmentsTopic.spec.ts @@ -1,12 +1,15 @@ import { SegmentsStatus, SegmentsTopic } from '../segmentsTopic' -import { PlaylistHandler } from '../../collections/playlist' +import { PlaylistHandler } from '../../collections/playlistHandler' import { protectString, unprotectString } from '@sofie-automation/server-core-integration' import { SegmentsHandler } from '../../collections/segmentsHandler' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' import { makeMockLogger, makeMockSubscriber, makeTestPlaylist } from './utils' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { PartsHandler } from '../../collections/partsHandler' const RUNDOWN_1_ID = 'RUNDOWN_1' const RUNDOWN_2_ID = 'RUNDOWN_2' +const THROTTLE_PERIOD_MS = 205 function makeTestSegment(id: string, rank: number, rundownId: string): DBSegment { return { @@ -19,7 +22,33 @@ function makeTestSegment(id: string, rank: number, rundownId: string): DBSegment } } +function makeTestPart( + id: string, + rank: number, + rundownId: string, + segmentId: string, + partProps: Partial +): DBPart { + return { + _id: protectString(id), + externalId: `NCS_PART_${id}`, + title: `Part ${id}`, + _rank: rank, + rundownId: protectString(rundownId), + segmentId: protectString(segmentId), + expectedDurationWithPreroll: undefined, + ...partProps, + } +} + describe('SegmentsTopic', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + afterEach(() => { + jest.useRealTimers() + }) + it('notifies added subscribers immediately', async () => { const topic = new SegmentsTopic(makeMockLogger()) const mockSubscriber = makeMockSubscriber() @@ -65,6 +94,7 @@ describe('SegmentsTopic', () => { const testPlaylist2 = makeTestPlaylist('PLAYLIST_2') await topic.update(PlaylistHandler.name, testPlaylist2) + jest.advanceTimersByTime(THROTTLE_PERIOD_MS) const expectedStatus2: SegmentsStatus = { event: 'segments', @@ -96,6 +126,7 @@ describe('SegmentsTopic', () => { // ... this is enough to prove that it works as expected await topic.update(PlaylistHandler.name, testPlaylist2) + jest.advanceTimersByTime(THROTTLE_PERIOD_MS) // eslint-disable-next-line @typescript-eslint/unbound-method expect(mockSubscriber.send).toHaveBeenCalledTimes(0) @@ -117,15 +148,16 @@ describe('SegmentsTopic', () => { makeTestSegment('1_2', 2, RUNDOWN_1_ID), makeTestSegment('1_1', 1, RUNDOWN_1_ID), ]) + jest.advanceTimersByTime(THROTTLE_PERIOD_MS) const expectedStatus: SegmentsStatus = { event: 'segments', rundownPlaylistId: unprotectString(testPlaylist._id), segments: [ - { id: '1_1', rundownId: RUNDOWN_1_ID, name: 'Segment 1_1' }, - { id: '1_2', rundownId: RUNDOWN_1_ID, name: 'Segment 1_2' }, - { id: '2_1', rundownId: RUNDOWN_2_ID, name: 'Segment 2_1' }, - { id: '2_2', rundownId: RUNDOWN_2_ID, name: 'Segment 2_2' }, + { id: '1_1', rundownId: RUNDOWN_1_ID, name: 'Segment 1_1', timing: { expectedDurationMs: 0 } }, + { id: '1_2', rundownId: RUNDOWN_1_ID, name: 'Segment 1_2', timing: { expectedDurationMs: 0 } }, + { id: '2_1', rundownId: RUNDOWN_2_ID, name: 'Segment 2_1', timing: { expectedDurationMs: 0 } }, + { id: '2_2', rundownId: RUNDOWN_2_ID, name: 'Segment 2_2', timing: { expectedDurationMs: 0 } }, ], } expect(mockSubscriber.send.mock.calls).toEqual([[JSON.stringify(expectedStatus)]]) @@ -150,15 +182,150 @@ describe('SegmentsTopic', () => { const testPlaylist2 = makeTestPlaylist() testPlaylist2.rundownIdsInOrder = [protectString(RUNDOWN_2_ID), protectString(RUNDOWN_1_ID)] await topic.update(PlaylistHandler.name, testPlaylist2) + jest.advanceTimersByTime(THROTTLE_PERIOD_MS) const expectedStatus: SegmentsStatus = { event: 'segments', rundownPlaylistId: unprotectString(testPlaylist._id), segments: [ - { id: '2_1', rundownId: RUNDOWN_2_ID, name: 'Segment 2_1' }, - { id: '2_2', rundownId: RUNDOWN_2_ID, name: 'Segment 2_2' }, - { id: '1_1', rundownId: RUNDOWN_1_ID, name: 'Segment 1_1' }, - { id: '1_2', rundownId: RUNDOWN_1_ID, name: 'Segment 1_2' }, + { id: '2_1', rundownId: RUNDOWN_2_ID, name: 'Segment 2_1', timing: { expectedDurationMs: 0 } }, + { id: '2_2', rundownId: RUNDOWN_2_ID, name: 'Segment 2_2', timing: { expectedDurationMs: 0 } }, + { id: '1_1', rundownId: RUNDOWN_1_ID, name: 'Segment 1_1', timing: { expectedDurationMs: 0 } }, + { id: '1_2', rundownId: RUNDOWN_1_ID, name: 'Segment 1_2', timing: { expectedDurationMs: 0 } }, + ], + } + expect(mockSubscriber.send.mock.calls).toEqual([[JSON.stringify(expectedStatus)]]) + }) + + it('exposes budgetDuration', async () => { + const topic = new SegmentsTopic(makeMockLogger()) + const mockSubscriber = makeMockSubscriber() + + const testPlaylist = makeTestPlaylist() + await topic.update(PlaylistHandler.name, testPlaylist) + + topic.addSubscriber(mockSubscriber) + mockSubscriber.send.mockClear() + + const segment_1_1_id = '1_1' + const segment_1_2_id = '1_2' + const segment_2_2_id = '2_2' + await topic.update(SegmentsHandler.name, [ + makeTestSegment('2_1', 1, RUNDOWN_2_ID), + makeTestSegment(segment_2_2_id, 2, RUNDOWN_2_ID), + makeTestSegment(segment_1_2_id, 2, RUNDOWN_1_ID), + makeTestSegment(segment_1_1_id, 1, RUNDOWN_1_ID), + ]) + mockSubscriber.send.mockClear() + await topic.update(PartsHandler.name, [ + makeTestPart('1_2_1', 1, RUNDOWN_1_ID, segment_1_2_id, { + budgetDuration: 10000, + }), + makeTestPart('2_2_1', 1, RUNDOWN_1_ID, segment_2_2_id, { + budgetDuration: 40000, + }), + makeTestPart('1_2_2', 2, RUNDOWN_1_ID, segment_1_2_id, { + budgetDuration: 5000, + }), + makeTestPart('1_1_2', 2, RUNDOWN_1_ID, segment_1_1_id, { + budgetDuration: 1000, + }), + makeTestPart('1_1_1', 1, RUNDOWN_1_ID, segment_1_1_id, { + budgetDuration: 3000, + }), + makeTestPart('2_2_2', 2, RUNDOWN_1_ID, segment_2_2_id, { + budgetDuration: 11000, + }), + makeTestPart('1_1_2', 2, RUNDOWN_1_ID, segment_1_1_id, { + budgetDuration: 1000, + }), + ]) + jest.advanceTimersByTime(THROTTLE_PERIOD_MS) + + const expectedStatus: SegmentsStatus = { + event: 'segments', + rundownPlaylistId: unprotectString(testPlaylist._id), + segments: [ + { + id: '1_1', + rundownId: RUNDOWN_1_ID, + name: 'Segment 1_1', + timing: { expectedDurationMs: 0, budgetDurationMs: 5000 }, + }, + { + id: '1_2', + rundownId: RUNDOWN_1_ID, + name: 'Segment 1_2', + timing: { expectedDurationMs: 0, budgetDurationMs: 15000 }, + }, + { id: '2_1', rundownId: RUNDOWN_2_ID, name: 'Segment 2_1', timing: { expectedDurationMs: 0 } }, + { + id: '2_2', + rundownId: RUNDOWN_2_ID, + name: 'Segment 2_2', + timing: { expectedDurationMs: 0, budgetDurationMs: 51000 }, + }, + ], + } + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockSubscriber.send).toHaveBeenCalledTimes(1) + expect(JSON.parse(mockSubscriber.send.mock.calls[0][0] as string)).toEqual(expectedStatus) + }) + + it('exposes expectedDuration', async () => { + const topic = new SegmentsTopic(makeMockLogger()) + const mockSubscriber = makeMockSubscriber() + + const testPlaylist = makeTestPlaylist() + await topic.update(PlaylistHandler.name, testPlaylist) + + topic.addSubscriber(mockSubscriber) + mockSubscriber.send.mockClear() + + const segment_1_1_id = '1_1' + const segment_1_2_id = '1_2' + const segment_2_2_id = '2_2' + await topic.update(SegmentsHandler.name, [ + makeTestSegment('2_1', 1, RUNDOWN_2_ID), + makeTestSegment(segment_2_2_id, 2, RUNDOWN_2_ID), + makeTestSegment(segment_1_2_id, 2, RUNDOWN_1_ID), + makeTestSegment(segment_1_1_id, 1, RUNDOWN_1_ID), + ]) + mockSubscriber.send.mockClear() + await topic.update(PartsHandler.name, [ + makeTestPart('1_2_1', 1, RUNDOWN_1_ID, segment_1_2_id, { + expectedDurationWithPreroll: 10000, + }), + makeTestPart('2_2_1', 1, RUNDOWN_1_ID, segment_2_2_id, { + expectedDurationWithPreroll: 40000, + }), + makeTestPart('1_2_2', 2, RUNDOWN_1_ID, segment_1_2_id, { + expectedDurationWithPreroll: 5000, + }), + makeTestPart('1_1_2', 2, RUNDOWN_1_ID, segment_1_1_id, { + expectedDurationWithPreroll: 1000, + }), + makeTestPart('1_1_1', 1, RUNDOWN_1_ID, segment_1_1_id, { + expectedDurationWithPreroll: 3000, + }), + makeTestPart('2_2_2', 2, RUNDOWN_1_ID, segment_2_2_id, { + expectedDurationWithPreroll: 11000, + }), + makeTestPart('1_1_2', 2, RUNDOWN_1_ID, segment_1_1_id, { + expectedDurationWithPreroll: 1000, + }), + ]) + jest.advanceTimersByTime(THROTTLE_PERIOD_MS) + + const expectedStatus: SegmentsStatus = { + event: 'segments', + rundownPlaylistId: unprotectString(testPlaylist._id), + segments: [ + { id: '1_1', rundownId: RUNDOWN_1_ID, name: 'Segment 1_1', timing: { expectedDurationMs: 5000 } }, + { id: '1_2', rundownId: RUNDOWN_1_ID, name: 'Segment 1_2', timing: { expectedDurationMs: 15000 } }, + { id: '2_1', rundownId: RUNDOWN_2_ID, name: 'Segment 2_1', timing: { expectedDurationMs: 0 } }, + { id: '2_2', rundownId: RUNDOWN_2_ID, name: 'Segment 2_2', timing: { expectedDurationMs: 51000 } }, ], } expect(mockSubscriber.send.mock.calls).toEqual([[JSON.stringify(expectedStatus)]]) @@ -181,6 +348,7 @@ describe('SegmentsTopic', () => { const testPlaylist2 = makeTestPlaylist() testPlaylist2.rundownIdsInOrder = [protectString(RUNDOWN_2_ID), protectString(RUNDOWN_1_ID)] await topic.update(PlaylistHandler.name, testPlaylist2) + jest.advanceTimersByTime(THROTTLE_PERIOD_MS) const expectedStatus: SegmentsStatus = { event: 'segments', @@ -189,7 +357,7 @@ describe('SegmentsTopic', () => { { id: '1_1', rundownId: RUNDOWN_1_ID, name: 'Segment 1_1' }, { id: '1_2', rundownId: RUNDOWN_1_ID, name: 'Segment 1_2', identifier: 'SomeIdentifier' }, ], - } - expect(mockSubscriber.send.mock.calls).toEqual([[JSON.stringify(expectedStatus)]]) + } as SegmentsStatus + expect(JSON.parse(mockSubscriber.send.mock.calls[0][0] as string)).toMatchObject(expectedStatus) }) }) diff --git a/packages/live-status-gateway/src/topics/activePlaylist.ts b/packages/live-status-gateway/src/topics/activePlaylistTopic.ts similarity index 72% rename from packages/live-status-gateway/src/topics/activePlaylist.ts rename to packages/live-status-gateway/src/topics/activePlaylistTopic.ts index 83461a65b2..28ecce1256 100644 --- a/packages/live-status-gateway/src/topics/activePlaylist.ts +++ b/packages/live-status-gateway/src/topics/activePlaylistTopic.ts @@ -14,16 +14,23 @@ import { } from '@sofie-automation/blueprints-integration' import { literal } from '@sofie-automation/shared-lib/dist/lib/lib' import { WebSocketTopicBase, WebSocketTopic, CollectionObserver } from '../wsHandler' -import { PartInstanceName, PartInstancesHandler } from '../collections/partInstances' +import { SelectedPartInstances, PartInstancesHandler } from '../collections/partInstancesHandler' import { applyAndValidateOverrides } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' import { AdLibPiece } from '@sofie-automation/corelib/dist/dataModel/AdLibPiece' import { interpollateTranslation } from '@sofie-automation/corelib/dist/TranslatableMessage' -import { AdLibsHandler } from '../collections/adLibs' -import { GlobalAdLibsHandler } from '../collections/globalAdLibs' -import { PlaylistHandler } from '../collections/playlist' -import { ShowStyleBaseHandler } from '../collections/showStyleBase' -import { AdLibActionsHandler } from '../collections/adLibActions' -import { GlobalAdLibActionsHandler } from '../collections/globalAdLibActions' +import { AdLibsHandler } from '../collections/adLibsHandler' +import { GlobalAdLibsHandler } from '../collections/globalAdLibsHandler' +import { PlaylistHandler } from '../collections/playlistHandler' +import { ShowStyleBaseHandler } from '../collections/showStyleBaseHandler' +import { AdLibActionsHandler } from '../collections/adLibActionsHandler' +import { GlobalAdLibActionsHandler } from '../collections/globalAdLibActionsHandler' +import { CurrentSegmentTiming, calculateCurrentSegmentTiming } from './helpers/segmentTiming' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { PartsHandler } from '../collections/partsHandler' +import _ = require('underscore') +import { PartTiming, calculateCurrentPartTiming } from './helpers/partTiming' + +const THROTTLE_PERIOD_MS = 20 interface PartStatus { id: string @@ -32,6 +39,15 @@ interface PartStatus { autoNext?: boolean } +interface CurrentPartStatus extends PartStatus { + timing: PartTiming +} + +interface CurrentSegmentStatus { + id: string + timing: CurrentSegmentTiming +} + interface AdLibActionType { name: string label: string @@ -51,7 +67,8 @@ export interface ActivePlaylistStatus { id: string | null name: string rundownIds: string[] - currentPart: PartStatus | null + currentPart: CurrentPartStatus | null + currentSegment: CurrentSegmentStatus | null nextPart: PartStatus | null adLibs: AdLibStatus[] globalAdLibs: AdLibStatus[] @@ -62,9 +79,10 @@ export class ActivePlaylistTopic implements WebSocketTopic, CollectionObserver, - CollectionObserver>, + CollectionObserver, CollectionObserver, - CollectionObserver + CollectionObserver, + CollectionObserver { public observerName = ActivePlaylistTopic.name private _sourceLayersMap: Map = new Map() @@ -72,13 +90,21 @@ export class ActivePlaylistTopic private _activePlaylist: DBRundownPlaylist | undefined private _currentPartInstance: DBPartInstance | undefined private _nextPartInstance: DBPartInstance | undefined + private _firstInstanceInSegmentPlayout: DBPartInstance | undefined + private _partInstancesInCurrentSegment: DBPartInstance[] = [] private _adLibActions: AdLibAction[] | undefined private _abLibs: AdLibPiece[] | undefined private _globalAdLibActions: RundownBaselineAdLibAction[] | undefined private _globalAdLibs: RundownBaselineAdLibItem[] | undefined + private _partsBySegmentId: Record = {} + private throttledSendStatusToAll: () => void constructor(logger: Logger) { super(ActivePlaylistTopic.name, logger) + this.throttledSendStatusToAll = _.throttle(this.sendStatusToAll.bind(this), THROTTLE_PERIOD_MS, { + leading: false, + trailing: true, + }) } addSubscriber(ws: WebSocket): void { @@ -87,8 +113,16 @@ export class ActivePlaylistTopic } sendStatus(subscribers: Iterable): void { - const currentPartInstance = this._currentPartInstance ? this._currentPartInstance.part : null - const nextPartInstance = this._nextPartInstance ? this._nextPartInstance.part : null + if ( + this._currentPartInstance?._id !== this._activePlaylist?.currentPartInfo?.partInstanceId || + this._nextPartInstance?._id !== this._activePlaylist?.nextPartInfo?.partInstanceId + ) { + // data is inconsistent, let's wait + return + } + + const currentPart = this._currentPartInstance ? this._currentPartInstance.part : null + const nextPart = this._nextPartInstance ? this._nextPartInstance.part : null const adLibs: AdLibStatus[] = [] const globalAdLibs: AdLibStatus[] = [] @@ -190,20 +224,37 @@ export class ActivePlaylistTopic id: unprotectString(this._activePlaylist._id), name: this._activePlaylist.name, rundownIds: this._activePlaylist.rundownIdsInOrder.map((r) => unprotectString(r)), - currentPart: currentPartInstance + currentPart: + this._currentPartInstance && currentPart + ? literal({ + id: unprotectString(currentPart._id), + name: currentPart.title, + autoNext: currentPart.autoNext, + segmentId: unprotectString(currentPart.segmentId), + timing: calculateCurrentPartTiming( + this._currentPartInstance, + this._partInstancesInCurrentSegment + ), + }) + : null, + currentSegment: + this._currentPartInstance && currentPart + ? literal({ + id: unprotectString(currentPart.segmentId), + timing: calculateCurrentSegmentTiming( + this._currentPartInstance, + this._firstInstanceInSegmentPlayout, + this._partInstancesInCurrentSegment, + this._partsBySegmentId[unprotectString(currentPart.segmentId)] ?? [] + ), + }) + : null, + nextPart: nextPart ? literal({ - id: unprotectString(currentPartInstance._id), - name: currentPartInstance.title, - autoNext: currentPartInstance.autoNext, - segmentId: unprotectString(currentPartInstance.segmentId), - }) - : null, - nextPart: nextPartInstance - ? literal({ - id: unprotectString(nextPartInstance._id), - name: nextPartInstance.title, - autoNext: nextPartInstance.autoNext, - segmentId: unprotectString(nextPartInstance.segmentId), + id: unprotectString(nextPart._id), + name: nextPart.title, + autoNext: nextPart.autoNext, + segmentId: unprotectString(nextPart.segmentId), }) : null, adLibs, @@ -215,6 +266,7 @@ export class ActivePlaylistTopic name: '', rundownIds: [], currentPart: null, + currentSegment: null, nextPart: null, adLibs: [], globalAdLibs: [], @@ -230,11 +282,12 @@ export class ActivePlaylistTopic data: | DBRundownPlaylist | DBShowStyleBase - | Map + | SelectedPartInstances | AdLibAction[] | RundownBaselineAdLibAction[] | AdLibPiece[] | RundownBaselineAdLibItem[] + | DBPart[] | undefined ): Promise { switch (source) { @@ -282,10 +335,14 @@ export class ActivePlaylistTopic break } case PartInstancesHandler.name: { - const partInstances = data as Map - this._logger.info(`${this._name} received partInstances update from ${source}`) - this._currentPartInstance = partInstances.get(PartInstanceName.current) - this._nextPartInstance = partInstances.get(PartInstanceName.next) + const partInstances = data as SelectedPartInstances + this._logger.info( + `${this._name} received partInstances update from ${source} with ${partInstances.inCurrentSegment.length} instances in segment` + ) + this._currentPartInstance = partInstances.current + this._nextPartInstance = partInstances.next + this._firstInstanceInSegmentPlayout = partInstances.firstInSegmentPlayout + this._partInstancesInCurrentSegment = partInstances.inCurrentSegment break } case AdLibActionsHandler.name: { @@ -312,10 +369,19 @@ export class ActivePlaylistTopic this._globalAdLibs = globalAdLibs break } + case PartsHandler.name: { + this._partsBySegmentId = _.groupBy(data as DBPart[], 'segmentId') + this._logger.info(`${this._name} received parts update from ${source}`) + break + } default: throw new Error(`${this._name} received unsupported update from ${source}}`) } + this.throttledSendStatusToAll() + } + + private sendStatusToAll() { this.sendStatus(this._subscribers) } } diff --git a/packages/live-status-gateway/src/topics/helpers/partTiming.ts b/packages/live-status-gateway/src/topics/helpers/partTiming.ts new file mode 100644 index 0000000000..6ef75c355e --- /dev/null +++ b/packages/live-status-gateway/src/topics/helpers/partTiming.ts @@ -0,0 +1,42 @@ +import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' + +export interface PartTiming { + startTime: number + expectedDurationMs: number + projectedEndTime: number +} + +export function calculateCurrentPartTiming( + currentPartInstance: DBPartInstance, + segmentPartInstances: DBPartInstance[] +): PartTiming { + const isMemberOfDisplayDurationGroup = currentPartInstance.part.displayDurationGroup !== undefined + let expectedDuration = currentPartInstance.part.expectedDuration ?? 0 + + if (isMemberOfDisplayDurationGroup && currentPartInstance.part.expectedDuration === 0) { + // TODO: This implementation currently only handles the simplest use case of Display Duration Groups, + // where all members of a group are within a single Segment, and one or more Parts with expectedDuration===0 + // follow (not necessarily immediately) a Part with expectedDuration!==0. + const displayDurationGroup = segmentPartInstances.filter( + (partInstance) => partInstance.part.displayDurationGroup === currentPartInstance.part.displayDurationGroup + ) + const groupDuration = displayDurationGroup.reduce((sum, partInstance) => { + return sum + (partInstance.part.expectedDurationWithPreroll ?? 0) + }, 0) + const groupPlayed = displayDurationGroup.reduce((sum, partInstance) => { + return (partInstance.timings?.duration ?? 0) + sum + }, 0) + expectedDuration = groupDuration - groupPlayed + } + + const startTime = + currentPartInstance.timings?.reportedStartedPlayback ?? + currentPartInstance.timings?.plannedStartedPlayback ?? + Date.now() + + return { + startTime, + expectedDurationMs: currentPartInstance.part.expectedDuration ?? 0, + projectedEndTime: startTime + expectedDuration, + } +} diff --git a/packages/live-status-gateway/src/topics/helpers/segmentTiming.ts b/packages/live-status-gateway/src/topics/helpers/segmentTiming.ts new file mode 100644 index 0000000000..966354e6d9 --- /dev/null +++ b/packages/live-status-gateway/src/topics/helpers/segmentTiming.ts @@ -0,0 +1,50 @@ +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' + +export interface SegmentTiming { + budgetDurationMs?: number + expectedDurationMs: number +} + +export interface CurrentSegmentTiming extends SegmentTiming { + projectedEndTime: number +} + +export function calculateCurrentSegmentTiming( + currentPartInstance: DBPartInstance, + firstInstanceInSegmentPlayout: DBPartInstance | undefined, + segmentPartInstances: DBPartInstance[], + segmentParts: DBPart[] +): CurrentSegmentTiming { + const segmentTiming = calculateSegmentTiming(segmentParts) + const playedDurations = segmentPartInstances.reduce((sum, partInstance) => { + return (partInstance.timings?.duration ?? 0) + sum + }, 0) + const currentPartInstanceStart = + currentPartInstance.timings?.reportedStartedPlayback ?? + currentPartInstance.timings?.plannedStartedPlayback ?? + Date.now() + const leftToPlay = segmentTiming.expectedDurationMs - playedDurations + const projectedEndTime = leftToPlay + currentPartInstanceStart + const projectedBudgetEndTime = + (firstInstanceInSegmentPlayout?.timings?.reportedStartedPlayback ?? + firstInstanceInSegmentPlayout?.timings?.plannedStartedPlayback ?? + 0) + (segmentTiming.budgetDurationMs ?? 0) + return { + ...segmentTiming, + projectedEndTime: segmentTiming.budgetDurationMs != null ? projectedBudgetEndTime : projectedEndTime, + } +} + +export function calculateSegmentTiming(segmentParts: DBPart[]): SegmentTiming { + return { + budgetDurationMs: segmentParts.reduce((sum, part): number | undefined => { + return part.budgetDuration != null && !part.untimed ? (sum ?? 0) + part.budgetDuration : sum + }, undefined), + expectedDurationMs: segmentParts.reduce((sum, part): number => { + return part.expectedDurationWithPreroll != null && !part.untimed + ? sum + part.expectedDurationWithPreroll + : sum + }, 0), + } +} diff --git a/packages/live-status-gateway/src/topics/segmentsTopic.ts b/packages/live-status-gateway/src/topics/segmentsTopic.ts index 87643b7526..a037f15ec0 100644 --- a/packages/live-status-gateway/src/topics/segmentsTopic.ts +++ b/packages/live-status-gateway/src/topics/segmentsTopic.ts @@ -3,17 +3,24 @@ import { WebSocket } from 'ws' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { WebSocketTopicBase, WebSocketTopic, CollectionObserver } from '../wsHandler' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' -import { PlaylistHandler } from '../collections/playlist' +import { PlaylistHandler } from '../collections/playlistHandler' import { groupByToMap } from '@sofie-automation/corelib/dist/lib' import { unprotectString } from '@sofie-automation/shared-lib/dist/lib/protectedString' import { SegmentsHandler } from '../collections/segmentsHandler' -import isShallowEqual from '@sofie-automation/shared-lib/dist/lib/isShallowEqual' +import areElementsShallowEqual from '@sofie-automation/shared-lib/dist/lib/isShallowEqual' +import { PartsHandler } from '../collections/partsHandler' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import _ = require('underscore') +import { SegmentTiming, calculateSegmentTiming } from './helpers/segmentTiming' + +const THROTTLE_PERIOD_MS = 200 interface SegmentStatus { id: string identifier?: string rundownId: string name: string + timing: SegmentTiming } export interface SegmentsStatus { @@ -24,15 +31,25 @@ export interface SegmentsStatus { export class SegmentsTopic extends WebSocketTopicBase - implements WebSocketTopic, CollectionObserver, CollectionObserver + implements + WebSocketTopic, + CollectionObserver, + CollectionObserver, + CollectionObserver { public observerName = SegmentsTopic.name private _activePlaylist: DBRundownPlaylist | undefined private _segments: DBSegment[] = [] + private _partsBySegment: Record = {} private _orderedSegments: DBSegment[] = [] + private throttledSendStatusToAll: () => void constructor(logger: Logger) { super(SegmentsTopic.name, logger) + this.throttledSendStatusToAll = _.throttle(this.sendStatusToAll.bind(this), THROTTLE_PERIOD_MS, { + leading: true, + trailing: true, + }) } addSubscriber(ws: WebSocket): void { @@ -44,12 +61,16 @@ export class SegmentsTopic const segmentsStatus: SegmentsStatus = { event: 'segments', rundownPlaylistId: this._activePlaylist ? unprotectString(this._activePlaylist._id) : null, - segments: this._orderedSegments.map((segment) => ({ - id: unprotectString(segment._id), - rundownId: unprotectString(segment.rundownId), - name: segment.name, - identifier: segment.identifier, - })), + segments: this._orderedSegments.map((segment) => { + const segmentId = unprotectString(segment._id) + return { + id: segmentId, + rundownId: unprotectString(segment.rundownId), + name: segment.name, + timing: calculateSegmentTiming(this._partsBySegment[segmentId] ?? []), + identifier: segment.identifier, + } + }), } for (const subscriber of subscribers) { @@ -57,9 +78,10 @@ export class SegmentsTopic } } - async update(source: string, data: DBRundownPlaylist | DBSegment[] | undefined): Promise { + async update(source: string, data: DBRundownPlaylist | DBSegment[] | DBPart[] | undefined): Promise { const prevSegments = this._segments const prevRundownOrder = this._activePlaylist?.rundownIdsInOrder ?? [] + const prevParts = this._partsBySegment const prevPlaylistId = this._activePlaylist?._id switch (source) { case PlaylistHandler.name: { @@ -72,6 +94,11 @@ export class SegmentsTopic this._logger.info(`${this._name} received segments update from ${source}`) break } + case PartsHandler.name: { + this._partsBySegment = _.groupBy(data as DBPart[], 'segmentId') + this._logger.info(`${this._name} received parts update from ${source}`) + break + } default: throw new Error(`${this._name} received unsupported update from ${source}}`) } @@ -80,17 +107,22 @@ export class SegmentsTopic if ( this._activePlaylist._id !== prevPlaylistId || prevSegments !== this._segments || - !isShallowEqual(prevRundownOrder, this._activePlaylist.rundownIdsInOrder) + prevParts !== this._partsBySegment || + !areElementsShallowEqual(prevRundownOrder, this._activePlaylist.rundownIdsInOrder) ) { const segmentsByRundownId = groupByToMap(this._segments, 'rundownId') this._orderedSegments = this._activePlaylist.rundownIdsInOrder.flatMap((rundownId) => { return segmentsByRundownId.get(rundownId)?.sort((a, b) => a._rank - b._rank) ?? [] }) - this.sendStatus(this._subscribers) + this.throttledSendStatusToAll() } } else { this._orderedSegments = [] - this.sendStatus(this._subscribers) + this.throttledSendStatusToAll() } } + + private sendStatusToAll() { + this.sendStatus(this._subscribers) + } } diff --git a/packages/live-status-gateway/src/topics/studio.ts b/packages/live-status-gateway/src/topics/studioTopic.ts similarity index 95% rename from packages/live-status-gateway/src/topics/studio.ts rename to packages/live-status-gateway/src/topics/studioTopic.ts index f9517264fc..5b1ab82c9d 100644 --- a/packages/live-status-gateway/src/topics/studio.ts +++ b/packages/live-status-gateway/src/topics/studioTopic.ts @@ -5,8 +5,8 @@ import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { literal } from '@sofie-automation/shared-lib/dist/lib/lib' import { WebSocketTopicBase, WebSocketTopic, CollectionObserver } from '../wsHandler' -import { StudioHandler } from '../collections/studio' -import { PlaylistsHandler } from '../collections/playlist' +import { StudioHandler } from '../collections/studioHandler' +import { PlaylistsHandler } from '../collections/playlistHandler' type PlaylistActivationStatus = 'deactivated' | 'rehearsal' | 'activated' diff --git a/packages/server-core-integration/src/lib/ddpClient.ts b/packages/server-core-integration/src/lib/ddpClient.ts index dd913cfa19..b66a225085 100644 --- a/packages/server-core-integration/src/lib/ddpClient.ts +++ b/packages/server-core-integration/src/lib/ddpClient.ts @@ -519,16 +519,17 @@ export class DDPClient extends EventEmitter { if (!this.collections[name]) { this.collections[name] = {} } - if (!this.collections[name][id]) { - this.collections[name][id] = { _id: id } - } + + const addedDocument = this.collections[name][id] ? { ...this.collections[name][id] } : { _id: id } if (data.fields) { Object.entries(data.fields).forEach(([key, value]) => { - this.collections[name][id][key] = value + addedDocument[key] = value }) } + this.collections[name][id] = addedDocument + if (this.observers[name]) { Object.values(this.observers[name]).forEach((ob) => ob.added(id, data.fields)) } @@ -570,20 +571,25 @@ export class DDPClient extends EventEmitter { const clearedFields = data.cleared || [] const newFields: { [attr: string]: unknown } = {} + // cloning allows detection of changed objects in `find` results using shallow comparison + const updatedDocument = { ...this.collections[name][id] } + if (data.fields) { Object.entries(data.fields).forEach(([key, value]) => { - oldFields[key] = this.collections[name][id][key] + oldFields[key] = updatedDocument[key] newFields[key] = value - this.collections[name][id][key] = value + updatedDocument[key] = value }) } if (data.cleared) { data.cleared.forEach((value) => { - delete this.collections[name][id][value] + delete updatedDocument[value] }) } + this.collections[name][id] = updatedDocument + if (this.observers[name]) { Object.values(this.observers[name]).forEach((ob) => ob.changed(id, oldFields, clearedFields, newFields) diff --git a/packages/shared-lib/src/lib/isShallowEqual.ts b/packages/shared-lib/src/lib/isShallowEqual.ts index d2c07c741d..ee95c58d79 100644 --- a/packages/shared-lib/src/lib/isShallowEqual.ts +++ b/packages/shared-lib/src/lib/isShallowEqual.ts @@ -1,4 +1,4 @@ -export default function isShallowEqual(a: any[], b: any[]): boolean { +export default function areElementsShallowEqual(a: any[], b: any[]): boolean { if (a.length !== b.length) return false return a.every((value, index) => value === b[index]) } diff --git a/packages/yarn.lock b/packages/yarn.lock index f4d08b454b..b7b8503779 100644 --- a/packages/yarn.lock +++ b/packages/yarn.lock @@ -15365,6 +15365,7 @@ asn1@evs-broadcast/node-asn1: influx: ^5.9.2 jest-mock-extended: ^3.0.5 tslib: ^2.4.0 + type-fest: ^4.5.0 underscore: ^1.13.6 winston: ^3.8.1 ws: ^8.10.0 @@ -22726,6 +22727,13 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"type-fest@npm:^4.5.0": + version: 4.5.0 + resolution: "type-fest@npm:4.5.0" + checksum: cddc3900f19671e16ec49080e5c06c197d5554a75a1b758ff6d07af1089925ade4bcc9d1af080bd4a4a4fdf973a90ad4a83229203c854a444c511f8a3daeb56d + languageName: node + linkType: hard + "type-is@npm:~1.6.18": version: 1.6.18 resolution: "type-is@npm:1.6.18"