diff --git a/meteor/client/lib/rundown.ts b/meteor/client/lib/rundown.ts index 8bf735e173..e3f446e798 100644 --- a/meteor/client/lib/rundown.ts +++ b/meteor/client/lib/rundown.ts @@ -28,7 +28,7 @@ import { processAndPrunePieceInstanceTimings, resolvePrunedPieceInstance, } from '@sofie-automation/corelib/dist/playout/processAndPrune' -import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' +import { PieceInstance, PieceInstancePiece } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' import { IAdLibListItem } from '../ui/Shelf/AdLibListItem' import { BucketAdLibItem, BucketAdLibUi } from '../ui/Shelf/RundownViewBuckets' import { FindOptions } from '../../lib/collections/lib' @@ -675,8 +675,9 @@ export namespace RundownUtils { ) { // if previousItem is infinite, currentItem caps it within the current part if (previousItem.instance.infinite) { - previousItem.instance.piece.lifespan = PieceLifespan.WithinPart - delete previousItem.instance.infinite + ;(previousItem.instance.piece as PieceInstancePiece).lifespan = + PieceLifespan.WithinPart + delete (previousItem.instance as PieceInstance).infinite } if ( diff --git a/meteor/client/lib/rundownLayouts.ts b/meteor/client/lib/rundownLayouts.ts index 2acb76c507..3bd7d6cb89 100644 --- a/meteor/client/lib/rundownLayouts.ts +++ b/meteor/client/lib/rundownLayouts.ts @@ -8,6 +8,7 @@ import { getCurrentTime } from '../../lib/lib' import { invalidateAt } from './../../lib/invalidatingTime' import { memoizedIsolatedAutorun } from '../../lib/memoizedIsolatedAutorun' import { PartInstances, PieceInstances } from '../collections' +import { ReadonlyDeep } from 'type-fest' /** * If the conditions of the filter are met, activePieceInstance will include the first piece instance found that matches the filter, otherwise it will be undefined. @@ -16,9 +17,9 @@ export function getIsFilterActive( playlist: DBRundownPlaylist, showStyleBase: UIShowStyleBase, panel: RequiresActiveLayers -): { active: boolean; activePieceInstance: PieceInstance | undefined } { +): { active: boolean; activePieceInstance: ReadonlyDeep | undefined } { const unfinishedPieces = getUnfinishedPieceInstancesReactive(playlist, showStyleBase) - let activePieceInstance: PieceInstance | undefined + let activePieceInstance: ReadonlyDeep | undefined const activeLayers = unfinishedPieces.map((p) => p.piece.sourceLayerId) const containsEveryRequiredLayer = panel.requireAllAdditionalSourcelayers ? panel.additionalLayers?.length && panel.additionalLayers.every((s) => activeLayers.includes(s)) @@ -35,7 +36,7 @@ export function getIsFilterActive( ) { activePieceInstance = panel.requiredLayerIds && panel.requiredLayerIds.length - ? unfinishedPieces.find((piece: PieceInstance) => { + ? unfinishedPieces.find((piece: ReadonlyDeep) => { return ( (panel.requiredLayerIds || []).indexOf(piece.piece.sourceLayerId) !== -1 && piece.partInstanceId === playlist.currentPartInfo?.partInstanceId @@ -53,7 +54,7 @@ export function getIsFilterActive( export function getUnfinishedPieceInstancesReactive( playlist: DBRundownPlaylist, showStyleBase: UIShowStyleBase -): PieceInstance[] { +): ReadonlyDeep[] { if (playlist.activationId && playlist.currentPartInfo) { return memoizedIsolatedAutorun( ( @@ -62,7 +63,7 @@ export function getUnfinishedPieceInstancesReactive( showStyleBase: UIShowStyleBase ) => { const now = getCurrentTime() - let prospectivePieces: PieceInstance[] = [] + let prospectivePieces: ReadonlyDeep[] = [] const partInstance = PartInstances.findOne(currentPartInstanceId) diff --git a/meteor/client/lib/shelf.ts b/meteor/client/lib/shelf.ts index b6318705ce..5342c8fc59 100644 --- a/meteor/client/lib/shelf.ts +++ b/meteor/client/lib/shelf.ts @@ -46,8 +46,11 @@ export interface AdlibSegmentUi extends DBSegment { isCompatibleShowStyle: boolean } -export function getNextPiecesReactive(playlist: DBRundownPlaylist, showsStyleBase: UIShowStyleBase): PieceInstance[] { - let prospectivePieceInstances: PieceInstance[] = [] +export function getNextPiecesReactive( + playlist: DBRundownPlaylist, + showsStyleBase: UIShowStyleBase +): ReadonlyDeep[] { + let prospectivePieceInstances: ReadonlyDeep[] = [] if (playlist.activationId && playlist.nextPartInfo) { prospectivePieceInstances = PieceInstances.find({ playlistActivationId: playlist.activationId, @@ -88,7 +91,11 @@ export function getNextPiecesReactive(playlist: DBRundownPlaylist, showsStyleBas export function getUnfinishedPieceInstancesGrouped( playlist: DBRundownPlaylist, showStyleBase: UIShowStyleBase -): { unfinishedPieceInstances: PieceInstance[]; unfinishedAdLibIds: PieceId[]; unfinishedTags: string[] } { +): { + unfinishedPieceInstances: ReadonlyDeep[] + unfinishedAdLibIds: PieceId[] + unfinishedTags: readonly string[] +} { const unfinishedPieceInstances = getUnfinishedPieceInstancesReactive(playlist, showStyleBase) const unfinishedAdLibIds: PieceId[] = unfinishedPieceInstances @@ -111,13 +118,13 @@ export function getUnfinishedPieceInstancesGrouped( export function getNextPieceInstancesGrouped( playlist: DBRundownPlaylist, showsStyleBase: UIShowStyleBase -): { nextAdLibIds: PieceId[]; nextTags: string[]; nextPieceInstances: PieceInstance[] } { +): { nextAdLibIds: PieceId[]; nextTags: readonly string[]; nextPieceInstances: ReadonlyDeep[] } { const nextPieceInstances = getNextPiecesReactive(playlist, showsStyleBase) const nextAdLibIds: PieceId[] = nextPieceInstances .filter((piece) => !!piece.adLibSourceId) .map((piece) => piece.adLibSourceId!) - const nextTags: string[] = nextPieceInstances + const nextTags = nextPieceInstances .filter((piece) => !!piece.piece.tags) .map((piece) => piece.piece.tags!) .reduce((a, b) => a.concat(b), []) @@ -125,7 +132,11 @@ export function getNextPieceInstancesGrouped( return { nextAdLibIds, nextTags, nextPieceInstances } } -export function isAdLibOnAir(unfinishedAdLibIds: PieceId[], unfinishedTags: string[], adLib: AdLibPieceUi): boolean { +export function isAdLibOnAir( + unfinishedAdLibIds: PieceId[], + unfinishedTags: readonly string[], + adLib: AdLibPieceUi +): boolean { if ( unfinishedAdLibIds.includes(adLib._id) || (adLib.currentPieceTags && @@ -137,7 +148,7 @@ export function isAdLibOnAir(unfinishedAdLibIds: PieceId[], unfinishedTags: stri return false } -export function isAdLibNext(nextAdLibIds: PieceId[], nextTags: string[], adLib: AdLibPieceUi): boolean { +export function isAdLibNext(nextAdLibIds: PieceId[], nextTags: readonly string[], adLib: AdLibPieceUi): boolean { if ( nextAdLibIds.includes(adLib._id) || (adLib.nextPieceTags && @@ -151,7 +162,7 @@ export function isAdLibNext(nextAdLibIds: PieceId[], nextTags: string[], adLib: export function isAdLibDisplayedAsOnAir( unfinishedAdLibIds: PieceId[], - unfinishedTags: string[], + unfinishedTags: readonly string[], adLib: AdLibPieceUi ): boolean { const isOnAir = isAdLibOnAir(unfinishedAdLibIds, unfinishedTags, adLib) diff --git a/meteor/client/ui/ClipTrimPanel/ClipTrimDialog.tsx b/meteor/client/ui/ClipTrimPanel/ClipTrimDialog.tsx index bf9a82321f..7d3d5d18b7 100644 --- a/meteor/client/ui/ClipTrimPanel/ClipTrimDialog.tsx +++ b/meteor/client/ui/ClipTrimPanel/ClipTrimDialog.tsx @@ -13,12 +13,13 @@ import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { PieceInstancePiece } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' import { UIStudio } from '../../../lib/api/studios' import { RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { ReadonlyDeep } from 'type-fest' export interface IProps { playlistId: RundownPlaylistId rundown: Rundown studio: UIStudio - selectedPiece: PieceInstancePiece + selectedPiece: ReadonlyDeep onClose?: () => void } diff --git a/meteor/client/ui/FloatingInspectors/FloatingInspectorHelpers/FloatingInspectorTimeInformationRow.tsx b/meteor/client/ui/FloatingInspectors/FloatingInspectorHelpers/FloatingInspectorTimeInformationRow.tsx index fab6948209..9efc0de561 100644 --- a/meteor/client/ui/FloatingInspectors/FloatingInspectorHelpers/FloatingInspectorTimeInformationRow.tsx +++ b/meteor/client/ui/FloatingInspectors/FloatingInspectorHelpers/FloatingInspectorTimeInformationRow.tsx @@ -5,9 +5,10 @@ import { PieceLifespan } from '@sofie-automation/blueprints-integration' import { TFunction, useTranslation } from 'react-i18next' import { Time } from '../../../../lib/lib' import Moment from 'react-moment' +import { ReadonlyDeep } from 'type-fest' interface IProps { - piece: Omit + piece: ReadonlyDeep> pieceRenderedDuration: number | null pieceRenderedIn: number | null changed?: Time @@ -39,7 +40,7 @@ export const FloatingInspectorTimeInformationRow: React.FunctionComponent): string { +function getLifeSpanText(t: TFunction, piece: ReadonlyDeep>): string { switch (piece.lifespan) { case PieceLifespan.WithinPart: return t('Until next take') @@ -59,7 +60,7 @@ function getLifeSpanText(t: TFunction, piece: Omit, + piece: ReadonlyDeep>, pieceRenderedDuration: number | null ): string { return RundownUtils.formatTimeToShortTime( diff --git a/meteor/client/ui/FloatingInspectors/L3rdFloatingInspector.tsx b/meteor/client/ui/FloatingInspectors/L3rdFloatingInspector.tsx index db3d72090b..5eee8f40e8 100644 --- a/meteor/client/ui/FloatingInspectors/L3rdFloatingInspector.tsx +++ b/meteor/client/ui/FloatingInspectors/L3rdFloatingInspector.tsx @@ -9,9 +9,10 @@ import { Time } from '../../../lib/lib' import { PieceInstancePiece } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' import { FloatingInspectorTimeInformationRow } from './FloatingInspectorHelpers/FloatingInspectorTimeInformationRow' import { IFloatingInspectorPosition, useInspectorPosition } from './IFloatingInspectorPosition' +import { ReadonlyDeep } from 'type-fest' interface IProps { - piece: Omit + piece: ReadonlyDeep> pieceRenderedDuration: number | null pieceRenderedIn: number | null showMiniInspector: boolean diff --git a/meteor/client/ui/PieceIcons/PieceIcon.tsx b/meteor/client/ui/PieceIcons/PieceIcon.tsx index 860e24f085..0ada6d4793 100644 --- a/meteor/client/ui/PieceIcons/PieceIcon.tsx +++ b/meteor/client/ui/PieceIcons/PieceIcon.tsx @@ -24,6 +24,7 @@ import { RundownPlaylistActivationId, ShowStyleBaseId, } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { ReadonlyDeep } from 'type-fest' export interface IPropsHeader { partInstanceId: PartInstanceId @@ -33,7 +34,7 @@ export interface IPropsHeader { } export const PieceIcon = (props: { - pieceInstance: PieceInstance | undefined + pieceInstance: ReadonlyDeep | undefined sourceLayer: ISourceLayer | undefined renderUnknown?: boolean }): JSX.Element | null => { @@ -100,7 +101,7 @@ export function PieceIconContainerNoSub({ sourceLayers, renderUnknown, }: { - pieceInstances: PieceInstance[] + pieceInstances: ReadonlyDeep sourceLayers: { [key: string]: ISourceLayer } diff --git a/meteor/client/ui/PieceIcons/PieceName.tsx b/meteor/client/ui/PieceIcons/PieceName.tsx index 32f7edefd4..30acdeed39 100644 --- a/meteor/client/ui/PieceIcons/PieceName.tsx +++ b/meteor/client/ui/PieceIcons/PieceName.tsx @@ -7,6 +7,7 @@ import { IPropsHeader } from './PieceIcon' import { findPieceInstanceToShow } from './utils' import { PieceGeneric } from '@sofie-automation/corelib/dist/dataModel/Piece' import { RundownPlaylistActivationId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { ReadonlyDeep } from 'type-fest' interface INamePropsHeader extends IPropsHeader { partName: string @@ -20,7 +21,7 @@ const supportedLayers = new Set([ SourceLayerType.LOCAL, ]) -function getLocalPieceLabel(piece: PieceGeneric): JSX.Element | null { +function getLocalPieceLabel(piece: ReadonlyDeep): JSX.Element | null { const { color } = piece.content as EvsContent return ( <> @@ -34,7 +35,7 @@ function getLocalPieceLabel(piece: PieceGeneric): JSX.Element | null { ) } -function getPieceLabel(piece: PieceGeneric, type: SourceLayerType): JSX.Element | null { +function getPieceLabel(piece: ReadonlyDeep, type: SourceLayerType): JSX.Element | null { switch (type) { case SourceLayerType.LOCAL: return getLocalPieceLabel(piece) diff --git a/meteor/client/ui/PieceIcons/Renderers/SplitInputIcon.tsx b/meteor/client/ui/PieceIcons/Renderers/SplitInputIcon.tsx index c4b7a32e78..af2e97dd4e 100644 --- a/meteor/client/ui/PieceIcons/Renderers/SplitInputIcon.tsx +++ b/meteor/client/ui/PieceIcons/Renderers/SplitInputIcon.tsx @@ -3,13 +3,16 @@ import { PieceGeneric } from '@sofie-automation/corelib/dist/dataModel/Piece' import { SplitsContent, SourceLayerType } from '@sofie-automation/blueprints-integration' import { RundownUtils } from '../../../lib/rundown' import classNames from 'classnames' +import { ReadonlyDeep } from 'type-fest' + +type SplitIconPieceType = ReadonlyDeep> export default class SplitInputIcon extends React.Component<{ - abbreviation?: string - piece?: Omit + abbreviation: string | undefined + piece: SplitIconPieceType | undefined hideLabel?: boolean }> { - private getCameraLabel(piece: Omit | undefined) { + private getCameraLabel(piece: SplitIconPieceType | undefined) { if (piece && piece.content) { const c = piece.content as SplitsContent const camera = c.boxSourceConfiguration.find((i) => i.type === SourceLayerType.CAMERA) @@ -29,7 +32,7 @@ export default class SplitInputIcon extends React.Component<{ } } - private getLeftSourceType(piece: Omit | undefined): string { + private getLeftSourceType(piece: SplitIconPieceType | undefined): string { if (piece && piece.content) { const c = piece.content as SplitsContent const left = (c.boxSourceConfiguration && c.boxSourceConfiguration[0])?.type || SourceLayerType.CAMERA @@ -38,7 +41,7 @@ export default class SplitInputIcon extends React.Component<{ return 'camera' } - private getRightSourceType(piece: Omit | undefined): string { + private getRightSourceType(piece: SplitIconPieceType | undefined): string { if (piece && piece.content) { const c = piece.content as SplitsContent const right = (c.boxSourceConfiguration && c.boxSourceConfiguration[1])?.type || SourceLayerType.REMOTE diff --git a/meteor/client/ui/PieceIcons/utils.ts b/meteor/client/ui/PieceIcons/utils.ts index 5ca152b20d..99d440369f 100644 --- a/meteor/client/ui/PieceIcons/utils.ts +++ b/meteor/client/ui/PieceIcons/utils.ts @@ -5,10 +5,11 @@ import { IPropsHeader } from './PieceIcon' import { PieceExtended } from '../../../lib/Rundown' import { UIShowStyleBases } from '../Collections' import { PieceInstances } from '../../collections' +import { ReadonlyDeep } from 'type-fest' export interface IFoundPieceInstance { sourceLayer: ISourceLayer | undefined - pieceInstance: PieceInstance | undefined + pieceInstance: ReadonlyDeep | undefined } export function findPieceInstanceToShow( @@ -31,12 +32,12 @@ export function findPieceInstanceToShow( } export function findPieceInstanceToShowFromInstances( - pieceInstances: PieceInstance[], + pieceInstances: ReadonlyDeep, sourceLayers: SourceLayers, selectedLayerTypes: Set ): IFoundPieceInstance { let foundSourceLayer: ISourceLayer | undefined - let foundPiece: PieceInstance | undefined + let foundPiece: ReadonlyDeep | undefined for (const pieceInstance of pieceInstances) { const layer = sourceLayers[pieceInstance.piece.sourceLayerId] diff --git a/meteor/client/ui/RundownView/RundownViewShelf.tsx b/meteor/client/ui/RundownView/RundownViewShelf.tsx index 1e7054061a..be7e9ff28d 100644 --- a/meteor/client/ui/RundownView/RundownViewShelf.tsx +++ b/meteor/client/ui/RundownView/RundownViewShelf.tsx @@ -47,9 +47,9 @@ interface IRundownViewShelfTrackedProps { outputLayers: OutputLayers sourceLayers: SourceLayers unfinishedAdLibIds: PieceId[] - unfinishedTags: string[] + unfinishedTags: readonly string[] nextAdLibIds: PieceId[] - nextTags: string[] + nextTags: readonly string[] } interface IRundownViewShelfState { diff --git a/meteor/client/ui/SegmentTimeline/Parts/FlattenedSourceLayers.tsx b/meteor/client/ui/SegmentTimeline/Parts/FlattenedSourceLayers.tsx index 9836fe94ef..67d7639a01 100644 --- a/meteor/client/ui/SegmentTimeline/Parts/FlattenedSourceLayers.tsx +++ b/meteor/client/ui/SegmentTimeline/Parts/FlattenedSourceLayers.tsx @@ -7,6 +7,7 @@ import { SourceLayerItemContainer } from '../SourceLayerItemContainer' import { ISourceLayerPropsBase, useMouseContext } from './SourceLayer' import { ISourceLayerExtended } from '../../../../lib/Rundown' import { PieceInstancePiece } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' +import { ReadonlyDeep } from 'type-fest' interface IFlattenedSourceLayerProps extends ISourceLayerPropsBase { layers: ISourceLayerUi[] @@ -17,7 +18,7 @@ export function FlattenedSourceLayers(props: IFlattenedSourceLayerProps): JSX.El const { getPartContext, onMouseDown } = useMouseContext(props) const piecesForLayers = useMemo(() => { - const piecesForLayers: Map = new Map() + const piecesForLayers: Map[]> = new Map() for (const layer of props.layers) { piecesForLayers.set( layer._id, diff --git a/meteor/client/ui/Shelf/AdLibRegionPanel.tsx b/meteor/client/ui/Shelf/AdLibRegionPanel.tsx index f084674c5f..417c722f2c 100644 --- a/meteor/client/ui/Shelf/AdLibRegionPanel.tsx +++ b/meteor/client/ui/Shelf/AdLibRegionPanel.tsx @@ -30,6 +30,7 @@ import { withMediaObjectStatus } from '../SegmentTimeline/withMediaObjectStatus' import { ISourceLayer } from '@sofie-automation/blueprints-integration' import { UIStudios } from '../Collections' import { Meteor } from 'meteor/meteor' +import { ReadonlyDeep } from 'type-fest' interface IState { objId?: string @@ -247,13 +248,13 @@ export const AdLibRegionPanel = translateWithTracker< ) // Pick thumbnails to display - const nextThumbnail: PieceInstance | undefined = nextPieceInstances.find((p) => + const nextThumbnail: ReadonlyDeep | undefined = nextPieceInstances.find((p) => props.panel.thumbnailSourceLayerIds?.includes(p.piece.sourceLayerId) ) - const currentThumbnail: PieceInstance | undefined = !props.panel.hideThumbnailsForActivePieces + const currentThumbnail: ReadonlyDeep | undefined = !props.panel.hideThumbnailsForActivePieces ? unfinishedPieceInstances.find((p) => props.panel.thumbnailSourceLayerIds?.includes(p.piece.sourceLayerId)) : undefined - const thumbnailPiece: PieceInstance | undefined = props.panel.thumbnailPriorityNextPieces + const thumbnailPiece: ReadonlyDeep | undefined = props.panel.thumbnailPriorityNextPieces ? nextThumbnail ?? currentThumbnail : currentThumbnail ?? nextThumbnail diff --git a/meteor/client/ui/Shelf/DashboardPanel.tsx b/meteor/client/ui/Shelf/DashboardPanel.tsx index 75192935c6..4364466e51 100644 --- a/meteor/client/ui/Shelf/DashboardPanel.tsx +++ b/meteor/client/ui/Shelf/DashboardPanel.tsx @@ -51,9 +51,9 @@ export interface IDashboardPanelProps { export interface IDashboardPanelTrackedProps { studio: UIStudio | undefined unfinishedAdLibIds: PieceId[] - unfinishedTags: string[] + unfinishedTags: readonly string[] nextAdLibIds: PieceId[] - nextTags: string[] + nextTags: readonly string[] } interface DashboardPositionableElement { diff --git a/meteor/client/ui/Shelf/EndWordsPanel.tsx b/meteor/client/ui/Shelf/EndWordsPanel.tsx index bea7c56945..e6f111f067 100644 --- a/meteor/client/ui/Shelf/EndWordsPanel.tsx +++ b/meteor/client/ui/Shelf/EndWordsPanel.tsx @@ -17,6 +17,7 @@ import { getUnfinishedPieceInstancesReactive } from '../../lib/rundownLayouts' import { getScriptPreview } from '../../lib/ui/scriptPreview' import { UIShowStyleBase } from '../../../lib/api/showStyles' import { PieceInstances } from '../../collections' +import { ReadonlyDeep } from 'type-fest' interface IEndsWordsPanelProps { visible?: boolean @@ -79,12 +80,12 @@ function getPieceWithScript(props: IEndsWordsPanelProps): PieceInstance | undefi ) const highestStartedPlayback = unfinishedPiecesIncludingFinishedPiecesWhereEndTimeHaveNotBeenSet.reduce( - (hsp, piece: PieceInstance) => Math.max(hsp, piece.reportedStartedPlayback ?? 0), + (hsp, piece: ReadonlyDeep) => Math.max(hsp, piece.reportedStartedPlayback ?? 0), 0 ) const unfinishedPieces = unfinishedPiecesIncludingFinishedPiecesWhereEndTimeHaveNotBeenSet.filter( - (pieceInstance: PieceInstance) => { + (pieceInstance: ReadonlyDeep) => { return !pieceInstance.reportedStartedPlayback || pieceInstance.reportedStartedPlayback == highestStartedPlayback } ) diff --git a/meteor/client/ui/Shelf/Inspector/ItemRenderers/NoraItemEditor.tsx b/meteor/client/ui/Shelf/Inspector/ItemRenderers/NoraItemEditor.tsx index 97d0448366..b206807e31 100644 --- a/meteor/client/ui/Shelf/Inspector/ItemRenderers/NoraItemEditor.tsx +++ b/meteor/client/ui/Shelf/Inspector/ItemRenderers/NoraItemEditor.tsx @@ -4,6 +4,7 @@ import { parseMosPluginMessageXml, MosPluginMessage } from '../../../../lib/pars import { PieceGeneric } from '@sofie-automation/corelib/dist/dataModel/Piece' import { createMosAppInfoXmlString } from '../../../../lib/data/mos/plugin-support' import { logger } from '../../../../../lib/logging' +import { ReadonlyDeep } from 'type-fest' //TODO: figure out what the origin should be const LOCAL_ORIGIN = `${window.location.protocol}//${window.location.host}` @@ -13,7 +14,7 @@ export const MODULE_BROWSER_ORIGIN = `${MODULE_BROWSER_URL.protocol}//${MODULE_B export { NoraItemEditor } interface INoraEditorProps { - piece: Omit + piece: ReadonlyDeep> } class NoraItemEditor extends React.Component { diff --git a/meteor/client/ui/Shelf/PieceCountdownPanel.tsx b/meteor/client/ui/Shelf/PieceCountdownPanel.tsx index 465e769d26..ae117b1d3a 100644 --- a/meteor/client/ui/Shelf/PieceCountdownPanel.tsx +++ b/meteor/client/ui/Shelf/PieceCountdownPanel.tsx @@ -17,6 +17,7 @@ import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceIns import { VTContent } from '@sofie-automation/blueprints-integration' import { getUnfinishedPieceInstancesReactive } from '../../lib/rundownLayouts' import { UIShowStyleBase } from '../../../lib/api/showStyles' +import { ReadonlyDeep } from 'type-fest' interface IPieceCountdownPanelProps { visible?: boolean layout: RundownLayoutBase @@ -26,7 +27,7 @@ interface IPieceCountdownPanelProps { } interface IPieceCountdownPanelTrackedProps { - livePieceInstance?: PieceInstance + livePieceInstance?: ReadonlyDeep } interface IState { @@ -106,9 +107,9 @@ export class PieceCountdownPanelInner extends MeteorReactComponent< export const PieceCountdownPanel = withTracker( (props: IPieceCountdownPanelProps & IPieceCountdownPanelTrackedProps) => { const unfinishedPieces = getUnfinishedPieceInstancesReactive(props.playlist, props.showStyleBase) - const livePieceInstance: PieceInstance | undefined = + const livePieceInstance: ReadonlyDeep | undefined = props.panel.sourceLayerIds && props.panel.sourceLayerIds.length - ? unfinishedPieces.find((piece: PieceInstance) => { + ? unfinishedPieces.find((piece: ReadonlyDeep) => { return ( (props.panel.sourceLayerIds || []).indexOf(piece.piece.sourceLayerId) !== -1 && piece.partInstanceId === props.playlist.currentPartInfo?.partInstanceId diff --git a/meteor/server/api/playout/playout.ts b/meteor/server/api/playout/playout.ts index 332d031ac9..f5b11df21f 100644 --- a/meteor/server/api/playout/playout.ts +++ b/meteor/server/api/playout/playout.ts @@ -29,7 +29,7 @@ export namespace ServerPlayoutAPI { ]) if (blueprint === undefined) return 'missingBlueprint' - return shouldUpdateStudioBaselineInner(PackageInfo.version, studio, timeline, blueprint) + return shouldUpdateStudioBaselineInner(PackageInfo.version, studio, timeline ?? null, blueprint) } else { return false } diff --git a/packages/corelib/src/dataModel/Part.ts b/packages/corelib/src/dataModel/Part.ts index 7105505a71..1ba160af5e 100644 --- a/packages/corelib/src/dataModel/Part.ts +++ b/packages/corelib/src/dataModel/Part.ts @@ -3,6 +3,7 @@ import { ITranslatableMessage } from '../TranslatableMessage' import { ProtectedStringProperties } from '../protectedString' import { PartId, RundownId, SegmentId } from './Ids' import { PartNote } from './Notes' +import { ReadonlyDeep } from 'type-fest' export interface PartInvalidReason { message: ITranslatableMessage @@ -33,6 +34,6 @@ export interface DBPart extends ProtectedStringProperties, 'invalid' | 'floated'>): boolean { return !part.invalid && !part.floated } diff --git a/packages/corelib/src/dataModel/PieceInstance.ts b/packages/corelib/src/dataModel/PieceInstance.ts index b862918d4f..fc8f3f9db8 100644 --- a/packages/corelib/src/dataModel/PieceInstance.ts +++ b/packages/corelib/src/dataModel/PieceInstance.ts @@ -10,6 +10,7 @@ import { } from './Ids' import { Piece } from './Piece' import { omit } from '../lib' +import { ReadonlyDeep } from 'type-fest' export type PieceInstancePiece = Omit @@ -81,7 +82,7 @@ export interface PieceInstance { } export interface ResolvedPieceInstance { - instance: PieceInstance + instance: ReadonlyDeep /** Calculated start point within the PartInstance */ resolvedStart: number @@ -92,8 +93,8 @@ export interface ResolvedPieceInstance { timelinePriority: number } -export function omitPiecePropertiesForInstance(piece: Piece): PieceInstancePiece { - return omit(piece, 'startRundownId', 'startSegmentId') +export function omitPiecePropertiesForInstance(piece: Piece | PieceInstancePiece): PieceInstancePiece { + return omit(piece as Piece, 'startRundownId', 'startSegmentId') } export function rewrapPieceToInstance( @@ -105,7 +106,7 @@ export function rewrapPieceToInstance( ): PieceInstance { return { isTemporary, - _id: protectString(`${partInstanceId}_${piece._id}`), + _id: getPieceInstanceIdForPiece(partInstanceId, piece._id), rundownId: rundownId, playlistActivationId: playlistActivationId, partInstanceId: partInstanceId, @@ -127,3 +128,7 @@ export function wrapPieceToInstance( partInstanceId === protectString('') || isTemporary ) } + +export function getPieceInstanceIdForPiece(partInstanceId: PartInstanceId, pieceId: PieceId): PieceInstanceId { + return protectString(`${partInstanceId}_${pieceId}`) +} diff --git a/packages/corelib/src/lib.ts b/packages/corelib/src/lib.ts index 21d1d31f3d..54e0a48de0 100644 --- a/packages/corelib/src/lib.ts +++ b/packages/corelib/src/lib.ts @@ -59,7 +59,7 @@ export function max(vals: T[], iterator: _.ListIterator): T | undefin } } -export function min(vals: T[], iterator: _.ListIterator): T | undefined { +export function min(vals: T[] | readonly T[], iterator: _.ListIterator): T | undefined { if (vals.length <= 1) { return vals[0] } else { @@ -165,7 +165,7 @@ export function normalizeArrayFunc(array: Array, getKey: (o: T) => string) * normalizeArray([{ a: '1', b: 2}], 'a') * ``` */ -export function normalizeArray(array: Array, indexKey: keyof T): { [indexKey: string]: T } { +export function normalizeArray(array: Array | readonly T[], indexKey: keyof T): { [indexKey: string]: T } { const normalizedObject: any = {} for (const obj of array) { normalizedObject[obj[indexKey]] = obj @@ -196,7 +196,10 @@ export function normalizeArrayToMap(array: readonly T[], i * normalizeArrayToMapFunc([{ a: 1, b: 2}], (o) => o.a + o.b) * ``` */ -export function normalizeArrayToMapFunc(array: Array, getKey: (o: T) => K | undefined): Map { +export function normalizeArrayToMapFunc( + array: Array | readonly T[], + getKey: (o: T) => K | undefined +): Map { const normalizedObject = new Map() for (const item of array) { const key = getKey(item) @@ -213,7 +216,10 @@ export function normalizeArrayToMapFunc(array: Array, getKey: (o: T) => * @param array Array of items to group * @param indexKey Name of the property to use as the group-key */ -export function groupByToMap(array: Array | IterableIterator, indexKey: K): Map { +export function groupByToMap( + array: Array | readonly T[] | IterableIterator, + indexKey: K +): Map { const groupedItems = new Map() for (const item of array) { const key = item[indexKey] @@ -234,7 +240,7 @@ export function groupByToMap(array: Array | IterableIte * @param getKey Function to get the group-key of the object */ export function groupByToMapFunc( - array: Array | IterableIterator, + array: Array | readonly T[] | IterableIterator, getKey: (o: T) => K | undefined ): Map { const groupedItems = new Map() diff --git a/packages/corelib/src/playout/infinites.ts b/packages/corelib/src/playout/infinites.ts index 387986315b..76d0b4479c 100644 --- a/packages/corelib/src/playout/infinites.ts +++ b/packages/corelib/src/playout/infinites.ts @@ -19,12 +19,12 @@ import _ = require('underscore') import { MongoQuery } from '../mongo' import { DBSegment, SegmentOrphanedReason } from '../dataModel/Segment' -export function buildPiecesStartingInThisPartQuery(part: DBPart): MongoQuery { +export function buildPiecesStartingInThisPartQuery(part: ReadonlyDeep): MongoQuery { return { startPartId: part._id } } export function buildPastInfinitePiecesForThisPartQuery( - part: DBPart, + part: ReadonlyDeep, partIdsToReceiveOnSegmentEndFrom: PartId[], segmentsToReceiveOnRundownEndFrom: SegmentId[], rundownIdsBeforeThisInPlaylist: RundownId[] @@ -95,19 +95,19 @@ export function getPlayheadTrackingInfinitesForPart( segmentsToReceiveOnRundownEndFromSet: Set, rundownsToReceiveOnShowStyleEndFrom: RundownId[], rundownsToShowstyles: Map, - currentPartInstance: DBPartInstance, + currentPartInstance: ReadonlyDeep, playingSegment: ReadonlyDeep>, - currentPartPieceInstances: PieceInstance[], - rundown: ReadonlyDeep>, - part: DBPart, - segment: ReadonlyDeep>, + currentPartPieceInstances: ReadonlyDeep, + intoRundown: ReadonlyDeep>, + intoPart: ReadonlyDeep, + intoSegment: ReadonlyDeep>, newInstanceId: PartInstanceId, nextPartIsAfterCurrentPart: boolean, isTemporary: boolean ): PieceInstance[] { if ( - segment._id !== playingSegment._id && - (segment.orphaned === SegmentOrphanedReason.SCRATCHPAD || + intoSegment._id !== playingSegment._id && + (intoSegment.orphaned === SegmentOrphanedReason.SCRATCHPAD || playingSegment.orphaned === SegmentOrphanedReason.SCRATCHPAD) ) { // If crossing the boundary between of the scratchpad, don't continue any infinites @@ -116,10 +116,10 @@ export function getPlayheadTrackingInfinitesForPart( const canContinueAdlibOnEnds = nextPartIsAfterCurrentPart interface InfinitePieceSet { - [PieceLifespan.OutOnShowStyleEnd]?: PieceInstance - [PieceLifespan.OutOnRundownEnd]?: PieceInstance - [PieceLifespan.OutOnSegmentEnd]?: PieceInstance - onChange?: PieceInstance + [PieceLifespan.OutOnShowStyleEnd]?: ReadonlyDeep + [PieceLifespan.OutOnRundownEnd]?: ReadonlyDeep + [PieceLifespan.OutOnSegmentEnd]?: ReadonlyDeep + onChange?: ReadonlyDeep } const piecesOnSourceLayers = new Map() @@ -127,7 +127,7 @@ export function getPlayheadTrackingInfinitesForPart( rundownsToReceiveOnShowStyleEndFrom, rundownsToShowstyles, currentPartInstance.rundownId, - rundown + intoRundown ) const groupedPlayingPieceInstances = groupByToMapFunc(currentPartPieceInstances, (p) => p.piece.sourceLayerId) @@ -143,7 +143,7 @@ export function getPlayheadTrackingInfinitesForPart( } // Some basic resolving, to figure out which is our candidate - let lastPieceInstance: PieceInstance | undefined + let lastPieceInstance: ReadonlyDeep | undefined for (const candidate of lastPieceInstances) { if (lastPieceInstance === undefined || isCandidateBetterToBeContinued(lastPieceInstance, candidate)) { lastPieceInstance = candidate @@ -155,13 +155,13 @@ export function getPlayheadTrackingInfinitesForPart( let isUsed = false switch (lastPieceInstance.piece.lifespan) { case PieceLifespan.OutOnSegmentChange: - if (currentPartInstance.segmentId === part.segmentId) { + if (currentPartInstance.segmentId === intoPart.segmentId) { // Still in the same segment isUsed = true } break case PieceLifespan.OutOnRundownChange: - if (lastPieceInstance.rundownId === part.rundownId) { + if (lastPieceInstance.rundownId === intoPart.rundownId) { // Still in the same rundown isUsed = true } @@ -203,14 +203,14 @@ export function getPlayheadTrackingInfinitesForPart( switch (mode) { case PieceLifespan.OutOnSegmentEnd: isValid = - currentPartInstance.segmentId === part.segmentId && + currentPartInstance.segmentId === intoPart.segmentId && partsToReceiveOnSegmentEndFromSet.has(candidatePiece.piece.startPartId) break case PieceLifespan.OutOnRundownEnd: isValid = - candidatePiece.rundownId === part.rundownId && + candidatePiece.rundownId === intoPart.rundownId && (segmentsToReceiveOnRundownEndFromSet.has(currentPartInstance.segmentId) || - currentPartInstance.segmentId === part.segmentId) + currentPartInstance.segmentId === intoPart.segmentId) break case PieceLifespan.OutOnShowStyleEnd: isValid = canContinueShowStyleEndInfinites @@ -231,7 +231,7 @@ export function getPlayheadTrackingInfinitesForPart( const instance = rewrapPieceToInstance( p.piece, playlistActivationId, - part.rundownId, + intoPart.rundownId, newInstanceId, isTemporary ) @@ -265,7 +265,7 @@ export function getPlayheadTrackingInfinitesForPart( ) } -function markPieceInstanceAsContinuation(previousInstance: PieceInstance, instance: PieceInstance) { +function markPieceInstanceAsContinuation(previousInstance: ReadonlyDeep, instance: PieceInstance) { instance._id = protectString(`${instance._id}_continue`) instance.dynamicallyInserted = previousInstance.dynamicallyInserted instance.adLibSourceId = previousInstance.adLibSourceId @@ -274,13 +274,13 @@ function markPieceInstanceAsContinuation(previousInstance: PieceInstance, instan } export function isPiecePotentiallyActiveInPart( - previousPartInstance: DBPartInstance | undefined, + previousPartInstance: ReadonlyDeep | undefined, partsToReceiveOnSegmentEndFrom: Set, segmentsToReceiveOnRundownEndFrom: Set, rundownsToReceiveOnShowStyleEndFrom: RundownId[], rundownsToShowstyles: Map, rundown: ReadonlyDeep>, - part: DBPart, + part: ReadonlyDeep, pieceToCheck: Piece ): boolean { // If its from the current part @@ -366,12 +366,12 @@ export function isPiecePotentiallyActiveInPart( */ export function getPieceInstancesForPart( playlistActivationId: RundownPlaylistActivationId, - playingPartInstance: DBPartInstance | undefined, + playingPartInstance: ReadonlyDeep | undefined, playingSegment: ReadonlyDeep> | undefined, - playingPieceInstances: PieceInstance[] | undefined, + playingPieceInstances: ReadonlyDeep | undefined, rundown: ReadonlyDeep>, segment: ReadonlyDeep>, - part: DBPart, + part: ReadonlyDeep, partsToReceiveOnSegmentEndFromSet: Set, segmentsToReceiveOnRundownEndFromSet: Set, rundownsToReceiveOnShowStyleEndFrom: RundownId[], @@ -519,7 +519,10 @@ export function getPieceInstancesForPart( return result } -export function isCandidateMoreImportant(best: PieceInstance, candidate: PieceInstance): boolean | undefined { +export function isCandidateMoreImportant( + best: ReadonlyDeep, + candidate: ReadonlyDeep +): boolean | undefined { // Prioritise the one from this part over previous part if (best.infinite?.fromPreviousPart && !candidate.infinite?.fromPreviousPart) { // Prefer the candidate as it is not from previous @@ -557,7 +560,10 @@ export function isCandidateMoreImportant(best: PieceInstance, candidate: PieceIn return undefined } -export function isCandidateBetterToBeContinued(best: PieceInstance, candidate: PieceInstance): boolean { +export function isCandidateBetterToBeContinued( + best: ReadonlyDeep, + candidate: ReadonlyDeep +): boolean { // Fallback to id, as we dont have any other criteria and this will be stable. // Note: we shouldnt even get here, as it shouldnt be possible for multiple to start at the same time, but it is possible return isCandidateMoreImportant(best, candidate) ?? best.piece._id < candidate.piece._id @@ -567,13 +573,13 @@ function continueShowStyleEndInfinites( rundownsToReceiveOnShowStyleEndFrom: RundownId[], rundownsToShowstyles: Map, previousRundownId: RundownId, - rundown: ReadonlyDeep> + targetRundown: ReadonlyDeep> ): boolean { let canContinueShowStyleEndInfinites = true - if (rundown.showStyleBaseId !== rundownsToShowstyles.get(previousRundownId)) { + if (targetRundown.showStyleBaseId !== rundownsToShowstyles.get(previousRundownId)) { canContinueShowStyleEndInfinites = false } else { - const targetShowStyle = rundown.showStyleBaseId + const targetShowStyle = targetRundown.showStyleBaseId canContinueShowStyleEndInfinites = rundownsToReceiveOnShowStyleEndFrom .slice(rundownsToReceiveOnShowStyleEndFrom.indexOf(previousRundownId)) .every((r) => rundownsToShowstyles.get(r) === targetShowStyle) diff --git a/packages/corelib/src/playout/processAndPrune.ts b/packages/corelib/src/playout/processAndPrune.ts index a64301652e..c9cb0f3377 100644 --- a/packages/corelib/src/playout/processAndPrune.ts +++ b/packages/corelib/src/playout/processAndPrune.ts @@ -5,6 +5,7 @@ import { SourceLayers } from '../dataModel/ShowStyleBase' import { assertNever, groupByToMapFunc } from '../lib' import _ = require('underscore') import { isCandidateBetterToBeContinued, isCandidateMoreImportant } from './infinites' +import { ReadonlyDeep } from 'type-fest' /** * Get the `enable: { start: ?? }` for the new piece in terms that can be used as an `end` for another object @@ -13,14 +14,14 @@ function getPieceStartTime(newPieceStart: number | 'now'): number | RelativeReso return typeof newPieceStart === 'number' ? newPieceStart : { offsetFromNow: 0 } } -function isClear(piece?: PieceInstance): boolean { +function isClear(piece?: ReadonlyDeep): boolean { return !!piece?.piece.virtual } function isCappedByAVirtual( activePieces: PieceInstanceOnInfiniteLayers, key: keyof PieceInstanceOnInfiniteLayers, - newPiece: PieceInstance + newPiece: ReadonlyDeep ): boolean { if ( (key === 'onRundownEnd' || key === 'onShowStyleEnd') && @@ -41,7 +42,7 @@ export interface RelativeResolvedEndCap { offsetFromNow: number } -export interface PieceInstanceWithTimings extends PieceInstance { +export interface PieceInstanceWithTimings extends ReadonlyDeep { /** * This is a maximum end point of the pieceInstance. * If the pieceInstance also has a enable.duration or userDuration set then the shortest one will need to be used @@ -62,7 +63,7 @@ export interface PieceInstanceWithTimings extends PieceInstance { */ export function processAndPrunePieceInstanceTimings( sourceLayers: SourceLayers, - pieces: PieceInstance[], + pieces: ReadonlyDeep, nowInPart: number, keepDisabledPieces?: boolean, includeVirtual?: boolean @@ -88,9 +89,9 @@ export function processAndPrunePieceInstanceTimings( ) for (const pieces of groupedPieces.values()) { // Group and sort the pieces so that we can step through each point in time - const piecesByStart: Array<[number | 'now', PieceInstance[]]> = _.sortBy( + const piecesByStart: Array<[number | 'now', ReadonlyDeep]> = _.sortBy( Array.from(groupByToMapFunc(pieces, (p) => p.piece.enable.start).entries()).map(([k, v]) => - literal<[number | 'now', PieceInstance[]]>([k === 'now' ? 'now' : Number(k), v]) + literal<[number | 'now', ReadonlyDeep]>([k === 'now' ? 'now' : Number(k), v]) ), ([k]) => (k === 'now' ? nowInPart : k) ) @@ -163,7 +164,7 @@ interface PieceInstanceOnInfiniteLayers { onSegmentEnd?: PieceInstanceWithTimings other?: PieceInstanceWithTimings } -function findPieceInstancesOnInfiniteLayers(pieces: PieceInstance[]): PieceInstanceOnInfiniteLayers { +function findPieceInstancesOnInfiniteLayers(pieces: ReadonlyDeep): PieceInstanceOnInfiniteLayers { if (pieces.length === 0) { return {} } diff --git a/packages/corelib/src/playout/timings.ts b/packages/corelib/src/playout/timings.ts index 991415e280..08c3b6865e 100644 --- a/packages/corelib/src/playout/timings.ts +++ b/packages/corelib/src/playout/timings.ts @@ -4,12 +4,13 @@ import { DBPart } from '../dataModel/Part' import { PieceInstance, PieceInstancePiece } from '../dataModel/PieceInstance' import { Piece } from '../dataModel/Piece' import { RundownHoldState } from '../dataModel/RundownPlaylist' +import { ReadonlyDeep } from 'type-fest' /** * Calculate the total pre-roll duration of a PartInstance * Note: once the part has been taken this should not be recalculated. Doing so may result in the timings shifting */ -function calculatePartPreroll(pieces: CalculateTimingsPiece[]): number { +function calculatePartPreroll(pieces: ReadonlyDeep): number { const candidates: number[] = [] for (const piece of pieces) { if (piece.pieceType !== IBlueprintPieceType.Normal) { @@ -30,7 +31,7 @@ function calculatePartPreroll(pieces: CalculateTimingsPiece[]): number { /** * Calculate the total post-roll duration of a PartInstance */ -function calculatePartPostroll(pieces: CalculateTimingsPiece[]): number { +function calculatePartPostroll(pieces: ReadonlyDeep): number { const candidates: number[] = [] for (const piece of pieces) { if (!piece.postrollDuration) { @@ -75,7 +76,7 @@ export function calculatePartTimings( fromPart: CalculateTimingsFromPart | undefined, fromPieces: CalculateTimingsPiece[] | undefined, toPart: CalculateTimingsToPart, - toPieces: CalculateTimingsPiece[] + toPieces: ReadonlyDeep // toPartPreroll: number ): PartCalculatedTimings { // If in a hold, we cant do the transition @@ -140,8 +141,8 @@ export function calculatePartTimings( } export function getPartTimingsOrDefaults( - partInstance: DBPartInstance, - pieceInstances: PieceInstance[] + partInstance: ReadonlyDeep, + pieceInstances: ReadonlyDeep ): PartCalculatedTimings { if (partInstance.partPlayoutTimings) { return partInstance.partPlayoutTimings @@ -166,7 +167,7 @@ function calculateExpectedDurationWithPreroll(rawDuration: number, timings: Part export function calculatePartExpectedDurationWithPreroll( part: DBPart, - pieces: PieceInstancePiece[] + pieces: ReadonlyDeep ): number | undefined { if (part.expectedDuration === undefined) return undefined diff --git a/packages/corelib/src/studio/baseline.ts b/packages/corelib/src/studio/baseline.ts index 19bdde66d6..d1766e1245 100644 --- a/packages/corelib/src/studio/baseline.ts +++ b/packages/corelib/src/studio/baseline.ts @@ -7,7 +7,7 @@ import { Blueprint } from '../dataModel/Blueprint' export function shouldUpdateStudioBaselineInner( coreVersion: string, studio: ReadonlyDeep, - studioTimeline: ReadonlyDeep | undefined, + studioTimeline: ReadonlyDeep | null, studioBlueprint: Pick | null ): string | false { if (!studioTimeline) return 'noBaseline' diff --git a/packages/corelib/src/studio/playout.ts b/packages/corelib/src/studio/playout.ts index fac78f93da..e93e15db2c 100644 --- a/packages/corelib/src/studio/playout.ts +++ b/packages/corelib/src/studio/playout.ts @@ -1,21 +1,22 @@ +import { ReadonlyDeep } from 'type-fest' +import _ = require('underscore') import { PeripheralDevice } from '../dataModel/PeripheralDevice' /** * Calculate what the expected latency is going to be for a device. * The returned latency represents the amount of time we expect the device will need to receive, process and execute a timeline */ -export function getExpectedLatency(peripheralDevice: PeripheralDevice): { +export function getExpectedLatency(peripheralDevice: ReadonlyDeep): { average: number safe: number fastest: number } { if (peripheralDevice.latencies && peripheralDevice.latencies.length) { - peripheralDevice.latencies.sort((a, b) => { + const latencies = _.sortBy(peripheralDevice.latencies, (a, b) => { if (a > b) return 1 if (a < b) return -1 return 0 }) - const latencies = peripheralDevice.latencies let total = 0 for (const latency of latencies) { total += latency diff --git a/packages/job-worker/src/__mocks__/context.ts b/packages/job-worker/src/__mocks__/context.ts index a129e4dd52..5ca56aeb2e 100644 --- a/packages/job-worker/src/__mocks__/context.ts +++ b/packages/job-worker/src/__mocks__/context.ts @@ -16,7 +16,7 @@ import { preprocessStudioConfig, preprocessShowStyleConfig, } from '../blueprints/config' -import { ReadOnlyCacheBase } from '../cache/CacheBase' +import { BaseModel } from '../modelBase' import { PlaylistLock, RundownLock } from '../jobs/lock' import { ReadonlyDeep } from 'type-fest' import { @@ -114,7 +114,7 @@ export class MockJobContext implements JobContext { } } - trackCache(_cache: ReadOnlyCacheBase): void { + trackCache(_cache: BaseModel): void { // TODO // throw new Error('Method not implemented.') } diff --git a/packages/job-worker/src/__mocks__/helpers/snapshot.ts b/packages/job-worker/src/__mocks__/helpers/snapshot.ts index 19212a75b6..4d5b35f92a 100644 --- a/packages/job-worker/src/__mocks__/helpers/snapshot.ts +++ b/packages/job-worker/src/__mocks__/helpers/snapshot.ts @@ -26,6 +26,8 @@ type Data = * Remove certain fields from data that change often, so that it can be used in snapshots * @param data */ +export function fixSnapshot(data: Data, sortData?: boolean): Data +export function fixSnapshot(data: Array, sortData?: boolean): Array export function fixSnapshot(data: Data | Array, sortData?: boolean): Data | Array { if (_.isArray(data)) { const dataArray: any[] = _.map(data, (d) => fixSnapshot(d)) diff --git a/packages/job-worker/src/__mocks__/partinstance.ts b/packages/job-worker/src/__mocks__/partinstance.ts index 1f949b335e..79f7111b2c 100644 --- a/packages/job-worker/src/__mocks__/partinstance.ts +++ b/packages/job-worker/src/__mocks__/partinstance.ts @@ -2,10 +2,11 @@ import { RundownPlaylistActivationId } from '@sofie-automation/corelib/dist/data import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { protectString } from '@sofie-automation/corelib/dist/protectedString' +import { ReadonlyDeep } from 'type-fest' export function wrapPartToTemporaryInstance( playlistActivationId: RundownPlaylistActivationId, - part: DBPart + part: ReadonlyDeep ): DBPartInstance { return { _id: protectString(`${part._id}_tmp_instance`), @@ -15,6 +16,6 @@ export function wrapPartToTemporaryInstance( segmentPlayoutId: protectString(''), // Only needed when stored in the db, and filled in nearer the time takeCount: -1, rehearsal: false, - part: part, + part: part as DBPart, } } diff --git a/packages/job-worker/src/blueprints/__tests__/config.test.ts b/packages/job-worker/src/blueprints/__tests__/config.test.ts index bb6b6b3115..2e77bd5dd8 100644 --- a/packages/job-worker/src/blueprints/__tests__/config.test.ts +++ b/packages/job-worker/src/blueprints/__tests__/config.test.ts @@ -1,12 +1,8 @@ import { protectString } from '@sofie-automation/corelib/dist/protectedString' import { setupMockShowStyleCompound } from '../../__mocks__/presetCollections' import { setupDefaultJobEnvironment } from '../../__mocks__/context' -import { - getShowStyleConfigRef, - getStudioConfigRef, - preprocessStudioConfig, - retrieveBlueprintConfigRefs, -} from '../config' +import { preprocessStudioConfig, retrieveBlueprintConfigRefs } from '../config' +import { getShowStyleConfigRef, getStudioConfigRef } from '../configRefs' import { wrapDefaultObject } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' import { DEFAULT_MINIMUM_TAKE_SPAN } from '@sofie-automation/shared-lib/dist/core/constants' diff --git a/packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts b/packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts index 05cb4aeedd..499d34ee92 100644 --- a/packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts +++ b/packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ import * as _ from 'underscore' import { IBlueprintPart, @@ -7,16 +8,15 @@ import { } from '@sofie-automation/blueprints-integration' import { ActionExecutionContext, ActionPartChange } from '../context/adlibActions' import { isTooCloseToAutonext } from '../../playout/lib' -import { CacheForPlayout } from '../../playout/cache' +import { PlayoutModel } from '../../playout/model/PlayoutModel' import { WatchedPackagesHelper } from '../context/watchedPackages' import { MockJobContext, setupDefaultJobEnvironment } from '../../__mocks__/context' -import { runJobWithPlayoutCache } from '../../playout/lock' +import { runJobWithPlayoutModel } from '../../playout/lock' import { defaultRundownPlaylist } from '../../__mocks__/defaultCollectionObjects' import { protectString, unprotectString } from '@sofie-automation/corelib/dist/protectedString' -import { clone, getRandomId, literal, omit } from '@sofie-automation/corelib/dist/lib' +import { clone, getRandomId, literal, normalizeArrayToMapFunc, omit } from '@sofie-automation/corelib/dist/lib' import { PartInstanceId, - PieceInstanceId, RundownId, RundownPlaylistActivationId, RundownPlaylistId, @@ -24,7 +24,6 @@ import { import { setupDefaultRundown, setupMockShowStyleCompound } from '../../__mocks__/presetCollections' import { SourceLayers } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' import { JobContext } from '../../jobs' -import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { getCurrentTime } from '../../lib' @@ -32,21 +31,24 @@ import { EmptyPieceTimelineObjectsBlob, serializePieceTimelineObjectsBlob, } from '@sofie-automation/corelib/dist/dataModel/Piece' -import { ReadOnlyCache } from '../../cache/CacheBase' +import { PlayoutPartInstanceModel } from '../../playout/model/PlayoutPartInstanceModel' import { convertPartInstanceToBlueprints, convertPieceInstanceToBlueprints } from '../context/lib' import { TimelineObjRundown, TimelineObjType } from '@sofie-automation/corelib/dist/dataModel/Timeline' +import { PlayoutPartInstanceModelImpl } from '../../playout/model/implementation/PlayoutPartInstanceModelImpl' +import { writePartInstancesAndPieceInstances } from '../../playout/model/implementation/SavePlayoutModel' +import { PlayoutPieceInstanceModel } from '../../playout/model/PlayoutPieceInstanceModel' +import { DatabasePersistedModel } from '../../modelBase' import * as PlayoutAdlib from '../../playout/adlibUtils' type TinnerStopPieces = jest.MockedFunction const innerStopPiecesMock = jest.spyOn(PlayoutAdlib, 'innerStopPieces') as TinnerStopPieces -const innerStartAdLibPieceOrig = PlayoutAdlib.innerStartAdLibPiece -type TinnerStartAdLibPiece = jest.MockedFunction -const innerStartAdLibPieceMock = jest.spyOn(PlayoutAdlib, 'innerStartAdLibPiece') as TinnerStartAdLibPiece - -const innerStartQueuedAdLibOrig = PlayoutAdlib.innerStartQueuedAdLib -type TinnerStartQueuedAdLib = jest.MockedFunction -const innerStartQueuedAdLibMock = jest.spyOn(PlayoutAdlib, 'innerStartQueuedAdLib') as TinnerStartQueuedAdLib +const insertQueuedPartWithPiecesOrig = PlayoutAdlib.insertQueuedPartWithPieces +type TinsertQueuedPartWithPieces = jest.MockedFunction +const insertQueuedPartWithPiecesMock = jest.spyOn( + PlayoutAdlib, + 'insertQueuedPartWithPieces' +) as TinsertQueuedPartWithPieces jest.mock('../../playout/resolvedPieces') import { getResolvedPiecesForCurrentPartInstance } from '../../playout/resolvedPieces' @@ -67,12 +69,13 @@ type TpostProcessTimelineObjects = jest.MockedFunction { async function generateSparsePieceInstances( context: MockJobContext, activationId: RundownPlaylistActivationId, rundownId: RundownId - ): Promise { + ): Promise { const parts = await context.mockCollections.Parts.findFetch({ rundownId }) for (let i = 0; i < parts.length; i++) { const part = parts[i] @@ -130,24 +133,31 @@ describe('Test blueprint api context', () => { } } - return context.mockCollections.PartInstances.findFetch({ rundownId }) + const partInstances = await context.mockCollections.PartInstances.findFetch({ rundownId }) + return Promise.all( + partInstances.map(async (partInstance) => { + // This isn't performant, but that shouldn't matter here + const pieceInstances = await context.mockCollections.PieceInstances.findFetch({ + partInstanceId: partInstance._id, + }) + return new PlayoutPartInstanceModelImpl(partInstance, pieceInstances, false) + }) + ) } - // let context: MockJobContext - // beforeAll(async () => { - // context = await setupDefaultJobEnvironment() - // }) - - async function getActionExecutionContext(jobContext: JobContext, cache: CacheForPlayout) { - const playlist = cache.Playlist.doc + async function getActionExecutionContext(jobContext: JobContext, playoutModel: PlayoutModel) { + const playlist = playoutModel.Playlist expect(playlist).toBeTruthy() - const rundown = cache.Rundowns.findOne(() => true) as DBRundown + const rundown = playoutModel.Rundowns[0] expect(rundown).toBeTruthy() const activationId = playlist.activationId as RundownPlaylistActivationId expect(activationId).toBeTruthy() - const showStyle = await jobContext.getShowStyleCompound(rundown.showStyleVariantId, rundown.showStyleBaseId) + const showStyle = await jobContext.getShowStyleCompound( + rundown.Rundown.showStyleVariantId, + rundown.Rundown.showStyleBaseId + ) const showStyleConfig = jobContext.getShowStyleBlueprintConfig(showStyle) const watchedPackages = WatchedPackagesHelper.empty(jobContext) // Not needed by the tests for now @@ -158,7 +168,7 @@ describe('Test blueprint api context', () => { identifier: 'action', }, jobContext, - cache, + playoutModel, showStyle, showStyleConfig, rundown, @@ -174,19 +184,19 @@ describe('Test blueprint api context', () => { } } - async function wrapWithCache( + async function wrapWithPlayoutModel( context: JobContext, playlistId: RundownPlaylistId, - fcn: (cache: CacheForPlayout) => Promise + fcn: (playoutModel: PlayoutModel & DatabasePersistedModel) => Promise ): Promise { - return runJobWithPlayoutCache(context, { playlistId }, null, fcn) + return runJobWithPlayoutModel(context, { playlistId }, null, fcn as any) } async function setupMyDefaultRundown(): Promise<{ jobContext: MockJobContext playlistId: RundownPlaylistId rundownId: RundownId - allPartInstances: DBPartInstance[] + allPartInstances: PlayoutPartInstanceModel[] }> { const context = setupDefaultJobEnvironment() @@ -217,14 +227,29 @@ describe('Test blueprint api context', () => { } } + async function saveAllToDatabase( + context: JobContext, + playoutModel: PlayoutModel & DatabasePersistedModel, + allPartInstances: PlayoutPartInstanceModel[] + ) { + // We need to push changes back to 'mongo' for these tests + await Promise.all( + writePartInstancesAndPieceInstances( + context, + normalizeArrayToMapFunc(allPartInstances as PlayoutPartInstanceModelImpl[], (p) => p.PartInstance._id) + ) + ) + await playoutModel.saveAllToDatabase() + } + async function setPartInstances( jobContext: MockJobContext, playlistId: RundownPlaylistId, - currentPartInstance: DBPartInstance | PieceInstance | undefined | null, - nextPartInstance: DBPartInstance | PieceInstance | undefined | null, - previousPartInstance?: DBPartInstance | PieceInstance | null + currentPartInstance: PlayoutPartInstanceModel | DBPartInstance | PieceInstance | undefined | null, + nextPartInstance: PlayoutPartInstanceModel | DBPartInstance | PieceInstance | undefined | null, + previousPartInstance?: PlayoutPartInstanceModel | DBPartInstance | PieceInstance | null ) { - const convertInfo = (info: DBPartInstance | PieceInstance | null) => { + const convertInfo = (info: PlayoutPartInstanceModel | DBPartInstance | PieceInstance | null) => { if (!info) { return null } else if ('partInstanceId' in info) { @@ -234,6 +259,13 @@ describe('Test blueprint api context', () => { manuallySelected: false, consumesQueuedSegmentId: false, } + } else if ('PartInstance' in info) { + return { + partInstanceId: info.PartInstance._id, + rundownId: info.PartInstance.rundownId, + manuallySelected: false, + consumesQueuedSegmentId: false, + } } else { return { partInstanceId: info._id, @@ -274,8 +306,8 @@ describe('Test blueprint api context', () => { test('invalid parameters', async () => { const { jobContext, playlistId } = await setupMyDefaultRundown() - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) // @ts-ignore await expect(context.getPartInstance()).rejects.toThrow('Unknown part "undefined"') @@ -291,37 +323,37 @@ describe('Test blueprint api context', () => { test('valid parameters', async () => { const { jobContext, playlistId, allPartInstances } = await setupMyDefaultRundown() - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) - expect(cache.PartInstances.documents.size).toBe(0) + expect(playoutModel.LoadedPartInstances).toHaveLength(0) await expect(context.getPartInstance('next')).resolves.toBeUndefined() await expect(context.getPartInstance('current')).resolves.toBeUndefined() }) await setPartInstances(jobContext, playlistId, allPartInstances[1], undefined) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) - expect(cache.PartInstances.documents.size).toBe(2) + expect(playoutModel.LoadedPartInstances).toHaveLength(2) // Check the current part await expect(context.getPartInstance('next')).resolves.toBeUndefined() await expect(context.getPartInstance('current')).resolves.toMatchObject({ - _id: allPartInstances[1]._id, + _id: allPartInstances[1].PartInstance._id, }) }) await setPartInstances(jobContext, playlistId, null, allPartInstances[2]) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) - expect(cache.PartInstances.documents.size).toBe(3) + expect(playoutModel.LoadedPartInstances).toHaveLength(3) // Now the next part await expect(context.getPartInstance('next')).resolves.toMatchObject({ - _id: allPartInstances[2]._id, + _id: allPartInstances[2].PartInstance._id, }) await expect(context.getPartInstance('current')).resolves.toBeUndefined() }) @@ -331,8 +363,8 @@ describe('Test blueprint api context', () => { test('invalid parameters', async () => { const { jobContext, playlistId } = await setupMyDefaultRundown() - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) // @ts-ignore await expect(context.getPieceInstances()).rejects.toThrow('Unknown part "undefined"') @@ -348,20 +380,20 @@ describe('Test blueprint api context', () => { test('valid parameters', async () => { const { jobContext, playlistId, allPartInstances } = await setupMyDefaultRundown() - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) - expect(cache.PartInstances.documents.size).toBe(0) + expect(playoutModel.LoadedPartInstances).toHaveLength(0) await expect(context.getPieceInstances('next')).resolves.toHaveLength(0) await expect(context.getPieceInstances('current')).resolves.toHaveLength(0) }) await setPartInstances(jobContext, playlistId, allPartInstances[1], undefined) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) - expect(cache.PartInstances.documents.size).toBe(2) + expect(playoutModel.LoadedPartInstances).toHaveLength(2) // Check the current part await expect(context.getPieceInstances('next')).resolves.toHaveLength(0) @@ -369,10 +401,10 @@ describe('Test blueprint api context', () => { }) await setPartInstances(jobContext, playlistId, null, allPartInstances[2]) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) - expect(cache.PartInstances.documents.size).toBe(3) + expect(playoutModel.LoadedPartInstances).toHaveLength(3) // Now the next part await expect(context.getPieceInstances('next')).resolves.toHaveLength(1) @@ -384,8 +416,8 @@ describe('Test blueprint api context', () => { test('invalid parameters', async () => { const { jobContext, playlistId } = await setupMyDefaultRundown() - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) // @ts-ignore await expect(context.getResolvedPieceInstances()).rejects.toThrow('Unknown part "undefined"') @@ -403,10 +435,10 @@ describe('Test blueprint api context', () => { test('valid parameters', async () => { const { jobContext, playlistId, allPartInstances } = await setupMyDefaultRundown() - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) - expect(cache.PartInstances.documents.size).toBe(0) + expect(playoutModel.LoadedPartInstances).toHaveLength(0) expect(getResolvedPiecesForCurrentPartInstanceMock).toHaveBeenCalledTimes(0) @@ -419,16 +451,14 @@ describe('Test blueprint api context', () => { getResolvedPiecesForCurrentPartInstanceMock.mockImplementation( ( context2: JobContext, - cache2: ReadOnlyCache, sourceLayers: SourceLayers, - partInstance: Pick, + partInstance: PlayoutPartInstanceModel, now?: number ) => { expect(context2).toBe(jobContext) - expect(cache2).toBeInstanceOf(CacheForPlayout) expect(sourceLayers).toBeTruthy() expect(now).toBeFalsy() - mockCalledIds.push(partInstance._id) + mockCalledIds.push(partInstance.PartInstance._id) return [ { instance: { @@ -445,27 +475,27 @@ describe('Test blueprint api context', () => { ) await setPartInstances(jobContext, playlistId, allPartInstances[1], undefined) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) - expect(cache.PartInstances.documents.size).toBe(2) + expect(playoutModel.LoadedPartInstances).toHaveLength(2) // Check the current part await expect(context.getResolvedPieceInstances('next')).resolves.toHaveLength(0) await expect( context.getResolvedPieceInstances('current').then((res) => res.map((p) => p._id)) ).resolves.toEqual(['abc']) expect(getResolvedPiecesForCurrentPartInstanceMock).toHaveBeenCalledTimes(1) - expect(mockCalledIds).toEqual([allPartInstances[1]._id]) + expect(mockCalledIds).toEqual([allPartInstances[1].PartInstance._id]) }) mockCalledIds = [] getResolvedPiecesForCurrentPartInstanceMock.mockClear() await setPartInstances(jobContext, playlistId, null, allPartInstances[2]) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) - expect(cache.PartInstances.documents.size).toBe(3) + expect(playoutModel.LoadedPartInstances).toHaveLength(3) // Now the next part await expect( @@ -473,7 +503,7 @@ describe('Test blueprint api context', () => { ).resolves.toEqual(['abc']) await expect(context.getResolvedPieceInstances('current')).resolves.toHaveLength(0) expect(getResolvedPiecesForCurrentPartInstanceMock).toHaveBeenCalledTimes(1) - expect(mockCalledIds).toEqual([allPartInstances[2]._id]) + expect(mockCalledIds).toEqual([allPartInstances[2].PartInstance._id]) }) }) }) @@ -481,11 +511,11 @@ describe('Test blueprint api context', () => { test('invalid parameters', async () => { const { jobContext, playlistId } = await setupMyDefaultRundown() - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) // We need to push changes back to 'mongo' for these tests - await cache.saveAllToDatabase() + await playoutModel.saveAllToDatabase() // @ts-ignore await expect(context.findLastPieceOnLayer()).resolves.toBeUndefined() @@ -497,12 +527,15 @@ describe('Test blueprint api context', () => { test('basic and original only', async () => { const { jobContext, playlistId, allPartInstances } = await setupMyDefaultRundown() - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context, rundown, activationId } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) + + allPartInstances[0].setTaken(getCurrentTime(), 0) // We need to push changes back to 'mongo' for these tests - await cache.saveAllToDatabase() + await saveAllToDatabase(jobContext, playoutModel, allPartInstances) + // const allPartInstances = playoutModel.SortedLoadedPartInstances expect(allPartInstances).toHaveLength(5) const sourceLayerIds = Object.keys(context.showStyleCompound.sourceLayers) @@ -513,16 +546,9 @@ describe('Test blueprint api context', () => { await expect(context.findLastPieceOnLayer(sourceLayerIds[1])).resolves.toBeUndefined() // Insert a piece that is played - const pieceId0: PieceInstanceId = getRandomId() - cache.PieceInstances.insert({ - _id: pieceId0, - rundownId: rundown._id, - partInstanceId: allPartInstances[0]._id, - playlistActivationId: activationId, - dynamicallyInserted: getCurrentTime(), - piece: { + const insertedPieceInstance = allPartInstances[0].insertAdlibbedPiece( + { _id: getRandomId(), - startPartId: allPartInstances[0].part._id, externalId: '', name: 'abc', sourceLayerId: sourceLayerIds[0], @@ -534,29 +560,23 @@ describe('Test blueprint api context', () => { content: {}, timelineObjectsString: EmptyPieceTimelineObjectsBlob, }, - plannedStartedPlayback: 1000, - }) + undefined + ) + insertedPieceInstance.setPlannedStartedPlayback(1000) // We need to push changes back to 'mongo' for these tests - await cache.saveAllToDatabase() + await saveAllToDatabase(jobContext, playoutModel, allPartInstances) await expect(context.findLastPieceOnLayer(sourceLayerIds[0])).resolves.toMatchObject({ - _id: pieceId0, + _id: insertedPieceInstance.PieceInstance._id, }) await expect( context.findLastPieceOnLayer(sourceLayerIds[0], { originalOnly: true }) ).resolves.toBeUndefined() // Insert another more recent piece that is played - const pieceId1: PieceInstanceId = getRandomId() - cache.PieceInstances.insert({ - _id: pieceId1, - rundownId: rundown._id, - partInstanceId: allPartInstances[0]._id, - playlistActivationId: activationId, - dynamicallyInserted: getCurrentTime(), - piece: { + const insertedPieceInstance2 = allPartInstances[0].insertAdlibbedPiece( + { _id: getRandomId(), - startPartId: allPartInstances[0].part._id, externalId: '', name: 'abc', sourceLayerId: sourceLayerIds[0], @@ -568,13 +588,15 @@ describe('Test blueprint api context', () => { content: {}, timelineObjectsString: EmptyPieceTimelineObjectsBlob, }, - plannedStartedPlayback: 2000, - }) + undefined + ) + insertedPieceInstance2.setPlannedStartedPlayback(2000) + // We need to push changes back to 'mongo' for these tests - await cache.saveAllToDatabase() + await saveAllToDatabase(jobContext, playoutModel, allPartInstances) await expect(context.findLastPieceOnLayer(sourceLayerIds[0])).resolves.toMatchObject({ - _id: pieceId1, + _id: insertedPieceInstance2.PieceInstance._id, }) await expect( context.findLastPieceOnLayer(sourceLayerIds[0], { originalOnly: true }) @@ -587,11 +609,13 @@ describe('Test blueprint api context', () => { await setPartInstances(jobContext, playlistId, allPartInstances[2], undefined) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context, rundown, activationId } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) + + allPartInstances[0].setTaken(getCurrentTime(), 0) // We need to push changes back to 'mongo' for these tests - await cache.saveAllToDatabase() + await saveAllToDatabase(jobContext, playoutModel, allPartInstances) expect(allPartInstances).toHaveLength(5) @@ -603,16 +627,9 @@ describe('Test blueprint api context', () => { await expect(context.findLastPieceOnLayer(sourceLayerIds[1])).resolves.toBeUndefined() // Insert a couple of pieces that are played - const pieceId0: PieceInstanceId = getRandomId() - cache.PieceInstances.insert({ - _id: pieceId0, - rundownId: rundown._id, - partInstanceId: allPartInstances[0]._id, - playlistActivationId: activationId, - dynamicallyInserted: getCurrentTime(), - piece: { + const insertedPieceInstance = allPartInstances[0].insertAdlibbedPiece( + { _id: getRandomId(), - startPartId: allPartInstances[0].part._id, externalId: '', name: 'abc', sourceLayerId: sourceLayerIds[0], @@ -624,18 +641,12 @@ describe('Test blueprint api context', () => { content: {}, timelineObjectsString: EmptyPieceTimelineObjectsBlob, }, - plannedStartedPlayback: 1000, - }) - const pieceId1: PieceInstanceId = getRandomId() - cache.PieceInstances.insert({ - _id: pieceId1, - rundownId: rundown._id, - partInstanceId: allPartInstances[2]._id, - playlistActivationId: activationId, - dynamicallyInserted: getCurrentTime(), - piece: { + undefined + ) + insertedPieceInstance.setPlannedStartedPlayback(1000) + const insertedPieceInstance2 = allPartInstances[2].insertAdlibbedPiece( + { _id: getRandomId(), - startPartId: allPartInstances[2].part._id, externalId: '', name: 'abc', sourceLayerId: sourceLayerIds[0], @@ -647,19 +658,20 @@ describe('Test blueprint api context', () => { content: {}, timelineObjectsString: EmptyPieceTimelineObjectsBlob, }, - plannedStartedPlayback: 2000, - }) + undefined + ) + insertedPieceInstance2.setPlannedStartedPlayback(2000) // We need to push changes back to 'mongo' for these tests - await cache.saveAllToDatabase() + await saveAllToDatabase(jobContext, playoutModel, allPartInstances) // Check it await expect(context.findLastPieceOnLayer(sourceLayerIds[0])).resolves.toMatchObject({ - _id: pieceId1, + _id: insertedPieceInstance2.PieceInstance._id, }) await expect( context.findLastPieceOnLayer(sourceLayerIds[0], { excludeCurrentPart: true }) ).resolves.toMatchObject({ - _id: pieceId0, + _id: insertedPieceInstance.PieceInstance._id, }) }) }) @@ -669,11 +681,13 @@ describe('Test blueprint api context', () => { await setPartInstances(jobContext, playlistId, allPartInstances[2], undefined) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context, rundown, activationId } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) + + allPartInstances[0].setTaken(getCurrentTime(), 0) // We need to push changes back to 'mongo' for these tests - await cache.saveAllToDatabase() + await saveAllToDatabase(jobContext, playoutModel, allPartInstances) expect(allPartInstances).toHaveLength(5) @@ -685,15 +699,9 @@ describe('Test blueprint api context', () => { await expect(context.findLastPieceOnLayer(sourceLayerIds[1])).resolves.toBeUndefined() // Insert a couple of pieces that are played - const pieceId0: PieceInstanceId = getRandomId() - cache.PieceInstances.insert({ - _id: pieceId0, - rundownId: rundown._id, - partInstanceId: allPartInstances[0]._id, - playlistActivationId: activationId, - piece: { + const insertedPieceInstance = allPartInstances[0].insertAdlibbedPiece( + { _id: getRandomId(), - startPartId: allPartInstances[0].part._id, externalId: '', name: 'abc', sourceLayerId: sourceLayerIds[0], @@ -705,17 +713,12 @@ describe('Test blueprint api context', () => { content: {}, timelineObjectsString: EmptyPieceTimelineObjectsBlob, }, - plannedStartedPlayback: 1000, - }) - const pieceId1: PieceInstanceId = getRandomId() - cache.PieceInstances.insert({ - _id: pieceId1, - rundownId: rundown._id, - partInstanceId: allPartInstances[2]._id, - playlistActivationId: activationId, - piece: { + undefined + ) + insertedPieceInstance.setPlannedStartedPlayback(1000) + const insertedPieceInstance2 = allPartInstances[2].insertAdlibbedPiece( + { _id: getRandomId(), - startPartId: allPartInstances[2].part._id, externalId: '', name: 'abc', sourceLayerId: sourceLayerIds[0], @@ -731,28 +734,29 @@ describe('Test blueprint api context', () => { content: {}, timelineObjectsString: EmptyPieceTimelineObjectsBlob, }, - plannedStartedPlayback: 2000, - }) + undefined + ) + insertedPieceInstance2.setPlannedStartedPlayback(2000) // We need to push changes back to 'mongo' for these tests - await cache.saveAllToDatabase() + await saveAllToDatabase(jobContext, playoutModel, allPartInstances) // Check it await expect(context.findLastPieceOnLayer(sourceLayerIds[0])).resolves.toMatchObject({ - _id: pieceId1, + _id: insertedPieceInstance2.PieceInstance._id, }) await expect( context.findLastPieceOnLayer(sourceLayerIds[0], { pieceMetaDataFilter: {} }) ).resolves.toMatchObject({ - _id: pieceId1, + _id: insertedPieceInstance2.PieceInstance._id, }) await expect( context.findLastPieceOnLayer(sourceLayerIds[0], { pieceMetaDataFilter: { prop1: 'hello' } }) - ).resolves.toMatchObject({ _id: pieceId1 }) + ).resolves.toMatchObject({ _id: insertedPieceInstance2.PieceInstance._id }) await expect( context.findLastPieceOnLayer(sourceLayerIds[0], { pieceMetaDataFilter: { prop1: { $ne: 'hello' } }, }) - ).resolves.toMatchObject({ _id: pieceId0 }) + ).resolves.toMatchObject({ _id: insertedPieceInstance.PieceInstance._id }) }) }) }) @@ -761,11 +765,11 @@ describe('Test blueprint api context', () => { test('No Current Part', async () => { const { jobContext, playlistId } = await setupMyDefaultRundown() - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) // We need to push changes back to 'mongo' for these tests - await cache.saveAllToDatabase() + await playoutModel.saveAllToDatabase() const sourceLayerIds = Object.keys(context.showStyleCompound.sourceLayers) expect(sourceLayerIds).toHaveLength(4) @@ -796,15 +800,15 @@ describe('Test blueprint api context', () => { // Set Part 1 as current part await setPartInstances(jobContext, playlistId, partInstances[0], undefined) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) const sourceLayerIds = Object.keys(context.showStyleCompound.sourceLayers) expect(sourceLayerIds).toHaveLength(4) const expectedPieceInstanceSourceLayer0 = pieceInstances.find( (p) => - p.partInstanceId === cache.Playlist.doc.currentPartInfo?.partInstanceId && + p.partInstanceId === playoutModel.Playlist.currentPartInfo?.partInstanceId && p.piece.sourceLayerId === sourceLayerIds[0] ) expect(expectedPieceInstanceSourceLayer0).not.toBeUndefined() @@ -818,7 +822,7 @@ describe('Test blueprint api context', () => { const expectedPieceInstanceSourceLayer1 = pieceInstances.find( (p) => - p.partInstanceId === cache.Playlist.doc.currentPartInfo?.partInstanceId && + p.partInstanceId === playoutModel.Playlist.currentPartInfo?.partInstanceId && p.piece.sourceLayerId === sourceLayerIds[1] ) expect(expectedPieceInstanceSourceLayer1).not.toBeUndefined() @@ -847,8 +851,8 @@ describe('Test blueprint api context', () => { // Set Part 1 as current part await setPartInstances(jobContext, playlistId, partInstances[0], undefined) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) const sourceLayerIds = Object.keys(context.showStyleCompound.sourceLayers) expect(sourceLayerIds).toHaveLength(4) @@ -879,15 +883,15 @@ describe('Test blueprint api context', () => { // Set Part 2 as current part await setPartInstances(jobContext, playlistId, partInstances[1], undefined) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) const sourceLayerIds = Object.keys(context.showStyleCompound.sourceLayers) expect(sourceLayerIds).toHaveLength(4) const expectedPieceInstanceSourceLayer0 = pieceInstances.find( (p) => - p.partInstanceId === cache.Playlist.doc.currentPartInfo?.partInstanceId && + p.partInstanceId === playoutModel.Playlist.currentPartInfo?.partInstanceId && p.piece.sourceLayerId === sourceLayerIds[0] ) expect(expectedPieceInstanceSourceLayer0).not.toBeUndefined() @@ -919,8 +923,8 @@ describe('Test blueprint api context', () => { test('invalid parameters', async () => { const { jobContext, playlistId } = await setupMyDefaultRundown() - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) // @ts-ignore await expect(context.getPartInstanceForPreviousPiece()).rejects.toThrow( @@ -953,42 +957,50 @@ describe('Test blueprint api context', () => { const { jobContext, playlistId, allPartInstances } = await setupMyDefaultRundown() // Try with nothing in the cache - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) - expect(cache.PartInstances.documents.size).toBe(0) + expect(playoutModel.LoadedPartInstances).toHaveLength(0) await expect( - context.getPartInstanceForPreviousPiece({ partInstanceId: allPartInstances[1]._id } as any) + context.getPartInstanceForPreviousPiece({ + partInstanceId: allPartInstances[1].PartInstance._id, + } as any) ).resolves.toMatchObject({ - _id: allPartInstances[1]._id, + _id: allPartInstances[1].PartInstance._id, }) await expect( - context.getPartInstanceForPreviousPiece({ partInstanceId: allPartInstances[4]._id } as any) + context.getPartInstanceForPreviousPiece({ + partInstanceId: allPartInstances[4].PartInstance._id, + } as any) ).resolves.toMatchObject({ - _id: allPartInstances[4]._id, + _id: allPartInstances[4].PartInstance._id, }) }) // Again with stuff in the cache await setPartInstances(jobContext, playlistId, allPartInstances[1], undefined) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) - expect(cache.PartInstances.documents.size).toBe(2) + expect(playoutModel.LoadedPartInstances).toHaveLength(2) await expect( - context.getPartInstanceForPreviousPiece({ partInstanceId: allPartInstances[1]._id } as any) + context.getPartInstanceForPreviousPiece({ + partInstanceId: allPartInstances[1].PartInstance._id, + } as any) ).resolves.toMatchObject({ - _id: allPartInstances[1]._id, + _id: allPartInstances[1].PartInstance._id, }) await expect( - context.getPartInstanceForPreviousPiece({ partInstanceId: allPartInstances[4]._id } as any) + context.getPartInstanceForPreviousPiece({ + partInstanceId: allPartInstances[4].PartInstance._id, + } as any) ).resolves.toMatchObject({ - _id: allPartInstances[4]._id, + _id: allPartInstances[4].PartInstance._id, }) }) }) @@ -998,8 +1010,8 @@ describe('Test blueprint api context', () => { test('invalid parameters', async () => { const { jobContext, playlistId } = await setupMyDefaultRundown() - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) // @ts-ignore await expect(context.getPartForPreviousPiece()).rejects.toThrow( @@ -1032,32 +1044,31 @@ describe('Test blueprint api context', () => { const { jobContext, playlistId, allPartInstances } = await setupMyDefaultRundown() // Try with nothing in the cache - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) - expect(cache.PartInstances.documents.size).toBe(0) - expect(cache.PieceInstances.documents.size).toBe(0) + expect(playoutModel.LoadedPartInstances).toHaveLength(0) const pieceInstance0 = (await jobContext.mockCollections.PieceInstances.findOne({ - partInstanceId: allPartInstances[0]._id, + partInstanceId: allPartInstances[0].PartInstance._id, })) as PieceInstance expect(pieceInstance0).not.toBeUndefined() await expect( context.getPartForPreviousPiece({ _id: unprotectString(pieceInstance0.piece._id) }) ).resolves.toMatchObject({ - _id: allPartInstances[0].part._id, + _id: allPartInstances[0].PartInstance.part._id, }) const pieceInstance1 = (await jobContext.mockCollections.PieceInstances.findOne({ - partInstanceId: allPartInstances[1]._id, + partInstanceId: allPartInstances[1].PartInstance._id, })) as PieceInstance expect(pieceInstance1).not.toBeUndefined() await expect( context.getPartForPreviousPiece({ _id: unprotectString(pieceInstance1.piece._id) }) ).resolves.toMatchObject({ - _id: allPartInstances[1].part._id, + _id: allPartInstances[1].PartInstance.part._id, }) }) }) @@ -1066,7 +1077,6 @@ describe('Test blueprint api context', () => { describe('insertPiece', () => { beforeEach(() => { postProcessPiecesMock.mockClear() - innerStartAdLibPieceMock.mockClear() }) test('bad parameters', async () => { @@ -1074,8 +1084,14 @@ describe('Test blueprint api context', () => { await setPartInstances(jobContext, playlistId, allPartInstances[0], undefined) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) + + const currentPartInstance = playoutModel.CurrentPartInstance! + expect(currentPartInstance).toBeTruthy() + currentPartInstance.setTaken(getCurrentTime(), 0) + + const insertSpy = jest.spyOn(currentPartInstance, 'insertAdlibbedPiece') // @ts-ignore await expect(context.insertPiece()).rejects.toThrow('Unknown part "undefined"') @@ -1085,14 +1101,14 @@ describe('Test blueprint api context', () => { await expect(context.insertPiece('next')).rejects.toThrow('Cannot insert piece when no active part') expect(postProcessPiecesMock).toHaveBeenCalledTimes(0) - expect(innerStartAdLibPieceMock).toHaveBeenCalledTimes(0) + expect(insertSpy).toHaveBeenCalledTimes(0) postProcessPiecesMock.mockImplementationOnce(() => { throw new Error('Mock process error') }) await expect(context.insertPiece('current', {} as any)).rejects.toThrow('Mock process error') expect(postProcessPiecesMock).toHaveBeenCalledTimes(1) - expect(innerStartAdLibPieceMock).toHaveBeenCalledTimes(0) + expect(insertSpy).toHaveBeenCalledTimes(0) }) }) @@ -1103,8 +1119,8 @@ describe('Test blueprint api context', () => { await setPartInstances(jobContext, playlistId, partInstance, undefined) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) postProcessPiecesMock.mockImplementationOnce(() => [ { @@ -1112,29 +1128,34 @@ describe('Test blueprint api context', () => { timelineObjectsString: EmptyPieceTimelineObjectsBlob, } as any, ]) - innerStartAdLibPieceMock.mockImplementationOnce(innerStartAdLibPieceOrig) + + const currentPartInstance = playoutModel.CurrentPartInstance! + expect(currentPartInstance).toBeTruthy() + currentPartInstance.setTaken(getCurrentTime(), 0) + + const insertSpy = jest.spyOn(currentPartInstance, 'insertAdlibbedPiece') const newPieceInstanceId = (await context.insertPiece('current', { externalId: 'input1' } as any)) ._id - expect(newPieceInstanceId).toMatch(/randomId([0-9]+)_part0_0_instance_randomId([0-9]+)/) + expect(newPieceInstanceId).toMatch(/randomId(\d+)_part0_0_instance_randomId(\d+)/) expect(postProcessPiecesMock).toHaveBeenCalledTimes(1) expect(postProcessPiecesMock).toHaveBeenCalledWith( expect.anything(), [{ externalId: 'input1' }], 'blueprint0', - partInstance.rundownId, - partInstance.segmentId, - partInstance.part._id, + partInstance.PartInstance.rundownId, + partInstance.PartInstance.segmentId, + partInstance.PartInstance.part._id, true ) - expect(innerStartAdLibPieceMock).toHaveBeenCalledTimes(1) + expect(insertSpy).toHaveBeenCalledTimes(1) // check some properties not exposed to the blueprints - const newPieceInstance = cache.PieceInstances.findOne( - protectString(newPieceInstanceId) - ) as PieceInstance - expect(newPieceInstance.dynamicallyInserted).toBeTruthy() - expect(newPieceInstance.partInstanceId).toEqual(partInstance._id) + const newPieceInstance = playoutModel.findPieceInstance(protectString(newPieceInstanceId)) + ?.pieceInstance as PlayoutPieceInstanceModel + expect(newPieceInstance).toBeTruthy() + expect(newPieceInstance.PieceInstance.dynamicallyInserted).toBeTruthy() + expect(newPieceInstance.PieceInstance.partInstanceId).toEqual(partInstance.PartInstance._id) }) }) }) @@ -1144,18 +1165,18 @@ describe('Test blueprint api context', () => { const { jobContext, playlistId, allPartInstances } = await setupMyDefaultRundown() const pieceInstance = (await jobContext.mockCollections.PieceInstances.findOne({ - partInstanceId: allPartInstances[0]._id, + partInstanceId: allPartInstances[0].PartInstance._id, })) as PieceInstance expect(pieceInstance).toBeTruthy() const pieceInstanceOther = (await jobContext.mockCollections.PieceInstances.findOne({ - partInstanceId: allPartInstances[1]._id, + partInstanceId: allPartInstances[1].PartInstance._id, })) as PieceInstance expect(pieceInstanceOther).toBeTruthy() await setPartInstances(jobContext, playlistId, undefined, undefined, allPartInstances[0]) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) await expect(context.updatePieceInstance('abc', {})).rejects.toThrow( 'Some valid properties must be defined' @@ -1177,8 +1198,8 @@ describe('Test blueprint api context', () => { // Set a current part instance await setPartInstances(jobContext, playlistId, pieceInstance, undefined, pieceInstanceOther) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) await expect(context.updatePieceInstance('abc', { sourceLayerId: 'new' })).rejects.toThrow( 'PieceInstance could not be found' ) @@ -1192,8 +1213,8 @@ describe('Test blueprint api context', () => { // Set as next part instance await setPartInstances(jobContext, playlistId, null, pieceInstance, pieceInstanceOther) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) await expect(context.updatePieceInstance('abc', { sourceLayerId: 'new' })).rejects.toThrow( 'PieceInstance could not be found' ) @@ -1216,11 +1237,13 @@ describe('Test blueprint api context', () => { expect(pieceInstance0).toBeTruthy() await setPartInstances(jobContext, playlistId, pieceInstance0, undefined) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) // Ensure there are no pending updates already - expect(cache.PieceInstances.isModified()).toBeFalsy() + for (const partInstance of playoutModel.LoadedPartInstances) { + expect((partInstance as PlayoutPartInstanceModelImpl).HasAnyChanges()).toBeFalsy() + } // Update it and expect it to match const pieceInstance0Before = clone(pieceInstance0) @@ -1246,10 +1269,11 @@ describe('Test blueprint api context', () => { unprotectString(pieceInstance0._id), pieceInstance0Delta ) - const pieceInstance1 = cache.PieceInstances.findOne(pieceInstance0._id) as PieceInstance + const { pieceInstance: pieceInstance1, partInstance: partInstance1 } = + playoutModel.findPieceInstance(pieceInstance0._id)! expect(pieceInstance1).toBeTruthy() - expect(resultPiece).toEqual(convertPieceInstanceToBlueprints(pieceInstance1)) + expect(resultPiece).toEqual(convertPieceInstanceToBlueprints(pieceInstance1.PieceInstance)) const pieceInstance0After = { ...pieceInstance0Before, piece: { @@ -1263,14 +1287,10 @@ describe('Test blueprint api context', () => { ), }, } - expect(pieceInstance1).toEqual(pieceInstance0After) - expect( - Array.from(cache.PieceInstances.documents.values()).filter((doc) => !doc || !!doc.updated) - ).toMatchObject([ - { - updated: true, - document: { _id: pieceInstance1._id }, - }, + expect(pieceInstance1.PieceInstance).toEqual(pieceInstance0After) + expect((partInstance1 as PlayoutPartInstanceModelImpl).PartInstanceHasChanges).toBeFalsy() + expect((partInstance1 as PlayoutPartInstanceModelImpl).ChangedPieceInstanceIds()).toEqual([ + pieceInstance1.PieceInstance._id, ]) expect(context.nextPartState).toEqual(ActionPartChange.NONE) @@ -1282,15 +1302,14 @@ describe('Test blueprint api context', () => { describe('queuePart', () => { beforeEach(() => { postProcessPiecesMock.mockClear() - innerStartAdLibPieceMock.mockClear() - innerStartQueuedAdLibMock.mockClear() + insertQueuedPartWithPiecesMock.mockClear() }) test('bad parameters', async () => { const { jobContext, playlistId, rundownId } = await setupMyDefaultRundown() - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) // No next-part // @ts-ignore @@ -1303,8 +1322,8 @@ describe('Test blueprint api context', () => { expect(partInstance).toBeTruthy() await setPartInstances(jobContext, playlistId, partInstance, undefined) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) // Next part has already been modified context.nextPartState = ActionPartChange.SAFE_CHANGE @@ -1318,48 +1337,26 @@ describe('Test blueprint api context', () => { 'New part must contain at least one piece' ) - // expect( - // context.queuePart( - // // @ts-ignore - // { - // floated: true, - // }, - // [{}] - // ).part.floated - // ).toBeFalsy() - // expect( - // context.queuePart( - // // @ts-ignore - // { - // invalid: true, - // }, - // [{}] - // ).part.invalid - // ).toBeFalsy() - expect(postProcessPiecesMock).toHaveBeenCalledTimes(0) - expect(innerStartAdLibPieceMock).toHaveBeenCalledTimes(0) - expect(innerStartQueuedAdLibMock).toHaveBeenCalledTimes(0) + expect(insertQueuedPartWithPiecesMock).toHaveBeenCalledTimes(0) postProcessPiecesMock.mockImplementationOnce(() => { throw new Error('Mock process error') }) await expect(context.queuePart({} as any, [{}] as any)).rejects.toThrow('Mock process error') expect(postProcessPiecesMock).toHaveBeenCalledTimes(1) - expect(innerStartAdLibPieceMock).toHaveBeenCalledTimes(0) - expect(innerStartQueuedAdLibMock).toHaveBeenCalledTimes(0) - - partInstance.part.autoNext = true - partInstance.part.expectedDuration = 700 - partInstance.timings = { - plannedStartedPlayback: getCurrentTime(), - plannedStoppedPlayback: undefined, - playOffset: 0, - take: undefined, - } - cache.PartInstances.replace(partInstance) + expect(insertQueuedPartWithPiecesMock).toHaveBeenCalledTimes(0) + + const partInstanceModel = playoutModel.getPartInstance(partInstance._id) as PlayoutPartInstanceModel + expect(partInstanceModel).toBeTruthy() - expect(isTooCloseToAutonext(partInstance, true)).toBeTruthy() + partInstanceModel.updatePartProps({ + autoNext: true, + expectedDuration: 700, + }) + partInstanceModel.setPlannedStartedPlayback(getCurrentTime()) + + expect(isTooCloseToAutonext(partInstanceModel.PartInstance, true)).toBeTruthy() await expect(context.queuePart({} as any, [{}] as any)).rejects.toThrow( 'Too close to an autonext to queue a part' ) @@ -1378,8 +1375,8 @@ describe('Test blueprint api context', () => { expect(partInstance).toBeTruthy() await setPartInstances(jobContext, playlistId, partInstance, undefined) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) const newPiece: IBlueprintPiece = { name: 'test piece', @@ -1398,34 +1395,32 @@ describe('Test blueprint api context', () => { } expect(postProcessPiecesMock).toHaveBeenCalledTimes(0) - expect(innerStartAdLibPieceMock).toHaveBeenCalledTimes(0) - expect(innerStartQueuedAdLibMock).toHaveBeenCalledTimes(0) + expect(insertQueuedPartWithPiecesMock).toHaveBeenCalledTimes(0) // Create it with most of the real flow postProcessPiecesMock.mockImplementationOnce(postProcessPiecesOrig) - innerStartQueuedAdLibMock.mockImplementationOnce(innerStartQueuedAdLibOrig) + insertQueuedPartWithPiecesMock.mockImplementationOnce(insertQueuedPartWithPiecesOrig) expect((await context.queuePart(newPart, [newPiece]))._id).toEqual( - cache.Playlist.doc.nextPartInfo?.partInstanceId + playoutModel.Playlist.nextPartInfo?.partInstanceId ) expect(postProcessPiecesMock).toHaveBeenCalledTimes(1) - expect(innerStartAdLibPieceMock).toHaveBeenCalledTimes(0) - expect(innerStartQueuedAdLibMock).toHaveBeenCalledTimes(1) + expect(insertQueuedPartWithPiecesMock).toHaveBeenCalledTimes(1) // Verify some properties not exposed to the blueprints - const newPartInstance = cache.PartInstances.findOne( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-non-null-asserted-optional-chain - cache.Playlist.doc.nextPartInfo?.partInstanceId! - ) as DBPartInstance + const newPartInstance = playoutModel.getPartInstance( + playoutModel.Playlist.nextPartInfo!.partInstanceId + )! expect(newPartInstance).toBeTruthy() - expect(newPartInstance.part._rank).toBeLessThan(9000) - expect(newPartInstance.part._rank).toBeGreaterThan(partInstance.part._rank) - expect(newPartInstance.orphaned).toEqual('adlib-part') + expect(newPartInstance.PartInstance.part._rank).toBeLessThan(9000) + expect(newPartInstance.PartInstance.part._rank).toBeGreaterThan(partInstance.part._rank) + expect(newPartInstance.PartInstance.orphaned).toEqual('adlib-part') const newNextPartInstances = await context.getPieceInstances('next') expect(newNextPartInstances).toHaveLength(1) - // @ts-ignore - expect(newNextPartInstances[0].partInstanceId).toEqual(newPartInstance._id) + expect(newNextPartInstances[0].partInstanceId).toEqual( + unprotectString(newPartInstance.PartInstance._id) + ) expect(context.nextPartState).toEqual(ActionPartChange.SAFE_CHANGE) expect(context.currentPartState).toEqual(ActionPartChange.NONE) @@ -1437,29 +1432,9 @@ describe('Test blueprint api context', () => { test('invalid parameters', async () => { const { jobContext, playlistId } = await setupMyDefaultRundown() - // Bad instance id - await jobContext.mockCollections.RundownPlaylists.update(playlistId, { - $set: { - currentPartInfo: { - partInstanceId: protectString('abc'), - rundownId: protectString('def'), - manuallySelected: false, - consumesQueuedSegmentId: false, - }, - }, - }) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) - - // Bad instance id - await expect(context.stopPiecesOnLayers(['lay1'], 34)).rejects.toThrow( - 'Cannot stop pieceInstances when no current partInstance' - ) - }) - await setPartInstances(jobContext, playlistId, null, undefined) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) innerStopPiecesMock.mockClear() await expect(context.stopPiecesOnLayers(['lay1'], 34)).resolves.toEqual([]) @@ -1478,17 +1453,17 @@ describe('Test blueprint api context', () => { expect(currentPartInstance).toBeTruthy() await setPartInstances(jobContext, playlistId, currentPartInstance, undefined) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) innerStopPiecesMock.mockClear() let filter: (piece: PieceInstance) => boolean = null as any innerStopPiecesMock.mockImplementationOnce( - (context2, cache2, showStyleBase, partInstance, filter2, offset) => { + (context2, playoutModel2, showStyleBase, partInstance, filter2, offset) => { expect(context2).toBe(jobContext) - expect(cache2).toBe(cache) + expect(playoutModel2).toBe(playoutModel) expect(showStyleBase).toBeTruthy() - expect(partInstance).toStrictEqual(currentPartInstance) + expect(partInstance.PartInstance).toStrictEqual(currentPartInstance) expect(offset).toEqual(34) filter = filter2 @@ -1516,29 +1491,9 @@ describe('Test blueprint api context', () => { test('invalid parameters', async () => { const { jobContext, playlistId } = await setupMyDefaultRundown() - // Bad instance id - await jobContext.mockCollections.RundownPlaylists.update(playlistId, { - $set: { - currentPartInfo: { - partInstanceId: protectString('abc'), - rundownId: protectString('def'), - manuallySelected: false, - consumesQueuedSegmentId: false, - }, - }, - }) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) - - // Bad instance id - await expect(context.stopPieceInstances(['lay1'], 34)).rejects.toThrow( - 'Cannot stop pieceInstances when no current partInstance' - ) - }) - await setPartInstances(jobContext, playlistId, null, undefined) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) innerStopPiecesMock.mockClear() await expect(context.stopPieceInstances(['lay1'], 34)).resolves.toEqual([]) @@ -1557,17 +1512,17 @@ describe('Test blueprint api context', () => { expect(currentPartInstance).toBeTruthy() await setPartInstances(jobContext, playlistId, currentPartInstance, undefined) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) innerStopPiecesMock.mockClear() let filter: (piece: PieceInstance) => boolean = null as any innerStopPiecesMock.mockImplementationOnce( - (context2, cache2, showStyleBase, partInstance, filter2, offset) => { + (context2, playoutModel2, showStyleBase, partInstance, filter2, offset) => { expect(context2).toBe(jobContext) - expect(cache2).toBe(cache) + expect(playoutModel2).toBe(playoutModel) expect(showStyleBase).toBeTruthy() - expect(partInstance).toStrictEqual(currentPartInstance) + expect(partInstance.PartInstance).toStrictEqual(currentPartInstance) expect(offset).toEqual(34) filter = filter2 @@ -1591,13 +1546,40 @@ describe('Test blueprint api context', () => { }) }) describe('removePieceInstances', () => { + interface PieceInstanceCounts { + other: number + previous: number + current: number + next: number + } + function getPieceInstanceCounts(playoutModel: PlayoutModel): PieceInstanceCounts { + let other = 0 + for (const partInstance of playoutModel.OlderPartInstances) { + other += partInstance.PieceInstances.length + } + + return { + other, + previous: playoutModel.PreviousPartInstance?.PieceInstances?.length ?? 0, + current: playoutModel.CurrentPartInstance?.PieceInstances?.length ?? 0, + next: playoutModel.NextPartInstance?.PieceInstances?.length ?? 0, + } + } + + function expectCountsToEqual(counts: PieceInstanceCounts, old: PieceInstanceCounts): void { + expect(counts.previous).toEqual(old.previous) + expect(counts.current).toEqual(old.current) + expect(counts.next).toEqual(old.next) + expect(counts.other).toEqual(old.other) + } + test('invalid parameters', async () => { const { jobContext, playlistId, rundownId, allPartInstances } = await setupMyDefaultRundown() // No instance id await setPartInstances(jobContext, playlistId, undefined, null) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) // No instance id await expect(context.removePieceInstances('next', ['lay1'])).rejects.toThrow( @@ -1609,14 +1591,17 @@ describe('Test blueprint api context', () => { const partInstance = allPartInstances[0] await setPartInstances(jobContext, playlistId, undefined, partInstance) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) - const beforePieceInstancesCount = cache.PieceInstances.findAll(null).length // Because only those frm current, next, prev are included.. - expect(beforePieceInstancesCount).not.toEqual(0) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) + const beforePieceInstancesCounts = getPieceInstanceCounts(playoutModel) + expect(beforePieceInstancesCounts.previous).toEqual(0) + expect(beforePieceInstancesCounts.current).toEqual(0) + expect(beforePieceInstancesCounts.next).not.toEqual(0) + expect(beforePieceInstancesCounts.other).toEqual(0) const pieceInstanceFromOther = (await jobContext.mockCollections.PieceInstances.findOne({ rundownId, - partInstanceId: { $ne: partInstance._id }, + partInstanceId: { $ne: partInstance.PartInstance._id }, })) as PieceInstance expect(pieceInstanceFromOther).toBeTruthy() @@ -1625,7 +1610,7 @@ describe('Test blueprint api context', () => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion context.removePieceInstances('next', [unprotectString(pieceInstanceFromOther._id)]) ).resolves.toEqual([]) // Try and remove something belonging to a different part - expect(cache.PieceInstances.findAll(null).length).toEqual(beforePieceInstancesCount) + expectCountsToEqual(getPieceInstanceCounts(playoutModel), beforePieceInstancesCounts) }) }) @@ -1635,21 +1620,25 @@ describe('Test blueprint api context', () => { const partInstance = allPartInstances[0] await setPartInstances(jobContext, playlistId, undefined, partInstance) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) - expect(cache.PieceInstances.findAll(null).length).not.toEqual(0) + const beforePieceInstancesCounts = getPieceInstanceCounts(playoutModel) + expect(beforePieceInstancesCounts.previous).toEqual(0) + expect(beforePieceInstancesCounts.current).toEqual(0) + expect(beforePieceInstancesCounts.next).not.toEqual(0) + expect(beforePieceInstancesCounts.other).toEqual(0) // Find the instance, and create its backing piece - const targetPieceInstance = cache.PieceInstances.findOne(() => true) as PieceInstance + const targetPieceInstance = playoutModel.NextPartInstance!.PieceInstances[0] expect(targetPieceInstance).toBeTruthy() await expect( - context.removePieceInstances('next', [unprotectString(targetPieceInstance._id)]) - ).resolves.toEqual([unprotectString(targetPieceInstance._id)]) + context.removePieceInstances('next', [unprotectString(targetPieceInstance.PieceInstance._id)]) + ).resolves.toEqual([unprotectString(targetPieceInstance.PieceInstance._id)]) // Ensure it was all removed - expect(cache.PieceInstances.findOne(targetPieceInstance._id)).toBeFalsy() + expect(playoutModel.findPieceInstance(targetPieceInstance.PieceInstance._id)).toBeFalsy() expect(context.nextPartState).toEqual(ActionPartChange.SAFE_CHANGE) }) }) @@ -1669,15 +1658,9 @@ describe('Test blueprint api context', () => { })) as DBPartInstance expect(partInstanceOther).toBeTruthy() - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) - await expect(context.updatePartInstance('current', {})).rejects.toThrow( - 'Some valid properties must be defined' - ) - await expect( - context.updatePartInstance('current', { _id: 'bad', nope: 'ok' } as any) - ).rejects.toThrow('Some valid properties must be defined') await expect(context.updatePartInstance('current', { title: 'new' })).rejects.toThrow( 'PartInstance could not be found' ) @@ -1685,8 +1668,15 @@ describe('Test blueprint api context', () => { // Set a current part instance await setPartInstances(jobContext, playlistId, partInstance, undefined) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) + await expect(context.updatePartInstance('current', {})).rejects.toThrow( + 'Some valid properties must be defined' + ) + await expect( + context.updatePartInstance('current', { _id: 'bad', nope: 'ok' } as any) + ).rejects.toThrow('Some valid properties must be defined') + await expect(context.updatePartInstance('next', { title: 'new' })).rejects.toThrow( 'PartInstance could not be found' ) @@ -1703,11 +1693,11 @@ describe('Test blueprint api context', () => { expect(partInstance0).toBeTruthy() await setPartInstances(jobContext, playlistId, undefined, partInstance0) - await wrapWithCache(jobContext, playlistId, async (cache) => { - const { context } = await getActionExecutionContext(jobContext, cache) + await wrapWithPlayoutModel(jobContext, playlistId, async (playoutModel) => { + const { context } = await getActionExecutionContext(jobContext, playoutModel) // Ensure there are no pending updates already - expect(cache.PartInstances.isModified()).toBeFalsy() + expect((playoutModel.NextPartInstance! as PlayoutPartInstanceModelImpl).HasAnyChanges()).toBeFalsy() // Update it and expect it to match const partInstance0Before = clone(partInstance0) @@ -1718,11 +1708,11 @@ describe('Test blueprint api context', () => { classes: ['123'], badProperty: 9, // This will be dropped } - const resultPiece = await context.updatePartInstance('next', partInstance0Delta) - const partInstance1 = cache.PartInstances.findOne(() => true) as DBPartInstance + const resultPart = await context.updatePartInstance('next', partInstance0Delta) + const partInstance1 = playoutModel.NextPartInstance! as PlayoutPartInstanceModelImpl expect(partInstance1).toBeTruthy() - expect(resultPiece).toEqual(convertPartInstanceToBlueprints(partInstance1)) + expect(resultPart).toEqual(convertPartInstanceToBlueprints(partInstance1.PartInstance)) const pieceInstance0After = { ...partInstance0Before, @@ -1731,15 +1721,9 @@ describe('Test blueprint api context', () => { ..._.omit(partInstance0Delta, 'badProperty', '_id'), }, } - expect(partInstance1).toEqual(pieceInstance0After) - expect( - Array.from(cache.PartInstances.documents.values()).filter((doc) => !doc || !!doc.updated) - ).toMatchObject([ - { - updated: true, - document: { _id: partInstance1._id }, - }, - ]) + expect(partInstance1.PartInstance).toEqual(pieceInstance0After) + expect(partInstance1.PartInstanceHasChanges).toBeTruthy() + expect(partInstance1.ChangedPieceInstanceIds()).toHaveLength(0) expect(context.nextPartState).toEqual(ActionPartChange.SAFE_CHANGE) expect(context.currentPartState).toEqual(ActionPartChange.NONE) diff --git a/packages/job-worker/src/blueprints/__tests__/context.test.ts b/packages/job-worker/src/blueprints/__tests__/context.test.ts index d067b4a194..307289c2df 100644 --- a/packages/job-worker/src/blueprints/__tests__/context.test.ts +++ b/packages/job-worker/src/blueprints/__tests__/context.test.ts @@ -2,7 +2,7 @@ import { getHash } from '@sofie-automation/corelib/dist/lib' import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' import { applyAndValidateOverrides } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' import { MockJobContext, setupDefaultJobEnvironment } from '../../__mocks__/context' -import { getShowStyleConfigRef, getStudioConfigRef } from '../config' +import { getShowStyleConfigRef, getStudioConfigRef } from '../configRefs' import { CommonContext } from '../context/CommonContext' import { StudioContext } from '../context/StudioContext' import { ShowStyleContext } from '../context/ShowStyleContext' diff --git a/packages/job-worker/src/blueprints/config.ts b/packages/job-worker/src/blueprints/config.ts index 4dc7584a63..77ae34389d 100644 --- a/packages/job-worker/src/blueprints/config.ts +++ b/packages/job-worker/src/blueprints/config.ts @@ -16,18 +16,6 @@ import { protectString } from '@sofie-automation/corelib/dist/protectedString' import { ProcessedShowStyleCompound, StudioCacheContext } from '../jobs' import { applyAndValidateOverrides } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' -/** - * This whole ConfigRef logic will need revisiting for a multi-studio context, to ensure that there are strict boundaries across who can give to access to what. - * Especially relevant for multi-user. - */ -// export namespace ConfigRef { -export function getStudioConfigRef(studioId: StudioId, configKey: string): string { - return '${studio.' + studioId + '.' + configKey + '}' -} -export function getShowStyleConfigRef(showStyleVariantId: ShowStyleVariantId, configKey: string): string { - return '${showStyle.' + showStyleVariantId + '.' + configKey + '}' -} - /** * Parse a string containing BlueprintConfigRefs (`${studio.studio0.myConfigField}`) to replace the refs with the current values * @param context The studio context this is being run in diff --git a/packages/job-worker/src/blueprints/configRefs.ts b/packages/job-worker/src/blueprints/configRefs.ts new file mode 100644 index 0000000000..1b77747dc8 --- /dev/null +++ b/packages/job-worker/src/blueprints/configRefs.ts @@ -0,0 +1,14 @@ +import { ShowStyleVariantId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' + +/** + * This whole ConfigRef logic will need revisiting for a multi-studio context, to ensure that there are strict boundaries across who can give to access to what. + * Especially relevant for multi-user. + */ +// export namespace ConfigRef { + +export function getStudioConfigRef(studioId: StudioId, configKey: string): string { + return '${studio.' + studioId + '.' + configKey + '}' +} +export function getShowStyleConfigRef(showStyleVariantId: ShowStyleVariantId, configKey: string): string { + return '${showStyle.' + showStyleVariantId + '.' + configKey + '}' +} diff --git a/packages/job-worker/src/blueprints/context/OnTimelineGenerateContext.ts b/packages/job-worker/src/blueprints/context/OnTimelineGenerateContext.ts index eeefcffea3..21a87eed19 100644 --- a/packages/job-worker/src/blueprints/context/OnTimelineGenerateContext.ts +++ b/packages/job-worker/src/blueprints/context/OnTimelineGenerateContext.ts @@ -27,7 +27,7 @@ export class OnTimelineGenerateContext extends RundownContext implements ITimeli readonly previousPartInstance: Readonly | undefined readonly abSessionsHelper: AbSessionHelper - readonly #pieceInstanceCache = new Map() + readonly #pieceInstanceCache = new Map>() constructor( studio: ReadonlyDeep, @@ -36,10 +36,10 @@ export class OnTimelineGenerateContext extends RundownContext implements ITimeli showStyleBlueprintConfig: ProcessedShowStyleConfig, playlist: ReadonlyDeep, rundown: ReadonlyDeep, - previousPartInstance: DBPartInstance | undefined, - currentPartInstance: DBPartInstance | undefined, - nextPartInstance: DBPartInstance | undefined, - pieceInstances: ResolvedPieceInstance[] + previousPartInstance: ReadonlyDeep | undefined, + currentPartInstance: ReadonlyDeep | undefined, + nextPartInstance: ReadonlyDeep | undefined, + pieceInstances: ReadonlyDeep ) { super( { diff --git a/packages/job-worker/src/blueprints/context/PartEventContext.ts b/packages/job-worker/src/blueprints/context/PartEventContext.ts index a66f04b4c4..880aa4b923 100644 --- a/packages/job-worker/src/blueprints/context/PartEventContext.ts +++ b/packages/job-worker/src/blueprints/context/PartEventContext.ts @@ -19,7 +19,7 @@ export class PartEventContext extends RundownContext implements IPartEventContex showStyleCompound: ReadonlyDeep, showStyleBlueprintConfig: ProcessedShowStyleConfig, rundown: ReadonlyDeep, - partInstance: DBPartInstance + partInstance: ReadonlyDeep ) { super( { diff --git a/packages/job-worker/src/blueprints/context/RundownActivationContext.ts b/packages/job-worker/src/blueprints/context/RundownActivationContext.ts index cbe4cf95fa..3e64bade68 100644 --- a/packages/job-worker/src/blueprints/context/RundownActivationContext.ts +++ b/packages/job-worker/src/blueprints/context/RundownActivationContext.ts @@ -3,17 +3,17 @@ import { PeripheralDeviceId } from '@sofie-automation/shared-lib/dist/core/model import { ReadonlyDeep } from 'type-fest' import { JobContext, ProcessedShowStyleCompound } from '../../jobs' import { executePeripheralDeviceAction, listPlayoutDevices } from '../../peripheralDevice' -import { CacheForPlayout } from '../../playout/cache' +import { PlayoutModel } from '../../playout/model/PlayoutModel' import { RundownEventContext } from './RundownEventContext' import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' export class RundownActivationContext extends RundownEventContext implements IRundownActivationContext { - private readonly _cache: CacheForPlayout + private readonly _playoutModel: PlayoutModel private readonly _context: JobContext constructor( context: JobContext, - cache: CacheForPlayout, + playoutModel: PlayoutModel, showStyleCompound: ReadonlyDeep, rundown: ReadonlyDeep ) { @@ -26,11 +26,11 @@ export class RundownActivationContext extends RundownEventContext implements IRu ) this._context = context - this._cache = cache + this._playoutModel = playoutModel } async listPlayoutDevices(): Promise { - return listPlayoutDevices(this._context, this._cache) + return listPlayoutDevices(this._context, this._playoutModel) } async executeTSRAction( diff --git a/packages/job-worker/src/blueprints/context/ShowStyleContext.ts b/packages/job-worker/src/blueprints/context/ShowStyleContext.ts index 48608befbd..7a243320b5 100644 --- a/packages/job-worker/src/blueprints/context/ShowStyleContext.ts +++ b/packages/job-worker/src/blueprints/context/ShowStyleContext.ts @@ -1,7 +1,8 @@ import { IOutputLayer, IShowStyleContext, ISourceLayer } from '@sofie-automation/blueprints-integration' import { ReadonlyDeep } from 'type-fest' import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' -import { getShowStyleConfigRef, ProcessedStudioConfig, ProcessedShowStyleConfig } from '../config' +import { ProcessedStudioConfig, ProcessedShowStyleConfig } from '../config' +import { getShowStyleConfigRef } from '../configRefs' import { ProcessedShowStyleCompound } from '../../jobs' import { ContextInfo } from './CommonContext' import { StudioContext } from './StudioContext' diff --git a/packages/job-worker/src/blueprints/context/StudioContext.ts b/packages/job-worker/src/blueprints/context/StudioContext.ts index 378a60971d..f1627c483b 100644 --- a/packages/job-worker/src/blueprints/context/StudioContext.ts +++ b/packages/job-worker/src/blueprints/context/StudioContext.ts @@ -3,7 +3,8 @@ import { ReadonlyDeep } from 'type-fest' import { DBStudio, MappingsExt } from '@sofie-automation/corelib/dist/dataModel/Studio' import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' import { StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { getStudioConfigRef, ProcessedStudioConfig } from '../config' +import { ProcessedStudioConfig } from '../config' +import { getStudioConfigRef } from '../configRefs' import { applyAndValidateOverrides } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' import { CommonContext, ContextInfo } from './CommonContext' diff --git a/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts b/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts index 8638795502..b5c712951d 100644 --- a/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts +++ b/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts @@ -1,11 +1,8 @@ -import { PieceInstanceId, RundownPlaylistActivationId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' -import { PieceInstance, wrapPieceToInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' +import { PieceInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' import { normalizeArrayToMap, omit } from '@sofie-automation/corelib/dist/lib' import { protectString, protectStringArray, unprotectStringArray } from '@sofie-automation/corelib/dist/protectedString' -import { DbCacheWriteCollection } from '../../cache/CacheCollection' -import { CacheForPlayout } from '../../playout/cache' -import { setupPieceInstanceInfiniteProperties } from '../../playout/pieces' +import { PlayoutPartInstanceModel } from '../../playout/model/PlayoutPartInstanceModel' import { ReadonlyDeep } from 'type-fest' import _ = require('underscore') import { ContextInfo } from './CommonContext' @@ -23,14 +20,12 @@ import { import { postProcessPieces, postProcessTimelineObjects } from '../postProcess' import { IBlueprintPieceObjectsSampleKeys, - IBlueprintMutatablePartSampleKeys, convertPieceInstanceToBlueprints, convertPartInstanceToBlueprints, } from './lib' import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' import { JobContext, ProcessedShowStyleCompound } from '../../jobs' -import { logChanges } from '../../cache/lib' import { PieceTimelineObjectsBlob, serializePieceTimelineObjectsBlob, @@ -41,22 +36,18 @@ export class SyncIngestUpdateToPartInstanceContext extends RundownUserContext implements ISyncIngestUpdateToPartInstanceContext { - private readonly _partInstanceCache: DbCacheWriteCollection - private readonly _pieceInstanceCache: DbCacheWriteCollection - private readonly _proposedPieceInstances: Map + private readonly _proposedPieceInstances: Map> - private partInstance: DBPartInstance | undefined + private partInstance: PlayoutPartInstanceModel | null constructor( private readonly _context: JobContext, contextInfo: ContextInfo, - private readonly playlistActivationId: RundownPlaylistActivationId, studio: ReadonlyDeep, showStyleCompound: ReadonlyDeep, rundown: ReadonlyDeep, - partInstance: DBPartInstance, - pieceInstances: PieceInstance[], - proposedPieceInstances: PieceInstance[], + partInstance: PlayoutPartInstanceModel, + proposedPieceInstances: ReadonlyDeep, private playStatus: 'previous' | 'current' | 'next' ) { super( @@ -70,35 +61,9 @@ export class SyncIngestUpdateToPartInstanceContext this.partInstance = partInstance - // Create temporary cache databases, so that we can update the main cache only once we know the operation has succeeded - this._pieceInstanceCache = DbCacheWriteCollection.createFromArray( - this._context, - this._context.directCollections.PieceInstances, - pieceInstances - ) - this._partInstanceCache = DbCacheWriteCollection.createFromArray( - this._context, - this._context.directCollections.PartInstances, - [partInstance] - ) - this._proposedPieceInstances = normalizeArrayToMap(proposedPieceInstances, '_id') } - applyChangesToCache(cache: CacheForPlayout): void { - if (this._partInstanceCache.isModified() || this._pieceInstanceCache.isModified()) { - this.logInfo(`Found ingest changes to apply to PartInstance`) - } else { - this.logInfo(`No ingest changes to apply to PartInstance`) - } - - const pieceChanges = this._pieceInstanceCache.updateOtherCacheWithData(cache.PieceInstances) - const partChanges = this._partInstanceCache.updateOtherCacheWithData(cache.PartInstances) - - logChanges('PartInstances', partChanges) - logChanges('PieceInstances', pieceChanges) - } - syncPieceInstance( pieceInstanceId: string, modifiedPiece?: Omit @@ -122,20 +87,19 @@ export class SyncIngestUpdateToPartInstanceContext }, ], this.showStyleCompound.blueprintId, - this.partInstance.rundownId, - this.partInstance.segmentId, - this.partInstance.part._id, + this.partInstance.PartInstance.rundownId, + this.partInstance.PartInstance.segmentId, + this.partInstance.PartInstance.part._id, this.playStatus === 'current' )[0] : proposedPieceInstance.piece - const existingPieceInstance = this._pieceInstanceCache.findOne(proposedPieceInstance._id) - const newPieceInstance: PieceInstance = { - ...existingPieceInstance, + const newPieceInstance: ReadonlyDeep = { ...proposedPieceInstance, piece: piece, } - this._pieceInstanceCache.replace(newPieceInstance) + this.partInstance.mergeOrInsertPieceInstance(newPieceInstance) + return convertPieceInstanceToBlueprints(newPieceInstance) } @@ -148,19 +112,15 @@ export class SyncIngestUpdateToPartInstanceContext this._context, [trimmedPiece], this.showStyleCompound.blueprintId, - this.partInstance.rundownId, - this.partInstance.segmentId, - this.partInstance.part._id, + this.partInstance.PartInstance.rundownId, + this.partInstance.PartInstance.segmentId, + this.partInstance.PartInstance.part._id, this.playStatus === 'current' )[0] - const newPieceInstance = wrapPieceToInstance(piece, this.playlistActivationId, this.partInstance._id) - - // Ensure the infinite-ness is setup correctly. We assume any piece inserted starts in the current part - setupPieceInstanceInfiniteProperties(newPieceInstance) - this._pieceInstanceCache.insert(newPieceInstance) + const newPieceInstance = this.partInstance.insertPlannedPiece(piece) - return convertPieceInstanceToBlueprints(newPieceInstance) + return convertPieceInstanceToBlueprints(newPieceInstance.PieceInstance) } updatePieceInstance(pieceInstanceId: string, updatedPiece: Partial): IBlueprintPieceInstance { // filter the submission to the allowed ones @@ -171,11 +131,11 @@ export class SyncIngestUpdateToPartInstanceContext if (!this.partInstance) throw new Error(`PartInstance has been removed`) - const pieceInstance = this._pieceInstanceCache.findOne(protectString(pieceInstanceId)) + const pieceInstance = this.partInstance.getPieceInstance(protectString(pieceInstanceId)) if (!pieceInstance) { throw new Error(`PieceInstance "${pieceInstanceId}" could not be found`) } - if (pieceInstance.partInstanceId !== this.partInstance._id) { + if (pieceInstance.PieceInstance.partInstanceId !== this.partInstance.PartInstance._id) { throw new Error(`PieceInstance "${pieceInstanceId}" does not belong to the current PartInstance`) } @@ -183,7 +143,7 @@ export class SyncIngestUpdateToPartInstanceContext if (trimmedPiece.content?.timelineObjects) { timelineObjectsString = serializePieceTimelineObjectsBlob( postProcessTimelineObjects( - pieceInstance.piece._id, + pieceInstance.PieceInstance.piece._id, this.showStyleCompound.blueprintId, trimmedPiece.content.timelineObjects ) @@ -192,87 +152,53 @@ export class SyncIngestUpdateToPartInstanceContext trimmedPiece.content = omit(trimmedPiece.content, 'timelineObjects') as WithTimeline } - this._pieceInstanceCache.updateOne(pieceInstance._id, (p) => { - if (timelineObjectsString !== undefined) p.piece.timelineObjectsString = timelineObjectsString - - return { - ...p, - piece: { - ...p.piece, - ...(trimmedPiece as any), // TODO: this needs to be more type safe - }, - } - }) - - const updatedPieceInstance = this._pieceInstanceCache.findOne(pieceInstance._id) - if (!updatedPieceInstance) { - throw new Error(`PieceInstance "${pieceInstanceId}" could not be found, after applying changes`) + pieceInstance.updatePieceProps(trimmedPiece as any) // TODO: this needs to be more type safe + if (timelineObjectsString !== undefined) { + pieceInstance.updatePieceProps({ + timelineObjectsString, + }) } - return convertPieceInstanceToBlueprints(updatedPieceInstance) + return convertPieceInstanceToBlueprints(pieceInstance.PieceInstance) } updatePartInstance(updatePart: Partial): IBlueprintPartInstance { - // filter the submission to the allowed ones - const trimmedProps: Partial = _.pick(updatePart, [ - ...IBlueprintMutatablePartSampleKeys, - ]) - if (Object.keys(trimmedProps).length === 0) { - throw new Error(`Cannot update PartInstance. Some valid properties must be defined`) - } - if (!this.partInstance) throw new Error(`PartInstance has been removed`) // for autoNext, the new expectedDuration cannot be shorter than the time a part has been on-air for - if (trimmedProps.expectedDuration && (trimmedProps.autoNext ?? this.partInstance.part.autoNext)) { - const onAir = this.partInstance.timings?.reportedStartedPlayback + if (updatePart.expectedDuration && (updatePart.autoNext ?? this.partInstance.PartInstance.part.autoNext)) { + const onAir = this.partInstance.PartInstance.timings?.reportedStartedPlayback const minTime = Date.now() - (onAir ?? 0) + EXPECTED_INGEST_TO_PLAYOUT_TIME - if (onAir && minTime > trimmedProps.expectedDuration) { - trimmedProps.expectedDuration = minTime + if (onAir && minTime > updatePart.expectedDuration) { + updatePart.expectedDuration = minTime } } - this._partInstanceCache.updateOne(this.partInstance._id, (p) => { - return { - ...p, - part: { - ...p.part, - ...trimmedProps, - }, - } - }) - - const updatedPartInstance = this._partInstanceCache.findOne(this.partInstance._id) - if (!updatedPartInstance) { - throw new Error(`PartInstance could not be found, after applying changes`) + if (!this.partInstance.updatePartProps(updatePart)) { + throw new Error(`Cannot update PartInstance. Some valid properties must be defined`) } - return convertPartInstanceToBlueprints(updatedPartInstance) + return convertPartInstanceToBlueprints(this.partInstance.PartInstance) } removePartInstance(): void { if (this.playStatus !== 'next') throw new Error(`Only the 'next' PartInstance can be removed`) - if (this.partInstance) { - const partInstanceId = this.partInstance._id - - this._partInstanceCache.remove(partInstanceId) - this._pieceInstanceCache.remove((piece) => piece.partInstanceId === partInstanceId) - } + this.partInstance = null } removePieceInstances(...pieceInstanceIds: string[]): string[] { if (!this.partInstance) throw new Error(`PartInstance has been removed`) - const partInstanceId = this.partInstance._id const rawPieceInstanceIdSet = new Set(protectStringArray(pieceInstanceIds)) - const pieceInstances = this._pieceInstanceCache.findAll( - (p) => p.partInstanceId === partInstanceId && rawPieceInstanceIdSet.has(p._id) + const pieceInstances = this.partInstance.PieceInstances.filter((p) => + rawPieceInstanceIdSet.has(p.PieceInstance._id) ) - const pieceInstanceIdsToRemove = pieceInstances.map((p) => p._id) + const pieceInstanceIdsToRemove = pieceInstances.map((p) => p.PieceInstance._id) - const pieceInstanceIdsSet = new Set(pieceInstanceIdsToRemove) - this._pieceInstanceCache.remove((p) => pieceInstanceIdsSet.has(p._id)) + for (const id of pieceInstanceIdsToRemove) { + this.partInstance.removePieceInstance(id) + } return unprotectStringArray(pieceInstanceIdsToRemove) } diff --git a/packages/job-worker/src/blueprints/context/adlibActions.ts b/packages/job-worker/src/blueprints/context/adlibActions.ts index 346666d2c0..6aa94a37b1 100644 --- a/packages/job-worker/src/blueprints/context/adlibActions.ts +++ b/packages/job-worker/src/blueprints/context/adlibActions.ts @@ -16,17 +16,12 @@ import { TSR, IBlueprintPlayoutDevice, } from '@sofie-automation/blueprints-integration' -import { - PartInstanceId, - PeripheralDeviceId, - RundownPlaylistActivationId, -} from '@sofie-automation/corelib/dist/dataModel/Ids' -import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' -import { UserError, UserErrorMessage } from '@sofie-automation/corelib/dist/error' +import { PartInstanceId, PeripheralDeviceId, PieceInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { assertNever, getRandomId, omit } from '@sofie-automation/corelib/dist/lib' import { logger } from '../../logging' import { ReadonlyDeep } from 'type-fest' -import { CacheForPlayout, getRundownIDsFromCache } from '../../playout/cache' +import { PlayoutModel } from '../../playout/model/PlayoutModel' +import { PlayoutPartInstanceModel } from '../../playout/model/PlayoutPartInstanceModel' import { UserContextInfo } from './CommonContext' import { ShowStyleUserContext } from './ShowStyleUserContext' import { WatchedPackagesHelper } from './watchedPackages' @@ -37,16 +32,14 @@ import { unprotectString, unprotectStringArray, } from '@sofie-automation/corelib/dist/protectedString' -import { setupPieceInstanceInfiniteProperties } from '../../playout/pieces' import { getResolvedPiecesForCurrentPartInstance } from '../../playout/resolvedPieces' import { JobContext, ProcessedShowStyleCompound } from '../../jobs' import { MongoQuery } from '../../db' -import { PieceInstance, wrapPieceToInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' +import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' import { innerFindLastPieceOnLayer, innerFindLastScriptedPieceOnLayer, - innerStartAdLibPiece, - innerStartQueuedAdLib, + insertQueuedPartWithPieces, innerStopPieces, } from '../../playout/adlibUtils' import { @@ -54,25 +47,25 @@ import { PieceTimelineObjectsBlob, serializePieceTimelineObjectsBlob, } from '@sofie-automation/corelib/dist/dataModel/Piece' -import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { convertPartInstanceToBlueprints, + convertPartToBlueprints, convertPieceInstanceToBlueprints, convertPieceToBlueprints, convertResolvedPieceInstanceToBlueprints, getMediaObjectDuration, - IBlueprintMutatablePartSampleKeys, IBlueprintPieceObjectsSampleKeys, } from './lib' import { postProcessPieces, postProcessTimelineObjects } from '../postProcess' import { isTooCloseToAutonext } from '../../playout/lib' -import { isPartPlayable } from '@sofie-automation/corelib/dist/dataModel/Part' +import { DBPart, isPartPlayable } from '@sofie-automation/corelib/dist/dataModel/Part' import { moveNextPart } from '../../playout/moveNextPart' import _ = require('underscore') import { ProcessedShowStyleConfig } from '../config' import { DatastorePersistenceMode } from '@sofie-automation/shared-lib/dist/core/model/TimelineDatastore' import { getDatastoreId } from '../../playout/datastore' import { executePeripheralDeviceAction, listPlayoutDevices } from '../../peripheralDevice' +import { PlayoutRundownModel } from '../../playout/model/PlayoutRundownModel' export enum ActionPartChange { NONE = 0, @@ -128,9 +121,8 @@ export class DatastoreActionExecutionContext /** Actions */ export class ActionExecutionContext extends ShowStyleUserContext implements IActionExecutionContext, IEventContext { private readonly _context: JobContext - private readonly _cache: CacheForPlayout - private readonly rundown: DBRundown - private readonly playlistActivationId: RundownPlaylistActivationId + private readonly _playoutModel: PlayoutModel + private readonly rundown: PlayoutRundownModel /** To be set by any mutation methods on this context. Indicates to core how extensive the changes are to the current partInstance */ public currentPartState: ActionPartChange = ActionPartChange.NONE @@ -142,28 +134,25 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct constructor( contextInfo: UserContextInfo, context: JobContext, - cache: CacheForPlayout, + playoutModel: PlayoutModel, showStyle: ReadonlyDeep, _showStyleBlueprintConfig: ProcessedShowStyleConfig, - rundown: DBRundown, + rundown: PlayoutRundownModel, watchedPackages: WatchedPackagesHelper ) { super(contextInfo, context, showStyle, watchedPackages) this._context = context - this._cache = cache + this._playoutModel = playoutModel this.rundown = rundown this.takeAfterExecute = false - - if (!this._cache.Playlist.doc.activationId) throw UserError.create(UserErrorMessage.InactiveRundown) - this.playlistActivationId = this._cache.Playlist.doc.activationId } - private _getPartInstanceId(part: 'current' | 'next'): PartInstanceId | undefined { + private _getPartInstance(part: 'current' | 'next'): PlayoutPartInstanceModel | null { switch (part) { case 'current': - return this._cache.Playlist.doc.currentPartInfo?.partInstanceId + return this._playoutModel.CurrentPartInstance case 'next': - return this._cache.Playlist.doc.nextPartInfo?.partInstanceId + return this._playoutModel.NextPartInstance default: assertNever(part) logger.warn(`Blueprint action requested unknown PartInstance "${part}"`) @@ -172,37 +161,22 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct } async getPartInstance(part: 'current' | 'next'): Promise { - const partInstanceId = this._getPartInstanceId(part) - if (!partInstanceId) { - return undefined - } + const partInstance = this._getPartInstance(part) - const partInstance = this._cache.PartInstances.findOne(partInstanceId) - return partInstance && convertPartInstanceToBlueprints(partInstance) + return partInstance ? convertPartInstanceToBlueprints(partInstance.PartInstance) : undefined } async getPieceInstances(part: 'current' | 'next'): Promise { - const partInstanceId = this._getPartInstanceId(part) - if (!partInstanceId) { - return [] - } - - const pieceInstances = this._cache.PieceInstances.findAll((p) => p.partInstanceId === partInstanceId) - return pieceInstances.map(convertPieceInstanceToBlueprints) + const partInstance = this._getPartInstance(part) + return partInstance?.PieceInstances?.map((p) => convertPieceInstanceToBlueprints(p.PieceInstance)) ?? [] } async getResolvedPieceInstances(part: 'current' | 'next'): Promise { - const partInstanceId = this._getPartInstanceId(part) - if (!partInstanceId) { - return [] - } - - const partInstance = this._cache.PartInstances.findOne(partInstanceId) + const partInstance = this._getPartInstance(part) if (!partInstance) { return [] } const resolvedInstances = getResolvedPiecesForCurrentPartInstance( this._context, - this._cache, this.showStyleCompound.sourceLayers, partInstance ) @@ -226,15 +200,15 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct } } - if (options?.excludeCurrentPart && this._cache.Playlist.doc.currentPartInfo) { - query['partInstanceId'] = { $ne: this._cache.Playlist.doc.currentPartInfo.partInstanceId } + if (options?.excludeCurrentPart && this._playoutModel.Playlist.currentPartInfo) { + query['partInstanceId'] = { $ne: this._playoutModel.Playlist.currentPartInfo.partInstanceId } } const sourceLayerId = Array.isArray(sourceLayerId0) ? sourceLayerId0 : [sourceLayerId0] const lastPieceInstance = await innerFindLastPieceOnLayer( this._context, - this._cache, + this._playoutModel, sourceLayerId, options?.originalOnly || false, query @@ -259,19 +233,18 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct } } - if (options?.excludeCurrentPart && this._cache.Playlist.doc.currentPartInfo) { - const currentPartInstance = this._cache.PartInstances.findOne( - this._cache.Playlist.doc.currentPartInfo.partInstanceId - ) - - if (currentPartInstance) { - query['startPartId'] = { $ne: currentPartInstance.part._id } - } + if (options?.excludeCurrentPart && this._playoutModel.CurrentPartInstance) { + query['startPartId'] = { $ne: this._playoutModel.CurrentPartInstance.PartInstance.part._id } } const sourceLayerId = Array.isArray(sourceLayerId0) ? sourceLayerId0 : [sourceLayerId0] - const lastPiece = await innerFindLastScriptedPieceOnLayer(this._context, this._cache, sourceLayerId, query) + const lastPiece = await innerFindLastScriptedPieceOnLayer( + this._context, + this._playoutModel, + sourceLayerId, + query + ) return lastPiece && convertPieceToBlueprints(lastPiece) } @@ -283,13 +256,13 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct throw new Error('Cannot find PartInstance from invalid PieceInstance') } - const cached = this._cache.PartInstances.findOne(partInstanceId) - if (cached) { - return convertPartInstanceToBlueprints(cached) + const loadedPartInstanceModel = this._playoutModel.getPartInstance(partInstanceId) + if (loadedPartInstanceModel) { + return convertPartInstanceToBlueprints(loadedPartInstanceModel.PartInstance) } - // It might be reset and so not in the cache - const rundownIds = getRundownIDsFromCache(this._cache) + // It might be reset and so not in the loaded model + const rundownIds = this._playoutModel.getRundownIds() const oldInstance = await this._context.directCollections.PartInstances.findOne({ _id: partInstanceId, rundownId: { $in: rundownIds }, @@ -308,25 +281,23 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct const pieceDB = await this._context.directCollections.Pieces.findOne({ _id: protectString(piece._id), - startRundownId: { $in: getRundownIDsFromCache(this._cache) }, + startRundownId: { $in: this._playoutModel.getRundownIds() }, }) if (!pieceDB) throw new Error(`Cannot find Piece ${piece._id}`) - return this._cache.Parts.findOne(pieceDB.startPartId) + const rundown = this._playoutModel.getRundown(pieceDB.startRundownId) + const segment = rundown?.getSegment(pieceDB.startSegmentId) + const part = segment?.getPart(pieceDB.startPartId) + return part ? convertPartToBlueprints(part) : undefined } async insertPiece(part: 'current' | 'next', rawPiece: IBlueprintPiece): Promise { - const partInstanceId = this._getPartInstanceId(part) - if (!partInstanceId) { - throw new Error('Cannot insert piece when no active part') - } - - const partInstance = this._cache.PartInstances.findOne(partInstanceId) + const partInstance = this._getPartInstance(part) if (!partInstance) { - throw new Error('Cannot queue part when no partInstance') + throw new Error('Cannot insert piece when no active part') } - const rundown = this._cache.Rundowns.findOne(partInstance.rundownId) + const rundown = this._playoutModel.getRundown(partInstance.PartInstance.rundownId) if (!rundown) { throw new Error('Failed to find rundown of partInstance') } @@ -337,16 +308,15 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct this._context, [trimmedPiece], this.showStyleCompound.blueprintId, - partInstance.rundownId, - partInstance.segmentId, - partInstance.part._id, + partInstance.PartInstance.rundownId, + partInstance.PartInstance.segmentId, + partInstance.PartInstance.part._id, part === 'current' )[0] piece._id = getRandomId() // Make id random, as postProcessPieces is too predictable (for ingest) - const newPieceInstance = wrapPieceToInstance(piece, this.playlistActivationId, partInstance._id) // Do the work - innerStartAdLibPiece(this._context, this._cache, rundown, partInstance, newPieceInstance) + const newPieceInstance = partInstance.insertAdlibbedPiece(piece, undefined) if (part === 'current') { this.currentPartState = Math.max(this.currentPartState, ActionPartChange.SAFE_CHANGE) @@ -354,7 +324,7 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct this.nextPartState = Math.max(this.nextPartState, ActionPartChange.SAFE_CHANGE) } - return convertPieceInstanceToBlueprints(newPieceInstance) + return convertPieceInstanceToBlueprints(newPieceInstance.PieceInstance) } async updatePieceInstance( pieceInstanceId: string, @@ -366,21 +336,23 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct throw new Error('Some valid properties must be defined') } - const pieceInstance = this._cache.PieceInstances.findOne(protectString(pieceInstanceId)) - if (!pieceInstance) { + const foundPieceInstance = this._playoutModel.findPieceInstance(protectString(pieceInstanceId)) + if (!foundPieceInstance) { throw new Error('PieceInstance could not be found') } - if (pieceInstance.infinite?.fromPreviousPart) { + const { pieceInstance } = foundPieceInstance + + if (pieceInstance.PieceInstance.infinite?.fromPreviousPart) { throw new Error('Cannot update an infinite piece that is continued from a previous part') } const updatesCurrentPart: ActionPartChange = - pieceInstance.partInstanceId === this._cache.Playlist.doc.currentPartInfo?.partInstanceId + pieceInstance.PieceInstance.partInstanceId === this._playoutModel.Playlist.currentPartInfo?.partInstanceId ? ActionPartChange.SAFE_CHANGE : ActionPartChange.NONE const updatesNextPart: ActionPartChange = - pieceInstance.partInstanceId === this._cache.Playlist.doc.nextPartInfo?.partInstanceId + pieceInstance.PieceInstance.partInstanceId === this._playoutModel.Playlist.nextPartInfo?.partInstanceId ? ActionPartChange.SAFE_CHANGE : ActionPartChange.NONE if (!updatesCurrentPart && !updatesNextPart) { @@ -391,7 +363,7 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct if (trimmedPiece.content?.timelineObjects) { timelineObjectsString = serializePieceTimelineObjectsBlob( postProcessTimelineObjects( - pieceInstance.piece._id, + pieceInstance.PieceInstance.piece._id, this.showStyleCompound.blueprintId, trimmedPiece.content.timelineObjects ) @@ -400,32 +372,18 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct trimmedPiece.content = omit(trimmedPiece.content, 'timelineObjects') as WithTimeline } - setupPieceInstanceInfiniteProperties(pieceInstance) + pieceInstance.updatePieceProps(trimmedPiece as any) // TODO: this needs to be more type safe + if (timelineObjectsString !== undefined) pieceInstance.updatePieceProps({ timelineObjectsString }) - this._cache.PieceInstances.updateOne(pieceInstance._id, (p) => { - if (timelineObjectsString !== undefined) p.piece.timelineObjectsString = timelineObjectsString - - return { - ...p, - piece: { - ...p.piece, - ...(trimmedPiece as any), // TODO: this needs to be more type safe - }, - } - }) + // setupPieceInstanceInfiniteProperties(pieceInstance) this.nextPartState = Math.max(this.nextPartState, updatesNextPart) this.currentPartState = Math.max(this.currentPartState, updatesCurrentPart) - const updatedPieceInstance = this._cache.PieceInstances.findOne(pieceInstance._id) - if (!updatedPieceInstance) throw new Error('PieceInstance disappeared!') - - return convertPieceInstanceToBlueprints(updatedPieceInstance) + return convertPieceInstanceToBlueprints(pieceInstance.PieceInstance) } async queuePart(rawPart: IBlueprintPart, rawPieces: IBlueprintPiece[]): Promise { - const currentPartInstance = this._cache.Playlist.doc.currentPartInfo - ? this._cache.PartInstances.findOne(this._cache.Playlist.doc.currentPartInfo.partInstanceId) - : undefined + const currentPartInstance = this._playoutModel.CurrentPartInstance if (!currentPartInstance) { throw new Error('Cannot queue part when no current partInstance') } @@ -437,7 +395,7 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct throw new Error('Cannot queue part when next part has already been modified') } - if (isTooCloseToAutonext(currentPartInstance, true)) { + if (isTooCloseToAutonext(currentPartInstance.PartInstance, true)) { throw new Error('Too close to an autonext to queue a part') } @@ -445,92 +403,62 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct throw new Error('New part must contain at least one piece') } - const newPartInstance: DBPartInstance = { + const newPart: Omit = { + ...rawPart, _id: getRandomId(), - rundownId: currentPartInstance.rundownId, - segmentId: currentPartInstance.segmentId, - playlistActivationId: this.playlistActivationId, - segmentPlayoutId: currentPartInstance.segmentPlayoutId, - takeCount: currentPartInstance.takeCount + 1, - rehearsal: currentPartInstance.rehearsal, - part: { - ...rawPart, - _id: getRandomId(), - rundownId: currentPartInstance.rundownId, - segmentId: currentPartInstance.segmentId, - _rank: 99999, // Corrected in innerStartQueuedAdLib - notes: [], - invalid: false, - invalidReason: undefined, - floated: false, - expectedDurationWithPreroll: undefined, // Filled in later - }, - } - - if (!isPartPlayable(newPartInstance.part)) { - throw new Error('Cannot queue a part which is not playable') + _rank: 99999, // Corrected in innerStartQueuedAdLib + notes: [], + invalid: false, + invalidReason: undefined, + floated: false, + expectedDurationWithPreroll: undefined, // Filled in later } const pieces = postProcessPieces( this._context, rawPieces, this.showStyleCompound.blueprintId, - currentPartInstance.rundownId, - newPartInstance.segmentId, - newPartInstance.part._id, + currentPartInstance.PartInstance.rundownId, + currentPartInstance.PartInstance.segmentId, + newPart._id, false ) - const newPieceInstances = pieces.map((piece) => - wrapPieceToInstance(piece, this.playlistActivationId, newPartInstance._id) - ) + + if (!isPartPlayable(newPart)) { + throw new Error('Cannot queue a part which is not playable') + } // Do the work - await innerStartQueuedAdLib( + const newPartInstance = await insertQueuedPartWithPieces( this._context, - this._cache, + this._playoutModel, this.rundown, currentPartInstance, - newPartInstance, - newPieceInstances + newPart, + pieces, + undefined ) this.nextPartState = ActionPartChange.SAFE_CHANGE - this.queuedPartInstanceId = newPartInstance._id + this.queuedPartInstanceId = newPartInstance.PartInstance._id - return convertPartInstanceToBlueprints(newPartInstance) + return convertPartInstanceToBlueprints(newPartInstance.PartInstance) } async moveNextPart(partDelta: number, segmentDelta: number): Promise { - await moveNextPart(this._context, this._cache, partDelta, segmentDelta) + await moveNextPart(this._context, this._playoutModel, partDelta, segmentDelta) } async updatePartInstance( part: 'current' | 'next', props: Partial ): Promise { - // filter the submission to the allowed ones - const trimmedProps: Partial = _.pick(props, IBlueprintMutatablePartSampleKeys) - if (Object.keys(trimmedProps).length === 0) { - throw new Error('Some valid properties must be defined') - } - - const partInstanceId = this._getPartInstanceId(part) - if (!partInstanceId) { - throw new Error('PartInstance could not be found') - } - - const partInstance = this._cache.PartInstances.findOne(partInstanceId) + const partInstance = this._getPartInstance(part) if (!partInstance) { throw new Error('PartInstance could not be found') } - this._cache.PartInstances.updateOne(partInstance._id, (p) => { - return { - ...p, - part: { - ...p.part, - ...trimmedProps, - }, - } - }) + if (!partInstance.updatePartProps(props)) { + throw new Error('Some valid properties must be defined') + } this.nextPartState = Math.max( this.nextPartState, @@ -541,12 +469,7 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct part === 'current' ? ActionPartChange.SAFE_CHANGE : ActionPartChange.NONE ) - const updatedPartInstance = this._cache.PartInstances.findOne(partInstance._id) - if (!updatedPartInstance) { - throw new Error('PartInstance could not be found, after applying changes') - } - - return convertPartInstanceToBlueprints(updatedPartInstance) + return convertPartInstanceToBlueprints(partInstance.PartInstance) } async stopPiecesOnLayers(sourceLayerIds: string[], timeOffset?: number | undefined): Promise { @@ -570,23 +493,24 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct ) } async removePieceInstances(_part: 'next', pieceInstanceIds: string[]): Promise { - const partInstanceId = this._cache.Playlist.doc.nextPartInfo?.partInstanceId // this._getPartInstanceId(part) - if (!partInstanceId) { + const partInstance = this._getPartInstance('next') + if (!partInstance) { throw new Error('Cannot remove pieceInstances when no selected partInstance') } - const rawPieceInstanceIdSet = new Set(protectStringArray(pieceInstanceIds)) - const pieceInstances = this._cache.PieceInstances.findAll( - (p) => p.partInstanceId === partInstanceId && rawPieceInstanceIdSet.has(p._id) - ) + const rawPieceInstanceIds = protectStringArray(pieceInstanceIds) - const pieceInstanceIdsToRemove = pieceInstances.map((p) => p._id) - const pieceInstanceIdsSet = new Set(pieceInstanceIdsToRemove) - this._cache.PieceInstances.remove((p) => p.partInstanceId === partInstanceId && pieceInstanceIdsSet.has(p._id)) + const removedPieceInstanceIds: PieceInstanceId[] = [] + + for (const id of rawPieceInstanceIds) { + if (partInstance.removePieceInstance(id)) { + removedPieceInstanceIds.push(id) + } + } this.nextPartState = Math.max(this.nextPartState, ActionPartChange.SAFE_CHANGE) - return unprotectStringArray(pieceInstanceIdsToRemove) + return unprotectStringArray(removedPieceInstanceIds) } async takeAfterExecuteAction(take: boolean): Promise { @@ -599,32 +523,29 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct if (time !== null && (time < getCurrentTime() || typeof time !== 'number')) throw new Error('Cannot block taking out of the current part, to a time in the past') - const partInstanceId = this._cache.Playlist.doc.currentPartInfo?.partInstanceId - if (!partInstanceId) { + const partInstance = this._playoutModel.CurrentPartInstance + if (!partInstance) { throw new Error('Cannot block take when there is no part playing') } - this._cache.PartInstances.updateOne(partInstanceId, (doc) => { - if (time) { - doc.blockTakeUntil = time - } else { - delete doc.blockTakeUntil - } - return doc - }) + + partInstance.blockTakeUntil(time) } - private _stopPiecesByRule(filter: (pieceInstance: PieceInstance) => boolean, timeOffset: number | undefined) { - if (!this._cache.Playlist.doc.currentPartInfo) { + private _stopPiecesByRule( + filter: (pieceInstance: ReadonlyDeep) => boolean, + timeOffset: number | undefined + ) { + if (!this._playoutModel.Playlist.currentPartInfo) { return [] } - const partInstance = this._cache.PartInstances.findOne(this._cache.Playlist.doc.currentPartInfo.partInstanceId) + const partInstance = this._playoutModel.CurrentPartInstance if (!partInstance) { throw new Error('Cannot stop pieceInstances when no current partInstance') } const stoppedIds = innerStopPieces( this._context, - this._cache, + this._playoutModel, this.showStyleCompound.sourceLayers, partInstance, filter, @@ -643,7 +564,7 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct } async listPlayoutDevices(): Promise { - return listPlayoutDevices(this._context, this._cache) + return listPlayoutDevices(this._context, this._playoutModel) } async executeTSRAction( @@ -659,7 +580,7 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct const id = protectString(`${studioId}_${key}`) const collection = this._context.directCollections.TimelineDatastores - this._cache.deferAfterSave(async () => { + this._playoutModel.deferAfterSave(async () => { await collection.replace({ _id: id, studioId: studioId, @@ -678,7 +599,7 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct const id = getDatastoreId(studioId, key) const collection = this._context.directCollections.TimelineDatastores - this._cache.deferAfterSave(async () => { + this._playoutModel.deferAfterSave(async () => { await collection.remove({ _id: id }) }) } diff --git a/packages/job-worker/src/blueprints/context/lib.ts b/packages/job-worker/src/blueprints/context/lib.ts index 656f4ea906..8d4935388c 100644 --- a/packages/job-worker/src/blueprints/context/lib.ts +++ b/packages/job-worker/src/blueprints/context/lib.ts @@ -14,6 +14,9 @@ import { clone, Complete, literal } from '@sofie-automation/corelib/dist/lib' import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' import { ReadonlyDeep } from 'type-fest' import { + ExpectedPackage, + ExpectedPlayoutItemGeneric, + HackPartMediaObjectSubscription, IBlueprintActionManifest, IBlueprintAdLibPieceDB, IBlueprintConfig, @@ -33,6 +36,7 @@ import { IBlueprintShowStyleVariant, IOutputLayer, ISourceLayer, + PieceAbSessionInfo, RundownPlaylistTiming, } from '@sofie-automation/blueprints-integration' import { JobContext, ProcessedShowStyleBase, ProcessedShowStyleVariant } from '../../jobs' @@ -101,7 +105,9 @@ export const IBlueprintMutatablePartSampleKeys = allKeysOfObject { +function convertPieceInstanceToBlueprintsInner( + pieceInstance: ReadonlyDeep +): Complete { const obj: Complete = { _id: unprotectString(pieceInstance._id), partInstanceId: unprotectString(pieceInstance.partInstanceId), @@ -128,7 +134,7 @@ function convertPieceInstanceToBlueprintsInner(pieceInstance: PieceInstance): Co * @param pieceInstance the PieceInstance to convert * @returns a cloned complete and clean IBlueprintPieceInstance */ -export function convertPieceInstanceToBlueprints(pieceInstance: PieceInstance): IBlueprintPieceInstance { +export function convertPieceInstanceToBlueprints(pieceInstance: ReadonlyDeep): IBlueprintPieceInstance { return convertPieceInstanceToBlueprintsInner(pieceInstance) } @@ -154,7 +160,7 @@ export function convertResolvedPieceInstanceToBlueprints( * @param partInstance the DBPartInstance to convert * @returns a cloned complete and clean IBlueprintPartInstance */ -export function convertPartInstanceToBlueprints(partInstance: DBPartInstance): IBlueprintPartInstance { +export function convertPartInstanceToBlueprints(partInstance: ReadonlyDeep): IBlueprintPartInstance { const obj: Complete = { _id: unprotectString(partInstance._id), segmentId: unprotectString(partInstance.segmentId), @@ -169,7 +175,7 @@ export function convertPartInstanceToBlueprints(partInstance: DBPartInstance): I return obj } -function convertPieceGenericToBlueprintsInner(piece: PieceGeneric): Complete { +function convertPieceGenericToBlueprintsInner(piece: ReadonlyDeep): Complete { const obj: Complete = { externalId: piece.externalId, name: piece.name, @@ -180,16 +186,16 @@ function convertPieceGenericToBlueprintsInner(piece: PieceGeneric): Complete(piece.expectedPlayoutItems), + tags: clone(piece.tags), allowDirectPlay: clone(piece.allowDirectPlay), - expectedPackages: clone(piece.expectedPackages), + expectedPackages: clone(piece.expectedPackages), hasSideEffects: piece.hasSideEffects, content: { ...clone(piece.content), timelineObjects: deserializePieceTimelineObjectsBlob(piece.timelineObjectsString), }, - abSessions: clone(piece.abSessions), + abSessions: clone(piece.abSessions), } return obj @@ -200,7 +206,7 @@ function convertPieceGenericToBlueprintsInner(piece: PieceGeneric): Complete): IBlueprintPieceDB { const obj: Complete = { ...convertPieceGenericToBlueprintsInner(piece), _id: unprotectString(piece._id), @@ -219,7 +225,7 @@ export function convertPieceToBlueprints(piece: PieceInstancePiece): IBlueprintP * @param part the Part to convert * @returns a cloned complete and clean IBlueprintPartDB */ -export function convertPartToBlueprints(part: DBPart): IBlueprintPartDB { +export function convertPartToBlueprints(part: ReadonlyDeep): IBlueprintPartDB { const obj: Complete = { _id: unprotectString(part._id), segmentId: unprotectString(part.segmentId), @@ -241,12 +247,14 @@ export function convertPartToBlueprints(part: DBPart): IBlueprintPartDB { budgetDuration: part.budgetDuration, holdMode: part.holdMode, shouldNotifyCurrentPlayingPart: part.shouldNotifyCurrentPlayingPart, - classes: clone(part.classes), - classesForNext: clone(part.classesForNext), + classes: clone(part.classes), + classesForNext: clone(part.classesForNext), displayDurationGroup: part.displayDurationGroup, displayDuration: part.displayDuration, identifier: part.identifier, - hackListenToMediaObjectUpdates: part.hackListenToMediaObjectUpdates, + hackListenToMediaObjectUpdates: clone( + part.hackListenToMediaObjectUpdates + ), } return obj diff --git a/packages/job-worker/src/blueprints/context/watchedPackages.ts b/packages/job-worker/src/blueprints/context/watchedPackages.ts index b6a3d02ac8..4b0b62f717 100644 --- a/packages/job-worker/src/blueprints/context/watchedPackages.ts +++ b/packages/job-worker/src/blueprints/context/watchedPackages.ts @@ -1,4 +1,3 @@ -import { DbCacheReadCollection } from '../../cache/CacheCollection' import { ExpectedPackageDB, ExpectedPackageDBBase } from '@sofie-automation/corelib/dist/dataModel/ExpectedPackages' import { PackageInfoDB } from '@sofie-automation/corelib/dist/dataModel/PackageInfos' import { JobContext } from '../../jobs' @@ -8,32 +7,22 @@ import { PackageInfo } from '@sofie-automation/blueprints-integration' import { unprotectObjectArray } from '@sofie-automation/corelib/dist/protectedString' import { CacheForIngest } from '../../ingest/cache' import { ReadOnlyCache } from '../../cache/CacheBase' +import { ReadonlyDeep } from 'type-fest' /** * This is a helper class to simplify exposing packageInfo to various places in the blueprints */ export class WatchedPackagesHelper { private constructor( - private readonly packages: DbCacheReadCollection, - private readonly packageInfos: DbCacheReadCollection + private readonly packages: ReadonlyDeep, + private readonly packageInfos: ReadonlyDeep ) {} /** * Create a helper with no packages. This should be used where the api is in place, but the update flow hasnt been implemented yet so we don't want to expose any data */ - static empty(context: JobContext): WatchedPackagesHelper { - const watchedPackages = DbCacheReadCollection.createFromArray( - context, - context.directCollections.ExpectedPackages, - [] - ) - const watchedPackageInfos = DbCacheReadCollection.createFromArray( - context, - context.directCollections.PackageInfos, - [] - ) - - return new WatchedPackagesHelper(watchedPackages, watchedPackageInfos) + static empty(_context: JobContext): WatchedPackagesHelper { + return new WatchedPackagesHelper([], []) } /** @@ -47,22 +36,14 @@ export class WatchedPackagesHelper { filter: FilterQuery> ): Promise { // Load all the packages and the infos that are watched - const watchedPackages = await DbCacheReadCollection.createFromDatabase( - context, - context.directCollections.ExpectedPackages, - { - ...filter, - studioId: studioId, - } as any - ) // TODO: don't use any here - const watchedPackageInfos = await DbCacheReadCollection.createFromDatabase( - context, - context.directCollections.PackageInfos, - { - studioId: studioId, - packageId: { $in: watchedPackages.findAll(null).map((p) => p._id) }, - } - ) + const watchedPackages = await context.directCollections.ExpectedPackages.findFetch({ + ...filter, + studioId: studioId, + } as any) // TODO: don't use any here + const watchedPackageInfos = await context.directCollections.PackageInfos.findFetch({ + studioId: studioId, + packageId: { $in: watchedPackages.map((p) => p._id) }, + }) return new WatchedPackagesHelper(watchedPackages, watchedPackageInfos) } @@ -80,21 +61,12 @@ export class WatchedPackagesHelper { const packages = cache.ExpectedPackages.findAll(func ?? null) // Load all the packages and the infos that are watched - const watchedPackages = DbCacheReadCollection.createFromArray( - context, - context.directCollections.ExpectedPackages, - packages - ) - const watchedPackageInfos = await DbCacheReadCollection.createFromDatabase( - context, - context.directCollections.PackageInfos, - { - studioId: context.studio._id, - packageId: { $in: packages.map((p) => p._id) }, - } - ) + const watchedPackageInfos = await context.directCollections.PackageInfos.findFetch({ + studioId: context.studio._id, + packageId: { $in: packages.map((p) => p._id) }, + }) - return new WatchedPackagesHelper(watchedPackages, watchedPackageInfos) + return new WatchedPackagesHelper(packages, watchedPackageInfos) } /** @@ -102,27 +74,19 @@ export class WatchedPackagesHelper { * This is useful so that all the data for a rundown can be loaded at the start of an ingest operation, and then subsets can be taken for particular blueprint methods without needing to do more db operations. * @param func A filter to check if each package should be included */ - filter(context: JobContext, func: (pkg: ExpectedPackageDB) => boolean): WatchedPackagesHelper { - const watchedPackages = DbCacheReadCollection.createFromArray( - context, - context.directCollections.ExpectedPackages, - this.packages.findAll(func) - ) + filter(_context: JobContext, func: (pkg: ReadonlyDeep) => boolean): WatchedPackagesHelper { + const watchedPackages = this.packages.filter(func) - const newPackageIds = new Set(watchedPackages.findAll(null).map((p) => p._id)) - const watchedPackageInfos = DbCacheReadCollection.createFromArray( - context, - context.directCollections.PackageInfos, - this.packageInfos.findAll((info) => newPackageIds.has(info.packageId)) - ) + const newPackageIds = new Set(watchedPackages.map((p) => p._id)) + const watchedPackageInfos = this.packageInfos.filter((info) => newPackageIds.has(info.packageId)) return new WatchedPackagesHelper(watchedPackages, watchedPackageInfos) } getPackageInfo(packageId: string): Readonly> { - const pkg = this.packages.findOne((pkg) => pkg.blueprintPackageId === packageId) + const pkg = this.packages.find((pkg) => pkg.blueprintPackageId === packageId) if (pkg) { - const info = this.packageInfos.findAll((p) => p.packageId === pkg._id) + const info = this.packageInfos.filter((p) => p.packageId === pkg._id) return unprotectObjectArray(info) } else { return [] diff --git a/packages/job-worker/src/cache/CacheBase.ts b/packages/job-worker/src/cache/CacheBase.ts index 216091f6fe..575eacfb67 100644 --- a/packages/job-worker/src/cache/CacheBase.ts +++ b/packages/job-worker/src/cache/CacheBase.ts @@ -7,6 +7,7 @@ import { IS_PRODUCTION } from '../environment' import { logger } from '../logging' import { sleep } from '@sofie-automation/corelib/dist/lib' import { JobContext } from '../jobs' +import { BaseModel } from '../modelBase' type DeferredFunction = (cache: Cache) => void | Promise type DeferredAfterSaveFunction> = (cache: ReadOnlyCache) => void | Promise @@ -30,7 +31,7 @@ export type ReadOnlyCache> = Omit< > /** This cache contains data relevant in a studio */ -export abstract class ReadOnlyCacheBase> { +export abstract class ReadOnlyCacheBase> implements BaseModel { protected _deferredBeforeSaveFunctions: DeferredFunction[] = [] protected _deferredAfterSaveFunctions: DeferredAfterSaveFunction[] = [] @@ -76,11 +77,9 @@ export abstract class ReadOnlyCacheBase> { this._deferredBeforeSaveFunctions.length = 0 // clear the array const { highPrioDBs, lowPrioDBs } = this.getAllCollections() - if (highPrioDBs.length) { - const anyThingChanged = anythingChanged( - sumChanges(...(await Promise.all(highPrioDBs.map(async (db) => db.updateDatabaseWithData())))) - ) + const allSaves = [...highPrioDBs.map(async (db) => db.updateDatabaseWithData())] + const anyThingChanged = anythingChanged(sumChanges(...(await Promise.all(allSaves)))) if (anyThingChanged && !process.env.JEST_WORKER_ID) { // Wait a little bit before saving the rest. // The idea is that this allows for the high priority publications to update (such as the Timeline), @@ -181,29 +180,6 @@ export abstract class ReadOnlyCacheBase> { if (span) span.end() } - - hasChanges(): boolean { - const { allDBs } = this.getAllCollections() - - if (this._deferredBeforeSaveFunctions.length > 0) { - logger.silly(`hasChanges: _deferredBeforeSaveFunctions.length=${this._deferredBeforeSaveFunctions.length}`) - return true - } - - if (this._deferredAfterSaveFunctions.length > 0) { - logger.silly(`hasChanges: _deferredAfterSaveFunctions.length=${this._deferredAfterSaveFunctions.length}`) - return true - } - - for (const db of allDBs) { - if (db.isModified()) { - logger.silly(`hasChanges: db=${db.name}`) - return true - } - } - - return false - } } export interface ICacheBase { /** Defer provided function (it will be run just before cache.saveAllToDatabase() ) */ diff --git a/packages/job-worker/src/cache/CacheCollection.ts b/packages/job-worker/src/cache/CacheCollection.ts index 83bf966fef..44a4f59ddf 100644 --- a/packages/job-worker/src/cache/CacheCollection.ts +++ b/packages/job-worker/src/cache/CacheCollection.ts @@ -216,7 +216,7 @@ export class DbCacheWriteCollection }> public static createFromArray }>( context: JobContext, collection: ICollection, - docs: TDoc[] + docs: TDoc[] | ReadonlyDeep ): DbCacheWriteCollection { const col = new DbCacheWriteCollection(context, collection) col.fillWithDataFromArray(docs as any) diff --git a/packages/job-worker/src/cache/__tests__/DatabaseCaches.test.ts b/packages/job-worker/src/cache/__tests__/DatabaseCaches.test.ts index 26bd7389d1..e0f0eba6bc 100644 --- a/packages/job-worker/src/cache/__tests__/DatabaseCaches.test.ts +++ b/packages/job-worker/src/cache/__tests__/DatabaseCaches.test.ts @@ -4,7 +4,6 @@ import { sleep } from '@sofie-automation/corelib/dist/lib' import { protectString } from '@sofie-automation/corelib/dist/protectedString' import { getRundownId } from '../../ingest/lib' import { CacheForIngest } from '../../ingest/cache' -import { CacheForStudio } from '../../studio/cache' import { MockJobContext, setupDefaultJobEnvironment } from '../../__mocks__/context' describe('DatabaseCaches', () => { @@ -292,31 +291,31 @@ describe('DatabaseCaches', () => { }).toThrow(/failed .+ assertion,.+ was modified/gi) } - { - const cache = await CacheForStudio.create(context) + // { + // const cache = await loadStudioPlayoutModel(context) - // Insert a document: - cache.deferBeforeSave(() => { - // - }) + // // Insert a document: + // cache.deferBeforeSave(() => { + // // + // }) - expect(() => { - cache.assertNoChanges() - }).toThrow(/failed .+ assertion,.+ deferred/gi) - } + // expect(() => { + // cache.assertNoChanges() + // }).toThrow(/failed .+ assertion,.+ deferred/gi) + // } - { - const cache = await CacheForStudio.create(context) + // { + // const cache = await loadStudioPlayoutModel(context) - // Insert a document: - cache.deferAfterSave(() => { - // - }) + // // Insert a document: + // cache.deferAfterSave(() => { + // // + // }) - expect(() => { - cache.assertNoChanges() - }).toThrow(/failed .+ assertion,.+ after-save deferred/gi) - } + // expect(() => { + // cache.assertNoChanges() + // }).toThrow(/failed .+ assertion,.+ after-save deferred/gi) + // } } finally { await lock.release() } diff --git a/packages/job-worker/src/cache/lib.ts b/packages/job-worker/src/cache/lib.ts index 2eed21ba3e..712b3432ab 100644 --- a/packages/job-worker/src/cache/lib.ts +++ b/packages/job-worker/src/cache/lib.ts @@ -1,17 +1,10 @@ -import { DbCacheReadCollection, DbCacheWriteCollection, SelectorFunction } from './CacheCollection' +import { DbCacheWriteCollection, SelectorFunction } from './CacheCollection' import { DbCacheWriteObject, DbCacheWriteOptionalObject } from './CacheObject' import { ProtectedString } from '@sofie-automation/corelib/dist/protectedString' import { logger } from '../logging' import { ChangedIds, SaveIntoDbHooks, saveIntoBase } from '../db/changes' import { JobContext } from '../jobs' -/** - * Check if an object is a DbCacheReadCollection - * @param o object to check - */ -export function isDbCacheReadCollection(o: unknown): o is DbCacheReadCollection { - return !!(o && typeof o === 'object' && 'fillWithDataFromDatabase' in o) -} /** * Check if an object is a writable db object. (DbCacheWriteCollection, DbCacheWriteObject or DbCacheWriteOptionalObject) * @param o object to check diff --git a/packages/job-worker/src/cache/utils.ts b/packages/job-worker/src/cache/utils.ts deleted file mode 100644 index 497d992c63..0000000000 --- a/packages/job-worker/src/cache/utils.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' -import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' -import { sortSegmentsInRundowns, sortPartsInSortedSegments } from '@sofie-automation/corelib/dist/playout/playlist' -import { DbCacheReadCollection } from './CacheCollection' -import { ReadonlyDeep } from 'type-fest' -import { RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' - -export function getOrderedSegmentsAndPartsFromCacheCollections( - partsCache: DbCacheReadCollection, - segmentsCache: DbCacheReadCollection, - rundownIdsInOrder: ReadonlyDeep -): { segments: DBSegment[]; parts: DBPart[] } { - const segments = sortSegmentsInRundowns( - segmentsCache.findAll(null, { - sort: { - rundownId: 1, - _rank: 1, - }, - }), - rundownIdsInOrder - ) - - const parts = sortPartsInSortedSegments( - partsCache.findAll(null, { - sort: { - rundownId: 1, - _rank: 1, - }, - }), - segments - ) - - return { - segments: segments, - parts: parts, - } -} diff --git a/packages/job-worker/src/ingest/__tests__/ingest.test.ts b/packages/job-worker/src/ingest/__tests__/ingest.test.ts index d46de0e0f1..aac8492c3e 100644 --- a/packages/job-worker/src/ingest/__tests__/ingest.test.ts +++ b/packages/job-worker/src/ingest/__tests__/ingest.test.ts @@ -17,7 +17,7 @@ import { import { DBRundown, Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { DBSegment, SegmentOrphanedReason } from '@sofie-automation/corelib/dist/dataModel/Segment' -import { getRandomId, getRandomString, literal } from '@sofie-automation/corelib/dist/lib' +import { getRandomString, literal } from '@sofie-automation/corelib/dist/lib' import { sortPartsInSortedSegments, sortSegmentsInRundowns } from '@sofie-automation/corelib/dist/playout/playlist' import { MongoQuery } from '../../db' import { MockJobContext, setupDefaultJobEnvironment } from '../../__mocks__/context' @@ -40,13 +40,13 @@ import { handleActivateRundownPlaylist } from '../../playout/activePlaylistJobs' import { PartInstanceId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { getSelectedPartInstances } from '../../playout/__tests__/lib' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' -import { runJobWithPlayoutCache } from '../../playout/lock' -import { getSelectedPartInstancesFromCache } from '../../playout/cache' +import { runJobWithPlayoutModel } from '../../playout/lock' import { protectString } from '@sofie-automation/corelib/dist/protectedString' -import { innerStartQueuedAdLib } from '../../playout/adlibUtils' +import { insertQueuedPartWithPieces } from '../../playout/adlibUtils' import { IngestJobs, RemoveOrphanedSegmentsProps } from '@sofie-automation/corelib/dist/worker/ingest' import { removeRundownPlaylistFromDb } from './lib' import { UserErrorMessage } from '@sofie-automation/corelib/dist/error' +import { PlayoutPartInstanceModel } from '../../playout/model/PlayoutPartInstanceModel' require('../../peripheralDevice.ts') // include in order to create the Meteor methods needed @@ -1998,48 +1998,43 @@ describe('Test ingest actions for rundowns and segments', () => { fromPartInstanceId: null, }) - const doQueuePart = async (partInstanceId: PartInstanceId): Promise => - runJobWithPlayoutCache( + const doQueuePart = async (): Promise => + runJobWithPlayoutModel( context, { playlistId: rundown.playlistId, }, null, async (cache) => { - const rundown0 = cache.Rundowns.findOne(() => true) as DBRundown + const rundown0 = cache.Rundowns[0] expect(rundown0).toBeTruthy() - const currentPartInstance = getSelectedPartInstancesFromCache(cache) - .currentPartInstance as DBPartInstance + const currentPartInstance = cache.CurrentPartInstance as PlayoutPartInstanceModel expect(currentPartInstance).toBeTruthy() - const newPartInstance: DBPartInstance = { - _id: partInstanceId, - rundownId: rundown0._id, - segmentId: currentPartInstance.segmentId, - playlistActivationId: currentPartInstance.playlistActivationId, - segmentPlayoutId: currentPartInstance.segmentPlayoutId, - takeCount: currentPartInstance.takeCount + 1, - rehearsal: true, - part: { - _id: protectString(`${partInstanceId}_part`), + // Simulate a queued part + const newPartInstance = await insertQueuedPartWithPieces( + context, + cache, + rundown0, + currentPartInstance, + { + _id: protectString(`after_${currentPartInstance.PartInstance._id}_part`), _rank: 0, - rundownId: rundown0._id, - segmentId: currentPartInstance.segmentId, - externalId: `${partInstanceId}_externalId`, + externalId: `after_${currentPartInstance.PartInstance._id}_externalId`, title: 'New part', expectedDurationWithPreroll: undefined, }, - } + [], + undefined + ) - // Simulate a queued part - await innerStartQueuedAdLib(context, cache, rundown0, currentPartInstance, newPartInstance, []) + return newPartInstance.PartInstance._id } ) // Queue and take an adlib-part - const partInstanceId0: PartInstanceId = getRandomId() - await doQueuePart(partInstanceId0) + const partInstanceId0 = await doQueuePart() { const playlist = (await context.mockCollections.RundownPlaylists.findOne( rundown.playlistId @@ -2122,8 +2117,7 @@ describe('Test ingest actions for rundowns and segments', () => { } // Queue and take another adlib-part - const partInstanceId1: PartInstanceId = getRandomId() - await doQueuePart(partInstanceId1) + const partInstanceId1 = await doQueuePart() { const playlist = (await context.mockCollections.RundownPlaylists.findOne( rundown.playlistId diff --git a/packages/job-worker/src/ingest/__tests__/updateNext.test.ts b/packages/job-worker/src/ingest/__tests__/updateNext.test.ts index 300eb75457..660207799f 100644 --- a/packages/job-worker/src/ingest/__tests__/updateNext.test.ts +++ b/packages/job-worker/src/ingest/__tests__/updateNext.test.ts @@ -7,7 +7,7 @@ import { protectString } from '@sofie-automation/corelib/dist/protectedString' import { saveIntoDb } from '../../db/changes' import { ensureNextPartIsValid as ensureNextPartIsValidRaw } from '../updateNext' import { MockJobContext, setupDefaultJobEnvironment } from '../../__mocks__/context' -import { runJobWithPlayoutCache } from '../../playout/lock' +import { runJobWithPlayoutModel } from '../../playout/lock' jest.mock('../../playout/setNext') import { setNextPart } from '../../playout/setNext' @@ -344,7 +344,7 @@ describe('ensureNextPartIsValid', () => { }) } async function ensureNextPartIsValid() { - await runJobWithPlayoutCache(context, { playlistId: rundownPlaylistId }, null, async (cache) => + await runJobWithPlayoutModel(context, { playlistId: rundownPlaylistId }, null, async (cache) => ensureNextPartIsValidRaw(context, cache) ) } diff --git a/packages/job-worker/src/ingest/commit.ts b/packages/job-worker/src/ingest/commit.ts index 7ef3952b5b..b9410f533d 100644 --- a/packages/job-worker/src/ingest/commit.ts +++ b/packages/job-worker/src/ingest/commit.ts @@ -7,9 +7,9 @@ import { } from '@sofie-automation/corelib/dist/dataModel/Ids' import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { unprotectString, protectString } from '@sofie-automation/corelib/dist/protectedString' -import { DbCacheReadCollection } from '../cache/CacheCollection' import { logger } from '../logging' -import { CacheForPlayout } from '../playout/cache' +import { PlayoutModel } from '../playout/model/PlayoutModel' +import { PlayoutRundownModel } from '../playout/model/PlayoutRundownModel' import { isTooCloseToAutonext } from '../playout/lib' import { allowedToMoveRundownOutOfPlaylist, updatePartInstanceRanks } from '../rundown' import { @@ -23,10 +23,10 @@ import { getRundown } from './lib' import { JobContext } from '../jobs' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' -import { runJobWithPlaylistLock, runWithPlaylistCache } from '../playout/lock' +import { runJobWithPlaylistLock, runWithPlayoutModel } from '../playout/lock' import { removeSegmentContents } from './cleanup' import { CommitIngestData } from './lock' -import { normalizeArrayToMap } from '@sofie-automation/corelib/dist/lib' +import { groupByToMap, groupByToMapFunc, normalizeArrayToMap } from '@sofie-automation/corelib/dist/lib' import { PlaylistLock } from '../jobs/lock' import { syncChangesToPartInstances } from './syncChangesToPartInstance' import { ensureNextPartIsValid } from './updateNext' @@ -41,8 +41,11 @@ import { orphanedHiddenSegmentPropertiesToPreserve, SegmentOrphanedReason, } from '@sofie-automation/corelib/dist/dataModel/Segment' -import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import { UserError, UserErrorMessage } from '@sofie-automation/corelib/dist/error' +import { PlayoutRundownModelImpl } from '../playout/model/implementation/PlayoutRundownModelImpl' +import { PlayoutSegmentModelImpl } from '../playout/model/implementation/PlayoutSegmentModelImpl' +import { ReadOnlyCache } from '../cache/CacheBase' +import { createPlayoutCachefromIngestCache } from '../playout/model/implementation/LoadPlayoutModel' export type BeforePartMapItem = { id: PartId; rank: number } export type BeforePartMap = ReadonlyMap> @@ -195,13 +198,17 @@ export async function CommitIngestOperation( await context.directCollections.RundownPlaylists.replace(newPlaylist) // ensure instances are updated for rundown changes await updatePartInstancesSegmentIds(context, ingestCache, data.renamedSegments) - await updatePartInstancesBasicProperties(context, ingestCache.Parts, ingestCache.RundownId, newPlaylist) + await updatePartInstancesBasicProperties( + context, + hackConvertIngestCacheToRundownWithSegments(ingestCache), + newPlaylist + ) // Update the playout to use the updated rundown await updatePartInstanceRanks(context, ingestCache, data.changedSegmentIds, beforePartMap) // Create the full playout cache, now we have the rundowns and playlist updated - const playoutCache = await CacheForPlayout.fromIngest( + const playoutCache = await createPlayoutCachefromIngestCache( context, playlistLock, newPlaylist, @@ -337,24 +344,38 @@ async function updatePartInstancesSegmentIds( } } +export function hackConvertIngestCacheToRundownWithSegments(cache: ReadOnlyCache): PlayoutRundownModel { + const rundown = cache.Rundown.doc + if (!rundown) { + throw new Error(`Rundown "${cache.RundownId}" ("${cache.RundownExternalId}") not found`) + } + + const groupedParts = groupByToMap(cache.Parts.findAll(null), 'segmentId') + const segmentsWithParts = cache.Segments.findAll(null).map( + (segment) => new PlayoutSegmentModelImpl(segment, groupedParts.get(segment._id) ?? []) + ) + const groupedSegmentsWithParts = groupByToMapFunc(segmentsWithParts, (s) => s.Segment.rundownId) + + return new PlayoutRundownModelImpl(rundown, groupedSegmentsWithParts.get(rundown._id) ?? [], []) +} + /** * Ensure some 'basic' PartInstances properties are in sync with their parts */ async function updatePartInstancesBasicProperties( context: JobContext, - partCache: DbCacheReadCollection, - rundownId: RundownId, + rundownModel: PlayoutRundownModel, playlist: ReadonlyDeep ) { // Get a list of all the Parts that are known to exist - const knownPartIds = partCache.findAll(null).map((p) => p._id) + const knownPartIds = rundownModel.getAllPartIds() // Find all the partInstances which are not reset, and are not orphaned, but their Part no longer exist (ie they should be orphaned) const partInstancesToOrphan: Array> = await context.directCollections.PartInstances.findFetch( { reset: { $ne: true }, - rundownId: rundownId, + rundownId: rundownModel.Rundown._id, orphaned: { $exists: false }, 'part._id': { $nin: knownPartIds }, }, @@ -472,9 +493,9 @@ export async function updatePlayoutAfterChangingRundownInPlaylist( insertedRundown: ReadonlyDeep | null ): Promise { // ensure the 'old' playout is updated to remove any references to the rundown - await runWithPlaylistCache(context, playlist, playlistLock, null, async (playoutCache) => { - if (playoutCache.Rundowns.documents.size === 0) { - if (playoutCache.Playlist.doc.activationId) + await runWithPlayoutModel(context, playlist, playlistLock, null, async (playoutCache) => { + if (playoutCache.Rundowns.length === 0) { + if (playoutCache.Playlist.activationId) throw new Error(`RundownPlaylist "${playoutCache.PlaylistId}" has no contents but is active...`) // Remove an empty playlist @@ -487,18 +508,16 @@ export async function updatePlayoutAfterChangingRundownInPlaylist( // Ensure playout is in sync if (insertedRundown) { - // If a rundown has changes, ensure instances are updated - await updatePartInstancesBasicProperties( - context, - playoutCache.Parts, - insertedRundown._id, - playoutCache.Playlist.doc - ) + const rundownModel = playoutCache.getRundown(insertedRundown._id) + if (rundownModel) { + // If a rundown has changes, ensure instances are updated + await updatePartInstancesBasicProperties(context, rundownModel, playoutCache.Playlist) + } } await ensureNextPartIsValid(context, playoutCache) - if (playoutCache.Playlist.doc.activationId) { + if (playoutCache.Playlist.activationId) { triggerUpdateTimelineAfterIngestData(context, playoutCache.PlaylistId) } }) @@ -816,25 +835,18 @@ async function preserveUnsyncedPlayingSegmentContents( } } -async function validateScratchpad(_context: JobContext, cache: CacheForPlayout) { - const scratchpadSegments = cache.Segments.findAll((s) => s.orphaned === SegmentOrphanedReason.SCRATCHPAD) +async function validateScratchpad(_context: JobContext, playoutModel: PlayoutModel) { + for (const rundown of playoutModel.Rundowns) { + const scratchpadSegment = rundown.getScratchpadSegment() - // Note: this assumes there will be up to one per rundown - for (const segment of scratchpadSegments) { - // Ensure the _rank is just before the real content - const otherSegmentsInRundown = cache.Segments.findAll( - (s) => s.rundownId === segment.rundownId && s.orphaned !== SegmentOrphanedReason.SCRATCHPAD - ) - const minNormalRank = Math.min(0, ...otherSegmentsInRundown.map((s) => s._rank)) + if (scratchpadSegment) { + // Ensure the _rank is just before the real content + const otherSegmentsInRundown = rundown.Segments.filter( + (s) => s.Segment.orphaned !== SegmentOrphanedReason.SCRATCHPAD + ) + const minNormalRank = Math.min(0, ...otherSegmentsInRundown.map((s) => s.Segment._rank)) - cache.Segments.updateOne(segment._id, (segment) => { - const newRank = minNormalRank - 1 - if (segment._rank !== newRank) { - segment._rank = newRank - return segment - } else { - return false - } - }) + rundown.setScratchpadSegmentRank(minNormalRank - 1) + } } } diff --git a/packages/job-worker/src/ingest/expectedPackages.ts b/packages/job-worker/src/ingest/expectedPackages.ts index f7d9ed09fe..93aacd185e 100644 --- a/packages/job-worker/src/ingest/expectedPackages.ts +++ b/packages/job-worker/src/ingest/expectedPackages.ts @@ -33,8 +33,8 @@ import { RundownBaselineAdLibAction } from '@sofie-automation/corelib/dist/dataM import { RundownBaselineAdLibItem } from '@sofie-automation/corelib/dist/dataModel/RundownBaselineAdLibPiece' import { saveIntoCache } from '../cache/lib' import { saveIntoDb } from '../db/changes' -import { CacheForPlayout } from '../playout/cache' -import { CacheForStudio } from '../studio/cache' +import { PlayoutModel } from '../playout/model/PlayoutModel' +import { StudioPlayoutModel } from '../studio/model/StudioPlayoutModel' import { ReadonlyDeep } from 'type-fest' import { ExpectedPackage, BlueprintResultBaseline } from '@sofie-automation/blueprints-integration' import { updateExpectedMediaItemsOnRundown } from './expectedMediaItems' @@ -371,7 +371,7 @@ export function updateBaselineExpectedPackagesOnRundown( baseline: BlueprintResultBaseline ): void { // @todo: this call is for backwards compatibility and soon to be removed - updateBaselineExpectedPlayoutItemsOnRundown(context, cache, baseline.expectedPlayoutItems) + updateBaselineExpectedPlayoutItemsOnRundown(context, cache, baseline.expectedPlayoutItems ?? []) // Fill in ids of unnamed expectedPackages setDefaultIdOnExpectedPackages(baseline.expectedPackages) @@ -400,33 +400,25 @@ export function updateBaselineExpectedPackagesOnRundown( export function updateBaselineExpectedPackagesOnStudio( context: JobContext, - cache: CacheForStudio | CacheForPlayout, + cache: StudioPlayoutModel | PlayoutModel, baseline: BlueprintResultBaseline ): void { // @todo: this call is for backwards compatibility and soon to be removed - updateBaselineExpectedPlayoutItemsOnStudio(context, cache, baseline.expectedPlayoutItems) + updateBaselineExpectedPlayoutItemsOnStudio(context, cache, baseline.expectedPlayoutItems ?? []) // Fill in ids of unnamed expectedPackages setDefaultIdOnExpectedPackages(baseline.expectedPackages) const bases = generateExpectedPackageBases(context.studio, context.studio._id, baseline.expectedPackages ?? []) - cache.deferAfterSave(async () => { - await saveIntoDb( - context, - context.directCollections.ExpectedPackages, - { - studioId: context.studio._id, + cache.setExpectedPackagesForStudioBaseline( + bases.map((item): ExpectedPackageDBFromStudioBaselineObjects => { + return { + ...item, fromPieceType: ExpectedPackageDBType.STUDIO_BASELINE_OBJECTS, - }, - bases.map((item): ExpectedPackageDBFromStudioBaselineObjects => { - return { - ...item, - fromPieceType: ExpectedPackageDBType.STUDIO_BASELINE_OBJECTS, - pieceId: null, - } - }) - ) - }) + pieceId: null, + } + }) + ) } export function setDefaultIdOnExpectedPackages(expectedPackages: ExpectedPackage.Any[] | undefined): void { diff --git a/packages/job-worker/src/ingest/expectedPlayoutItems.ts b/packages/job-worker/src/ingest/expectedPlayoutItems.ts index 3aba8d3747..0feb7ed809 100644 --- a/packages/job-worker/src/ingest/expectedPlayoutItems.ts +++ b/packages/job-worker/src/ingest/expectedPlayoutItems.ts @@ -11,10 +11,8 @@ import { RundownBaselineAdLibAction } from '@sofie-automation/corelib/dist/dataM import { getRandomId } from '@sofie-automation/corelib/dist/lib' import { protectString } from '@sofie-automation/corelib/dist/protectedString' import { saveIntoCache } from '../cache/lib' -import { saveIntoDb } from '../db/changes' -import { CacheForPlayout } from '../playout/cache' -import { CacheForStudio } from '../studio/cache' -import _ = require('underscore') +import { PlayoutModel } from '../playout/model/PlayoutModel' +import { StudioPlayoutModel } from '../studio/model/StudioPlayoutModel' import { ExpectedPlayoutItemGeneric } from '@sofie-automation/blueprints-integration' import { JobContext } from '../jobs' import { CacheForIngest } from './cache' @@ -28,7 +26,7 @@ function extractExpectedPlayoutItems( const expectedPlayoutItemsGeneric: ExpectedPlayoutItem[] = [] if (piece.expectedPlayoutItems) { - _.each(piece.expectedPlayoutItems, (pieceItem, i) => { + piece.expectedPlayoutItems.forEach((pieceItem, i) => { expectedPlayoutItemsGeneric.push({ ...pieceItem, _id: protectString(piece._id + '_' + i), @@ -77,13 +75,13 @@ export async function updateExpectedPlayoutItemsOnRundown(context: JobContext, c export function updateBaselineExpectedPlayoutItemsOnRundown( context: JobContext, cache: CacheForIngest, - items?: ExpectedPlayoutItemGeneric[] + items: ExpectedPlayoutItemGeneric[] ): void { saveIntoCache( context, cache.ExpectedPlayoutItems, (p) => p.baseline === 'rundown', - (items || []).map((item): ExpectedPlayoutItemRundown => { + items.map((item): ExpectedPlayoutItemRundown => { return { ...item, _id: getRandomId(), @@ -96,22 +94,17 @@ export function updateBaselineExpectedPlayoutItemsOnRundown( } export function updateBaselineExpectedPlayoutItemsOnStudio( context: JobContext, - cache: CacheForStudio | CacheForPlayout, - items?: ExpectedPlayoutItemGeneric[] + cache: StudioPlayoutModel | PlayoutModel, + items: ExpectedPlayoutItemGeneric[] ): void { - cache.deferAfterSave(async () => { - await saveIntoDb( - context, - context.directCollections.ExpectedPlayoutItems, - { studioId: context.studio._id, baseline: 'studio' }, - (items || []).map((item): ExpectedPlayoutItemStudio => { - return { - ...item, - _id: getRandomId(), - studioId: context.studio._id, - baseline: 'studio', - } - }) - ) - }) + cache.setExpectedPlayoutItemsForStudioBaseline( + items.map((item): ExpectedPlayoutItemStudio => { + return { + ...item, + _id: getRandomId(), + studioId: context.studio._id, + baseline: 'studio', + } + }) + ) } diff --git a/packages/job-worker/src/ingest/lock.ts b/packages/job-worker/src/ingest/lock.ts index b70cb2d6b2..785734312c 100644 --- a/packages/job-worker/src/ingest/lock.ts +++ b/packages/job-worker/src/ingest/lock.ts @@ -11,7 +11,7 @@ import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { RundownLock } from '../jobs/lock' import { groupByToMap } from '@sofie-automation/corelib/dist/lib' import { UserError } from '@sofie-automation/corelib/dist/error' -import { getOrderedSegmentsAndPartsFromCacheCollections } from '../cache/utils' +import { sortSegmentsInRundowns, sortPartsInSortedSegments } from '@sofie-automation/corelib/dist/playout/playlist' /** * The result of the initial stage of an Ingest operation @@ -182,10 +182,27 @@ function generatePartMap(cache: ReadOnlyCache): BeforePartMap { const rundown = cache.Rundown.doc if (!rundown) return new Map() - const segmentsAndParts = getOrderedSegmentsAndPartsFromCacheCollections(cache.Parts, cache.Segments, [ - cache.RundownId, - ]) - const existingRundownParts = groupByToMap(segmentsAndParts.parts, 'segmentId') + const orderedSegments = sortSegmentsInRundowns( + cache.Segments.findAll(null, { + sort: { + rundownId: 1, + _rank: 1, + }, + }), + [cache.RundownId] + ) + + const orderedParts = sortPartsInSortedSegments( + cache.Parts.findAll(null, { + sort: { + rundownId: 1, + _rank: 1, + }, + }), + orderedSegments + ) + + const existingRundownParts = groupByToMap(orderedParts, 'segmentId') const res = new Map>() for (const [segmentId, parts] of existingRundownParts.entries()) { diff --git a/packages/job-worker/src/ingest/mosDevice/__tests__/mosIngest.test.ts b/packages/job-worker/src/ingest/mosDevice/__tests__/mosIngest.test.ts index 4138e0809a..7797eed4a3 100644 --- a/packages/job-worker/src/ingest/mosDevice/__tests__/mosIngest.test.ts +++ b/packages/job-worker/src/ingest/mosDevice/__tests__/mosIngest.test.ts @@ -1222,21 +1222,30 @@ describe('Test recieved mos ingest payloads', () => { fromPartInstanceId: null, }) - const partInstances0 = await context.mockCollections.PartInstances.findFetch({ rundownId: rundown._id }) - const { segments: segments0, parts: parts0 } = await getRundownData({ _id: rundown._id }) + const partInstancesBefore = await context.mockCollections.PartInstances.findFetch({ + rundownId: rundown._id, + }) + const { segments: segmentsBefore, parts: partsBefore } = await getRundownData({ _id: rundown._id }) await mosReplaceBasicStory(rundown.externalId, 'ro1;s2;p1', 'ro1;s2;p1', 'SEGMENT2b;PART1') await mosReplaceBasicStory(rundown.externalId, 'ro1;s2;p2', 'ro1;s2;p2', 'SEGMENT2b;PART2') - const partInstances = await context.mockCollections.PartInstances.findFetch({ rundownId: rundown._id }) - const { segments, parts } = await getRundownData({ _id: rundown._id }) + const partInstancesAfter = await context.mockCollections.PartInstances.findFetch({ rundownId: rundown._id }) + const { segments: segmentsAfter, parts: partsAfter } = await getRundownData({ _id: rundown._id }) // Update expected data, for just the segment name and ids changing - applySegmentRenameToContents('SEGMENT2', 'SEGMENT2b', segments0, segments, parts0, partInstances0) + applySegmentRenameToContents( + 'SEGMENT2', + 'SEGMENT2b', + segmentsBefore, + segmentsAfter, + partsBefore, + partInstancesBefore + ) - expect(fixSnapshot(segments)).toMatchObject(fixSnapshot(segments0) || []) - expect(fixSnapshot(parts)).toMatchObject(fixSnapshot(parts0) || []) - expect(fixSnapshot(partInstances)).toMatchObject(fixSnapshot(partInstances0) || []) + expect(fixSnapshot(segmentsAfter)).toMatchObject(fixSnapshot(segmentsBefore)) + expect(fixSnapshot(partsAfter)).toMatchObject(fixSnapshot(partsBefore)) + expect(fixSnapshot(partInstancesAfter)).toMatchObject(fixSnapshot(partInstancesBefore)) } catch (e) { console.error(e) throw e @@ -1272,8 +1281,10 @@ describe('Test recieved mos ingest payloads', () => { fromPartInstanceId: null, }) - const partInstances0 = await context.mockCollections.PartInstances.findFetch({ rundownId: rundown._id }) - const { segments: segments0, parts: parts0 } = await getRundownData({ _id: rundown._id }) + const partInstancesBefore = await context.mockCollections.PartInstances.findFetch({ + rundownId: rundown._id, + }) + const { segments: segmentsBefore, parts: partsBefore } = await getRundownData({ _id: rundown._id }) // rename the segment for (const story of mosRO.Stories) { @@ -1300,15 +1311,22 @@ describe('Test recieved mos ingest payloads', () => { expect(rundown2.orphaned).toBeFalsy() } - const partInstances = await context.mockCollections.PartInstances.findFetch({ rundownId: rundown._id }) - const { segments, parts } = await getRundownData({ _id: rundown._id }) + const partInstancesAfter = await context.mockCollections.PartInstances.findFetch({ rundownId: rundown._id }) + const { segments: segmentsAfter, parts: partsAfter } = await getRundownData({ _id: rundown._id }) // Update expected data, for just the segment name and ids changing - applySegmentRenameToContents('SEGMENT2', 'SEGMENT2b', segments0, segments, parts0, partInstances0) + applySegmentRenameToContents( + 'SEGMENT2', + 'SEGMENT2b', + segmentsBefore, + segmentsAfter, + partsBefore, + partInstancesBefore + ) - expect(fixSnapshot(segments)).toMatchObject(fixSnapshot(segments0) || []) - expect(fixSnapshot(parts)).toMatchObject(fixSnapshot(parts0) || []) - expect(fixSnapshot(partInstances)).toMatchObject(fixSnapshot(partInstances0) || []) + expect(fixSnapshot(segmentsAfter)).toMatchObject(fixSnapshot(segmentsBefore)) + expect(fixSnapshot(partsAfter)).toMatchObject(fixSnapshot(partsBefore)) + expect(fixSnapshot(partInstancesAfter)).toMatchObject(fixSnapshot(partInstancesBefore)) } finally { // cleanup await handleDeactivateRundownPlaylist(context, { diff --git a/packages/job-worker/src/ingest/syncChangesToPartInstance.ts b/packages/job-worker/src/ingest/syncChangesToPartInstance.ts index f9ca1a4c25..f8d5f37aa8 100644 --- a/packages/job-worker/src/ingest/syncChangesToPartInstance.ts +++ b/packages/job-worker/src/ingest/syncChangesToPartInstance.ts @@ -1,7 +1,8 @@ import { BlueprintSyncIngestNewData, BlueprintSyncIngestPartInstance } from '@sofie-automation/blueprints-integration' import { ReadOnlyCache } from '../cache/CacheBase' import { JobContext } from '../jobs' -import { CacheForPlayout, getSelectedPartInstancesFromCache } from '../playout/cache' +import { PlayoutModel } from '../playout/model/PlayoutModel' +import { PlayoutPartInstanceModel } from '../playout/model/PlayoutPartInstanceModel' import { CacheForIngest } from './cache' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' @@ -15,7 +16,7 @@ import { getPieceInstancesForPart, syncPlayheadInfinitesForNextPartInstance, } from '../playout/infinites' -import { isTooCloseToAutonext, updateExpectedDurationWithPrerollForPartInstance } from '../playout/lib' +import { isTooCloseToAutonext } from '../playout/lib' import _ = require('underscore') import { SyncIngestUpdateToPartInstanceContext } from '../blueprints/context' import { @@ -25,15 +26,16 @@ import { convertPartToBlueprints, convertPieceInstanceToBlueprints, } from '../blueprints/context/lib' -import { getRundown } from './lib' import { validateScratchpartPartInstanceProperties } from '../playout/scratchpad' +import { ReadonlyDeep } from 'type-fest' +import { hackConvertIngestCacheToRundownWithSegments } from './commit' type PlayStatus = 'previous' | 'current' | 'next' type SyncedInstance = { - existingPartInstance: DBPartInstance - previousPartInstance: DBPartInstance | undefined + existingPartInstance: PlayoutPartInstanceModel + previousPartInstance: PlayoutPartInstanceModel | null playStatus: PlayStatus - newPart: DBPart | undefined + newPart: ReadonlyDeep | undefined piecesThatMayBeActive: Promise } @@ -41,43 +43,49 @@ type SyncedInstance = { * Attempt to sync the current and next Part into their PartInstances * This defers out to the Blueprints to do the syncing * @param context Context of the job ebeing run - * @param cache Playout cache containing containing the Rundown being ingested + * @param playoutModel Playout cache containing containing the Rundown being ingested * @param ingestCache Ingest cache for the Rundown */ export async function syncChangesToPartInstances( context: JobContext, - cache: CacheForPlayout, + playoutModel: PlayoutModel, ingestCache: ReadOnlyCache ): Promise { - if (cache.Playlist.doc.activationId) { + if (playoutModel.Playlist.activationId) { // Get the final copy of the rundown - const rundown = getRundown(ingestCache) + const rundownWrapped = hackConvertIngestCacheToRundownWithSegments(ingestCache) - const showStyle = await context.getShowStyleCompound(rundown.showStyleVariantId, rundown.showStyleBaseId) + const showStyle = await context.getShowStyleCompound( + rundownWrapped.Rundown.showStyleVariantId, + rundownWrapped.Rundown.showStyleBaseId + ) const blueprint = await context.getShowStyleBlueprint(showStyle._id) if (blueprint.blueprint.syncIngestUpdateToPartInstance) { - const playlistPartInstances = getSelectedPartInstancesFromCache(cache) + const currentPartInstance = playoutModel.CurrentPartInstance + const nextPartInstance = playoutModel.NextPartInstance + const previousPartInstance = playoutModel.PreviousPartInstance + const instances: SyncedInstance[] = [] - if (playlistPartInstances.currentPartInstance) { + if (currentPartInstance) { // If the currentPartInstance is adlibbed we probably also need to find the earliest // non-adlibbed Part within this segment and check it for updates too. It may have something // changed (like timing) that will affect what's going on. // The previous "planned" Part Instance needs to be inserted into the `instances` first, so that // it's ran first through the blueprints. - if (playlistPartInstances.currentPartInstance.orphaned === 'adlib-part') { + if (currentPartInstance.PartInstance.orphaned === 'adlib-part') { const partAndPartInstance = findLastUnorphanedPartInstanceInSegment( - cache, - playlistPartInstances.currentPartInstance + playoutModel, + currentPartInstance.PartInstance ) if (partAndPartInstance) { insertToSyncedInstanceCandidates( context, instances, - cache, + playoutModel, ingestCache, partAndPartInstance.partInstance, - undefined, + null, partAndPartInstance.part, 'previous' ) @@ -87,22 +95,22 @@ export async function syncChangesToPartInstances( findPartAndInsertToSyncedInstanceCandidates( context, instances, - cache, + playoutModel, ingestCache, - playlistPartInstances.currentPartInstance, - playlistPartInstances.previousPartInstance, + currentPartInstance, + previousPartInstance, 'current' ) } - if (playlistPartInstances.nextPartInstance) { + if (nextPartInstance) { findPartAndInsertToSyncedInstanceCandidates( context, instances, - cache, + playoutModel, ingestCache, - playlistPartInstances.nextPartInstance, - playlistPartInstances.currentPartInstance, - isTooCloseToAutonext(playlistPartInstances.currentPartInstance, false) ? 'current' : 'next' + nextPartInstance, + currentPartInstance, + isTooCloseToAutonext(currentPartInstance?.PartInstance, false) ? 'current' : 'next' ) } @@ -113,29 +121,29 @@ export async function syncChangesToPartInstances( newPart, piecesThatMayBeActive, } of instances) { - const pieceInstancesInPart = cache.PieceInstances.findAll( - (p) => p.partInstanceId === existingPartInstance._id - ) + const pieceInstancesInPart = existingPartInstance.PieceInstances - const partId = existingPartInstance.part._id + const partId = existingPartInstance.PartInstance.part._id const existingResultPartInstance: BlueprintSyncIngestPartInstance = { - partInstance: convertPartInstanceToBlueprints(existingPartInstance), - pieceInstances: pieceInstancesInPart.map(convertPieceInstanceToBlueprints), + partInstance: convertPartInstanceToBlueprints(existingPartInstance.PartInstance), + pieceInstances: pieceInstancesInPart.map((p) => convertPieceInstanceToBlueprints(p.PieceInstance)), } const proposedPieceInstances = getPieceInstancesForPart( context, - cache, + playoutModel, previousPartInstance, - rundown, - newPart ?? existingPartInstance.part, + rundownWrapped, + newPart ?? existingPartInstance.PartInstance.part, await piecesThatMayBeActive, - existingPartInstance._id + existingPartInstance.PartInstance._id ) logger.info(`Syncing ingest changes for part: ${partId} (orphaned: ${!!newPart})`) - const referencedAdlibIds = new Set(_.compact(pieceInstancesInPart.map((p) => p.adLibSourceId))) + const referencedAdlibIds = new Set( + _.compact(pieceInstancesInPart.map((p) => p.PieceInstance.adLibSourceId)) + ) const newResultData: BlueprintSyncIngestNewData = { part: newPart ? convertPartToBlueprints(newPart) : undefined, pieceInstances: proposedPieceInstances.map(convertPieceInstanceToBlueprints), @@ -154,18 +162,18 @@ export async function syncChangesToPartInstances( ), } + const partInstanceSnapshot = existingPartInstance.snapshotMakeCopy() + const syncContext = new SyncIngestUpdateToPartInstanceContext( context, { - name: `Update to ${existingPartInstance.part.externalId}`, - identifier: `rundownId=${existingPartInstance.part.rundownId},segmentId=${existingPartInstance.part.segmentId}`, + name: `Update to ${existingPartInstance.PartInstance.part.externalId}`, + identifier: `rundownId=${existingPartInstance.PartInstance.part.rundownId},segmentId=${existingPartInstance.PartInstance.part.segmentId}`, }, - cache.Playlist.doc.activationId, context.studio, showStyle, - rundown, + rundownWrapped.Rundown, existingPartInstance, - pieceInstancesInPart, proposedPieceInstances, playStatus ) @@ -178,24 +186,21 @@ export async function syncChangesToPartInstances( newResultData, playStatus ) - - // If the blueprint function throws, no changes will be synced to the cache: - syncContext.applyChangesToCache(cache) } catch (err) { logger.error(`Error in showStyleBlueprint.syncIngestUpdateToPartInstance: ${stringifyError(err)}`) + + // Operation failed, rollback the changes + existingPartInstance.snapshotRestore(partInstanceSnapshot) } if (playStatus === 'next') { - updateExpectedDurationWithPrerollForPartInstance(cache, existingPartInstance._id) + existingPartInstance.recalculateExpectedDurationWithPreroll() } // Save notes: - if (!existingPartInstance.part.notes) existingPartInstance.part.notes = [] - const notes: PartNote[] = existingPartInstance.part.notes - let changed = false + const newNotes: PartNote[] = [] for (const note of syncContext.notes) { - changed = true - notes.push( + newNotes.push( literal({ type: note.type, message: note.message, @@ -205,20 +210,22 @@ export async function syncChangesToPartInstances( }) ) } - if (changed) { + if (newNotes.length) { // TODO - these dont get shown to the user currently - // TODO - old notes from the sync may need to be pruned, or we will end up with duplicates and 'stuck' notes? - cache.PartInstances.updateOne(existingPartInstance._id, (p) => { - p.part.notes = notes - return p - }) + // TODO - old notes from the sync may need to be pruned, or we will end up with duplicates and 'stuck' notes?+ + existingPartInstance.appendNotes(newNotes) - validateScratchpartPartInstanceProperties(context, cache, existingPartInstance._id) + validateScratchpartPartInstanceProperties(context, playoutModel, existingPartInstance) } - if (existingPartInstance._id === cache.Playlist.doc.currentPartInfo?.partInstanceId) { + if (existingPartInstance.PartInstance._id === playoutModel.Playlist.currentPartInfo?.partInstanceId) { // This should be run after 'current', before 'next': - await syncPlayheadInfinitesForNextPartInstance(context, cache) + await syncPlayheadInfinitesForNextPartInstance( + context, + playoutModel, + playoutModel.CurrentPartInstance, + playoutModel.NextPartInstance + ) } } } else { @@ -233,11 +240,11 @@ export async function syncChangesToPartInstances( function insertToSyncedInstanceCandidates( context: JobContext, instances: SyncedInstance[], - cache: CacheForPlayout, + playoutModel: PlayoutModel, ingestCache: ReadOnlyCache, - thisPartInstance: DBPartInstance, - previousPartInstance: DBPartInstance | undefined, - part: DBPart | undefined, + thisPartInstance: PlayoutPartInstanceModel, + previousPartInstance: PlayoutPartInstanceModel | null, + part: ReadonlyDeep | undefined, playStatus: PlayStatus ): void { instances.push({ @@ -247,9 +254,9 @@ function insertToSyncedInstanceCandidates( newPart: part, piecesThatMayBeActive: fetchPiecesThatMayBeActiveForPart( context, - cache, + playoutModel, ingestCache, - part ?? thisPartInstance.part + part ?? thisPartInstance.PartInstance.part ), }) } @@ -261,18 +268,18 @@ function insertToSyncedInstanceCandidates( function findPartAndInsertToSyncedInstanceCandidates( context: JobContext, instances: SyncedInstance[], - cache: CacheForPlayout, + playoutModel: PlayoutModel, ingestCache: ReadOnlyCache, - thisPartInstance: DBPartInstance, - previousPartInstance: DBPartInstance | undefined, + thisPartInstance: PlayoutPartInstanceModel, + previousPartInstance: PlayoutPartInstanceModel | null, playStatus: PlayStatus ): void { - const newPart = cache.Parts.findOne(thisPartInstance.part._id) + const newPart = playoutModel.findPart(thisPartInstance.PartInstance.part._id) insertToSyncedInstanceCandidates( context, instances, - cache, + playoutModel, ingestCache, thisPartInstance, previousPartInstance, @@ -286,31 +293,26 @@ function findPartAndInsertToSyncedInstanceCandidates( * PartInstance that matches that Part. Returns them if found or returns `null` if it can't find anything. */ function findLastUnorphanedPartInstanceInSegment( - cache: CacheForPlayout, - currentPartInstance: DBPartInstance + playoutModel: PlayoutModel, + currentPartInstance: ReadonlyDeep ): { - partInstance: DBPartInstance - part: DBPart + partInstance: PlayoutPartInstanceModel + part: ReadonlyDeep } | null { // Find the "latest" (last played), non-orphaned PartInstance in this Segment, in this play-through - const previousPartInstance = cache.PartInstances.findOne( + const previousPartInstance = playoutModel.SortedLoadedPartInstances.reverse().find( (p) => - p.playlistActivationId === currentPartInstance.playlistActivationId && - p.segmentId === currentPartInstance.segmentId && - p.segmentPlayoutId === currentPartInstance.segmentPlayoutId && - p.takeCount < currentPartInstance.takeCount && - !!p.orphaned && - !p.reset, - { - sort: { - takeCount: -1, - }, - } + p.PartInstance.playlistActivationId === currentPartInstance.playlistActivationId && + p.PartInstance.segmentId === currentPartInstance.segmentId && + p.PartInstance.segmentPlayoutId === currentPartInstance.segmentPlayoutId && + p.PartInstance.takeCount < currentPartInstance.takeCount && + !!p.PartInstance.orphaned && + !p.PartInstance.reset ) if (!previousPartInstance) return null - const previousPart = cache.Parts.findOne(previousPartInstance.part._id) + const previousPart = playoutModel.findPart(previousPartInstance.PartInstance.part._id) if (!previousPart) return null return { diff --git a/packages/job-worker/src/ingest/updateNext.ts b/packages/job-worker/src/ingest/updateNext.ts index 435ee314f5..19c00b0425 100644 --- a/packages/job-worker/src/ingest/updateNext.ts +++ b/packages/job-worker/src/ingest/updateNext.ts @@ -1,10 +1,6 @@ import { isTooCloseToAutonext } from '../playout/lib' import { selectNextPart } from '../playout/selectNextPart' -import { - CacheForPlayout, - getOrderedSegmentsAndPartsFromPlayoutCache, - getSelectedPartInstancesFromCache, -} from '../playout/cache' +import { PlayoutModel } from '../playout/model/PlayoutModel' import { JobContext } from '../jobs' import { setNextPart } from '../playout/setNext' import { isPartPlayable } from '@sofie-automation/corelib/dist/dataModel/Part' @@ -14,21 +10,22 @@ import { updateTimeline } from '../playout/timeline/generate' * Make sure that the nextPartInstance for the current Playlist is still correct * This will often change the nextPartInstance * @param context Context of the job being run - * @param cache Playout Cache to operate on + * @param playoutModel Playout Cache to operate on */ -export async function ensureNextPartIsValid(context: JobContext, cache: CacheForPlayout): Promise { +export async function ensureNextPartIsValid(context: JobContext, playoutModel: PlayoutModel): Promise { const span = context.startSpan('api.ingest.ensureNextPartIsValid') // Ensure the next-id is still valid - const playlist = cache.Playlist.doc + const playlist = playoutModel.Playlist if (playlist?.activationId) { - const { currentPartInstance, nextPartInstance } = getSelectedPartInstancesFromCache(cache) + const currentPartInstance = playoutModel.CurrentPartInstance + const nextPartInstance = playoutModel.NextPartInstance if ( playlist.nextPartInfo?.manuallySelected && - nextPartInstance?.part && - isPartPlayable(nextPartInstance.part) && - nextPartInstance.orphaned !== 'deleted' + nextPartInstance && + isPartPlayable(nextPartInstance.PartInstance.part) && + nextPartInstance.PartInstance.orphaned !== 'deleted' ) { // Manual next part is almost always valid. This includes orphaned (adlib-part) partinstances span?.end() @@ -36,48 +33,51 @@ export async function ensureNextPartIsValid(context: JobContext, cache: CacheFor } // If we are close to an autonext, then leave it to avoid glitches - if (isTooCloseToAutonext(currentPartInstance) && nextPartInstance) { + if (isTooCloseToAutonext(currentPartInstance?.PartInstance) && nextPartInstance) { span?.end() return } - const allPartsAndSegments = getOrderedSegmentsAndPartsFromPlayoutCache(cache) + const orderedSegments = playoutModel.getAllOrderedSegments() + const orderedParts = playoutModel.getAllOrderedParts() if (currentPartInstance && nextPartInstance) { // Check if the part is the same const newNextPart = selectNextPart( context, playlist, - currentPartInstance, - nextPartInstance, - allPartsAndSegments + currentPartInstance.PartInstance, + nextPartInstance.PartInstance, + orderedSegments, + orderedParts ) if ( // Nothing should be nexted !newNextPart || // The nexted-part should be different to what is selected - newNextPart.part._id !== nextPartInstance.part._id || + newNextPart.part._id !== nextPartInstance.PartInstance.part._id || // The nexted-part Instance is no longer playable - !isPartPlayable(nextPartInstance.part) + !isPartPlayable(nextPartInstance.PartInstance.part) ) { // The 'new' next part is before the current next, so move the next point - await setNextPart(context, cache, newNextPart ?? null, false) + await setNextPart(context, playoutModel, newNextPart ?? null, false) - await updateTimeline(context, cache) + await updateTimeline(context, playoutModel) } - } else if (!nextPartInstance || nextPartInstance.orphaned === 'deleted') { + } else if (!nextPartInstance || nextPartInstance.PartInstance.orphaned === 'deleted') { // Don't have a nextPart or it has been deleted, so autoselect something const newNextPart = selectNextPart( context, playlist, - currentPartInstance ?? null, - nextPartInstance ?? null, - allPartsAndSegments + currentPartInstance?.PartInstance ?? null, + nextPartInstance?.PartInstance ?? null, + orderedSegments, + orderedParts ) - await setNextPart(context, cache, newNextPart ?? null, false) + await setNextPart(context, playoutModel, newNextPart ?? null, false) - await updateTimeline(context, cache) + await updateTimeline(context, playoutModel) } } diff --git a/packages/job-worker/src/jobs/index.ts b/packages/job-worker/src/jobs/index.ts index 65fb5a481c..6b4a8fad49 100644 --- a/packages/job-worker/src/jobs/index.ts +++ b/packages/job-worker/src/jobs/index.ts @@ -15,7 +15,7 @@ import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' import { ProcessedShowStyleConfig, ProcessedStudioConfig } from '../blueprints/config' import { StudioJobFunc } from '@sofie-automation/corelib/dist/worker/studio' import { PlaylistLock, RundownLock } from './lock' -import { ReadOnlyCacheBase } from '../cache/CacheBase' +import { BaseModel } from '../modelBase' import { TimelineComplete } from '@sofie-automation/corelib/dist/dataModel/Timeline' import { ProcessedShowStyleBase, ProcessedShowStyleVariant, ProcessedShowStyleCompound } from './showStyle' @@ -27,7 +27,7 @@ export { ProcessedShowStyleVariant, ProcessedShowStyleBase, ProcessedShowStyleCo */ export interface JobContext extends StudioCacheContext { /** Internal: Track a cache, to check it was saved at the end of the job */ - trackCache(cache: ReadOnlyCacheBase): void + trackCache(cache: BaseModel): void /** Aquire the CacheForPlayout/write lock for a Playlist */ lockPlaylist(playlistId: RundownPlaylistId): Promise diff --git a/packages/job-worker/src/modelBase.ts b/packages/job-worker/src/modelBase.ts new file mode 100644 index 0000000000..537666fb46 --- /dev/null +++ b/packages/job-worker/src/modelBase.ts @@ -0,0 +1,29 @@ +/** + * A base type for loaded Models + */ +export interface BaseModel { + /** + * Name to display in debug logs about this Model + */ + readonly DisplayName: string + + /** + * Mark the model as disposed + * After this call, the model should discard any pending changes, and reject any requests to persist the changes + */ + dispose(): void + + /** + * Assert that no changes should have been made to the cache, will throw an Error otherwise. This can be used in + * place of `saveAllToDatabase()`, when the code controlling the cache expects no changes to have been made and any + * changes made are an error and will cause issues. + */ + assertNoChanges(): void +} + +export interface DatabasePersistedModel { + /** + * Issue a save of the contents of this model to the database + */ + saveAllToDatabase(): Promise +} diff --git a/packages/job-worker/src/peripheralDevice.ts b/packages/job-worker/src/peripheralDevice.ts index 58e7b324dc..69aac9581f 100644 --- a/packages/job-worker/src/peripheralDevice.ts +++ b/packages/job-worker/src/peripheralDevice.ts @@ -1,12 +1,19 @@ import { IBlueprintPlayoutDevice, TSR } from '@sofie-automation/blueprints-integration' import { PeripheralDeviceCommandId, PeripheralDeviceId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { PeripheralDevice, PeripheralDeviceType } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' -import { Complete, createManualPromise, getRandomId, normalizeArrayToMap } from '@sofie-automation/corelib/dist/lib' +import { PeripheralDeviceType } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' +import { + clone, + Complete, + createManualPromise, + getRandomId, + normalizeArrayToMap, +} from '@sofie-automation/corelib/dist/lib' import { JobContext } from './jobs' import { getCurrentTime } from './lib' import { logger } from './logging' -import { CacheForPlayout } from './playout/cache' +import { PlayoutModel } from './playout/model/PlayoutModel' import { literal } from '@sofie-automation/shared-lib/dist/lib/lib' +import { SubdeviceAction } from '@sofie-automation/corelib/dist/deviceConfig' export async function executePeripheralDeviceAction( context: JobContext, @@ -183,11 +190,11 @@ async function executePeripheralDeviceGenericFunction( export async function listPlayoutDevices( context: JobContext, - cache: CacheForPlayout + playoutModel: PlayoutModel ): Promise { const parentDevicesMap = normalizeArrayToMap( - cache.PeripheralDevices.findAll( - (doc: PeripheralDevice) => doc.studioId === context.studioId && doc.type === PeripheralDeviceType.PLAYOUT + playoutModel.PeripheralDevices.filter( + (doc) => doc.studioId === context.studioId && doc.type === PeripheralDeviceType.PLAYOUT ), '_id' ) @@ -210,7 +217,9 @@ export async function listPlayoutDevices( return literal>({ deviceId: d._id, deviceType: d.subType as TSR.DeviceType, - actions: parentDevice?.configManifest?.subdeviceManifest?.[d.subType]?.actions, + actions: clone( + parentDevice?.configManifest?.subdeviceManifest?.[d.subType]?.actions + ), }) }) } diff --git a/packages/job-worker/src/playout/__tests__/__snapshots__/playout.test.ts.snap b/packages/job-worker/src/playout/__tests__/__snapshots__/playout.test.ts.snap index 48a2edb15c..0ca490d670 100644 --- a/packages/job-worker/src/playout/__tests__/__snapshots__/playout.test.ts.snap +++ b/packages/job-worker/src/playout/__tests__/__snapshots__/playout.test.ts.snap @@ -11,8 +11,8 @@ exports[`Playout API Basic rundown control 1`] = ` "core": "0.0.0-test", "studio": "asdf", }, - "timelineBlob": "[{"id":"playlist_randomId9000_status","objectType":"rundown","enable":{"while":1},"layer":"rundown_status","content":{"deviceType":"ABSTRACT"},"classes":["rundown_active"],"priority":0},{"id":"part_group_randomId9000_part0_0_randomId9002","objectType":"rundown","enable":{"start":"now"},"priority":5,"layer":"","content":{"deviceType":"ABSTRACT","type":"group"},"children":[],"isGroup":true,"metaData":{"isPieceTimeline":true}},{"id":"part_group_firstobject_randomId9000_part0_0_randomId9002","objectType":"rundown","enable":{"start":0},"layer":"group_first_object","content":{"deviceType":"ABSTRACT","type":"callback","callBack":"partPlaybackStarted","callBackData":{"rundownPlaylistId":"playlist_randomId9000","partInstanceId":"randomId9000_part0_0_randomId9002"},"callBackStopped":"partPlaybackStopped"},"inGroup":"part_group_randomId9000_part0_0_randomId9002","classes":[],"priority":0},{"id":"piece_group_control_randomId9000_part0_0_randomId9002_randomId9000_piece001","objectType":"rundown","enable":{"start":0},"layer":"vt0","priority":5,"content":{"deviceType":"ABSTRACT","type":"callback","callBack":"piecePlaybackStarted","callBackData":{"rundownPlaylistId":"playlist_randomId9000","partInstanceId":"randomId9000_part0_0_randomId9002","pieceInstanceId":"randomId9000_part0_0_randomId9002_randomId9000_piece001","dynamicallyInserted":false},"callBackStopped":"piecePlaybackStopped"},"classes":["current_part"],"inGroup":"part_group_randomId9000_part0_0_randomId9002","metaData":{"isPieceTimeline":true,"triggerPieceInstanceId":"randomId9000_part0_0_randomId9002_randomId9000_piece001"}},{"id":"piece_group_randomId9000_part0_0_randomId9002_randomId9000_piece001","content":{"deviceType":"ABSTRACT","type":"group"},"children":[],"inGroup":"part_group_randomId9000_part0_0_randomId9002","isGroup":true,"objectType":"rundown","enable":{"start":"#piece_group_control_randomId9000_part0_0_randomId9002_randomId9000_piece001.start - 0","end":"#piece_group_control_randomId9000_part0_0_randomId9002_randomId9000_piece001.end + 0"},"layer":"","metaData":{"isPieceTimeline":true,"pieceInstanceGroupId":"randomId9000_part0_0_randomId9002_randomId9000_piece001"},"priority":0}]", - "timelineHash": "randomId9006", + "timelineBlob": "[{"id":"playlist_randomId9000_status","objectType":"rundown","enable":{"while":1},"layer":"rundown_status","content":{"deviceType":"ABSTRACT"},"classes":["rundown_active"],"priority":0},{"id":"part_group_randomId9000_part0_0_randomId9003","objectType":"rundown","enable":{"start":"now"},"priority":5,"layer":"","content":{"deviceType":"ABSTRACT","type":"group"},"children":[],"isGroup":true,"metaData":{"isPieceTimeline":true}},{"id":"part_group_firstobject_randomId9000_part0_0_randomId9003","objectType":"rundown","enable":{"start":0},"layer":"group_first_object","content":{"deviceType":"ABSTRACT","type":"callback","callBack":"partPlaybackStarted","callBackData":{"rundownPlaylistId":"playlist_randomId9000","partInstanceId":"randomId9000_part0_0_randomId9003"},"callBackStopped":"partPlaybackStopped"},"inGroup":"part_group_randomId9000_part0_0_randomId9003","classes":[],"priority":0},{"id":"piece_group_control_randomId9000_part0_0_randomId9003_randomId9000_piece001","objectType":"rundown","enable":{"start":0},"layer":"vt0","priority":5,"content":{"deviceType":"ABSTRACT","type":"callback","callBack":"piecePlaybackStarted","callBackData":{"rundownPlaylistId":"playlist_randomId9000","partInstanceId":"randomId9000_part0_0_randomId9003","pieceInstanceId":"randomId9000_part0_0_randomId9003_randomId9000_piece001","dynamicallyInserted":false},"callBackStopped":"piecePlaybackStopped"},"classes":["current_part"],"inGroup":"part_group_randomId9000_part0_0_randomId9003","metaData":{"isPieceTimeline":true,"triggerPieceInstanceId":"randomId9000_part0_0_randomId9003_randomId9000_piece001"}},{"id":"piece_group_randomId9000_part0_0_randomId9003_randomId9000_piece001","content":{"deviceType":"ABSTRACT","type":"group"},"children":[],"inGroup":"part_group_randomId9000_part0_0_randomId9003","isGroup":true,"objectType":"rundown","enable":{"start":"#piece_group_control_randomId9000_part0_0_randomId9003_randomId9000_piece001.start - 0","end":"#piece_group_control_randomId9000_part0_0_randomId9003_randomId9000_piece001.end + 0"},"layer":"","metaData":{"isPieceTimeline":true,"pieceInstanceGroupId":"randomId9000_part0_0_randomId9003_randomId9000_piece001"},"priority":0}]", + "timelineHash": "randomId9008", }, ] `; @@ -55,7 +55,7 @@ exports[`Playout API Basic rundown control 3`] = ` "studio": "asdf", }, "timelineBlob": "[]", - "timelineHash": "randomId9007", + "timelineHash": "randomId9010", }, ] `; @@ -67,7 +67,6 @@ exports[`Playout API Basic rundown control 4`] = ` "currentPartInfo": null, "externalId": "MOCK_RUNDOWNPLAYLIST", "holdState": 0, - "lastTakeTime": 0, "modified": 0, "name": "Default RundownPlaylist", "nextPartInfo": null, diff --git a/packages/job-worker/src/playout/__tests__/__snapshots__/timeline.test.ts.snap b/packages/job-worker/src/playout/__tests__/__snapshots__/timeline.test.ts.snap index aedad990df..ef48638e9a 100644 --- a/packages/job-worker/src/playout/__tests__/__snapshots__/timeline.test.ts.snap +++ b/packages/job-worker/src/playout/__tests__/__snapshots__/timeline.test.ts.snap @@ -12,7 +12,7 @@ exports[`Timeline Adlib pieces Current part with preroll 1`] = ` "studio": "asdf", }, "timelineBlob": "[]", - "timelineHash": "randomId9013", + "timelineHash": "randomId9022", }, ] `; @@ -29,7 +29,7 @@ exports[`Timeline Adlib pieces Current part with preroll and adlib preroll 1`] = "studio": "asdf", }, "timelineBlob": "[]", - "timelineHash": "randomId9013", + "timelineHash": "randomId9022", }, ] `; @@ -45,8 +45,8 @@ exports[`Timeline Basic rundown 1`] = ` "core": "0.0.0-test", "studio": "asdf", }, - "timelineBlob": "[{"id":"playlist_randomId9000_status","objectType":"rundown","enable":{"while":1},"layer":"rundown_status","content":{"deviceType":"ABSTRACT"},"classes":["rundown_active"],"priority":0},{"id":"part_group_randomId9000_part0_0_randomId9002","objectType":"rundown","enable":{"start":"now"},"priority":5,"layer":"","content":{"deviceType":"ABSTRACT","type":"group"},"children":[],"isGroup":true,"metaData":{"isPieceTimeline":true}},{"id":"part_group_firstobject_randomId9000_part0_0_randomId9002","objectType":"rundown","enable":{"start":0},"layer":"group_first_object","content":{"deviceType":"ABSTRACT","type":"callback","callBack":"partPlaybackStarted","callBackData":{"rundownPlaylistId":"playlist_randomId9000","partInstanceId":"randomId9000_part0_0_randomId9002"},"callBackStopped":"partPlaybackStopped"},"inGroup":"part_group_randomId9000_part0_0_randomId9002","classes":[],"priority":0},{"id":"piece_group_control_randomId9000_part0_0_randomId9002_randomId9000_piece001","objectType":"rundown","enable":{"start":0},"layer":"vt0","priority":5,"content":{"deviceType":"ABSTRACT","type":"callback","callBack":"piecePlaybackStarted","callBackData":{"rundownPlaylistId":"playlist_randomId9000","partInstanceId":"randomId9000_part0_0_randomId9002","pieceInstanceId":"randomId9000_part0_0_randomId9002_randomId9000_piece001","dynamicallyInserted":false},"callBackStopped":"piecePlaybackStopped"},"classes":["current_part"],"inGroup":"part_group_randomId9000_part0_0_randomId9002","metaData":{"isPieceTimeline":true,"triggerPieceInstanceId":"randomId9000_part0_0_randomId9002_randomId9000_piece001"}},{"id":"piece_group_randomId9000_part0_0_randomId9002_randomId9000_piece001","content":{"deviceType":"ABSTRACT","type":"group"},"children":[],"inGroup":"part_group_randomId9000_part0_0_randomId9002","isGroup":true,"objectType":"rundown","enable":{"start":"#piece_group_control_randomId9000_part0_0_randomId9002_randomId9000_piece001.start - 0","end":"#piece_group_control_randomId9000_part0_0_randomId9002_randomId9000_piece001.end + 0"},"layer":"","metaData":{"isPieceTimeline":true,"pieceInstanceGroupId":"randomId9000_part0_0_randomId9002_randomId9000_piece001"},"priority":0}]", - "timelineHash": "randomId9007", + "timelineBlob": "[{"id":"playlist_randomId9000_status","objectType":"rundown","enable":{"while":1},"layer":"rundown_status","content":{"deviceType":"ABSTRACT"},"classes":["rundown_active"],"priority":0},{"id":"part_group_randomId9000_part0_0_randomId9003","objectType":"rundown","enable":{"start":"now"},"priority":5,"layer":"","content":{"deviceType":"ABSTRACT","type":"group"},"children":[],"isGroup":true,"metaData":{"isPieceTimeline":true}},{"id":"part_group_firstobject_randomId9000_part0_0_randomId9003","objectType":"rundown","enable":{"start":0},"layer":"group_first_object","content":{"deviceType":"ABSTRACT","type":"callback","callBack":"partPlaybackStarted","callBackData":{"rundownPlaylistId":"playlist_randomId9000","partInstanceId":"randomId9000_part0_0_randomId9003"},"callBackStopped":"partPlaybackStopped"},"inGroup":"part_group_randomId9000_part0_0_randomId9003","classes":[],"priority":0},{"id":"piece_group_control_randomId9000_part0_0_randomId9003_randomId9000_piece001","objectType":"rundown","enable":{"start":0},"layer":"vt0","priority":5,"content":{"deviceType":"ABSTRACT","type":"callback","callBack":"piecePlaybackStarted","callBackData":{"rundownPlaylistId":"playlist_randomId9000","partInstanceId":"randomId9000_part0_0_randomId9003","pieceInstanceId":"randomId9000_part0_0_randomId9003_randomId9000_piece001","dynamicallyInserted":false},"callBackStopped":"piecePlaybackStopped"},"classes":["current_part"],"inGroup":"part_group_randomId9000_part0_0_randomId9003","metaData":{"isPieceTimeline":true,"triggerPieceInstanceId":"randomId9000_part0_0_randomId9003_randomId9000_piece001"}},{"id":"piece_group_randomId9000_part0_0_randomId9003_randomId9000_piece001","content":{"deviceType":"ABSTRACT","type":"group"},"children":[],"inGroup":"part_group_randomId9000_part0_0_randomId9003","isGroup":true,"objectType":"rundown","enable":{"start":"#piece_group_control_randomId9000_part0_0_randomId9003_randomId9000_piece001.start - 0","end":"#piece_group_control_randomId9000_part0_0_randomId9003_randomId9000_piece001.end + 0"},"layer":"","metaData":{"isPieceTimeline":true,"pieceInstanceGroupId":"randomId9000_part0_0_randomId9003_randomId9000_piece001"},"priority":0}]", + "timelineHash": "randomId9010", }, ] `; @@ -63,7 +63,7 @@ exports[`Timeline Basic rundown 2`] = ` "studio": "asdf", }, "timelineBlob": "[]", - "timelineHash": "randomId9008", + "timelineHash": "randomId9012", }, ] `; @@ -80,7 +80,7 @@ exports[`Timeline In transitions Basic inTransition 1`] = ` "studio": "asdf", }, "timelineBlob": "[]", - "timelineHash": "randomId9009", + "timelineHash": "randomId9014", }, ] `; @@ -97,7 +97,7 @@ exports[`Timeline In transitions Basic inTransition with contentDelay + preroll "studio": "asdf", }, "timelineBlob": "[]", - "timelineHash": "randomId9009", + "timelineHash": "randomId9014", }, ] `; @@ -114,7 +114,7 @@ exports[`Timeline In transitions Basic inTransition with contentDelay 1`] = ` "studio": "asdf", }, "timelineBlob": "[]", - "timelineHash": "randomId9009", + "timelineHash": "randomId9014", }, ] `; @@ -131,7 +131,7 @@ exports[`Timeline In transitions Basic inTransition with planned pieces 1`] = ` "studio": "asdf", }, "timelineBlob": "[]", - "timelineHash": "randomId9009", + "timelineHash": "randomId9014", }, ] `; @@ -148,7 +148,7 @@ exports[`Timeline In transitions Preroll 1`] = ` "studio": "asdf", }, "timelineBlob": "[]", - "timelineHash": "randomId9009", + "timelineHash": "randomId9014", }, ] `; @@ -165,7 +165,7 @@ exports[`Timeline In transitions inTransition disabled 1`] = ` "studio": "asdf", }, "timelineBlob": "[]", - "timelineHash": "randomId9009", + "timelineHash": "randomId9014", }, ] `; @@ -182,7 +182,7 @@ exports[`Timeline In transitions inTransition is disabled during hold 1`] = ` "studio": "asdf", }, "timelineBlob": "[]", - "timelineHash": "randomId9010", + "timelineHash": "randomId9016", }, ] `; @@ -199,7 +199,7 @@ exports[`Timeline In transitions inTransition with existing infinites 1`] = ` "studio": "asdf", }, "timelineBlob": "[]", - "timelineHash": "randomId9010", + "timelineHash": "randomId9015", }, ] `; @@ -216,7 +216,7 @@ exports[`Timeline In transitions inTransition with new infinite 1`] = ` "studio": "asdf", }, "timelineBlob": "[]", - "timelineHash": "randomId9010", + "timelineHash": "randomId9015", }, ] `; @@ -233,7 +233,7 @@ exports[`Timeline Infinite Pieces Infinite Piece has stable timing across timeli "studio": "asdf", }, "timelineBlob": "[]", - "timelineHash": "randomId9010", + "timelineHash": "randomId9016", }, ] `; @@ -250,7 +250,7 @@ exports[`Timeline Out transitions Basic outTransition 1`] = ` "studio": "asdf", }, "timelineBlob": "[]", - "timelineHash": "randomId9009", + "timelineHash": "randomId9014", }, ] `; @@ -267,7 +267,7 @@ exports[`Timeline Out transitions outTransition + inTransition 1`] = ` "studio": "asdf", }, "timelineBlob": "[]", - "timelineHash": "randomId9009", + "timelineHash": "randomId9014", }, ] `; @@ -284,7 +284,7 @@ exports[`Timeline Out transitions outTransition + preroll (2) 1`] = ` "studio": "asdf", }, "timelineBlob": "[]", - "timelineHash": "randomId9009", + "timelineHash": "randomId9014", }, ] `; @@ -301,7 +301,7 @@ exports[`Timeline Out transitions outTransition + preroll 1`] = ` "studio": "asdf", }, "timelineBlob": "[]", - "timelineHash": "randomId9009", + "timelineHash": "randomId9014", }, ] `; @@ -318,7 +318,7 @@ exports[`Timeline Out transitions outTransition is disabled during hold 1`] = ` "studio": "asdf", }, "timelineBlob": "[]", - "timelineHash": "randomId9010", + "timelineHash": "randomId9016", }, ] `; diff --git a/packages/job-worker/src/playout/__tests__/actions.test.ts b/packages/job-worker/src/playout/__tests__/actions.test.ts index 15bbc6fdb7..810a750ca1 100644 --- a/packages/job-worker/src/playout/__tests__/actions.test.ts +++ b/packages/job-worker/src/playout/__tests__/actions.test.ts @@ -4,7 +4,7 @@ import { removeRundownFromDb } from '../../rundownPlaylists' import { setupDefaultRundownPlaylist, setupMockShowStyleCompound } from '../../__mocks__/presetCollections' import { activateRundownPlaylist } from '../activePlaylistActions' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { runJobWithPlayoutCache } from '../lock' +import { runJobWithPlayoutModel } from '../lock' import { runWithRundownLock } from '../../ingest/lock' jest.mock('../../peripheralDevice') @@ -48,8 +48,8 @@ describe('Playout Actions', () => { expect(executePeripheralDeviceFunctionMock).toHaveBeenCalledTimes(0) // Activating a rundown, to rehearsal - await runJobWithPlayoutCache(context, { playlistId: playlistId0 }, null, async (cache) => - activateRundownPlaylist(context, cache, true) + await runJobWithPlayoutModel(context, { playlistId: playlistId0 }, null, async (playoutModel) => + activateRundownPlaylist(context, playoutModel, true) ) await expect(getPlaylist0()).resolves.toMatchObject({ activationId: expect.stringMatching(/^randomId/), @@ -57,8 +57,8 @@ describe('Playout Actions', () => { }) // Activating a rundown - await runJobWithPlayoutCache(context, { playlistId: playlistId0 }, null, async (cache) => - activateRundownPlaylist(context, cache, false) + await runJobWithPlayoutModel(context, { playlistId: playlistId0 }, null, async (playoutModel) => + activateRundownPlaylist(context, playoutModel, false) ) await expect(getPlaylist0()).resolves.toMatchObject({ activationId: expect.stringMatching(/^randomId/), @@ -66,8 +66,8 @@ describe('Playout Actions', () => { }) // Activating a rundown, back to rehearsal - await runJobWithPlayoutCache(context, { playlistId: playlistId0 }, null, async (cache) => - activateRundownPlaylist(context, cache, true) + await runJobWithPlayoutModel(context, { playlistId: playlistId0 }, null, async (playoutModel) => + activateRundownPlaylist(context, playoutModel, true) ) await expect(getPlaylist0()).resolves.toMatchObject({ activationId: expect.stringMatching(/^randomId/), @@ -78,8 +78,8 @@ describe('Playout Actions', () => { // Activating another rundown await expect( - runJobWithPlayoutCache(context, { playlistId: playlistId1 }, null, async (cache) => - activateRundownPlaylist(context, cache, false) + runJobWithPlayoutModel(context, { playlistId: playlistId1 }, null, async (playoutModel) => + activateRundownPlaylist(context, playoutModel, false) ) ).rejects.toMatchToString(/only one rundown can be active/i) diff --git a/packages/job-worker/src/playout/__tests__/infinites.test.ts b/packages/job-worker/src/playout/__tests__/infinites.test.ts index ef3715e0f7..7a9b226fd9 100644 --- a/packages/job-worker/src/playout/__tests__/infinites.test.ts +++ b/packages/job-worker/src/playout/__tests__/infinites.test.ts @@ -1,12 +1,12 @@ import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { MockJobContext, setupDefaultJobEnvironment } from '../../__mocks__/context' import { ReadonlyDeep, SetRequired } from 'type-fest' -import { CacheForPlayout, getOrderedSegmentsAndPartsFromPlayoutCache } from '../cache' +import { PlayoutModel } from '../model/PlayoutModel' import { candidatePartIsAfterPreviewPartInstance } from '../infinites' import { setupDefaultRundownPlaylist, setupMockShowStyleCompound } from '../../__mocks__/presetCollections' import { getRandomId } from '@sofie-automation/corelib/dist/lib' import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' -import { runJobWithPlayoutCache } from '../lock' +import { runJobWithPlayoutModel } from '../lock' import { wrapPartToTemporaryInstance } from '../../__mocks__/partinstance' import { protectString } from '@sofie-automation/corelib/dist/protectedString' @@ -19,9 +19,9 @@ describe('canContinueAdlibOnEndInfinites', () => { await setupMockShowStyleCompound(context) }) - async function wrapWithCache( + async function wrapWithPlayoutModel( fcn: ( - cache: CacheForPlayout, + playoutModel: PlayoutModel, playlist: SetRequired, 'activationId'> ) => Promise ): Promise { @@ -42,26 +42,26 @@ describe('canContinueAdlibOnEndInfinites', () => { const rundown = (await context.mockCollections.Rundowns.findOne(defaultSetup.rundownId)) as DBRundown expect(rundown).toBeTruthy() - return runJobWithPlayoutCache(context, { playlistId: tmpPlaylist._id }, null, async (cache) => { - const playlist = cache.Playlist.doc as SetRequired, 'activationId'> + return runJobWithPlayoutModel(context, { playlistId: tmpPlaylist._id }, null, async (playoutModel) => { + const playlist = playoutModel.Playlist as SetRequired, 'activationId'> if (!playlist.activationId) throw new Error('Missing activationId') - return fcn(cache, playlist) + return fcn(playoutModel, playlist) }) } test('Basic case', async () => { - await wrapWithCache(async (cache, playlist) => { - const orderedPartsAndSegments = getOrderedSegmentsAndPartsFromPlayoutCache(cache) - expect(orderedPartsAndSegments.parts.length).toBeGreaterThan(2) + await wrapWithPlayoutModel(async (playoutModel, playlist) => { + const orderedSegments = playoutModel.getAllOrderedSegments() + const orderedParts = playoutModel.getAllOrderedParts() + expect(orderedParts.length).toBeGreaterThan(2) // At beginning expect( candidatePartIsAfterPreviewPartInstance( context, - playlist, - orderedPartsAndSegments.segments, - wrapPartToTemporaryInstance(playlist.activationId, orderedPartsAndSegments.parts[0]), - orderedPartsAndSegments.parts[1] + orderedSegments, + wrapPartToTemporaryInstance(playlist.activationId, orderedParts[0]), + orderedParts[1] ) ).toBeTruthy() @@ -69,10 +69,9 @@ describe('canContinueAdlibOnEndInfinites', () => { expect( candidatePartIsAfterPreviewPartInstance( context, - playlist, - orderedPartsAndSegments.segments, - wrapPartToTemporaryInstance(playlist.activationId, orderedPartsAndSegments.parts[0]), - orderedPartsAndSegments.parts[2] + orderedSegments, + wrapPartToTemporaryInstance(playlist.activationId, orderedParts[0]), + orderedParts[2] ) ).toBeTruthy() @@ -80,13 +79,9 @@ describe('canContinueAdlibOnEndInfinites', () => { expect( candidatePartIsAfterPreviewPartInstance( context, - playlist, - orderedPartsAndSegments.segments, - wrapPartToTemporaryInstance( - playlist.activationId, - orderedPartsAndSegments.parts[orderedPartsAndSegments.parts.length - 2] - ), - orderedPartsAndSegments.parts[orderedPartsAndSegments.parts.length - 1] + orderedSegments, + wrapPartToTemporaryInstance(playlist.activationId, orderedParts[orderedParts.length - 2]), + orderedParts[orderedParts.length - 1] ) ).toBeTruthy() @@ -94,45 +89,38 @@ describe('canContinueAdlibOnEndInfinites', () => { expect( candidatePartIsAfterPreviewPartInstance( context, - playlist, - orderedPartsAndSegments.segments, - wrapPartToTemporaryInstance(playlist.activationId, orderedPartsAndSegments.parts[0]), - - orderedPartsAndSegments.parts[orderedPartsAndSegments.parts.length - 1] + orderedSegments, + wrapPartToTemporaryInstance(playlist.activationId, orderedParts[0]), + orderedParts[orderedParts.length - 1] ) ).toBeTruthy() }) }) test('No previousPartInstance', async () => { - await wrapWithCache(async (cache, playlist) => { - const orderedPartsAndSegments = getOrderedSegmentsAndPartsFromPlayoutCache(cache) + await wrapWithPlayoutModel(async (playoutModel, _playlist) => { + const orderedSegments = playoutModel.getAllOrderedSegments() + const orderedParts = playoutModel.getAllOrderedParts() expect( - candidatePartIsAfterPreviewPartInstance( - context, - playlist, - orderedPartsAndSegments.segments, - undefined, - orderedPartsAndSegments.parts[1] - ) + candidatePartIsAfterPreviewPartInstance(context, orderedSegments, undefined, orderedParts[1]) ).toBeFalsy() }) }) test('Is before', async () => { - await wrapWithCache(async (cache, playlist) => { - const orderedPartsAndSegments = getOrderedSegmentsAndPartsFromPlayoutCache(cache) - expect(orderedPartsAndSegments.parts.length).toBeGreaterThan(2) + await wrapWithPlayoutModel(async (playoutModel, playlist) => { + const orderedSegments = playoutModel.getAllOrderedSegments() + const orderedParts = playoutModel.getAllOrderedParts() + expect(orderedParts.length).toBeGreaterThan(2) // At beginning expect( candidatePartIsAfterPreviewPartInstance( context, - playlist, - orderedPartsAndSegments.segments, - wrapPartToTemporaryInstance(playlist.activationId, orderedPartsAndSegments.parts[1]), - orderedPartsAndSegments.parts[0] + orderedSegments, + wrapPartToTemporaryInstance(playlist.activationId, orderedParts[1]), + orderedParts[0] ) ).toBeFalsy() @@ -140,13 +128,9 @@ describe('canContinueAdlibOnEndInfinites', () => { expect( candidatePartIsAfterPreviewPartInstance( context, - playlist, - orderedPartsAndSegments.segments, - wrapPartToTemporaryInstance( - playlist.activationId, - orderedPartsAndSegments.parts[orderedPartsAndSegments.parts.length - 1] - ), - orderedPartsAndSegments.parts[orderedPartsAndSegments.parts.length - 2] + orderedSegments, + wrapPartToTemporaryInstance(playlist.activationId, orderedParts[orderedParts.length - 1]), + orderedParts[orderedParts.length - 2] ) ).toBeFalsy() @@ -154,25 +138,22 @@ describe('canContinueAdlibOnEndInfinites', () => { expect( candidatePartIsAfterPreviewPartInstance( context, - playlist, - orderedPartsAndSegments.segments, - wrapPartToTemporaryInstance( - playlist.activationId, - orderedPartsAndSegments.parts[orderedPartsAndSegments.parts.length - 1] - ), - orderedPartsAndSegments.parts[0] + orderedSegments, + wrapPartToTemporaryInstance(playlist.activationId, orderedParts[orderedParts.length - 1]), + orderedParts[0] ) ).toBeFalsy() }) }) test('Orphaned PartInstance', async () => { - await wrapWithCache(async (cache, playlist) => { - const orderedPartsAndSegments = getOrderedSegmentsAndPartsFromPlayoutCache(cache) - expect(orderedPartsAndSegments.parts.length).toBeGreaterThan(2) + await wrapWithPlayoutModel(async (playoutModel, playlist) => { + const orderedSegments = playoutModel.getAllOrderedSegments() + const orderedParts = playoutModel.getAllOrderedParts() + expect(orderedParts.length).toBeGreaterThan(2) const candidatePart = { - ...orderedPartsAndSegments.parts[0], + ...orderedParts[0], } // Orphaned because it has no presence in the ordered list candidatePart._rank = candidatePart._rank + 0.1 @@ -182,9 +163,8 @@ describe('canContinueAdlibOnEndInfinites', () => { expect( candidatePartIsAfterPreviewPartInstance( context, - playlist, - orderedPartsAndSegments.segments, - wrapPartToTemporaryInstance(playlist.activationId, orderedPartsAndSegments.parts[0]), + orderedSegments, + wrapPartToTemporaryInstance(playlist.activationId, orderedParts[0]), candidatePart ) ).toBeTruthy() @@ -193,9 +173,8 @@ describe('canContinueAdlibOnEndInfinites', () => { expect( candidatePartIsAfterPreviewPartInstance( context, - playlist, - orderedPartsAndSegments.segments, - wrapPartToTemporaryInstance(playlist.activationId, orderedPartsAndSegments.parts[1]), + orderedSegments, + wrapPartToTemporaryInstance(playlist.activationId, orderedParts[1]), candidatePart ) ).toBeFalsy() diff --git a/packages/job-worker/src/playout/__tests__/resolvedPieces.test.ts b/packages/job-worker/src/playout/__tests__/resolvedPieces.test.ts index 5df30032eb..b748acfb01 100644 --- a/packages/job-worker/src/playout/__tests__/resolvedPieces.test.ts +++ b/packages/job-worker/src/playout/__tests__/resolvedPieces.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ import { setupMockShowStyleCompound } from '../../__mocks__/presetCollections' import { MockJobContext, setupDefaultJobEnvironment } from '../../__mocks__/context' import { SourceLayers } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' diff --git a/packages/job-worker/src/playout/__tests__/selectNextPart.test.ts b/packages/job-worker/src/playout/__tests__/selectNextPart.test.ts index 8fe16fb4fa..ed8d44cd7f 100644 --- a/packages/job-worker/src/playout/__tests__/selectNextPart.test.ts +++ b/packages/job-worker/src/playout/__tests__/selectNextPart.test.ts @@ -3,8 +3,11 @@ import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' import { protectString } from '@sofie-automation/corelib/dist/protectedString' +import { ReadonlyDeep } from 'type-fest' import { MockJobContext, setupDefaultJobEnvironment } from '../../__mocks__/context' -import { selectNextPart, PartsAndSegments } from '../selectNextPart' +import { PlayoutSegmentModelImpl } from '../model/implementation/PlayoutSegmentModelImpl' +import { PlayoutSegmentModel } from '../model/PlayoutSegmentModel' +import { selectNextPart } from '../selectNextPart' class MockPart { constructor( @@ -62,38 +65,56 @@ describe('selectNextPart', () => { defaultSegments = [new MockSegment(segment1, 1), new MockSegment(segment2, 2), new MockSegment(segment3, 3)] }) - function getSegmentsAndParts(): PartsAndSegments { - return { - parts: [...(defaultParts as unknown as DBPart[])], - segments: [...(defaultSegments as unknown as DBSegment[])], - } + function selectNextPart2( + previousPartInstance: ReadonlyDeep | null, + currentlySelectedPartInstance: ReadonlyDeep | null, + ignoreUnplayabale = true + ) { + const parts = [...(defaultParts as unknown as DBPart[])] + const segments: readonly PlayoutSegmentModel[] = defaultSegments.map( + (segment) => + new PlayoutSegmentModelImpl( + segment as unknown as DBSegment, + parts.filter((p) => p.segmentId === segment._id) + ) + ) + + return selectNextPart( + context, + defaultPlaylist, + previousPartInstance, + currentlySelectedPartInstance, + segments, + parts, + ignoreUnplayabale + ) } test('from nothing', () => { { // default - const nextPart = selectNextPart(context, defaultPlaylist, null, null, getSegmentsAndParts()) + const nextPart = selectNextPart2(null, null) expect(nextPart).toEqual({ index: 0, part: defaultParts[0], consumesQueuedSegmentId: false }) } { // first isnt playable defaultParts[0].playable = false - const nextPart = selectNextPart(context, defaultPlaylist, null, null, getSegmentsAndParts()) + const nextPart = selectNextPart2(null, null) expect(nextPart).toEqual({ index: 1, part: defaultParts[1], consumesQueuedSegmentId: false }) } { - // queuedSegmentId is set + // nextSegmentId is set defaultPlaylist.queuedSegmentId = segment3 - const nextPart = selectNextPart(context, defaultPlaylist, null, null, getSegmentsAndParts()) + const nextPart = selectNextPart2(null, null) expect(nextPart).toEqual({ index: 6, part: defaultParts[6], consumesQueuedSegmentId: true }) } { - // queuedSegmentId is set (and first there isnt playable) + // nextSegmentId is set (and first there isnt playable) defaultPlaylist.queuedSegmentId = segment2 - const nextPart = selectNextPart(context, defaultPlaylist, null, null, getSegmentsAndParts()) + const nextPart = selectNextPart2(null, null) expect(nextPart).toEqual({ index: 4, part: defaultParts[4], consumesQueuedSegmentId: true }) } }) @@ -101,28 +122,28 @@ describe('selectNextPart', () => { test('from nothing - allow unplayable', () => { { // default - const nextPart = selectNextPart(context, defaultPlaylist, null, null, getSegmentsAndParts(), false) + const nextPart = selectNextPart2(null, null, false) expect(nextPart).toEqual({ index: 0, part: defaultParts[0], consumesQueuedSegmentId: false }) } { // first isnt playable defaultParts[0].playable = false - const nextPart = selectNextPart(context, defaultPlaylist, null, null, getSegmentsAndParts(), false) + const nextPart = selectNextPart2(null, null, false) expect(nextPart).toEqual({ index: 0, part: defaultParts[0], consumesQueuedSegmentId: false }) } { - // queuedSegmentId is set + // nextSegmentId is set defaultPlaylist.queuedSegmentId = segment3 - const nextPart = selectNextPart(context, defaultPlaylist, null, null, getSegmentsAndParts(), false) + const nextPart = selectNextPart2(null, null, false) expect(nextPart).toEqual({ index: 6, part: defaultParts[6], consumesQueuedSegmentId: true }) } { - // queuedSegmentId is set (and first there isnt playable) + // nextSegmentId is set (and first there isnt playable) defaultPlaylist.queuedSegmentId = segment2 - const nextPart = selectNextPart(context, defaultPlaylist, null, null, getSegmentsAndParts(), false) + const nextPart = selectNextPart2(null, null, false) expect(nextPart).toEqual({ index: 3, part: defaultParts[3], consumesQueuedSegmentId: true }) } }) @@ -131,14 +152,14 @@ describe('selectNextPart', () => { const previousPartInstance = defaultParts[4].toPartInstance() { // default - const nextPart = selectNextPart(context, defaultPlaylist, previousPartInstance, null, getSegmentsAndParts()) + const nextPart = selectNextPart2(previousPartInstance, null) expect(nextPart).toEqual({ index: 5, part: defaultParts[5], consumesQueuedSegmentId: false }) } { // next isnt playable defaultParts[5].playable = false - const nextPart = selectNextPart(context, defaultPlaylist, previousPartInstance, null, getSegmentsAndParts()) + const nextPart = selectNextPart2(previousPartInstance, null) expect(nextPart).toEqual({ index: 6, part: defaultParts[6], consumesQueuedSegmentId: false }) } @@ -146,14 +167,14 @@ describe('selectNextPart', () => { // queuedSegmentId is set defaultParts[0].playable = false defaultPlaylist.queuedSegmentId = segment1 - const nextPart = selectNextPart(context, defaultPlaylist, previousPartInstance, null, getSegmentsAndParts()) + const nextPart = selectNextPart2(previousPartInstance, null) expect(nextPart).toEqual({ index: 1, part: defaultParts[1], consumesQueuedSegmentId: true }) } { - // queuedSegmentId is set (and first there isnt playable) + // nextSegmentId is set (and first there isnt playable) defaultPlaylist.queuedSegmentId = segment2 - const nextPart = selectNextPart(context, defaultPlaylist, previousPartInstance, null, getSegmentsAndParts()) + const nextPart = selectNextPart2(previousPartInstance, null) expect(nextPart).toEqual({ index: 4, part: defaultParts[4], consumesQueuedSegmentId: true }) } }) @@ -165,21 +186,21 @@ describe('selectNextPart', () => { { // single part is orphaned - const nextPart = selectNextPart(context, defaultPlaylist, previousPartInstance, null, getSegmentsAndParts()) + const nextPart = selectNextPart2(previousPartInstance, null) expect(nextPart).toEqual({ index: 4, part: defaultParts[4], consumesQueuedSegmentId: false }) } { // whole segment is orphaned/deleted defaultParts = defaultParts.filter((p) => p.segmentId !== previousPartInstance.segmentId) - const nextPart = selectNextPart(context, defaultPlaylist, previousPartInstance, null, getSegmentsAndParts()) + const nextPart = selectNextPart2(previousPartInstance, null) expect(nextPart).toEqual({ index: 3, part: defaultParts[3], consumesQueuedSegmentId: false }) } { // no parts after defaultParts = defaultParts.filter((p) => p.segmentId !== segment3) - const nextPart = selectNextPart(context, defaultPlaylist, previousPartInstance, null, getSegmentsAndParts()) + const nextPart = selectNextPart2(previousPartInstance, null) expect(nextPart).toEqual(null) } @@ -187,7 +208,7 @@ describe('selectNextPart', () => { // no parts after, but looping defaultPlaylist.loop = true defaultParts = defaultParts.filter((p) => p.segmentId !== segment3) - const nextPart = selectNextPart(context, defaultPlaylist, previousPartInstance, null, getSegmentsAndParts()) + const nextPart = selectNextPart2(previousPartInstance, null) expect(nextPart).toEqual({ index: 0, part: defaultParts[0], consumesQueuedSegmentId: false }) } }) @@ -196,28 +217,14 @@ describe('selectNextPart', () => { const previousPartInstance = defaultParts[4].toPartInstance() { // default - const nextPart = selectNextPart( - context, - defaultPlaylist, - previousPartInstance, - null, - getSegmentsAndParts(), - false - ) + const nextPart = selectNextPart2(previousPartInstance, null, false) expect(nextPart).toEqual({ index: 5, part: defaultParts[5], consumesQueuedSegmentId: false }) } { // next isnt playable defaultParts[5].playable = false - const nextPart = selectNextPart( - context, - defaultPlaylist, - previousPartInstance, - null, - getSegmentsAndParts(), - false - ) + const nextPart = selectNextPart2(previousPartInstance, null, false) expect(nextPart).toEqual({ index: 5, part: defaultParts[5], consumesQueuedSegmentId: false }) } }) diff --git a/packages/job-worker/src/playout/__tests__/timeline.test.ts b/packages/job-worker/src/playout/__tests__/timeline.test.ts index 5f516ea4ce..71112c6ef4 100644 --- a/packages/job-worker/src/playout/__tests__/timeline.test.ts +++ b/packages/job-worker/src/playout/__tests__/timeline.test.ts @@ -16,7 +16,7 @@ import { handleTakeNextPart } from '../take' import { handleActivateHold } from '../holdJobs' import { handleActivateRundownPlaylist, handleDeactivateRundownPlaylist } from '../activePlaylistJobs' import { fixSnapshot } from '../../__mocks__/helpers/snapshot' -import { runJobWithPlayoutCache } from '../lock' +import { runJobWithPlayoutModel } from '../lock' import { updateTimeline } from '../timeline/generate' import { getSelectedPartInstances, getSortedPartsForRundown } from './lib' import { PieceLifespan, IBlueprintPieceType, Time } from '@sofie-automation/blueprints-integration' @@ -61,8 +61,10 @@ import { PlayoutChangedResult, PlayoutChangedType, } from '@sofie-automation/shared-lib/dist/peripheralDevice/peripheralDeviceAPI' -import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' import * as _ from 'underscore' +import { PlayoutRundownModel } from '../model/PlayoutRundownModel' +import { PlayoutPartInstanceModel } from '../model/PlayoutPartInstanceModel' +import { PlayoutPartInstanceModelImpl } from '../model/implementation/PlayoutPartInstanceModelImpl' /** * An object used to represent the simplified timeline structure. @@ -175,19 +177,18 @@ function parsePieceGroupPrerollAndPostroll( * Check the timings of objects in the timeline to an expected result * Note: this ensures that every piece for the currentPartInstance is accounted for in the expected result */ -async function checkTimingsRaw( - context: MockJobContext, +function checkTimingsRaw( rundownId: RundownId, timeline: TimelineComplete | undefined, - currentPartInstance: DBPartInstance, - previousPartInstance: DBPartInstance | undefined, + currentPartInstance: PlayoutPartInstanceModel, + previousPartInstance: PlayoutPartInstanceModel | undefined, expectedTimings: PartTimelineTimings ) { const timelineObjs = timeline ? deserializeTimelineBlob(timeline?.timelineBlob) : [] const objs = normalizeArrayToMap(timelineObjs, 'id') // previous part group - const prevPartTlObj = previousPartInstance ? objs.get(getPartGroupId(previousPartInstance)) : undefined + const prevPartTlObj = previousPartInstance ? objs.get(getPartGroupId(previousPartInstance.PartInstance)) : undefined if (expectedTimings.previousPart) { expect(prevPartTlObj).toBeTruthy() expect(prevPartTlObj?.enable).toMatchObject(expectedTimings.previousPart) @@ -198,19 +199,17 @@ async function checkTimingsRaw( // current part group is assumed to start at now // Current pieces - const currentPieces = await context.directCollections.PieceInstances.findFetch({ - partInstanceId: currentPartInstance._id, - }) + const currentPieces = currentPartInstance.PieceInstances const targetCurrentPieces: PartTimelineTimings['currentPieces'] = {} const targetCurrentInfinitePieces: PartTimelineTimings['currentInfinitePieces'] = {} for (const piece of currentPieces) { - let entryId = unprotectString(piece.piece._id) + let entryId = unprotectString(piece.PieceInstance.piece._id) if (entryId.startsWith(unprotectString(rundownId))) entryId = entryId.substring(unprotectString(rundownId).length + 1) - if (piece.piece.lifespan === PieceLifespan.WithinPart) { - const pieceObj = objs.get(getPieceGroupId(piece)) - const controlObj = objs.get(getPieceControlObjectId(piece)) + if (piece.PieceInstance.piece.lifespan === PieceLifespan.WithinPart) { + const pieceObj = objs.get(getPieceGroupId(piece.PieceInstance)) + const controlObj = objs.get(getPieceControlObjectId(piece.PieceInstance)) targetCurrentPieces[entryId] = controlObj ? { @@ -219,13 +218,14 @@ async function checkTimingsRaw( } : null } else { - const partGroupId = getPartGroupId(protectString(unprotectString(piece._id))) + '_infinite' + const partGroupId = + getPartGroupId(protectString(unprotectString(piece.PieceInstance._id))) + '_infinite' const partObj = objs.get(partGroupId) if (!partObj) { targetCurrentInfinitePieces[entryId] = null } else { - const pieceObj = objs.get(getPieceGroupId(piece)) - const controlObj = objs.get(getPieceControlObjectId(piece)) + const pieceObj = objs.get(getPieceGroupId(piece.PieceInstance)) + const controlObj = objs.get(getPieceControlObjectId(piece.PieceInstance)) targetCurrentInfinitePieces[entryId] = { partGroup: partObj.enable, @@ -242,16 +242,14 @@ async function checkTimingsRaw( if (previousPartInstance) { // Previous pieces - const previousPieces = await context.directCollections.PieceInstances.findFetch({ - partInstanceId: previousPartInstance._id, - }) + const previousPieces = previousPartInstance.PieceInstances let previousOutTransition: PartTimelineTimings['previousOutTransition'] for (const piece of previousPieces) { - if (piece.piece.pieceType === IBlueprintPieceType.OutTransition) { + if (piece.PieceInstance.piece.pieceType === IBlueprintPieceType.OutTransition) { if (previousOutTransition !== undefined) throw new Error('Too many out transition pieces were found') - const pieceObj = objs.get(getPieceGroupId(piece)) - const controlObj = objs.get(getPieceControlObjectId(piece)) + const pieceObj = objs.get(getPieceGroupId(piece.PieceInstance)) + const controlObj = objs.get(getPieceControlObjectId(piece.PieceInstance)) previousOutTransition = controlObj ? { childGroup: parsePieceGroupPrerollAndPostroll(pieceObj?.enable ?? []), @@ -438,22 +436,22 @@ async function doDeactivatePlaylist(context: MockJobContext, playlistId: Rundown /** perform an update of the timeline */ async function doUpdateTimeline(context: MockJobContext, playlistId: RundownPlaylistId, forceNowToTime?: Time) { - await runJobWithPlayoutCache( + await runJobWithPlayoutModel( context, { playlistId: playlistId, }, null, - async (cache) => { - await updateTimeline(context, cache, forceNowToTime) + async (playoutModel) => { + await updateTimeline(context, playoutModel, forceNowToTime) } ) } interface SelectedPartInstances { - currentPartInstance: DBPartInstance | undefined - nextPartInstance: DBPartInstance | undefined - previousPartInstance: DBPartInstance | undefined + currentPartInstance: PlayoutPartInstanceModel | undefined + nextPartInstance: PlayoutPartInstanceModel | undefined + previousPartInstance: PlayoutPartInstanceModel | undefined } describe('Timeline', () => { @@ -542,8 +540,8 @@ describe('Timeline', () => { // }) } - await runJobWithPlayoutCache(context, { playlistId: playlistId0 }, null, async (cache) => { - await updateTimeline(context, cache) + await runJobWithPlayoutModel(context, { playlistId: playlistId0 }, null, async (playoutModel) => { + await updateTimeline(context, playoutModel) }) expect(fixSnapshot(await context.directCollections.Timelines.findFetch())).toMatchSnapshot() @@ -703,10 +701,22 @@ describe('Timeline', () => { )) as DBRundownPlaylist expect(playlist).toBeTruthy() const res = await getSelectedPartInstances(context, playlist) + + async function wrapPartInstance( + partInstance: DBPartInstance | null + ): Promise { + if (!partInstance) return undefined + + const pieceInstances = await context.directCollections.PieceInstances.findFetch({ + partInstanceId: partInstance?._id, + }) + return new PlayoutPartInstanceModelImpl(partInstance, pieceInstances, false) + } + return { - currentPartInstance: res.currentPartInstance ?? undefined, - nextPartInstance: res.nextPartInstance ?? undefined, - previousPartInstance: res.previousPartInstance ?? undefined, + currentPartInstance: await wrapPartInstance(res.currentPartInstance), + nextPartInstance: await wrapPartInstance(res.nextPartInstance), + previousPartInstance: await wrapPartInstance(res.previousPartInstance), } } @@ -720,7 +730,7 @@ describe('Timeline', () => { await doUpdateTimeline(context, playlistId0) const { currentPartInstance, previousPartInstance } = await getPartInstances() - return checkTimingsRaw(context, rundownId0, timeline, currentPartInstance!, previousPartInstance, timings) + return checkTimingsRaw(rundownId0, timeline, currentPartInstance!, previousPartInstance, timings) } // Run the required steps @@ -945,7 +955,7 @@ describe('Timeline', () => { const { currentPartInstance } = await getPartInstances() await checkTimings({ // old part ends immediately - previousPart: { end: `#${getPartGroupId(currentPartInstance!)}.start + 0` }, + previousPart: { end: `#${getPartGroupId(currentPartInstance!.PartInstance)}.start + 0` }, currentPieces: { // pieces are not delayed piece010: { @@ -1105,7 +1115,7 @@ describe('Timeline', () => { const { currentPartInstance } = await getPartInstances() await checkTimings({ - previousPart: { end: `#${getPartGroupId(currentPartInstance!)}.start + 500` }, // note: this seems odd, but the pieces are delayed to compensate + previousPart: { end: `#${getPartGroupId(currentPartInstance!.PartInstance)}.start + 500` }, // note: this seems odd, but the pieces are delayed to compensate currentPieces: { piece010: { controlObj: { start: 500 }, // note: Offset matches extension of previous partGroup @@ -1120,16 +1130,24 @@ describe('Timeline', () => { }) describe('Adlib pieces', () => { - async function doStartAdlibPiece( - playlistId: RundownPlaylistId, - currentPartInstance: DBPartInstance, - adlibSource: AdLibPiece - ) { - await runJobWithPlayoutCache(context, { playlistId }, null, async (cache) => { - const rundown = cache.Rundowns.findOne(currentPartInstance.rundownId) as Rundown + async function doStartAdlibPiece(playlistId: RundownPlaylistId, adlibSource: AdLibPiece) { + await runJobWithPlayoutModel(context, { playlistId }, null, async (playoutModel) => { + const currentPartInstance = playoutModel.CurrentPartInstance as PlayoutPartInstanceModel + expect(currentPartInstance).toBeTruthy() + + const rundown = playoutModel.getRundown( + currentPartInstance.PartInstance.rundownId + ) as PlayoutRundownModel expect(rundown).toBeTruthy() - return innerStartOrQueueAdLibPiece(context, cache, rundown, false, currentPartInstance, adlibSource) + return innerStartOrQueueAdLibPiece( + context, + playoutModel, + rundown, + false, + currentPartInstance, + adlibSource + ) }) } @@ -1208,10 +1226,9 @@ describe('Timeline', () => { // Insert an adlib piece await doStartAdlibPiece( playlistId, - currentPartInstance!, literal({ _id: protectString('adlib1'), - rundownId: currentPartInstance!.rundownId, + rundownId: currentPartInstance!.PartInstance.rundownId, externalId: 'fake', name: 'Adlibbed piece', lifespan: PieceLifespan.WithinPart, @@ -1223,7 +1240,7 @@ describe('Timeline', () => { }) ) - const adlibbedPieceId = 'randomId9007' + const adlibbedPieceId = 'randomId9010' // The adlib should be starting at 'now' await checkTimings({ @@ -1233,7 +1250,7 @@ describe('Timeline', () => { controlObj: { start: 500, // This one gave the preroll end: `#piece_group_control_${ - currentPartInstance!._id + currentPartInstance!.PartInstance._id }_${rundownId}_piece000_cap_now.start + 0`, }, childGroup: { @@ -1374,10 +1391,9 @@ describe('Timeline', () => { // Insert an adlib piece await doStartAdlibPiece( playlistId, - currentPartInstance!, literal({ _id: protectString('adlib1'), - rundownId: currentPartInstance!.rundownId, + rundownId: currentPartInstance!.PartInstance.rundownId, externalId: 'fake', name: 'Adlibbed piece', lifespan: PieceLifespan.WithinPart, @@ -1390,7 +1406,7 @@ describe('Timeline', () => { }) ) - const adlibbedPieceId = 'randomId9007' + const adlibbedPieceId = 'randomId9010' // The adlib should be starting at 'now' await checkTimings({ @@ -1400,7 +1416,7 @@ describe('Timeline', () => { controlObj: { start: 500, // This one gave the preroll end: `#piece_group_control_${ - currentPartInstance!._id + currentPartInstance!.PartInstance._id }_${_rundownId}_piece000_cap_now.start + 0`, }, childGroup: { @@ -1421,7 +1437,7 @@ describe('Timeline', () => { // Our adlibbed piece controlObj: { start: `#piece_group_control_${ - currentPartInstance!._id + currentPartInstance!.PartInstance._id }_${adlibbedPieceId}_start_now + 340`, }, childGroup: { @@ -1480,12 +1496,6 @@ describe('Timeline', () => { }) describe('Infinite Pieces', () => { - async function getPieceInstances(partInstanceId: PartInstanceId): Promise { - return context.directCollections.PieceInstances.findFetch({ - partInstanceId, - }) - } - test('Infinite Piece has stable timing across timeline regenerations with/without plannedStartedPlayback', async () => runTimelineTimings( async ( @@ -1549,31 +1559,31 @@ describe('Timeline', () => { }, }, partGroup: { - start: `#part_group_${currentPartInstance._id}.start`, + start: `#part_group_${currentPartInstance.PartInstance._id}.start`, }, }, }, previousOutTransition: undefined, }) - const currentPieceInstances = await getPieceInstances(currentPartInstance._id) + const currentPieceInstances = currentPartInstance.PieceInstances const pieceInstance0 = currentPieceInstances.find( - (instance) => instance.piece._id === protectString(`${rundownId}_piece000`) + (instance) => instance.PieceInstance.piece._id === protectString(`${rundownId}_piece000`) ) if (!pieceInstance0) throw new Error('pieceInstance0 must be defined') const pieceInstance1 = currentPieceInstances.find( - (instance) => instance.piece._id === protectString(`${rundownId}_piece001`) + (instance) => instance.PieceInstance.piece._id === protectString(`${rundownId}_piece001`) ) if (!pieceInstance1) throw new Error('pieceInstance1 must be defined') const currentTime = 12300 await doOnPlayoutPlaybackChanged(context, playlistId, { baseTime: currentTime, - partId: currentPartInstance._id, + partId: currentPartInstance.PartInstance._id, includePart: true, pieceOffsets: { - [unprotectString(pieceInstance0._id)]: 500, - [unprotectString(pieceInstance1._id)]: 500, + [unprotectString(pieceInstance0.PieceInstance._id)]: 500, + [unprotectString(pieceInstance1.PieceInstance._id)]: 500, }, }) diff --git a/packages/job-worker/src/playout/abPlayback/abSessionHelper.ts b/packages/job-worker/src/playout/abPlayback/abSessionHelper.ts index 2216e97e1f..5b97bd619b 100644 --- a/packages/job-worker/src/playout/abPlayback/abSessionHelper.ts +++ b/packages/job-worker/src/playout/abPlayback/abSessionHelper.ts @@ -44,7 +44,7 @@ export class AbSessionHelper { * Get the full session id for an ab playback session. * Note: sessionName should be unique within the segment unless pieces want to share a session */ - getPieceABSessionId(pieceInstance: PieceInstance, sessionName: string): string { + getPieceABSessionId(pieceInstance: ReadonlyDeep, sessionName: string): string { const partInstanceIndex = this.#partInstances.findIndex((p) => p._id === pieceInstance.partInstanceId) const partInstance = partInstanceIndex >= 0 ? this.#partInstances[partInstanceIndex] : undefined if (!partInstance) throw new Error('Unknown partInstanceId in call to getPieceABSessionId') diff --git a/packages/job-worker/src/playout/activePlaylistActions.ts b/packages/job-worker/src/playout/activePlaylistActions.ts index 21066e0fa6..67a56abe41 100644 --- a/packages/job-worker/src/playout/activePlaylistActions.ts +++ b/packages/job-worker/src/playout/activePlaylistActions.ts @@ -1,36 +1,32 @@ import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' -import { getRandomId } from '@sofie-automation/corelib/dist/lib' import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' import { getActiveRundownPlaylistsInStudioFromDb } from '../studio/lib' -import _ = require('underscore') import { JobContext } from '../jobs' import { logger } from '../logging' -import { CacheForPlayout, getOrderedSegmentsAndPartsFromPlayoutCache, getSelectedPartInstancesFromCache } from './cache' +import { PlayoutModel } from './model/PlayoutModel' import { resetRundownPlaylist } from './lib' import { selectNextPart } from './selectNextPart' import { setNextPart } from './setNext' import { updateStudioTimeline, updateTimeline } from './timeline/generate' import { getCurrentTime } from '../lib' -import { RundownHoldState } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { EventsJobs } from '@sofie-automation/corelib/dist/worker/events' -import { RundownPlaylistActivationId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { cleanTimelineDatastore } from './datastore' import { RundownActivationContext } from '../blueprints/context/RundownActivationContext' +import { ReadonlyDeep } from 'type-fest' export async function activateRundownPlaylist( context: JobContext, - cache: CacheForPlayout, + playoutModel: PlayoutModel, rehearsal: boolean ): Promise { - logger.info('Activating rundown ' + cache.Playlist.doc._id + (rehearsal ? ' (Rehearsal)' : '')) + logger.info('Activating rundown ' + playoutModel.Playlist._id + (rehearsal ? ' (Rehearsal)' : '')) rehearsal = !!rehearsal - const wasActive = !!cache.Playlist.doc.activationId + const wasActive = !!playoutModel.Playlist.activationId const anyOtherActiveRundowns = await getActiveRundownPlaylistsInStudioFromDb( context, context.studio._id, - cache.Playlist.doc._id + playoutModel.Playlist._id ) if (anyOtherActiveRundowns.length) { // logger.warn('Only one rundown can be active at the same time. Active rundowns: ' + _.map(anyOtherActiveRundowns, rundown => rundown._id)) @@ -41,89 +37,56 @@ export async function activateRundownPlaylist( ) } - if (!cache.Playlist.doc.activationId) { + if (!playoutModel.Playlist.activationId) { // Reset the playlist if it wasnt already active - await resetRundownPlaylist(context, cache) + await resetRundownPlaylist(context, playoutModel) } - const newActivationId: RundownPlaylistActivationId = getRandomId() - cache.Playlist.update((p) => { - p.activationId = newActivationId - p.rehearsal = rehearsal - return p - }) - - let rundown: DBRundown | undefined + const newActivationId = playoutModel.activatePlaylist(rehearsal) - const { currentPartInstance } = getSelectedPartInstancesFromCache(cache) - if (!currentPartInstance || currentPartInstance.reset) { - cache.Playlist.update((p) => { - p.currentPartInfo = null - p.nextPartInfo = null - p.previousPartInfo = null + let rundown: ReadonlyDeep | undefined - delete p.lastTakeTime - return p - }) + const currentPartInstance = playoutModel.CurrentPartInstance + if (!currentPartInstance || currentPartInstance.PartInstance.reset) { + playoutModel.clearSelectedPartInstances() // If we are not playing anything, then regenerate the next part const firstPart = selectNextPart( context, - cache.Playlist.doc, + playoutModel.Playlist, null, null, - getOrderedSegmentsAndPartsFromPlayoutCache(cache) + playoutModel.getAllOrderedSegments(), + playoutModel.getAllOrderedParts() ) - await setNextPart(context, cache, firstPart, false) + await setNextPart(context, playoutModel, firstPart, false) if (firstPart) { - rundown = cache.Rundowns.findOne(firstPart.part.rundownId) + rundown = playoutModel.getRundown(firstPart.part.rundownId)?.Rundown } } else { // Otherwise preserve the active partInstances - const partInstancesToPreserve = new Set( - _.compact([ - cache.Playlist.doc.nextPartInfo?.partInstanceId, - cache.Playlist.doc.currentPartInfo?.partInstanceId, - cache.Playlist.doc.previousPartInfo?.partInstanceId, - ]) - ) - cache.PartInstances.updateAll((p) => { - if (partInstancesToPreserve.has(p._id)) { - p.playlistActivationId = newActivationId - return p - } else { - return false - } - }) - cache.PieceInstances.updateAll((p) => { - if (partInstancesToPreserve.has(p.partInstanceId)) { - p.playlistActivationId = newActivationId - return p - } else { - return false - } - }) - - if (cache.Playlist.doc.nextPartInfo) { - const nextPartInstance = cache.PartInstances.findOne(cache.Playlist.doc.nextPartInfo.partInstanceId) - if (!nextPartInstance) - throw new Error(`Could not find nextPartInstance "${cache.Playlist.doc.nextPartInfo.partInstanceId}"`) - rundown = cache.Rundowns.findOne(nextPartInstance.rundownId) - if (!rundown) throw new Error(`Could not find rundown "${nextPartInstance.rundownId}"`) + for (const partInstance of playoutModel.SelectedPartInstances) { + partInstance.setPlaylistActivationId(newActivationId) + } + + const nextPartInstance = playoutModel.NextPartInstance + if (nextPartInstance) { + rundown = playoutModel.getRundown(nextPartInstance.PartInstance.rundownId)?.Rundown + if (!rundown) throw new Error(`Could not find rundown "${nextPartInstance.PartInstance.rundownId}"`) } } - await updateTimeline(context, cache) + await updateTimeline(context, playoutModel) - cache.deferBeforeSave(async () => { + playoutModel.deferBeforeSave(async () => { if (!rundown) return // if the proper rundown hasn't been found, there's little point doing anything else const showStyle = await context.getShowStyleCompound(rundown.showStyleVariantId, rundown.showStyleBaseId) const blueprint = await context.getShowStyleBlueprint(showStyle._id) try { if (blueprint.blueprint.onRundownActivate) { - const blueprintContext = new RundownActivationContext(context, cache, showStyle, rundown) + const blueprintContext = new RundownActivationContext(context, playoutModel, showStyle, rundown) await blueprint.blueprint.onRundownActivate(blueprintContext, wasActive) } @@ -132,21 +95,21 @@ export async function activateRundownPlaylist( } }) } -export async function deactivateRundownPlaylist(context: JobContext, cache: CacheForPlayout): Promise { - const rundown = await deactivateRundownPlaylistInner(context, cache) +export async function deactivateRundownPlaylist(context: JobContext, playoutModel: PlayoutModel): Promise { + const rundown = await deactivateRundownPlaylistInner(context, playoutModel) - await updateStudioTimeline(context, cache) + await updateStudioTimeline(context, playoutModel) - await cleanTimelineDatastore(context, cache) + await cleanTimelineDatastore(context, playoutModel) - cache.deferBeforeSave(async () => { + playoutModel.deferBeforeSave(async () => { if (rundown) { const showStyle = await context.getShowStyleCompound(rundown.showStyleVariantId, rundown.showStyleBaseId) const blueprint = await context.getShowStyleBlueprint(showStyle._id) try { if (blueprint.blueprint.onRundownDeActivate) { - const blueprintContext = new RundownActivationContext(context, cache, showStyle, rundown) + const blueprintContext = new RundownActivationContext(context, playoutModel, showStyle, rundown) await blueprint.blueprint.onRundownDeActivate(blueprintContext) } } catch (err) { @@ -157,54 +120,30 @@ export async function deactivateRundownPlaylist(context: JobContext, cache: Cach } export async function deactivateRundownPlaylistInner( context: JobContext, - cache: CacheForPlayout -): Promise { + playoutModel: PlayoutModel +): Promise | undefined> { const span = context.startSpan('deactivateRundownPlaylistInner') - logger.info(`Deactivating rundown playlist "${cache.Playlist.doc._id}"`) + logger.info(`Deactivating rundown playlist "${playoutModel.Playlist._id}"`) - const { currentPartInstance, nextPartInstance } = getSelectedPartInstancesFromCache(cache) + const currentPartInstance = playoutModel.CurrentPartInstance + const nextPartInstance = playoutModel.NextPartInstance - let rundown: DBRundown | undefined + let rundown: ReadonlyDeep | undefined if (currentPartInstance) { - rundown = cache.Rundowns.findOne(currentPartInstance.rundownId) - - cache.deferAfterSave(async () => { - context - .queueEventJob(EventsJobs.NotifyCurrentlyPlayingPart, { - rundownId: currentPartInstance.rundownId, - isRehearsal: !!cache.Playlist.doc.rehearsal, - partExternalId: null, - }) - .catch((e) => { - logger.warn(`Failed to queue NotifyCurrentlyPlayingPart job: ${e}`) - }) - }) + rundown = playoutModel.getRundown(currentPartInstance.PartInstance.rundownId)?.Rundown + + playoutModel.queueNotifyCurrentlyPlayingPartEvent(currentPartInstance.PartInstance.rundownId, null) } else if (nextPartInstance) { - rundown = cache.Rundowns.findOne(nextPartInstance.rundownId) + rundown = playoutModel.getRundown(nextPartInstance.PartInstance.rundownId)?.Rundown } - cache.Playlist.update((p) => { - p.previousPartInfo = null - p.currentPartInfo = null - p.holdState = RundownHoldState.NONE + playoutModel.deactivatePlaylist() - delete p.activationId - delete p.queuedSegmentId - - return p - }) - await setNextPart(context, cache, null, false) + await setNextPart(context, playoutModel, null, false) if (currentPartInstance) { // Set the current PartInstance as stopped - cache.PartInstances.updateOne(currentPartInstance._id, (instance) => { - if (instance.timings?.plannedStartedPlayback && !instance.timings.plannedStoppedPlayback) { - instance.timings.plannedStoppedPlayback = getCurrentTime() - instance.timings.duration = getCurrentTime() - instance.timings.plannedStartedPlayback - return instance - } - return false - }) + currentPartInstance.setPlannedStoppedPlayback(getCurrentTime()) } if (span) span.end() diff --git a/packages/job-worker/src/playout/activePlaylistJobs.ts b/packages/job-worker/src/playout/activePlaylistJobs.ts index 32b7e2ab3f..a3bd7be4a4 100644 --- a/packages/job-worker/src/playout/activePlaylistJobs.ts +++ b/packages/job-worker/src/playout/activePlaylistJobs.ts @@ -7,7 +7,7 @@ import { ResetRundownPlaylistProps, } from '@sofie-automation/corelib/dist/worker/studio' import { JobContext } from '../jobs' -import { runJobWithPlayoutCache } from './lock' +import { runJobWithPlayoutModel } from './lock' import { resetRundownPlaylist } from './lib' import { updateTimeline } from './timeline/generate' import { getActiveRundownPlaylistsInStudioFromDb } from '../studio/lib' @@ -43,19 +43,19 @@ export async function handlePrepareRundownPlaylistForBroadcast( context: JobContext, data: PrepareRundownForBroadcastProps ): Promise { - return runJobWithPlayoutCache( + return runJobWithPlayoutModel( context, data, - async (cache) => { - const playlist = cache.Playlist.doc + async (playoutModel) => { + const playlist = playoutModel.Playlist if (playlist.activationId) throw UserError.create(UserErrorMessage.RundownAlreadyActive) await checkNoOtherPlaylistsActive(context, playlist) }, - async (cache) => { - await resetRundownPlaylist(context, cache) + async (playoutModel) => { + await resetRundownPlaylist(context, playoutModel) - await activateRundownPlaylist(context, cache, true) // Activate rundownPlaylist (rehearsal) + await activateRundownPlaylist(context, playoutModel, true) // Activate rundownPlaylist (rehearsal) } ) } @@ -66,11 +66,11 @@ export async function handlePrepareRundownPlaylistForBroadcast( * Optionally activate the rundown at the end. */ export async function handleResetRundownPlaylist(context: JobContext, data: ResetRundownPlaylistProps): Promise { - return runJobWithPlayoutCache( + return runJobWithPlayoutModel( context, data, - async (cache) => { - const playlist = cache.Playlist.doc + async (playoutModel) => { + const playlist = playoutModel.Playlist if (playlist.activationId && !playlist.rehearsal && !context.studio.settings.allowRundownResetOnAir) { throw UserError.create(UserErrorMessage.RundownResetWhileActive) } @@ -87,7 +87,7 @@ export async function handleResetRundownPlaylist(context: JobContext, data: Rese // Try deactivating everything in parallel, although there should only ever be one active await Promise.allSettled( anyOtherActivePlaylists.map(async (otherRundownPlaylist) => - runJobWithPlayoutCache( + runJobWithPlayoutModel( context, // 'forceResetAndActivateRundownPlaylist', { playlistId: otherRundownPlaylist._id }, @@ -109,15 +109,15 @@ export async function handleResetRundownPlaylist(context: JobContext, data: Rese } } }, - async (cache) => { - await resetRundownPlaylist(context, cache) + async (playoutModel) => { + await resetRundownPlaylist(context, playoutModel) if (data.activate) { // Do the activation - await activateRundownPlaylist(context, cache, data.activate !== 'active') // Activate rundown - } else if (cache.Playlist.doc.activationId) { + await activateRundownPlaylist(context, playoutModel, data.activate !== 'active') // Activate rundown + } else if (playoutModel.Playlist.activationId) { // Only update the timeline if this is the active playlist - await updateTimeline(context, cache) + await updateTimeline(context, playoutModel) } } ) @@ -130,16 +130,16 @@ export async function handleActivateRundownPlaylist( context: JobContext, data: ActivateRundownPlaylistProps ): Promise { - return runJobWithPlayoutCache( + return runJobWithPlayoutModel( context, // 'activateRundownPlaylist', data, - async (cache) => { - const playlist = cache.Playlist.doc + async (playoutModel) => { + const playlist = playoutModel.Playlist await checkNoOtherPlaylistsActive(context, playlist) }, - async (cache) => { - await activateRundownPlaylist(context, cache, data.rehearsal) + async (playoutModel) => { + await activateRundownPlaylist(context, playoutModel, data.rehearsal) } ) } @@ -151,13 +151,13 @@ export async function handleDeactivateRundownPlaylist( context: JobContext, data: DeactivateRundownPlaylistProps ): Promise { - return runJobWithPlayoutCache( + return runJobWithPlayoutModel( context, // 'deactivateRundownPlaylist', data, null, - async (cache) => { - await deactivateRundownPlaylist(context, cache) + async (playoutModel) => { + await deactivateRundownPlaylist(context, playoutModel) } ) } diff --git a/packages/job-worker/src/playout/adlibAction.ts b/packages/job-worker/src/playout/adlibAction.ts index 0c40474fde..26d114abf6 100644 --- a/packages/job-worker/src/playout/adlibAction.ts +++ b/packages/job-worker/src/playout/adlibAction.ts @@ -10,9 +10,8 @@ import { WatchedPackagesHelper } from '../blueprints/context/watchedPackages' import { JobContext, ProcessedShowStyleCompound } from '../jobs' import { getCurrentTime } from '../lib' import { ReadonlyDeep } from 'type-fest' -import { CacheForPlayoutPreInit, CacheForPlayout } from './cache' +import { PlayoutModel } from './model/PlayoutModel' import { syncPlayheadInfinitesForNextPartInstance } from './infinites' -import { updateExpectedDurationWithPrerollForPartInstance } from './lib' import { runJobWithPlaylistLock } from './lock' import { updateTimeline } from './timeline/generate' import { performTakeToNextedPart } from './take' @@ -20,6 +19,8 @@ import { ActionUserData } from '@sofie-automation/blueprints-integration' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { logger } from '../logging' import { validateScratchpartPartInstanceProperties } from './scratchpad' +import { PlayoutRundownModel } from './model/PlayoutRundownModel' +import { createPlayoutModelfromInitModel, loadPlayoutModelPreInit } from './model/implementation/LoadPlayoutModel' /** * Execute an AdLib Action @@ -35,9 +36,9 @@ export async function handleExecuteAdlibAction( if (!playlist.activationId) throw UserError.create(UserErrorMessage.InactiveRundown, undefined, 412) if (!playlist.currentPartInfo) throw UserError.create(UserErrorMessage.NoCurrentPart, undefined, 412) - const initCache = await CacheForPlayoutPreInit.createPreInit(context, lock, playlist, false) + const initCache = await loadPlayoutModelPreInit(context, lock, playlist, false) - const rundown = initCache.Rundowns.findOne(playlist.currentPartInfo.rundownId) + const rundown = initCache.getRundown(playlist.currentPartInfo.rundownId) if (!rundown) throw new Error(`Current Rundown "${playlist.currentPartInfo.rundownId}" could not be found`) const showStyle = await context.getShowStyleCompound(rundown.showStyleVariantId, rundown.showStyleBaseId) @@ -81,23 +82,27 @@ export async function handleExecuteAdlibAction( if (blueprint.blueprint.executeAction) { // load a full cache for the regular actions & executet the handler - const fullCache: CacheForPlayout = await CacheForPlayout.fromInit(context, initCache) + const playoutModel = await createPlayoutModelfromInitModel(context, initCache) + + const fullRundown = playoutModel.getRundown(rundown._id) + if (!fullRundown) throw new Error(`Rundown "${rundown._id}" missing between caches`) + try { const res: ExecuteActionResult = await executeActionInner( context, - fullCache, - rundown, + playoutModel, + fullRundown, showStyle, blueprint, watchedPackages, actionParameters ) - await fullCache.saveAllToDatabase() + await playoutModel.saveAllToDatabase() return res } catch (err) { - fullCache.dispose() + playoutModel.dispose() throw err } } @@ -121,8 +126,8 @@ export interface ExecuteActionParameters { export async function executeActionInner( context: JobContext, - cache: CacheForPlayout, - rundown: DBRundown, + playoutModel: PlayoutModel, + rundown: PlayoutRundownModel, showStyle: ReadonlyDeep, blueprint: ReadonlyDeep, watchedPackages: WatchedPackagesHelper, @@ -130,18 +135,18 @@ export async function executeActionInner( ): Promise { const now = getCurrentTime() - const playlist = cache.Playlist.doc + const playlist = playoutModel.Playlist const actionContext = new ActionExecutionContext( { - name: `${rundown.name}(${playlist.name})`, - identifier: `playlist=${playlist._id},rundown=${rundown._id},currentPartInstance=${ + name: `${rundown.Rundown.name}(${playlist.name})`, + identifier: `playlist=${playlist._id},rundown=${rundown.Rundown._id},currentPartInstance=${ playlist.currentPartInfo?.partInstanceId },execution=${getRandomId()}`, tempSendUserNotesIntoBlackHole: true, // TODO-CONTEXT store these notes }, context, - cache, + playoutModel, showStyle, context.getShowStyleBlueprintConfig(showStyle), rundown, @@ -169,7 +174,7 @@ export async function executeActionInner( throw UserError.fromUnknown(err, UserErrorMessage.InternalError) } - await applyAnyExecutionSideEffects(context, cache, actionContext, now) + await applyAnyExecutionSideEffects(context, playoutModel, actionContext, now) return { queuedPartInstanceId: actionContext.queuedPartInstanceId, @@ -179,7 +184,7 @@ export async function executeActionInner( async function applyAnyExecutionSideEffects( context: JobContext, - cache: CacheForPlayout, + playoutModel: PlayoutModel, actionContext: ActionExecutionContext, now: number ) { @@ -187,33 +192,38 @@ async function applyAnyExecutionSideEffects( actionContext.currentPartState !== ActionPartChange.NONE || actionContext.nextPartState !== ActionPartChange.NONE ) { - await syncPlayheadInfinitesForNextPartInstance(context, cache) + await syncPlayheadInfinitesForNextPartInstance( + context, + playoutModel, + playoutModel.CurrentPartInstance, + playoutModel.NextPartInstance + ) } if (actionContext.nextPartState !== ActionPartChange.NONE) { - const nextPartInstanceId = cache.Playlist.doc.nextPartInfo?.partInstanceId - if (nextPartInstanceId) { - updateExpectedDurationWithPrerollForPartInstance(cache, nextPartInstanceId) + const nextPartInstance = playoutModel.NextPartInstance + if (nextPartInstance) { + nextPartInstance.recalculateExpectedDurationWithPreroll() - validateScratchpartPartInstanceProperties(context, cache, nextPartInstanceId) + validateScratchpartPartInstanceProperties(context, playoutModel, nextPartInstance) } } if (actionContext.currentPartState !== ActionPartChange.NONE) { - const currentPartInstanceId = cache.Playlist.doc.currentPartInfo?.partInstanceId - if (currentPartInstanceId) { - validateScratchpartPartInstanceProperties(context, cache, currentPartInstanceId) + const currentPartInstance = playoutModel.CurrentPartInstance + if (currentPartInstance) { + validateScratchpartPartInstanceProperties(context, playoutModel, currentPartInstance) } } if (actionContext.takeAfterExecute) { - await performTakeToNextedPart(context, cache, now) + await performTakeToNextedPart(context, playoutModel, now) } else { if ( actionContext.currentPartState !== ActionPartChange.NONE || actionContext.nextPartState !== ActionPartChange.NONE ) { - await updateTimeline(context, cache) + await updateTimeline(context, playoutModel) } } } diff --git a/packages/job-worker/src/playout/adlibJobs.ts b/packages/job-worker/src/playout/adlibJobs.ts index 5ab0e062f8..679a2e38e0 100644 --- a/packages/job-worker/src/playout/adlibJobs.ts +++ b/packages/job-worker/src/playout/adlibJobs.ts @@ -1,11 +1,10 @@ import { AdLibPiece } from '@sofie-automation/corelib/dist/dataModel/AdLibPiece' import { BucketAdLib } from '@sofie-automation/corelib/dist/dataModel/BucketAdLibPiece' -import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' -import { PieceInstance, PieceInstancePiece } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' +import { PieceInstancePiece } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' import { RundownHoldState } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { assertNever } from '@sofie-automation/corelib/dist/lib' +import { assertNever, clone } from '@sofie-automation/corelib/dist/lib' import { logger } from '../logging' -import { ProcessedShowStyleBase, JobContext } from '../jobs' +import { JobContext, ProcessedShowStyleCompound } from '../jobs' import { AdlibPieceStartProps, DisableNextPieceProps, @@ -13,11 +12,12 @@ import { StopPiecesOnSourceLayersProps, TakePieceAsAdlibNowProps, } from '@sofie-automation/corelib/dist/worker/studio' -import { CacheForPlayout, getRundownIDsFromCache, getSelectedPartInstancesFromCache } from './cache' -import { runJobWithPlayoutCache } from './lock' +import { PlayoutModel } from './model/PlayoutModel' +import { PlayoutPartInstanceModel } from './model/PlayoutPartInstanceModel' +import { runJobWithPlayoutModel } from './lock' import { updateTimeline } from './timeline/generate' import { getCurrentTime } from '../lib' -import { convertAdLibToPieceInstance, convertPieceToAdLibPiece, sortPieceInstancesByStart } from './pieces' +import { comparePieceStart, convertAdLibToGenericPiece, convertPieceToAdLibPiece } from './pieces' import { getResolvedPiecesForCurrentPartInstance } from './resolvedPieces' import { syncPlayheadInfinitesForNextPartInstance } from './infinites' import { UserError, UserErrorMessage } from '@sofie-automation/corelib/dist/error' @@ -29,16 +29,17 @@ import { WatchedPackagesHelper } from '../blueprints/context/watchedPackages' import { innerFindLastPieceOnLayer, innerStartOrQueueAdLibPiece, innerStopPieces } from './adlibUtils' import _ = require('underscore') import { executeActionInner } from './adlibAction' +import { PlayoutPieceInstanceModel } from './model/PlayoutPieceInstanceModel' /** * Play an existing Piece in the Rundown as an AdLib */ export async function handleTakePieceAsAdlibNow(context: JobContext, data: TakePieceAsAdlibNowProps): Promise { - return runJobWithPlayoutCache( + return runJobWithPlayoutModel( context, data, - async (cache) => { - const playlist = cache.Playlist.doc + async (playoutModel) => { + const playlist = playoutModel.Playlist if (!playlist.activationId) throw UserError.create(UserErrorMessage.InactiveRundown) if (playlist.holdState === RundownHoldState.ACTIVE || playlist.holdState === RundownHoldState.PENDING) { throw UserError.create(UserErrorMessage.DuringHold) @@ -47,15 +48,21 @@ export async function handleTakePieceAsAdlibNow(context: JobContext, data: TakeP if (playlist.currentPartInfo?.partInstanceId !== data.partInstanceId) throw UserError.create(UserErrorMessage.AdlibCurrentPart) }, - async (cache) => { - const rundownIds = getRundownIDsFromCache(cache) + async (playoutModel) => { + const currentPartInstance = playoutModel.CurrentPartInstance + if (!currentPartInstance) throw UserError.create(UserErrorMessage.InactiveRundown) + const currentRundown = playoutModel.getRundown(currentPartInstance.PartInstance.rundownId) + if (!currentRundown) + throw new Error(`Missing Rundown for PartInstance: ${currentPartInstance.PartInstance._id}`) - const pieceInstanceToCopy = cache.PieceInstances.findOne( + const rundownIds = playoutModel.getRundownIds() + + const pieceInstanceToCopy = playoutModel.findPieceInstance( data.pieceInstanceIdOrPieceIdToCopy as PieceInstanceId ) const pieceToCopy = pieceInstanceToCopy - ? pieceInstanceToCopy.piece + ? clone(pieceInstanceToCopy.pieceInstance.PieceInstance.piece) : ((await context.directCollections.Pieces.findOne({ _id: data.pieceInstanceIdOrPieceIdToCopy as PieceId, startRundownId: { $in: rundownIds }, @@ -67,12 +74,10 @@ export async function handleTakePieceAsAdlibNow(context: JobContext, data: TakeP ) } - const partInstance = cache.PartInstances.findOne(data.partInstanceId) - if (!partInstance) throw new Error(`PartInstance "${data.partInstanceId}" not found!`) - const rundown = cache.Rundowns.findOne(partInstance.rundownId) - if (!rundown) throw new Error(`Rundown "${partInstance.rundownId}" not found!`) - - const showStyleBase = await context.getShowStyleBase(rundown.showStyleBaseId) + const showStyleCompound = await context.getShowStyleCompound( + currentRundown.Rundown.showStyleVariantId, + currentRundown.Rundown.showStyleBaseId + ) if (!pieceToCopy.allowDirectPlay) { throw UserError.from( new Error( @@ -80,79 +85,82 @@ export async function handleTakePieceAsAdlibNow(context: JobContext, data: TakeP ), UserErrorMessage.PieceAsAdlibNotDirectPlayable ) - } else { - switch (pieceToCopy.allowDirectPlay.type) { - case IBlueprintDirectPlayType.AdLibPiece: - await pieceTakeNowAsAdlib( - context, - cache, - showStyleBase, - partInstance, - pieceToCopy, - pieceInstanceToCopy - ) - break - case IBlueprintDirectPlayType.AdLibAction: { - const executeProps = pieceToCopy.allowDirectPlay - const showStyle = await context.getShowStyleCompound( - rundown.showStyleVariantId, - rundown.showStyleBaseId - ) - const blueprint = await context.getShowStyleBlueprint(showStyle._id) - const watchedPackages = WatchedPackagesHelper.empty(context) // TODO: should this be able to retrieve any watched packages? + } - await executeActionInner(context, cache, rundown, showStyle, blueprint, watchedPackages, { + switch (pieceToCopy.allowDirectPlay.type) { + case IBlueprintDirectPlayType.AdLibPiece: + await pieceTakeNowAsAdlib( + context, + playoutModel, + showStyleCompound, + currentPartInstance, + pieceToCopy, + pieceInstanceToCopy + ) + break + case IBlueprintDirectPlayType.AdLibAction: { + const executeProps = pieceToCopy.allowDirectPlay + + const blueprint = await context.getShowStyleBlueprint(showStyleCompound._id) + const watchedPackages = WatchedPackagesHelper.empty(context) // TODO: should this be able to retrieve any watched packages? + + await executeActionInner( + context, + playoutModel, + currentRundown, + showStyleCompound, + blueprint, + watchedPackages, + { ...executeProps, triggerMode: undefined, - }) - break - } - default: - assertNever(pieceToCopy.allowDirectPlay) - throw UserError.from( - new Error( - `PieceInstance or Piece "${data.pieceInstanceIdOrPieceIdToCopy}" cannot be direct played!` - ), - UserErrorMessage.PieceAsAdlibNotDirectPlayable - ) + } + ) + break } + default: + assertNever(pieceToCopy.allowDirectPlay) + throw UserError.from( + new Error( + `PieceInstance or Piece "${data.pieceInstanceIdOrPieceIdToCopy}" cannot be direct played!` + ), + UserErrorMessage.PieceAsAdlibNotDirectPlayable + ) } } ) } async function pieceTakeNowAsAdlib( context: JobContext, - cache: CacheForPlayout, - showStyleBase: ReadonlyDeep, - partInstance: DBPartInstance, + playoutModel: PlayoutModel, + showStyleBase: ReadonlyDeep, + currentPartInstance: PlayoutPartInstanceModel, pieceToCopy: PieceInstancePiece, - pieceInstanceToCopy: PieceInstance | undefined + pieceInstanceToCopy: + | { partInstance: PlayoutPartInstanceModel; pieceInstance: PlayoutPieceInstanceModel } + | undefined ): Promise { - const playlist = cache.Playlist.doc - if (!playlist.activationId) throw UserError.create(UserErrorMessage.InactiveRundown) - - const newPieceInstance = convertAdLibToPieceInstance( - context, - playlist.activationId, - pieceToCopy, - partInstance, - false - ) + const genericAdlibPiece = convertAdLibToGenericPiece(pieceToCopy, false) + /*const newPieceInstance = */ currentPartInstance.insertAdlibbedPiece(genericAdlibPiece, pieceToCopy._id) // Disable the original piece if from the same Part - if (pieceInstanceToCopy && pieceInstanceToCopy.partInstanceId === partInstance._id) { + if ( + pieceInstanceToCopy && + pieceInstanceToCopy.pieceInstance.PieceInstance.partInstanceId === currentPartInstance.PartInstance._id + ) { // Ensure the piece being copied isnt currently live if ( - pieceInstanceToCopy.plannedStartedPlayback && - pieceInstanceToCopy.plannedStartedPlayback <= getCurrentTime() + pieceInstanceToCopy.pieceInstance.PieceInstance.plannedStartedPlayback && + pieceInstanceToCopy.pieceInstance.PieceInstance.plannedStartedPlayback <= getCurrentTime() ) { const resolvedPieces = getResolvedPiecesForCurrentPartInstance( context, - cache, showStyleBase.sourceLayers, - partInstance + currentPartInstance + ) + const resolvedPieceBeingCopied = resolvedPieces.find( + (p) => p.instance._id === pieceInstanceToCopy.pieceInstance.PieceInstance._id ) - const resolvedPieceBeingCopied = resolvedPieces.find((p) => p.instance._id === pieceInstanceToCopy._id) if ( resolvedPieceBeingCopied?.resolvedDuration !== undefined && @@ -163,32 +171,36 @@ async function pieceTakeNowAsAdlib( // logger.debug(`Piece "${piece._id}" is currently live and cannot be used as an ad-lib`) throw UserError.from( new Error( - `PieceInstance "${pieceInstanceToCopy._id}" is currently live and cannot be used as an ad-lib` + `PieceInstance "${pieceInstanceToCopy.pieceInstance.PieceInstance._id}" is currently live and cannot be used as an ad-lib` ), UserErrorMessage.PieceAsAdlibCurrentlyLive ) } } - cache.PieceInstances.remove(pieceInstanceToCopy._id) + // TODO: is this ok? + pieceInstanceToCopy.pieceInstance.setDisabled(true) } - cache.PieceInstances.insert(newPieceInstance) - - await syncPlayheadInfinitesForNextPartInstance(context, cache) + await syncPlayheadInfinitesForNextPartInstance( + context, + playoutModel, + playoutModel.CurrentPartInstance, + playoutModel.NextPartInstance + ) - await updateTimeline(context, cache) + await updateTimeline(context, playoutModel) } /** * Play an AdLib piece by its id */ export async function handleAdLibPieceStart(context: JobContext, data: AdlibPieceStartProps): Promise { - return runJobWithPlayoutCache( + return runJobWithPlayoutModel( context, data, - async (cache) => { - const playlist = cache.Playlist.doc + async (playoutModel) => { + const playlist = playoutModel.Playlist if (!playlist.activationId) throw UserError.create(UserErrorMessage.InactiveRundown) if (playlist.holdState === RundownHoldState.ACTIVE || playlist.holdState === RundownHoldState.PENDING) { throw UserError.create(UserErrorMessage.DuringHold) @@ -197,16 +209,16 @@ export async function handleAdLibPieceStart(context: JobContext, data: AdlibPiec if (!data.queue && playlist.currentPartInfo?.partInstanceId !== data.partInstanceId) throw UserError.create(UserErrorMessage.AdlibCurrentPart) }, - async (cache) => { - const partInstance = cache.PartInstances.findOne(data.partInstanceId) + async (playoutModel) => { + const partInstance = playoutModel.getPartInstance(data.partInstanceId) if (!partInstance) throw new Error(`PartInstance "${data.partInstanceId}" not found!`) - const rundown = cache.Rundowns.findOne(partInstance.rundownId) - if (!rundown) throw new Error(`Rundown "${partInstance.rundownId}" not found!`) + const rundown = playoutModel.getRundown(partInstance.PartInstance.rundownId) + if (!rundown) throw new Error(`Rundown "${partInstance.PartInstance.rundownId}" not found!`) // Rundows that share the same showstyle variant as the current rundown, so adlibs from these rundowns are safe to play - const safeRundownIds = cache.Rundowns.findAll( - (rd) => rd.showStyleVariantId === rundown.showStyleVariantId - ).map((r) => r._id) + const safeRundownIds = playoutModel.Rundowns.filter( + (rd) => rd.Rundown.showStyleVariantId === rundown.Rundown.showStyleVariantId + ).map((r) => r.Rundown._id) let adLibPiece: AdLibPiece | BucketAdLib | undefined if (data.pieceType === 'baseline') { @@ -225,10 +237,10 @@ export async function handleAdLibPieceStart(context: JobContext, data: AdlibPiec studioId: context.studioId, }) - if (bucketAdlib && bucketAdlib.showStyleVariantId !== rundown.showStyleVariantId) { + if (bucketAdlib && bucketAdlib.showStyleVariantId !== rundown.Rundown.showStyleVariantId) { throw UserError.from( new Error( - `Bucket AdLib "${data.adLibPieceId}" is not compatible with rundown "${rundown._id}"!` + `Bucket AdLib "${data.adLibPieceId}" is not compatible with rundown "${rundown.Rundown._id}"!` ), UserErrorMessage.BucketAdlibIncompatible ) @@ -253,7 +265,7 @@ export async function handleAdLibPieceStart(context: JobContext, data: AdlibPiec UserErrorMessage.AdlibUnplayable ) - await innerStartOrQueueAdLibPiece(context, cache, rundown, !!data.queue, partInstance, adLibPiece) + await innerStartOrQueueAdLibPiece(context, playoutModel, rundown, !!data.queue, partInstance, adLibPiece) } ) } @@ -265,25 +277,25 @@ export async function handleStartStickyPieceOnSourceLayer( context: JobContext, data: StartStickyPieceOnSourceLayerProps ): Promise { - return runJobWithPlayoutCache( + return runJobWithPlayoutModel( context, data, - async (cache) => { - const playlist = cache.Playlist.doc + async (playoutModel) => { + const playlist = playoutModel.Playlist if (!playlist.activationId) throw UserError.create(UserErrorMessage.InactiveRundown) if (playlist.holdState === RundownHoldState.ACTIVE || playlist.holdState === RundownHoldState.PENDING) { throw UserError.create(UserErrorMessage.DuringHold) } if (!playlist.currentPartInfo) throw UserError.create(UserErrorMessage.NoCurrentPart) }, - async (cache) => { - const { currentPartInstance } = getSelectedPartInstancesFromCache(cache) + async (playoutModel) => { + const currentPartInstance = playoutModel.CurrentPartInstance if (!currentPartInstance) throw UserError.create(UserErrorMessage.NoCurrentPart) - const rundown = cache.Rundowns.findOne(currentPartInstance.rundownId) - if (!rundown) throw new Error(`Rundown "${currentPartInstance.rundownId}" not found!`) + const rundown = playoutModel.getRundown(currentPartInstance.PartInstance.rundownId) + if (!rundown) throw new Error(`Rundown "${currentPartInstance.PartInstance.rundownId}" not found!`) - const showStyleBase = await context.getShowStyleBase(rundown.showStyleBaseId) + const showStyleBase = await context.getShowStyleBase(rundown.Rundown.showStyleBaseId) const sourceLayer = showStyleBase.sourceLayers[data.sourceLayerId] if (!sourceLayer) throw new Error(`Source layer "${data.sourceLayerId}" not found!`) @@ -295,7 +307,7 @@ export async function handleStartStickyPieceOnSourceLayer( const lastPieceInstance = await innerFindLastPieceOnLayer( context, - cache, + playoutModel, [sourceLayer._id], sourceLayer.stickyOriginalOnly || false ) @@ -304,7 +316,7 @@ export async function handleStartStickyPieceOnSourceLayer( } const lastPiece = convertPieceToAdLibPiece(context, lastPieceInstance.piece) - await innerStartOrQueueAdLibPiece(context, cache, rundown, false, currentPartInstance, lastPiece) + await innerStartOrQueueAdLibPiece(context, playoutModel, rundown, false, currentPartInstance, lastPiece) } ) } @@ -317,31 +329,31 @@ export async function handleStopPiecesOnSourceLayers( data: StopPiecesOnSourceLayersProps ): Promise { if (data.sourceLayerIds.length === 0) return - return runJobWithPlayoutCache( + return runJobWithPlayoutModel( context, data, - async (cache) => { - const playlist = cache.Playlist.doc + async (playoutModel) => { + const playlist = playoutModel.Playlist if (!playlist.activationId) throw UserError.create(UserErrorMessage.InactiveRundown) if (playlist.holdState === RundownHoldState.ACTIVE || playlist.holdState === RundownHoldState.PENDING) { throw UserError.create(UserErrorMessage.DuringHold) } if (!playlist.currentPartInfo) throw UserError.create(UserErrorMessage.NoCurrentPart) }, - async (cache) => { - const partInstance = cache.PartInstances.findOne(data.partInstanceId) + async (playoutModel) => { + const partInstance = playoutModel.getPartInstance(data.partInstanceId) if (!partInstance) throw new Error(`PartInstance "${data.partInstanceId}" not found!`) - const lastStartedPlayback = partInstance.timings?.plannedStartedPlayback + const lastStartedPlayback = partInstance.PartInstance.timings?.plannedStartedPlayback if (!lastStartedPlayback) throw new Error(`Part "${data.partInstanceId}" has yet to start playback!`) - const rundown = cache.Rundowns.findOne(partInstance.rundownId) - if (!rundown) throw new Error(`Rundown "${partInstance.rundownId}" not found!`) + const rundown = playoutModel.getRundown(partInstance.PartInstance.rundownId) + if (!rundown) throw new Error(`Rundown "${partInstance.PartInstance.rundownId}" not found!`) - const showStyleBase = await context.getShowStyleBase(rundown.showStyleBaseId) + const showStyleBase = await context.getShowStyleBase(rundown.Rundown.showStyleBaseId) const sourceLayerIds = new Set(data.sourceLayerIds) const changedIds = innerStopPieces( context, - cache, + playoutModel, showStyleBase.sourceLayers, partInstance, (pieceInstance) => sourceLayerIds.has(pieceInstance.piece.sourceLayerId), @@ -349,9 +361,14 @@ export async function handleStopPiecesOnSourceLayers( ) if (changedIds.length) { - await syncPlayheadInfinitesForNextPartInstance(context, cache) + await syncPlayheadInfinitesForNextPartInstance( + context, + playoutModel, + playoutModel.CurrentPartInstance, + playoutModel.NextPartInstance + ) - await updateTimeline(context, cache) + await updateTimeline(context, playoutModel) } } ) @@ -361,59 +378,58 @@ export async function handleStopPiecesOnSourceLayers( * Disable the next Piece which allows being disabled */ export async function handleDisableNextPiece(context: JobContext, data: DisableNextPieceProps): Promise { - return runJobWithPlayoutCache( + return runJobWithPlayoutModel( context, data, - async (cache) => { - const playlist = cache.Playlist.doc + async (playoutModel) => { + const playlist = playoutModel.Playlist if (!playlist.activationId) throw UserError.create(UserErrorMessage.InactiveRundown) if (!playlist.currentPartInfo) throw UserError.create(UserErrorMessage.NoCurrentPart) }, - async (cache) => { - const playlist = cache.Playlist.doc + async (playoutModel) => { + const playlist = playoutModel.Playlist - const { currentPartInstance, nextPartInstance } = getSelectedPartInstancesFromCache(cache) + const currentPartInstance = playoutModel.CurrentPartInstance + const nextPartInstance = playoutModel.NextPartInstance if (!currentPartInstance) throw new Error(`PartInstance "${playlist.currentPartInfo?.partInstanceId}" not found!`) - const rundown = cache.Rundowns.findOne(currentPartInstance.rundownId) - if (!rundown) throw new Error(`Rundown "${currentPartInstance.rundownId}" not found!`) - const showStyleBase = await context.getShowStyleBase(rundown.showStyleBaseId) + const rundown = playoutModel.getRundown(currentPartInstance.PartInstance.rundownId) + if (!rundown) throw new Error(`Rundown "${currentPartInstance.PartInstance.rundownId}" not found!`) + const showStyleBase = await context.getShowStyleBase(rundown.Rundown.showStyleBaseId) // logger.info(o) // logger.info(JSON.stringify(o, '', 2)) const allowedSourceLayers = showStyleBase.sourceLayers - const getNextPiece = (partInstance: DBPartInstance, ignoreStartedPlayback: boolean) => { + const getNextPiece = (partInstance: PlayoutPartInstanceModel, ignoreStartedPlayback: boolean) => { // Find next piece to disable let nowInPart = 0 - if (!ignoreStartedPlayback && partInstance.timings?.plannedStartedPlayback) { - nowInPart = getCurrentTime() - partInstance.timings?.plannedStartedPlayback + if (!ignoreStartedPlayback && partInstance.PartInstance.timings?.plannedStartedPlayback) { + nowInPart = getCurrentTime() - partInstance.PartInstance.timings?.plannedStartedPlayback } - const pieceInstances = cache.PieceInstances.findAll((p) => p.partInstanceId === partInstance._id) - - const filteredPieces = pieceInstances.filter((piece: PieceInstance) => { - const sourceLayer = allowedSourceLayers[piece.piece.sourceLayerId] + const filteredPieces = partInstance.PieceInstances.filter((piece) => { + const sourceLayer = allowedSourceLayers[piece.PieceInstance.piece.sourceLayerId] if ( sourceLayer?.allowDisable && - !piece.piece.virtual && - piece.piece.pieceType === IBlueprintPieceType.Normal + !piece.PieceInstance.piece.virtual && + piece.PieceInstance.piece.pieceType === IBlueprintPieceType.Normal ) return true return false }) - const sortedPieces: PieceInstance[] = sortPieceInstancesByStart( - _.sortBy(filteredPieces, (piece: PieceInstance) => { - const sourceLayer = allowedSourceLayers[piece.piece.sourceLayerId] - return sourceLayer?._rank || -9999 - }), - nowInPart - ) + const sortedByLayer = _.sortBy(filteredPieces, (piece) => { + const sourceLayer = allowedSourceLayers[piece.PieceInstance.piece.sourceLayerId] + return sourceLayer?._rank || -9999 + }) + + const sortedPieces = [...sortedByLayer] + sortedPieces.sort((a, b) => comparePieceStart(a.PieceInstance.piece, b.PieceInstance.piece, nowInPart)) const findLast = !!data.undo @@ -421,37 +437,41 @@ export async function handleDisableNextPiece(context: JobContext, data: DisableN return sortedPieces.find((piece) => { return ( - piece.piece.enable.start >= nowInPart && - ((!data.undo && !piece.disabled) || (data.undo && piece.disabled)) + piece.PieceInstance.piece.enable.start >= nowInPart && + ((!data.undo && !piece.PieceInstance.disabled) || (data.undo && piece.PieceInstance.disabled)) ) }) } - const partInstances: Array<[DBPartInstance | undefined, boolean]> = [ + const partInstances: Array<[PlayoutPartInstanceModel | null, boolean]> = [ [currentPartInstance, false], [nextPartInstance, true], // If not found in currently playing part, let's look in the next one: ] if (data.undo) partInstances.reverse() - let nextPieceInstance: PieceInstance | undefined + let disabledPiece = false for (const [partInstance, ignoreStartedPlayback] of partInstances) { - if (partInstance) { - nextPieceInstance = getNextPiece(partInstance, ignoreStartedPlayback) - if (nextPieceInstance) break + if (partInstance && !disabledPiece) { + const candidatePieceInstance = getNextPiece(partInstance, ignoreStartedPlayback) + if (candidatePieceInstance) { + logger.debug( + (data.undo ? 'Disabling' : 'Enabling') + + ' next PieceInstance ' + + candidatePieceInstance.PieceInstance._id + ) + candidatePieceInstance.setDisabled(!data.undo) + disabledPiece = true + + break + } } } - if (nextPieceInstance) { - logger.debug((data.undo ? 'Disabling' : 'Enabling') + ' next PieceInstance ' + nextPieceInstance._id) - cache.PieceInstances.updateOne(nextPieceInstance._id, (p) => { - p.disabled = !data.undo - return p - }) - - await updateTimeline(context, cache) + if (disabledPiece) { + await updateTimeline(context, playoutModel) } else { - cache.assertNoChanges() + playoutModel.assertNoChanges() throw UserError.create(UserErrorMessage.DisableNoPieceFound) } diff --git a/packages/job-worker/src/playout/adlibUtils.ts b/packages/job-worker/src/playout/adlibUtils.ts index e44c0f1b59..02351eb57e 100644 --- a/packages/job-worker/src/playout/adlibUtils.ts +++ b/packages/job-worker/src/playout/adlibUtils.ts @@ -1,97 +1,80 @@ import { AdLibPiece } from '@sofie-automation/corelib/dist/dataModel/AdLibPiece' import { BucketAdLib } from '@sofie-automation/corelib/dist/dataModel/BucketAdLibPiece' import { PartInstanceId, PieceId, PieceInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' -import { EmptyPieceTimelineObjectsBlob, Piece } from '@sofie-automation/corelib/dist/dataModel/Piece' -import { PieceInstance, rewrapPieceToInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' -import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' +import { Piece } from '@sofie-automation/corelib/dist/dataModel/Piece' +import { PieceInstance, PieceInstancePiece } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' import { assertNever, getRandomId, getRank } from '@sofie-automation/corelib/dist/lib' import { MongoQuery } from '@sofie-automation/corelib/dist/mongo' -import { calculatePartExpectedDurationWithPreroll } from '@sofie-automation/corelib/dist/playout/timings' import { getCurrentTime } from '../lib' import { JobContext } from '../jobs' -import { CacheForPlayout, getOrderedSegmentsAndPartsFromPlayoutCache, getRundownIDsFromCache } from './cache' +import { PlayoutModel } from './model/PlayoutModel' +import { PlayoutPartInstanceModel } from './model/PlayoutPartInstanceModel' import { fetchPiecesThatMayBeActiveForPart, getPieceInstancesForPart, syncPlayheadInfinitesForNextPartInstance, } from './infinites' -import { convertAdLibToPieceInstance, setupPieceInstanceInfiniteProperties } from './pieces' +import { convertAdLibToGenericPiece } from './pieces' import { getResolvedPiecesForCurrentPartInstance } from './resolvedPieces' import { updateTimeline } from './timeline/generate' -import { PieceLifespan, IBlueprintPieceType } from '@sofie-automation/blueprints-integration' +import { PieceLifespan } from '@sofie-automation/blueprints-integration' import { SourceLayers } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' import { updatePartInstanceRanksAfterAdlib } from '../rundown' import { selectNextPart } from './selectNextPart' import { setNextPart } from './setNext' import { calculateNowOffsetLatency } from './timeline/multi-gateway' import { logger } from '../logging' +import { ReadonlyDeep } from 'type-fest' +import { PlayoutRundownModel } from './model/PlayoutRundownModel' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { protectString } from '@sofie-automation/corelib/dist/protectedString' export async function innerStartOrQueueAdLibPiece( context: JobContext, - cache: CacheForPlayout, - rundown: DBRundown, + playoutModel: PlayoutModel, + rundown: PlayoutRundownModel, queue: boolean, - currentPartInstance: DBPartInstance, + currentPartInstance: PlayoutPartInstanceModel, adLibPiece: AdLibPiece | BucketAdLib ): Promise { - const playlist = cache.Playlist.doc - if (!playlist.activationId) throw new Error('RundownPlaylist is not active') - const span = context.startSpan('innerStartOrQueueAdLibPiece') let queuedPartInstanceId: PartInstanceId | undefined if (queue || adLibPiece.toBeQueued) { - const newPartInstance: DBPartInstance = { + const adlibbedPart: Omit = { _id: getRandomId(), - rundownId: rundown._id, - segmentId: currentPartInstance.segmentId, - playlistActivationId: playlist.activationId, - segmentPlayoutId: currentPartInstance.segmentPlayoutId, - takeCount: currentPartInstance.takeCount + 1, - rehearsal: !!playlist.rehearsal, - orphaned: 'adlib-part', - part: { - _id: getRandomId(), - _rank: 99999, // Corrected in innerStartQueuedAdLib - externalId: '', - segmentId: currentPartInstance.segmentId, - rundownId: rundown._id, - title: adLibPiece.name, - expectedDuration: adLibPiece.expectedDuration, - expectedDurationWithPreroll: adLibPiece.expectedDuration, // Filled in later - }, + _rank: 99999, // Corrected in innerStartQueuedAdLib + externalId: '', + title: adLibPiece.name, + expectedDuration: adLibPiece.expectedDuration, + expectedDurationWithPreroll: adLibPiece.expectedDuration, // Filled in later } - const newPieceInstance = convertAdLibToPieceInstance( - context, - playlist.activationId, - adLibPiece, - newPartInstance, - queue - ) - newPartInstance.part.expectedDurationWithPreroll = calculatePartExpectedDurationWithPreroll( - newPartInstance.part, - [newPieceInstance.piece] + const genericAdlibPiece = convertAdLibToGenericPiece(adLibPiece, true) + const newPartInstance = await insertQueuedPartWithPieces( + context, + playoutModel, + rundown, + currentPartInstance, + adlibbedPart, + [genericAdlibPiece], + adLibPiece._id ) - - await innerStartQueuedAdLib(context, cache, rundown, currentPartInstance, newPartInstance, [newPieceInstance]) - queuedPartInstanceId = newPartInstance._id + queuedPartInstanceId = newPartInstance.PartInstance._id // syncPlayheadInfinitesForNextPartInstance is handled by setNextPart } else { - const newPieceInstance = convertAdLibToPieceInstance( + const genericAdlibPiece = convertAdLibToGenericPiece(adLibPiece, false) + currentPartInstance.insertAdlibbedPiece(genericAdlibPiece, adLibPiece._id) + + await syncPlayheadInfinitesForNextPartInstance( context, - playlist.activationId, - adLibPiece, + playoutModel, currentPartInstance, - queue + playoutModel.NextPartInstance ) - innerStartAdLibPiece(context, cache, rundown, currentPartInstance, newPieceInstance) - - await syncPlayheadInfinitesForNextPartInstance(context, cache) } - await updateTimeline(context, cache) + await updateTimeline(context, playoutModel) if (span) span.end() return queuedPartInstanceId @@ -99,17 +82,17 @@ export async function innerStartOrQueueAdLibPiece( export async function innerFindLastPieceOnLayer( context: JobContext, - cache: CacheForPlayout, + playoutModel: PlayoutModel, sourceLayerId: string[], originalOnly: boolean, customQuery?: MongoQuery ): Promise { const span = context.startSpan('innerFindLastPieceOnLayer') - const rundownIds = getRundownIDsFromCache(cache) + const rundownIds = playoutModel.getRundownIds() const query: MongoQuery = { ...customQuery, - playlistActivationId: cache.Playlist.doc.activationId, + playlistActivationId: playoutModel.Playlist.activationId, rundownId: { $in: rundownIds }, 'piece.sourceLayerId': { $in: sourceLayerId }, plannedStartedPlayback: { @@ -137,14 +120,14 @@ export async function innerFindLastPieceOnLayer( export async function innerFindLastScriptedPieceOnLayer( context: JobContext, - cache: CacheForPlayout, + playoutModel: PlayoutModel, sourceLayerId: string[], customQuery?: MongoQuery ): Promise { const span = context.startSpan('innerFindLastScriptedPieceOnLayer') - const playlist = cache.Playlist.doc - const rundownIds = getRundownIDsFromCache(cache) + const playlist = playoutModel.Playlist + const rundownIds = playoutModel.getRundownIds() // TODO - this should throw instead of return more? @@ -152,7 +135,7 @@ export async function innerFindLastScriptedPieceOnLayer( return } - const currentPartInstance = cache.PartInstances.findOne(playlist.currentPartInfo.partInstanceId) + const currentPartInstance = playoutModel.CurrentPartInstance?.PartInstance if (!currentPartInstance) { return @@ -170,9 +153,10 @@ export async function innerFindLastScriptedPieceOnLayer( }) const pieceIdSet = new Set(pieces.map((p) => p.startPartId)) - const part = cache.Parts.findOne((p) => pieceIdSet.has(p._id) && p._rank <= currentPartInstance.part._rank, { - sort: { _rank: -1 }, - }) + const part = playoutModel + .getAllOrderedParts() + .filter((p) => pieceIdSet.has(p._id) && p._rank <= currentPartInstance.part._rank) + .reverse()[0] if (!part) { return @@ -203,108 +187,97 @@ export async function innerFindLastScriptedPieceOnLayer( return fullPiece } -export async function innerStartQueuedAdLib( +function updateRankForAdlibbedPartInstance( context: JobContext, - cache: CacheForPlayout, - rundown: DBRundown, - currentPartInstance: DBPartInstance, - newPartInstance: DBPartInstance, - newPieceInstances: PieceInstance[] -): Promise { - const span = context.startSpan('innerStartQueuedAdLib') - - // Ensure it is labelled as dynamic - newPartInstance.orphaned = 'adlib-part' + playoutModel: PlayoutModel, + newPartInstance: PlayoutPartInstanceModel +) { + const currentPartInstance = playoutModel.CurrentPartInstance + if (!currentPartInstance) throw new Error('CurrentPartInstance not found') // Find the following part, so we can pick a good rank const followingPart = selectNextPart( context, - cache.Playlist.doc, - currentPartInstance, + playoutModel.Playlist, + currentPartInstance.PartInstance, null, - getOrderedSegmentsAndPartsFromPlayoutCache(cache), + playoutModel.getAllOrderedSegments(), + playoutModel.getAllOrderedParts(), false // We want to insert it before any trailing invalid piece ) - newPartInstance.part._rank = getRank( - currentPartInstance.part, - followingPart?.part?.segmentId === newPartInstance.segmentId ? followingPart?.part : undefined + newPartInstance.setRank( + getRank( + currentPartInstance.PartInstance.part, + followingPart?.part?.segmentId === newPartInstance.PartInstance.segmentId ? followingPart?.part : undefined + ) ) - cache.PartInstances.insert(newPartInstance) - - newPieceInstances.forEach((pieceInstance) => { - // Ensure it is labelled as dynamic - pieceInstance.dynamicallyInserted = getCurrentTime() - pieceInstance.partInstanceId = newPartInstance._id - pieceInstance.piece.startPartId = newPartInstance.part._id - - setupPieceInstanceInfiniteProperties(pieceInstance) - - cache.PieceInstances.insert(pieceInstance) - }) + updatePartInstanceRanksAfterAdlib(playoutModel, newPartInstance.PartInstance.segmentId) +} - updatePartInstanceRanksAfterAdlib(cache, newPartInstance.part.segmentId) +export async function insertQueuedPartWithPieces( + context: JobContext, + playoutModel: PlayoutModel, + rundown: PlayoutRundownModel, + currentPartInstance: PlayoutPartInstanceModel, + newPart: Omit, + initialPieces: Omit[], + fromAdlibId: PieceId | undefined +): Promise { + const span = context.startSpan('insertQueuedPartWithPieces') + + const newPartFull: DBPart = { + ...newPart, + segmentId: currentPartInstance.PartInstance.segmentId, + rundownId: currentPartInstance.PartInstance.rundownId, + } - // Find and insert any rundown defined infinites that we should inherit - newPartInstance = cache.PartInstances.findOne(newPartInstance._id) as DBPartInstance - const possiblePieces = await fetchPiecesThatMayBeActiveForPart(context, cache, undefined, newPartInstance.part) + // Find any rundown defined infinites that we should inherit + const possiblePieces = await fetchPiecesThatMayBeActiveForPart(context, playoutModel, undefined, newPartFull) const infinitePieceInstances = getPieceInstancesForPart( context, - cache, + playoutModel, currentPartInstance, rundown, - newPartInstance.part, + newPartFull, possiblePieces, - newPartInstance._id + protectString('') // Replaced inside playoutModel.insertAdlibbedPartInstance + ) + + const newPartInstance = playoutModel.createAdlibbedPartInstance( + newPart, + initialPieces, + fromAdlibId, + infinitePieceInstances ) - for (const pieceInstance of infinitePieceInstances) { - cache.PieceInstances.insert(pieceInstance) - } - await setNextPart(context, cache, newPartInstance, false) + updateRankForAdlibbedPartInstance(context, playoutModel, newPartInstance) - if (span) span.end() -} + await setNextPart(context, playoutModel, newPartInstance, false) -export function innerStartAdLibPiece( - context: JobContext, - cache: CacheForPlayout, - _rundown: DBRundown, - existingPartInstance: DBPartInstance, - newPieceInstance: PieceInstance -): void { - const span = context.startSpan('innerStartAdLibPiece') - // Ensure it is labelled as dynamic - newPieceInstance.partInstanceId = existingPartInstance._id - newPieceInstance.piece.startPartId = existingPartInstance.part._id - newPieceInstance.dynamicallyInserted = getCurrentTime() - - setupPieceInstanceInfiniteProperties(newPieceInstance) - - // exclusiveGroup is handled at runtime by processAndPrunePieceInstanceTimings - - cache.PieceInstances.insert(newPieceInstance) if (span) span.end() + + return newPartInstance } export function innerStopPieces( context: JobContext, - cache: CacheForPlayout, + playoutModel: PlayoutModel, sourceLayers: SourceLayers, - currentPartInstance: DBPartInstance, - filter: (pieceInstance: PieceInstance) => boolean, + currentPartInstance: PlayoutPartInstanceModel, + filter: (pieceInstance: ReadonlyDeep) => boolean, timeOffset: number | undefined ): Array { const span = context.startSpan('innerStopPieces') const stoppedInstances: PieceInstanceId[] = [] - const lastStartedPlayback = currentPartInstance.timings?.plannedStartedPlayback + const lastStartedPlayback = currentPartInstance.PartInstance.timings?.plannedStartedPlayback if (lastStartedPlayback === undefined) { throw new Error('Cannot stop pieceInstances when partInstance hasnt started playback') } - const resolvedPieces = getResolvedPiecesForCurrentPartInstance(context, cache, sourceLayers, currentPartInstance) - const offsetRelativeToNow = (timeOffset || 0) + (calculateNowOffsetLatency(context, cache, undefined) || 0) + const resolvedPieces = getResolvedPiecesForCurrentPartInstance(context, sourceLayers, currentPartInstance) + const offsetRelativeToNow = (timeOffset || 0) + (calculateNowOffsetLatency(context, playoutModel, undefined) || 0) const stopAt = getCurrentTime() + offsetRelativeToNow const relativeStopAt = stopAt - lastStartedPlayback @@ -324,20 +297,25 @@ export function innerStopPieces( case PieceLifespan.OutOnRundownChange: { logger.info(`Blueprint action: Cropping PieceInstance "${pieceInstance._id}" to ${stopAt}`) - cache.PieceInstances.updateOne(pieceInstance._id, (p) => { - if (cache.isMultiGatewayMode) { - p.userDuration = { - endRelativeToNow: offsetRelativeToNow, - } - } else { - p.userDuration = { - endRelativeToPart: relativeStopAt, - } - } - return p - }) + const pieceInstanceModel = playoutModel.findPieceInstance(pieceInstance._id) + if (pieceInstanceModel) { + const newDuration: Required['userDuration'] = playoutModel.isMultiGatewayMode + ? { + endRelativeToNow: offsetRelativeToNow, + } + : { + endRelativeToPart: relativeStopAt, + } + + pieceInstanceModel.pieceInstance.setDuration(newDuration) + + stoppedInstances.push(pieceInstance._id) + } else { + logger.warn( + `Blueprint action: Failed to crop PieceInstance "${pieceInstance._id}", it was not found` + ) + } - stoppedInstances.push(pieceInstance._id) break } case PieceLifespan.OutOnSegmentEnd: @@ -347,36 +325,12 @@ export function innerStopPieces( `Blueprint action: Cropping PieceInstance "${pieceInstance._id}" to ${stopAt} with a virtual` ) - const pieceId: PieceId = getRandomId() - cache.PieceInstances.insert({ - ...rewrapPieceToInstance( - { - _id: pieceId, - externalId: '-', - enable: { start: relativeStopAt }, - lifespan: pieceInstance.piece.lifespan, - sourceLayerId: pieceInstance.piece.sourceLayerId, - outputLayerId: pieceInstance.piece.outputLayerId, - invalid: false, - name: '', - startPartId: currentPartInstance.part._id, - pieceType: IBlueprintPieceType.Normal, - virtual: true, - content: {}, - timelineObjectsString: EmptyPieceTimelineObjectsBlob, - }, - currentPartInstance.playlistActivationId, - currentPartInstance.rundownId, - currentPartInstance._id - ), - dynamicallyInserted: getCurrentTime(), - infinite: { - infiniteInstanceId: getRandomId(), - infiniteInstanceIndex: 0, - infinitePieceId: pieceId, - fromPreviousPart: false, - }, - }) + currentPartInstance.insertVirtualPiece( + relativeStopAt, + pieceInstance.piece.lifespan, + pieceInstance.piece.sourceLayerId, + pieceInstance.piece.outputLayerId + ) stoppedInstances.push(pieceInstance._id) break diff --git a/packages/job-worker/src/playout/cache.ts b/packages/job-worker/src/playout/cache.ts deleted file mode 100644 index 7b852f3fcc..0000000000 --- a/packages/job-worker/src/playout/cache.ts +++ /dev/null @@ -1,467 +0,0 @@ -import { RundownId, RundownPlaylistId, ShowStyleBaseId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { DbCacheWriteObject, DbCacheWriteOptionalObject } from '../cache/CacheObject' -import { CacheBase, ReadOnlyCache } from '../cache/CacheBase' -import { DbCacheReadCollection, DbCacheWriteCollection } from '../cache/CacheCollection' -import { PeripheralDevice, PeripheralDeviceType } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' -import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' -import { ReadonlyDeep } from 'type-fest' -import { JobContext } from '../jobs' -import { CacheForStudioBase } from '../studio/cache' -import { DBSegment, SegmentOrphanedReason } from '@sofie-automation/corelib/dist/dataModel/Segment' -import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' -import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' -import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' -import { TimelineComplete } from '@sofie-automation/corelib/dist/dataModel/Timeline' -import _ = require('underscore') -import { RundownBaselineObj } from '@sofie-automation/corelib/dist/dataModel/RundownBaselineObj' -import { cleanupRundownsForRemovedPlaylist } from '../rundownPlaylists' -import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' -import { PlaylistLock } from '../jobs/lock' -import { CacheForIngest } from '../ingest/cache' -import { MongoQuery } from '../db' -import { logger } from '../logging' -import { getOrderedSegmentsAndPartsFromCacheCollections } from '../cache/utils' - -/** - * This is a cache used for playout operations. - * It is intentionally very lightweight, with the intention of it to be used only for some initial verification that a playout operation can be performed. - */ -export class CacheForPlayoutPreInit extends CacheBase { - public readonly isPlayout = true - public readonly PlaylistId: RundownPlaylistId - - public readonly PlaylistLock: PlaylistLock - - public readonly PeripheralDevices: DbCacheReadCollection - - public readonly Playlist: DbCacheWriteObject - public readonly Rundowns: DbCacheReadCollection - - protected constructor( - context: JobContext, - playlistLock: PlaylistLock, - playlistId: RundownPlaylistId, - peripheralDevices: DbCacheReadCollection, - playlist: DbCacheWriteObject, - rundowns: DbCacheReadCollection - ) { - super(context) - - this.PlaylistId = playlistId - this.PlaylistLock = playlistLock - - this.PeripheralDevices = peripheralDevices - this.Playlist = playlist - this.Rundowns = rundowns - } - - public get DisplayName(): string { - return `CacheForPlayoutPreInit "${this.PlaylistId}"` - } - - static async createPreInit( - context: JobContext, - playlistLock: PlaylistLock, - tmpPlaylist: ReadonlyDeep, - reloadPlaylist = true - ): Promise> { - const span = context.startSpan('CacheForPlayoutPreInit.createPreInit') - if (span) span.setLabel('playlistId', unprotectString(tmpPlaylist._id)) - - if (!playlistLock.isLocked) { - throw new Error('Cannot create cache with released playlist lock') - } - - const initData = await CacheForPlayoutPreInit.loadInitData(context, tmpPlaylist, reloadPlaylist, undefined) - const res = new CacheForPlayoutPreInit(context, playlistLock, tmpPlaylist._id, ...initData) - if (span) span.end() - return res - } - - protected static async loadInitData( - context: JobContext, - tmpPlaylist: ReadonlyDeep, - reloadPlaylist: boolean, - existingRundowns: DbCacheReadCollection | undefined - ): Promise< - [ - DbCacheReadCollection, - DbCacheWriteObject, - DbCacheReadCollection - ] - > { - return Promise.all([ - DbCacheReadCollection.createFromDatabase(context, context.directCollections.PeripheralDevices, { - studioId: tmpPlaylist.studioId, - }), - reloadPlaylist - ? await DbCacheWriteObject.createFromDatabase( - context, - context.directCollections.RundownPlaylists, - false, - tmpPlaylist._id - ) - : DbCacheWriteObject.createFromDoc( - context, - context.directCollections.RundownPlaylists, - false, - tmpPlaylist - ), - existingRundowns ?? - DbCacheReadCollection.createFromDatabase(context, context.directCollections.Rundowns, { - playlistId: tmpPlaylist._id, - }), - ]) - } -} - -/** - * This is a cache used for playout operations. - * It contains everything that is needed to generate the timeline, and everything except for pieces needed to update the partinstances. - * Anything not in this cache should not be needed often, and only for specific operations (eg, AdlibActions needed to run one). - */ -export class CacheForPlayout extends CacheForPlayoutPreInit implements CacheForStudioBase { - private toBeRemoved = false - - public readonly Timeline: DbCacheWriteOptionalObject - - public readonly Segments: DbCacheWriteCollection - public readonly Parts: DbCacheReadCollection - public readonly PartInstances: DbCacheWriteCollection - public readonly PieceInstances: DbCacheWriteCollection - - public readonly BaselineObjects: DbCacheReadCollection - - protected constructor( - context: JobContext, - playlistLock: PlaylistLock, - playlistId: RundownPlaylistId, - peripheralDevices: DbCacheReadCollection, - playlist: DbCacheWriteObject, - rundowns: DbCacheReadCollection, - segments: DbCacheWriteCollection, - parts: DbCacheReadCollection, - partInstances: DbCacheWriteCollection, - pieceInstances: DbCacheWriteCollection, - timeline: DbCacheWriteOptionalObject, - baselineObjects: DbCacheReadCollection - ) { - super(context, playlistLock, playlistId, peripheralDevices, playlist, rundowns) - - this.Timeline = timeline - - this.Segments = segments - this.Parts = parts - - this.PartInstances = partInstances - this.PieceInstances = pieceInstances - - this.BaselineObjects = baselineObjects - } - - public get DisplayName(): string { - return `CacheForPlayout "${this.PlaylistId}"` - } - - static async fromInit( - context: JobContext, - initCache: ReadOnlyCache - ): Promise { - const span = context.startSpan('CacheForPlayout.fromInit') - if (span) span.setLabel('playlistId', unprotectString(initCache.PlaylistId)) - - // we are claiming the collections - initCache.assertNoChanges() - - if (!initCache.PlaylistLock.isLocked) { - throw new Error('Cannot create cache with released playlist lock') - } - - const content = await CacheForPlayout.loadContent( - context, - null, - initCache.Playlist.doc, - initCache.Rundowns.findAll(null).map((r) => r._id) - ) - - // Not strictly necessary, but make a copy of the collection that we know is writable - const mutablePlaylist = DbCacheWriteObject.createFromDoc( - context, - context.directCollections.RundownPlaylists, - false, - initCache.Playlist.doc - ) - - const res = new CacheForPlayout( - context, - initCache.PlaylistLock, - initCache.PlaylistId, - initCache.PeripheralDevices, - mutablePlaylist, - initCache.Rundowns, - ...content - ) - - if (span) span.end() - return res - } - - static async fromIngest( - context: JobContext, - playlistLock: PlaylistLock, - newPlaylist: ReadonlyDeep, - newRundowns: ReadonlyDeep>, - ingestCache: ReadOnlyCache - ): Promise { - const initData = await CacheForPlayoutPreInit.loadInitData( - context, - newPlaylist, - false, - DbCacheReadCollection.createFromArray(context, context.directCollections.Rundowns, newRundowns) - ) - - const contentData = await CacheForPlayout.loadContent( - context, - ingestCache, - newPlaylist, - newRundowns.map((r) => r._id) - ) - const res = new CacheForPlayout(context, playlistLock, newPlaylist._id, ...initData, ...contentData) - - return res - } - - /** - * Intitialise the full content of the cache - * @param ingestCache A CacheForIngest that is pending saving, if this is following an ingest operation - */ - private static async loadContent( - context: JobContext, - ingestCache: ReadOnlyCache | null, - playlist: ReadonlyDeep, - rundownIds: RundownId[] - ): Promise< - [ - DbCacheWriteCollection, - DbCacheReadCollection, - DbCacheWriteCollection, - DbCacheWriteCollection, - DbCacheWriteOptionalObject, - DbCacheReadCollection - ] - > { - const selectedPartInstanceIds = _.compact([ - playlist.currentPartInfo?.partInstanceId, - playlist.nextPartInfo?.partInstanceId, - playlist.previousPartInfo?.partInstanceId, - ]) - - const partInstancesCollection = Promise.resolve().then(async () => { - // Future: We could optimise away this query if we tracked the segmentIds of these PartInstances on the playlist - const segmentIds = _.uniq( - ( - await context.directCollections.PartInstances.findFetch( - { - _id: { $in: selectedPartInstanceIds }, - }, - { - projection: { - segmentId: 1, - }, - } - ) - ).map((p) => p.segmentId) - ) - - const partInstancesSelector: MongoQuery = { - rundownId: { $in: rundownIds }, - $or: [ - { - segmentId: { $in: segmentIds }, - reset: { $ne: true }, - }, - { - _id: { $in: selectedPartInstanceIds }, - }, - ], - } - // Filter the PieceInstances to the activationId, if possible - pieceInstancesSelector.playlistActivationId = playlist.activationId || { $exists: false } - - return DbCacheWriteCollection.createFromDatabase( - context, - context.directCollections.PartInstances, - partInstancesSelector - ) - }) - - // If there is an ingestCache, then avoid loading some bits from the db for that rundown - const loadRundownIds = ingestCache ? rundownIds.filter((id) => id !== ingestCache.RundownId) : rundownIds - const baselineFromIngest = ingestCache?.RundownBaselineObjs.getIfLoaded() - const loadBaselineIds = baselineFromIngest ? loadRundownIds : rundownIds - - const pieceInstancesSelector: MongoQuery = { - rundownId: { $in: rundownIds }, - partInstanceId: { $in: selectedPartInstanceIds }, - } - // Filter the PieceInstances to the activationId, if possible - pieceInstancesSelector.playlistActivationId = playlist.activationId || { $exists: false } - - const [segments, parts, baselineObjects, ...collections] = await Promise.all([ - DbCacheWriteCollection.createFromDatabase(context, context.directCollections.Segments, { - $or: [ - { - // In a different rundown - rundownId: { $in: loadRundownIds }, - }, - { - // Is the scratchpad - rundownId: { $in: rundownIds }, - orphaned: SegmentOrphanedReason.SCRATCHPAD, - }, - ], - }), - DbCacheReadCollection.createFromDatabase(context, context.directCollections.Parts, { - rundownId: { $in: loadRundownIds }, - }), - DbCacheReadCollection.createFromDatabase(context, context.directCollections.RundownBaselineObjects, { - rundownId: { $in: loadBaselineIds }, - }), - partInstancesCollection, - DbCacheWriteCollection.createFromDatabase( - context, - context.directCollections.PieceInstances, - pieceInstancesSelector - ), - // Future: This could be defered until we get to updateTimeline. It could be a small performance boost - DbCacheWriteOptionalObject.createOptionalFromDatabase( - context, - context.directCollections.Timelines, - context.studioId - ), - ]) - - if (ingestCache) { - // Populate the collections with the cached data instead - segments.fillWithDataFromArray(ingestCache.Segments.findAll(null), true) - parts.fillWithDataFromArray(ingestCache.Parts.findAll(null), true) - if (baselineFromIngest) { - baselineObjects.fillWithDataFromArray(baselineFromIngest.findAll(null), true) - } - } - - return [segments, parts, ...collections, baselineObjects] - } - - /** - * Remove the playlist when this cache is saved. - * The cache is cleared of any documents, and any deferred functions are discarded - * Note: any deferred functions that get added after this will be ignoted - */ - removePlaylist(): void { - if (this.Playlist.doc.activationId) { - throw new Error('Cannot remove the active RundownPlaylist') - } - this.toBeRemoved = true - - super.markCollectionsForRemoval() - } - - discardChanges(): void { - this.toBeRemoved = false - super.discardChanges() - - this.assertNoChanges() - } - - async saveAllToDatabase(): Promise { - logger.silly('saveAllToDatabase') - // TODO - ideally we should make sure to preserve the lock during this operation - if (!this.PlaylistLock.isLocked) { - throw new Error('Cannot save changes with released playlist lock') - } - - if (this.toBeRemoved) { - const span = this.context.startSpan('CacheForPlayout.saveAllToDatabase') - - // Ignoring any deferred functions - this._deferredAfterSaveFunctions.length = 0 - this._deferredBeforeSaveFunctions.length = 0 - - // Remove the playlist doc - await this.context.directCollections.RundownPlaylists.remove(this.PlaylistId) - - // Cleanup the Rundowns in their own locks - this.PlaylistLock.deferAfterRelease(async () => { - await cleanupRundownsForRemovedPlaylist(this.context, this.PlaylistId) - }) - - super.assertNoChanges() - span?.end() - } else { - return super.saveAllToDatabase() - } - } - - #isMultiGatewayMode: boolean | undefined = undefined - public get isMultiGatewayMode(): boolean { - if (this.#isMultiGatewayMode === undefined) { - if (this.context.studio.settings.forceMultiGatewayMode) { - this.#isMultiGatewayMode = true - } else { - const playoutDevices = this.PeripheralDevices.findAll( - (device) => device.type === PeripheralDeviceType.PLAYOUT - ) - this.#isMultiGatewayMode = playoutDevices.length > 1 - } - } - return this.#isMultiGatewayMode - } -} - -export function getOrderedSegmentsAndPartsFromPlayoutCache(cache: ReadOnlyCache): { - segments: DBSegment[] - parts: DBPart[] -} { - return getOrderedSegmentsAndPartsFromCacheCollections( - cache.Parts, - cache.Segments, - cache.Playlist.doc.rundownIdsInOrder - ) -} - -export function getRundownIDsFromCache(cache: ReadOnlyCache): RundownId[] { - return cache.Rundowns.findAll(null).map((r) => r._id) -} -export function getSelectedPartInstancesFromCache(cache: ReadOnlyCache): { - currentPartInstance: DBPartInstance | undefined - nextPartInstance: DBPartInstance | undefined - previousPartInstance: DBPartInstance | undefined -} { - const playlist = cache.Playlist.doc - - return { - currentPartInstance: playlist.currentPartInfo - ? cache.PartInstances.findOne(playlist.currentPartInfo.partInstanceId) - : undefined, - nextPartInstance: playlist.nextPartInfo - ? cache.PartInstances.findOne(playlist.nextPartInfo.partInstanceId) - : undefined, - previousPartInstance: playlist.previousPartInfo - ? cache.PartInstances.findOne(playlist.previousPartInfo.partInstanceId) - : undefined, - } -} -export function getShowStyleIdsRundownMappingFromCache( - cache: ReadOnlyCache -): Map { - const rundowns = cache.Rundowns.findAll(null) - const ret = new Map() - - for (const rundown of rundowns) { - ret.set(rundown._id, rundown.showStyleBaseId) - } - - return ret -} diff --git a/packages/job-worker/src/playout/datastore.ts b/packages/job-worker/src/playout/datastore.ts index f238c60760..28206349a5 100644 --- a/packages/job-worker/src/playout/datastore.ts +++ b/packages/job-worker/src/playout/datastore.ts @@ -3,15 +3,15 @@ import { StudioId, TimelineDatastoreEntryId } from '@sofie-automation/corelib/di import { deserializeTimelineBlob } from '@sofie-automation/corelib/dist/dataModel/Timeline' import { protectString } from '@sofie-automation/corelib/dist/protectedString' import { JobContext } from '../jobs' -import { CacheForPlayout } from './cache' +import { PlayoutModel } from './model/PlayoutModel' export function getDatastoreId(studioId: StudioId, key: string): TimelineDatastoreEntryId { return protectString(`${studioId}_${key}`) } /** Remove documents in the TimelineDatastore collection where mode is temporary and has no references from the timeline */ -export async function cleanTimelineDatastore(context: JobContext, cache: CacheForPlayout): Promise { - const timeline = cache.Timeline.doc +export async function cleanTimelineDatastore(context: JobContext, playoutModel: PlayoutModel): Promise { + const timeline = playoutModel.Timeline if (!timeline) { return diff --git a/packages/job-worker/src/playout/debug.ts b/packages/job-worker/src/playout/debug.ts index 31fe38dd4d..294bbfe315 100644 --- a/packages/job-worker/src/playout/debug.ts +++ b/packages/job-worker/src/playout/debug.ts @@ -2,13 +2,12 @@ import { DebugRegenerateNextPartInstanceProps, DebugSyncInfinitesForNextPartInstanceProps, } from '@sofie-automation/corelib/dist/worker/studio' -import { runJobWithStudioCache } from '../studio/lock' +import { runJobWithStudioPlayoutModel } from '../studio/lock' import { JobContext } from '../jobs' import { logger } from '../logging' -import { getSelectedPartInstancesFromCache } from './cache' import { syncPlayheadInfinitesForNextPartInstance } from './infinites' import { setNextPart } from './setNext' -import { runJobWithPlayoutCache } from './lock' +import { runJobWithPlayoutModel } from './lock' import { updateStudioTimeline, updateTimeline } from './timeline/generate' /** @@ -21,8 +20,13 @@ export async function handleDebugSyncPlayheadInfinitesForNextPartInstance( ): Promise { logger.info(`syncPlayheadInfinitesForNextPartInstance ${data.playlistId}`) - await runJobWithPlayoutCache(context, data, null, async (cache) => { - await syncPlayheadInfinitesForNextPartInstance(context, cache) + await runJobWithPlayoutModel(context, data, null, async (playoutModel) => { + await syncPlayheadInfinitesForNextPartInstance( + context, + playoutModel, + playoutModel.CurrentPartInstance, + playoutModel.NextPartInstance + ) }) } @@ -36,22 +40,22 @@ export async function handleDebugRegenerateNextPartInstance( ): Promise { logger.info('regenerateNextPartInstance') - await runJobWithPlayoutCache(context, data, null, async (cache) => { - const playlist = cache.Playlist.doc + await runJobWithPlayoutModel(context, data, null, async (playoutModel) => { + const playlist = playoutModel.Playlist const originalNextPartInfo = playlist.nextPartInfo if (originalNextPartInfo && playlist.activationId) { - const { nextPartInstance } = getSelectedPartInstancesFromCache(cache) - const part = nextPartInstance ? cache.Parts.findOne(nextPartInstance.part._id) : undefined + const nextPartInstance = playoutModel.NextPartInstance + const part = nextPartInstance ? playoutModel.findPart(nextPartInstance.PartInstance.part._id) : undefined if (part) { - await setNextPart(context, cache, null, false) + await setNextPart(context, playoutModel, null, false) await setNextPart( context, - cache, + playoutModel, { part: part, consumesQueuedSegmentId: false }, originalNextPartInfo.manuallySelected ) - await updateTimeline(context, cache) + await updateTimeline(context, playoutModel) } } }) @@ -63,10 +67,10 @@ export async function handleDebugRegenerateNextPartInstance( export async function handleDebugCrash(context: JobContext, data: DebugRegenerateNextPartInstanceProps): Promise { logger.info('debugCrash') - await runJobWithPlayoutCache(context, data, null, async (cache) => { + await runJobWithPlayoutModel(context, data, null, async (playoutModel) => { setTimeout(() => { //@ts-expect-error: 2339 - cache.callUndefined() + playoutModel.callUndefined() }, 10) }) } @@ -75,19 +79,18 @@ export async function handleDebugCrash(context: JobContext, data: DebugRegenerat * Debug: Regenerate the timeline for the Studio */ export async function handleDebugUpdateTimeline(context: JobContext, _data: void): Promise { - await runJobWithStudioCache(context, async (studioCache) => { + await runJobWithStudioPlayoutModel(context, async (studioCache) => { const activePlaylists = studioCache.getActiveRundownPlaylists() if (activePlaylists.length > 1) { throw new Error(`Too many active playlists`) } else if (activePlaylists.length > 0) { const playlist = activePlaylists[0] - await runJobWithPlayoutCache(context, { playlistId: playlist._id }, null, async (playoutCache) => { + await runJobWithPlayoutModel(context, { playlistId: playlist._id }, null, async (playoutCache) => { await updateTimeline(context, playoutCache) }) } else { await updateStudioTimeline(context, studioCache) - await studioCache.saveAllToDatabase() } }) } diff --git a/packages/job-worker/src/playout/holdJobs.ts b/packages/job-worker/src/playout/holdJobs.ts index 2ae69c75e5..08333c01ce 100644 --- a/packages/job-worker/src/playout/holdJobs.ts +++ b/packages/job-worker/src/playout/holdJobs.ts @@ -3,19 +3,18 @@ import { RundownHoldState } from '@sofie-automation/corelib/dist/dataModel/Rundo import { UserError, UserErrorMessage } from '@sofie-automation/corelib/dist/error' import { ActivateHoldProps, DeactivateHoldProps } from '@sofie-automation/corelib/dist/worker/studio' import { JobContext } from '../jobs' -import { getSelectedPartInstancesFromCache } from './cache' -import { runJobWithPlayoutCache } from './lock' +import { runJobWithPlayoutModel } from './lock' import { updateTimeline } from './timeline/generate' /** * Activate Hold */ export async function handleActivateHold(context: JobContext, data: ActivateHoldProps): Promise { - return runJobWithPlayoutCache( + return runJobWithPlayoutModel( context, data, - async (cache) => { - const playlist = cache.Playlist.doc + async (playoutModel) => { + const playlist = playoutModel.Playlist if (!playlist.activationId) throw UserError.create(UserErrorMessage.InactiveRundown) @@ -24,37 +23,34 @@ export async function handleActivateHold(context: JobContext, data: ActivateHold if (playlist.holdState) throw UserError.create(UserErrorMessage.HoldAlreadyActive) }, - async (cache) => { - const playlist = cache.Playlist.doc - const { currentPartInstance, nextPartInstance } = getSelectedPartInstancesFromCache(cache) + async (playoutModel) => { + const playlist = playoutModel.Playlist + const currentPartInstance = playoutModel.CurrentPartInstance if (!currentPartInstance) throw new Error(`PartInstance "${playlist.currentPartInfo?.partInstanceId}" not found!`) + const nextPartInstance = playoutModel.NextPartInstance if (!nextPartInstance) throw new Error(`PartInstance "${playlist.nextPartInfo?.partInstanceId}" not found!`) if ( - currentPartInstance.part.holdMode !== PartHoldMode.FROM || - nextPartInstance.part.holdMode !== PartHoldMode.TO || - currentPartInstance.part.segmentId !== nextPartInstance.part.segmentId + currentPartInstance.PartInstance.part.holdMode !== PartHoldMode.FROM || + nextPartInstance.PartInstance.part.holdMode !== PartHoldMode.TO || + currentPartInstance.PartInstance.part.segmentId !== nextPartInstance.PartInstance.part.segmentId ) { throw UserError.create(UserErrorMessage.HoldIncompatibleParts) } - const hasDynamicallyInserted = cache.PieceInstances.findOne( + const hasDynamicallyInserted = currentPartInstance.PieceInstances.find( (p) => - p.partInstanceId === currentPartInstance._id && - !!p.dynamicallyInserted && + !!p.PieceInstance.dynamicallyInserted && // If its a continuation of an infinite adlib it is probably a graphic, so is 'fine' - !p.infinite?.fromPreviousPart && - !p.infinite?.fromPreviousPlayhead + !p.PieceInstance.infinite?.fromPreviousPart && + !p.PieceInstance.infinite?.fromPreviousPlayhead ) if (hasDynamicallyInserted) throw UserError.create(UserErrorMessage.HoldAfterAdlib) - cache.Playlist.update((p) => { - p.holdState = RundownHoldState.PENDING - return p - }) + playoutModel.setHoldState(RundownHoldState.PENDING) - await updateTimeline(context, cache) + await updateTimeline(context, playoutModel) } ) } @@ -63,24 +59,21 @@ export async function handleActivateHold(context: JobContext, data: ActivateHold * Deactivate Hold */ export async function handleDeactivateHold(context: JobContext, data: DeactivateHoldProps): Promise { - return runJobWithPlayoutCache( + return runJobWithPlayoutModel( context, data, - async (cache) => { - const playlist = cache.Playlist.doc + async (playoutModel) => { + const playlist = playoutModel.Playlist if (!playlist.activationId) throw UserError.create(UserErrorMessage.InactiveRundown) if (playlist.holdState !== RundownHoldState.PENDING) throw UserError.create(UserErrorMessage.HoldNotCancelable) }, - async (cache) => { - cache.Playlist.update((p) => { - p.holdState = RundownHoldState.NONE - return p - }) + async (playoutModel) => { + playoutModel.setHoldState(RundownHoldState.NONE) - await updateTimeline(context, cache) + await updateTimeline(context, playoutModel) } ) } diff --git a/packages/job-worker/src/playout/infinites.ts b/packages/job-worker/src/playout/infinites.ts index a71e784947..05d8d98087 100644 --- a/packages/job-worker/src/playout/infinites.ts +++ b/packages/job-worker/src/playout/infinites.ts @@ -1,9 +1,8 @@ -import { PartInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PartInstanceId, RundownId, ShowStyleBaseId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { Piece } from '@sofie-automation/corelib/dist/dataModel/Piece' import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' -import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { getPieceInstancesForPart as libgetPieceInstancesForPart, getPlayheadTrackingInfinitesForPart as libgetPlayheadTrackingInfinitesForPart, @@ -13,44 +12,53 @@ import { import { processAndPrunePieceInstanceTimings } from '@sofie-automation/corelib/dist/playout/processAndPrune' import { JobContext } from '../jobs' import { ReadonlyDeep } from 'type-fest' -import { - CacheForPlayout, - getOrderedSegmentsAndPartsFromPlayoutCache, - getSelectedPartInstancesFromCache, - getShowStyleIdsRundownMappingFromCache, -} from './cache' +import { PlayoutModel } from './model/PlayoutModel' +import { PlayoutPartInstanceModel } from './model/PlayoutPartInstanceModel' +import { PlayoutSegmentModel } from './model/PlayoutSegmentModel' import { getCurrentTime } from '../lib' -import { saveIntoCache } from '../cache/lib' -import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { flatten } from '@sofie-automation/corelib/dist/lib' import _ = require('underscore') import { ReadOnlyCache } from '../cache/CacheBase' import { CacheForIngest } from '../ingest/cache' -import { DBSegment, SegmentOrphanedReason } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { SegmentOrphanedReason } from '@sofie-automation/corelib/dist/dataModel/Segment' import { sortRundownIDsInPlaylist } from '@sofie-automation/corelib/dist/playout/playlist' import { mongoWhere } from '@sofie-automation/corelib/dist/mongo' +import { PlayoutRundownModel } from './model/PlayoutRundownModel' /** When we crop a piece, set the piece as "it has definitely ended" this far into the future. */ export const DEFINITELY_ENDED_FUTURE_DURATION = 1 * 1000 +function getShowStyleIdsRundownMapping(rundowns: readonly PlayoutRundownModel[]): Map { + const ret = new Map() + + for (const rundown of rundowns) { + ret.set(rundown.Rundown._id, rundown.Rundown.showStyleBaseId) + } + + return ret +} + /** * We can only continue adlib onEnd infinites if we go forwards in the rundown. Any distance backwards will clear them. * */ export function candidatePartIsAfterPreviewPartInstance( _context: JobContext, - playlist: ReadonlyDeep, - orderedSegments: DBSegment[], - previousPartInstance: DBPartInstance | undefined, - candidateInstance: DBPart + orderedSegments: readonly PlayoutSegmentModel[], + previousPartInstance: ReadonlyDeep | undefined, + candidateInstance: ReadonlyDeep ): boolean { - if (previousPartInstance && playlist) { + if (previousPartInstance) { // When in the same segment, we can rely on the ranks to be in order. This is to handle orphaned parts, but is also valid for normal parts if (candidateInstance.segmentId === previousPartInstance.segmentId) { return candidateInstance._rank > previousPartInstance.part._rank } else { // Check if the segment is after the other - const previousSegmentIndex = orderedSegments.findIndex((s) => s._id === previousPartInstance.segmentId) - const candidateSegmentIndex = orderedSegments.findIndex((s) => s._id === candidateInstance.segmentId) + const previousSegmentIndex = orderedSegments.findIndex( + (s) => s.Segment._id === previousPartInstance.segmentId + ) + const candidateSegmentIndex = orderedSegments.findIndex( + (s) => s.Segment._id === candidateInstance.segmentId + ) if (previousSegmentIndex === -1 || candidateSegmentIndex === -1) { // Should never happen, as orphaned segments are kept around @@ -69,23 +77,28 @@ export function candidatePartIsAfterPreviewPartInstance( * Get the ids of parts, segments and rundowns before a given part in the playlist. * Note: this will return no segments and rundowns if the part is in the scratchpad */ -function getIdsBeforeThisPart(context: JobContext, cache: ReadOnlyCache, nextPart: DBPart) { +function getIdsBeforeThisPart(context: JobContext, playoutModel: PlayoutModel, nextPart: ReadonlyDeep) { const span = context.startSpan('getIdsBeforeThisPart') + const currentRundown = playoutModel.getRundown(nextPart.rundownId) + const currentSegment = currentRundown?.getSegment(nextPart.segmentId) + // Get the normal parts - const partsBeforeThisInSegment = cache.Parts.findAll( - (p) => p.segmentId === nextPart.segmentId && p._rank < nextPart._rank - ) + const partsBeforeThisInSegment = currentSegment?.Parts?.filter((p) => p._rank < nextPart._rank) ?? [] + // Find any orphaned parts - const partInstancesBeforeThisInSegment = cache.PartInstances.findAll( - (p) => p.segmentId === nextPart.segmentId && !!p.orphaned && p.part._rank < nextPart._rank + const partInstancesBeforeThisInSegment = playoutModel.LoadedPartInstances.filter( + (p) => + p.PartInstance.segmentId === nextPart.segmentId && + !!p.PartInstance.orphaned && + p.PartInstance.part._rank < nextPart._rank ) - partsBeforeThisInSegment.push(...partInstancesBeforeThisInSegment.map((p) => p.part)) + partsBeforeThisInSegment.push(...partInstancesBeforeThisInSegment.map((p) => p.PartInstance.part)) const partsBeforeThisInSegmentSorted = _.sortBy(partsBeforeThisInSegment, (p) => p._rank).map((p) => p._id) - const nextPartSegment = cache.Segments.findOne(nextPart.segmentId) - if (nextPartSegment?.orphaned === SegmentOrphanedReason.SCRATCHPAD) { + const nextPartSegment = currentRundown?.getSegment(nextPart.segmentId) + if (nextPartSegment?.Segment?.orphaned === SegmentOrphanedReason.SCRATCHPAD) { if (span) span.end() return { partsToReceiveOnSegmentEndFrom: partsBeforeThisInSegmentSorted, @@ -95,19 +108,20 @@ function getIdsBeforeThisPart(context: JobContext, cache: ReadOnlyCache - s.rundownId === nextPart.rundownId && - s._rank < currentSegment._rank && - s.orphaned !== SegmentOrphanedReason.SCRATCHPAD - ).map((p) => p._id) - : [] + const currentSegment = currentRundown?.getSegment(nextPart.segmentId) + const segmentsToReceiveOnRundownEndFrom = + currentRundown && currentSegment + ? currentRundown.Segments.filter( + (s) => + s.Segment.rundownId === nextPart.rundownId && + s.Segment._rank < currentSegment.Segment._rank && + s.Segment.orphaned !== SegmentOrphanedReason.SCRATCHPAD + ).map((p) => p.Segment._id) + : [] const sortedRundownIds = sortRundownIDsInPlaylist( - cache.Playlist.doc.rundownIdsInOrder, - cache.Rundowns.findAll(null).map((rd) => rd._id) + playoutModel.Playlist.rundownIdsInOrder, + playoutModel.Rundowns.map((rd) => rd.Rundown._id) ) const currentRundownIndex = sortedRundownIds.indexOf(nextPart.rundownId) const rundownsToReceiveOnShowStyleEndFrom = @@ -126,16 +140,16 @@ function getIdsBeforeThisPart(context: JobContext, cache: ReadOnlyCache, + playoutModel: PlayoutModel, unsavedIngestCache: Omit, 'Rundown'> | undefined, - part: DBPart + part: ReadonlyDeep ): Promise { const span = context.startSpan('fetchPiecesThatMayBeActiveForPart') @@ -151,7 +165,7 @@ export async function fetchPiecesThatMayBeActiveForPart( // Figure out the ids of everything else we will have to search through const { partsToReceiveOnSegmentEndFrom, segmentsToReceiveOnRundownEndFrom, rundownsToReceiveOnShowStyleEndFrom } = - getIdsBeforeThisPart(context, cache, part) + getIdsBeforeThisPart(context, playoutModel, part) if (unsavedIngestCache?.RundownId === part.rundownId) { // Find pieces for the current rundown @@ -196,56 +210,57 @@ export async function fetchPiecesThatMayBeActiveForPart( /** * Update the onChange infinites for the nextPartInstance to be up to date with the ones on the currentPartInstance * @param context Context for the current job - * @param cache Playout cache for the current playlist + * @param playoutModel Playout cache for the current playlist */ export async function syncPlayheadInfinitesForNextPartInstance( context: JobContext, - cache: CacheForPlayout + playoutModel: PlayoutModel, + fromPartInstance: PlayoutPartInstanceModel | null, + toPartInstance: PlayoutPartInstanceModel | null ): Promise { const span = context.startSpan('syncPlayheadInfinitesForNextPartInstance') - const { nextPartInstance, currentPartInstance } = getSelectedPartInstancesFromCache(cache) - if (nextPartInstance && currentPartInstance) { - const playlist = cache.Playlist.doc + + if (toPartInstance && fromPartInstance) { + const playlist = playoutModel.Playlist if (!playlist.activationId) throw new Error(`RundownPlaylist "${playlist._id}" is not active`) const { partsToReceiveOnSegmentEndFrom, segmentsToReceiveOnRundownEndFrom, rundownsToReceiveOnShowStyleEndFrom, - } = getIdsBeforeThisPart(context, cache, nextPartInstance.part) + } = getIdsBeforeThisPart(context, playoutModel, toPartInstance.PartInstance.part) - const rundown = cache.Rundowns.findOne(currentPartInstance.rundownId) - if (!rundown) throw new Error(`Rundown "${currentPartInstance.rundownId}" not found!`) + const currentRundown = playoutModel.getRundown(fromPartInstance.PartInstance.rundownId) + if (!currentRundown) throw new Error(`Rundown "${fromPartInstance.PartInstance.rundownId}" not found!`) - const currentSegment = cache.Segments.findOne(currentPartInstance.segmentId) - if (!currentSegment) throw new Error(`Segment "${currentPartInstance.segmentId}" not found!`) + const currentSegment = currentRundown.getSegment(fromPartInstance.PartInstance.segmentId) + if (!currentSegment) throw new Error(`Segment "${fromPartInstance.PartInstance.segmentId}" not found!`) - const nextSegment = cache.Segments.findOne(nextPartInstance.segmentId) - if (!nextSegment) throw new Error(`Segment "${nextPartInstance.segmentId}" not found!`) + const nextRundown = playoutModel.getRundown(toPartInstance.PartInstance.rundownId) + if (!nextRundown) throw new Error(`Rundown "${toPartInstance.PartInstance.rundownId}" not found!`) - const showStyleBase = await context.getShowStyleBase(rundown.showStyleBaseId) + const nextSegment = nextRundown.getSegment(toPartInstance.PartInstance.segmentId) + if (!nextSegment) throw new Error(`Segment "${toPartInstance.PartInstance.segmentId}" not found!`) - const orderedPartsAndSegments = getOrderedSegmentsAndPartsFromPlayoutCache(cache) + const showStyleBase = await context.getShowStyleBase(nextRundown.Rundown.showStyleBaseId) const nextPartIsAfterCurrentPart = candidatePartIsAfterPreviewPartInstance( context, - playlist, - orderedPartsAndSegments.segments, - currentPartInstance, - nextPartInstance.part + playoutModel.getAllOrderedSegments(), + fromPartInstance.PartInstance, + toPartInstance.PartInstance.part ) - const playingPieceInstances = cache.PieceInstances.findAll((p) => p.partInstanceId === currentPartInstance._id) - const nowInPart = getCurrentTime() - (currentPartInstance.timings?.plannedStartedPlayback ?? 0) + const nowInPart = getCurrentTime() - (fromPartInstance.PartInstance.timings?.plannedStartedPlayback ?? 0) const prunedPieceInstances = processAndPrunePieceInstanceTimings( showStyleBase.sourceLayers, - playingPieceInstances, + fromPartInstance.PieceInstances.map((p) => p.PieceInstance), nowInPart, undefined, true ) - const rundownIdsToShowstyleIds = getShowStyleIdsRundownMappingFromCache(cache) + const rundownIdsToShowstyleIds = getShowStyleIdsRundownMapping(playoutModel.Rundowns) const infinites = libgetPlayheadTrackingInfinitesForPart( playlist.activationId, @@ -253,23 +268,18 @@ export async function syncPlayheadInfinitesForNextPartInstance( new Set(segmentsToReceiveOnRundownEndFrom), rundownsToReceiveOnShowStyleEndFrom, rundownIdsToShowstyleIds, - currentPartInstance, - currentSegment, + fromPartInstance.PartInstance, + currentSegment?.Segment, prunedPieceInstances, - rundown, - nextPartInstance.part, - nextSegment, - nextPartInstance._id, + nextRundown.Rundown, + toPartInstance.PartInstance.part, + nextSegment?.Segment, + toPartInstance.PartInstance._id, nextPartIsAfterCurrentPart, false ) - saveIntoCache( - context, - cache.PieceInstances, - (p) => p.partInstanceId === nextPartInstance._id && !!p.infinite?.fromPreviousPlayhead, - infinites - ) + toPartInstance.replaceInfinitesFromPreviousPlayhead(infinites) } if (span) span.end() } @@ -277,7 +287,7 @@ export async function syncPlayheadInfinitesForNextPartInstance( /** * Calculate all of the onEnd PieceInstances for a PartInstance * @param context Context for the running job - * @param cache Playout cache for the current playlist + * @param playoutModel Playout cache for the current playlist * @param playingPartInstance The current PartInstance, if there is one * @param rundown The Rundown the Part belongs to * @param part The Part the PartInstance is based on @@ -287,56 +297,58 @@ export async function syncPlayheadInfinitesForNextPartInstance( */ export function getPieceInstancesForPart( context: JobContext, - cache: CacheForPlayout, - playingPartInstance: DBPartInstance | undefined, - rundown: ReadonlyDeep, - part: DBPart, + playoutModel: PlayoutModel, + playingPartInstance: PlayoutPartInstanceModel | null, + rundown: PlayoutRundownModel, + part: ReadonlyDeep, possiblePieces: Piece[], newInstanceId: PartInstanceId ): PieceInstance[] { const span = context.startSpan('getPieceInstancesForPart') const { partsToReceiveOnSegmentEndFrom, segmentsToReceiveOnRundownEndFrom, rundownsToReceiveOnShowStyleEndFrom } = - getIdsBeforeThisPart(context, cache, part) + getIdsBeforeThisPart(context, playoutModel, part) - const playlist = cache.Playlist.doc + const playlist = playoutModel.Playlist if (!playlist.activationId) throw new Error(`RundownPlaylist "${playlist._id}" is not active`) - const orderedPartsAndSegments = getOrderedSegmentsAndPartsFromPlayoutCache(cache) - const playingPieceInstances = playingPartInstance - ? cache.PieceInstances.findAll((p) => p.partInstanceId === playingPartInstance._id) - : [] + const playingPieceInstances = playingPartInstance?.PieceInstances ?? [] const nextPartIsAfterCurrentPart = candidatePartIsAfterPreviewPartInstance( context, - playlist, - orderedPartsAndSegments.segments, - playingPartInstance, + playoutModel.getAllOrderedSegments(), + playingPartInstance?.PartInstance, part ) - const rundownIdsToShowstyleIds = getShowStyleIdsRundownMappingFromCache(cache) + const rundownIdsToShowstyleIds = getShowStyleIdsRundownMapping(playoutModel.Rundowns) + + let playingRundown: PlayoutRundownModel | undefined + let playingSegment: PlayoutSegmentModel | undefined + if (playingPartInstance) { + playingRundown = playoutModel.getRundown(playingPartInstance.PartInstance.rundownId) + if (!playingRundown) throw new Error(`Rundown "${playingPartInstance.PartInstance.rundownId}" not found!`) - const playingSegment = playingPartInstance && cache.Segments.findOne(playingPartInstance.segmentId) - if (playingPartInstance && !playingSegment) - throw new Error(`Segment "${playingPartInstance?.segmentId}" not found!`) + playingSegment = playingRundown.getSegment(playingPartInstance.PartInstance.segmentId) + if (!playingSegment) throw new Error(`Segment "${playingPartInstance.PartInstance.segmentId}" not found!`) + } - const segment = cache.Segments.findOne(part.segmentId) + const segment = rundown.getSegment(part.segmentId) if (!segment) throw new Error(`Segment "${part.segmentId}" not found!`) const res = libgetPieceInstancesForPart( playlist.activationId, - playingPartInstance, - playingSegment, - playingPieceInstances, - rundown, - segment, + playingPartInstance?.PartInstance, + playingSegment?.Segment, + playingPieceInstances.map((p) => p.PieceInstance), + rundown.Rundown, + segment.Segment, part, new Set(partsToReceiveOnSegmentEndFrom), new Set(segmentsToReceiveOnRundownEndFrom), rundownsToReceiveOnShowStyleEndFrom, rundownIdsToShowstyleIds, possiblePieces, - orderedPartsAndSegments.parts.map((p) => p._id), + playoutModel.getAllOrderedParts().map((p) => p._id), newInstanceId, nextPartIsAfterCurrentPart, false diff --git a/packages/job-worker/src/playout/lib.ts b/packages/job-worker/src/playout/lib.ts index 11d230a41c..d0f20c543e 100644 --- a/packages/job-worker/src/playout/lib.ts +++ b/packages/job-worker/src/playout/lib.ts @@ -1,109 +1,76 @@ import { TimelineObjGeneric } from '@sofie-automation/corelib/dist/dataModel/Timeline' -import { applyToArray, clone, getRandomId } from '@sofie-automation/corelib/dist/lib' +import { applyToArray, clone } from '@sofie-automation/corelib/dist/lib' import { TSR } from '@sofie-automation/blueprints-integration' import { JobContext } from '../jobs' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' -import { RundownHoldState } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { PartInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { ReadonlyDeep } from 'type-fest' -import { CacheForPlayout, getOrderedSegmentsAndPartsFromPlayoutCache, getRundownIDsFromCache } from './cache' +import { PlayoutModel } from './model/PlayoutModel' import { logger } from '../logging' import { getCurrentTime } from '../lib' -import { calculatePartExpectedDurationWithPreroll } from '@sofie-automation/corelib/dist/playout/timings' import { MongoQuery } from '../db' import { mongoWhere } from '@sofie-automation/corelib/dist/mongo' import _ = require('underscore') import { setNextPart } from './setNext' import { selectNextPart } from './selectNextPart' -import { SegmentOrphanedReason } from '@sofie-automation/corelib/dist/dataModel/Segment' /** * Reset the rundownPlaylist (all of the rundowns within the playlist): * Remove all dynamically inserted/updated pieces, parts, timings etc.. */ -export async function resetRundownPlaylist(context: JobContext, cache: CacheForPlayout): Promise { - logger.info('resetRundownPlaylist ' + cache.Playlist.doc._id) +export async function resetRundownPlaylist(context: JobContext, playoutModel: PlayoutModel): Promise { + logger.info('resetRundownPlaylist ' + playoutModel.Playlist._id) // Remove all dunamically inserted pieces (adlibs etc) - // const rundownIds = new Set(getRundownIDsFromCache(cache)) + // const rundownIds = new Set((cache.getRundownIds())) - removePartInstancesWithPieceInstances(context, cache, { rehearsal: true }) - resetPartInstancesWithPieceInstances(context, cache) + playoutModel.resetPlaylist(!!playoutModel.Playlist.activationId) - // Remove the scratchpad - cache.Segments.remove((segment) => segment.orphaned === SegmentOrphanedReason.SCRATCHPAD) - - cache.Playlist.update((p) => { - p.previousPartInfo = null - p.currentPartInfo = null - p.holdState = RundownHoldState.NONE - p.resetTime = getCurrentTime() + playoutModel.removeAllRehearsalPartInstances() + resetPartInstancesWithPieceInstances(context, playoutModel) - delete p.lastTakeTime - delete p.startedPlayback - delete p.rundownsStartedPlayback - delete p.previousPersistentState - delete p.trackedAbSessions - delete p.queuedSegmentId - - return p - }) - - if (cache.Playlist.doc.activationId) { - // generate a new activationId - cache.Playlist.update((p) => { - p.activationId = getRandomId() - return p - }) + // Remove the scratchpad + for (const rundown of playoutModel.Rundowns) { + rundown.removeScratchpadSegment() + } + if (playoutModel.Playlist.activationId) { // put the first on queue: const firstPart = selectNextPart( context, - cache.Playlist.doc, + playoutModel.Playlist, null, null, - getOrderedSegmentsAndPartsFromPlayoutCache(cache) + playoutModel.getAllOrderedSegments(), + playoutModel.getAllOrderedParts() ) - await setNextPart(context, cache, firstPart, false) + await setNextPart(context, playoutModel, firstPart, false) } else { - await setNextPart(context, cache, null, false) + await setNextPart(context, playoutModel, null, false) } } /** * Reset selected or all partInstances with their pieceInstances - * @param cache + * @param playoutModel * @param selector if not provided, all partInstances will be reset */ export function resetPartInstancesWithPieceInstances( context: JobContext, - cache: CacheForPlayout, + playoutModel: PlayoutModel, selector?: MongoQuery ): void { - const partInstancesToReset = cache.PartInstances.updateAll((p) => { - if (!p.reset && (!selector || mongoWhere(p, selector))) { - p.reset = true - return p - } else { - return false + const partInstanceIdsToReset: PartInstanceId[] = [] + for (const partInstance of playoutModel.LoadedPartInstances) { + if (!partInstance.PartInstance.reset && (!selector || mongoWhere(partInstance.PartInstance, selector))) { + partInstance.markAsReset() + partInstanceIdsToReset.push(partInstance.PartInstance._id) } - }) - - // Reset any in the cache now - if (partInstancesToReset.length) { - cache.PieceInstances.updateAll((p) => { - if (!p.reset && partInstancesToReset.includes(p.partInstanceId)) { - p.reset = true - return p - } else { - return false - } - }) } // Defer ones which arent loaded - cache.deferAfterSave(async (cache) => { - const rundownIds = getRundownIDsFromCache(cache) - const partInstanceIdsInCache = cache.PartInstances.findAll(null).map((p) => p._id) + playoutModel.deferAfterSave(async (playoutModel) => { + const rundownIds = playoutModel.getRundownIds() + const partInstanceIdsInCache = playoutModel.LoadedPartInstances.map((p) => p.PartInstance._id) // Find all the partInstances which are not loaded, but should be reset const resetInDb = await context.directCollections.PartInstances.findFetch( @@ -122,7 +89,7 @@ export function resetPartInstancesWithPieceInstances( ).then((ps) => ps.map((p) => p._id)) // Do the reset - const allToReset = [...resetInDb, ...partInstancesToReset] + const allToReset = [...resetInDb, ...partInstanceIdsToReset] await Promise.all([ resetInDb.length ? context.directCollections.PartInstances.update( @@ -156,61 +123,6 @@ export function resetPartInstancesWithPieceInstances( }) } -/** - * Remove selected partInstances with their pieceInstances - */ -function removePartInstancesWithPieceInstances( - context: JobContext, - cache: CacheForPlayout, - selector: MongoQuery -): void { - const partInstancesToRemove = cache.PartInstances.remove((p) => mongoWhere(p, selector)) - - // Reset any in the cache now - if (partInstancesToRemove.length) { - cache.PieceInstances.remove((p) => partInstancesToRemove.includes(p.partInstanceId)) - } - - // Defer ones which arent loaded - cache.deferAfterSave(async (cache) => { - const rundownIds = getRundownIDsFromCache(cache) - // We need to keep any for PartInstances which are still existent in the cache (as they werent removed) - const partInstanceIdsInCache = cache.PartInstances.findAll(null).map((p) => p._id) - - // Find all the partInstances which are not loaded, but should be removed - const removeFromDb = await context.directCollections.PartInstances.findFetch( - { - $and: [ - selector, - { - // Not any which are in the cache, as they have already been done if needed - _id: { $nin: partInstanceIdsInCache }, - rundownId: { $in: rundownIds }, - }, - ], - }, - { projection: { _id: 1 } } - ).then((ps) => ps.map((p) => p._id)) - - // Do the remove - const allToRemove = [...removeFromDb, ...partInstancesToRemove] - await Promise.all([ - removeFromDb.length > 0 - ? context.directCollections.PartInstances.remove({ - _id: { $in: removeFromDb }, - rundownId: { $in: rundownIds }, - }) - : undefined, - allToRemove.length > 0 - ? context.directCollections.PieceInstances.remove({ - partInstanceId: { $in: allToRemove }, - rundownId: { $in: rundownIds }, - }) - : undefined, - ]) - }) -} - export function substituteObjectIds( rawEnable: TSR.Timeline.TimelineEnable | TSR.Timeline.TimelineEnable[], idMap: { [oldId: string]: string | undefined } @@ -307,28 +219,3 @@ export function isTooCloseToAutonext( return false } - -/** - * Update the expectedDurationWithPreroll on the specified PartInstance. - * The value is used by the UI to approximate the duration of a PartInstance as it will be played out - */ -export function updateExpectedDurationWithPrerollForPartInstance( - cache: CacheForPlayout, - partInstanceId: PartInstanceId -): void { - const nextPartInstance = cache.PartInstances.findOne(partInstanceId) - if (nextPartInstance) { - const pieceInstances = cache.PieceInstances.findAll((p) => p.partInstanceId === nextPartInstance._id) - - // Update expectedDurationWithPreroll of the next part instance, as it may have changed and is used by the ui until it is taken - const expectedDurationWithPreroll = calculatePartExpectedDurationWithPreroll( - nextPartInstance.part, - pieceInstances.map((p) => p.piece) - ) - - cache.PartInstances.updateOne(nextPartInstance._id, (doc) => { - doc.part.expectedDurationWithPreroll = expectedDurationWithPreroll - return doc - }) - } -} diff --git a/packages/job-worker/src/playout/lock.ts b/packages/job-worker/src/playout/lock.ts index 244a21d30b..4015b74aea 100644 --- a/packages/job-worker/src/playout/lock.ts +++ b/packages/job-worker/src/playout/lock.ts @@ -3,20 +3,20 @@ import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/Rund import { RundownPlayoutPropsBase } from '@sofie-automation/corelib/dist/worker/studio' import { logger } from '../logging' import { ReadonlyDeep } from 'type-fest' -import { ReadOnlyCache } from '../cache/CacheBase' import { JobContext } from '../jobs' import { PlaylistLock } from '../jobs/lock' -import { CacheForPlayoutPreInit, CacheForPlayout } from './cache' +import { PlayoutModel, PlayoutModelPreInit } from './model/PlayoutModel' +import { createPlayoutModelfromInitModel, loadPlayoutModelPreInit } from './model/implementation/LoadPlayoutModel' /** * Run a typical playout job - * This means loading the playout cache in stages, doing some calculations and saving the result + * This means loading the playout model in stages, doing some calculations and saving the result */ -export async function runJobWithPlayoutCache( +export async function runJobWithPlayoutModel( context: JobContext, data: RundownPlayoutPropsBase, - preInitFcn: null | ((cache: ReadOnlyCache) => Promise | void), - fcn: (cache: CacheForPlayout) => Promise | TRes + preInitFcn: null | ((playoutModel: PlayoutModelPreInit) => Promise | void), + fcn: (playoutModel: PlayoutModel) => Promise | TRes ): Promise { if (!data.playlistId) { throw new Error(`Job is missing playlistId`) @@ -29,7 +29,7 @@ export async function runJobWithPlayoutCache( throw new Error(`Job playlist "${data.playlistId}" not found or for another studio`) } - return runWithPlaylistCache(context, playlist, playlistLock, preInitFcn, fcn) + return runWithPlayoutModel(context, playlist, playlistLock, preInitFcn, fcn) }) } @@ -75,24 +75,24 @@ export async function runWithPlaylistLock( } } -export async function runWithPlaylistCache( +export async function runWithPlayoutModel( context: JobContext, playlist: ReadonlyDeep, lock: PlaylistLock, - preInitFcn: null | ((cache: ReadOnlyCache) => Promise | void), - fcn: (cache: CacheForPlayout) => Promise | TRes + preInitFcn: null | ((playoutModel: PlayoutModelPreInit) => Promise | void), + fcn: (playoutModel: PlayoutModel) => Promise | TRes ): Promise { - const initCache = await CacheForPlayoutPreInit.createPreInit(context, lock, playlist, false) + const initCache = await loadPlayoutModelPreInit(context, lock, playlist, false) if (preInitFcn) { await preInitFcn(initCache) } - const fullCache = await CacheForPlayout.fromInit(context, initCache) + const fullCache = await createPlayoutModelfromInitModel(context, initCache) try { const res = await fcn(fullCache) - logger.silly('runWithPlaylistCache: saveAllToDatabase') + logger.silly('runWithPlayoutModel: saveAllToDatabase') await fullCache.saveAllToDatabase() return res diff --git a/packages/job-worker/src/playout/lookahead/__tests__/findForLayer.test.ts b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer.test.ts index 7e49a88a81..f6b5393652 100644 --- a/packages/job-worker/src/playout/lookahead/__tests__/findForLayer.test.ts +++ b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer.test.ts @@ -7,6 +7,7 @@ import { setupDefaultJobEnvironment } from '../../../__mocks__/context' jest.mock('../findObjects') import { findLookaheadObjectsForPart } from '../findObjects' +import { ReadonlyDeep } from 'type-fest' type TfindLookaheadObjectsForPart = jest.MockedFunction const findLookaheadObjectsForPartMock = findLookaheadObjectsForPart as TfindLookaheadObjectsForPart findLookaheadObjectsForPartMock.mockImplementation(() => []) // Default mock @@ -128,7 +129,7 @@ describe('findLookaheadForLayer', () => { index: number, layer: string, partInfo: PartAndPieces, - previousPart: DBPart | undefined + previousPart: ReadonlyDeep | undefined ): void { expect(findLookaheadObjectsForPartMock).toHaveBeenNthCalledWith( index, diff --git a/packages/job-worker/src/playout/lookahead/__tests__/lookahead.test.ts b/packages/job-worker/src/playout/lookahead/__tests__/lookahead.test.ts index eac8ef8afa..7f69132be5 100644 --- a/packages/job-worker/src/playout/lookahead/__tests__/lookahead.test.ts +++ b/packages/job-worker/src/playout/lookahead/__tests__/lookahead.test.ts @@ -9,7 +9,7 @@ import { SelectedPartInstancesTimelineInfo } from '../../timeline/generate' import { getLookeaheadObjects } from '..' import { LookaheadMode, PlaylistTimingType, TSR } from '@sofie-automation/blueprints-integration' import { setupDefaultJobEnvironment, MockJobContext } from '../../../__mocks__/context' -import { runJobWithPlayoutCache } from '../../../playout/lock' +import { runJobWithPlayoutModel } from '../../../playout/lock' import { defaultRundownPlaylist } from '../../../__mocks__/defaultCollectionObjects' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' @@ -163,8 +163,8 @@ describe('Lookahead', () => { const fakeParts = partIds.map((p) => ({ part: { _id: p } as any, usesInTransition: true, pieces: [] })) getOrderedPartsAfterPlayheadMock.mockReturnValueOnce(fakeParts.map((p) => p.part)) - const res = await runJobWithPlayoutCache(context, { playlistId }, null, async (cache) => - getLookeaheadObjects(context, cache, partInstancesInfo) + const res = await runJobWithPlayoutModel(context, { playlistId }, null, async (playoutModel) => + getLookeaheadObjects(context, playoutModel, partInstancesInfo) ) expect(res).toHaveLength(0) @@ -206,8 +206,8 @@ describe('Lookahead', () => { ], })) - const res = await runJobWithPlayoutCache(context, { playlistId }, null, async (cache) => - getLookeaheadObjects(context, cache, partInstancesInfo) + const res = await runJobWithPlayoutModel(context, { playlistId }, null, async (playoutModel) => + getLookeaheadObjects(context, playoutModel, partInstancesInfo) ) expect(res).toMatchSnapshot() @@ -226,8 +226,8 @@ describe('Lookahead', () => { studio.mappingsWithOverrides.defaults['PRELOAD'].lookaheadMaxSearchDistance = 0 context.setStudio(studio) } - await runJobWithPlayoutCache(context, { playlistId }, null, async (cache) => - getLookeaheadObjects(context, cache, partInstancesInfo) + await runJobWithPlayoutModel(context, { playlistId }, null, async (playoutModel) => + getLookeaheadObjects(context, playoutModel, partInstancesInfo) ) expect(getOrderedPartsAfterPlayheadMock).toHaveBeenCalledTimes(1) expect(getOrderedPartsAfterPlayheadMock).toHaveBeenCalledWith(context, expect.anything(), 0) @@ -240,8 +240,8 @@ describe('Lookahead', () => { studio.mappingsWithOverrides.defaults['PRELOAD'].lookaheadMaxSearchDistance = 2000 context.setStudio(studio) } - await runJobWithPlayoutCache(context, { playlistId }, null, async (cache) => - getLookeaheadObjects(context, cache, partInstancesInfo) + await runJobWithPlayoutModel(context, { playlistId }, null, async (playoutModel) => + getLookeaheadObjects(context, playoutModel, partInstancesInfo) ) expect(getOrderedPartsAfterPlayheadMock).toHaveBeenCalledTimes(1) expect(getOrderedPartsAfterPlayheadMock).toHaveBeenCalledWith(context, expect.anything(), 2000) @@ -254,8 +254,8 @@ describe('Lookahead', () => { studio.mappingsWithOverrides.defaults['PRELOAD'].lookaheadMaxSearchDistance = -1 context.setStudio(studio) } - await runJobWithPlayoutCache(context, { playlistId }, null, async (cache) => - getLookeaheadObjects(context, cache, partInstancesInfo) + await runJobWithPlayoutModel(context, { playlistId }, null, async (playoutModel) => + getLookeaheadObjects(context, playoutModel, partInstancesInfo) ) expect(getOrderedPartsAfterPlayheadMock).toHaveBeenCalledTimes(1) expect(getOrderedPartsAfterPlayheadMock).toHaveBeenCalledWith(context, expect.anything(), 10) @@ -286,8 +286,8 @@ describe('Lookahead', () => { } // With a previous - await runJobWithPlayoutCache(context, { playlistId }, null, async (cache) => - getLookeaheadObjects(context, cache, partInstancesInfo) + await runJobWithPlayoutModel(context, { playlistId }, null, async (playoutModel) => + getLookeaheadObjects(context, playoutModel, partInstancesInfo) ) await expectLookaheadForLayerMock(playlistId, [], expectedPrevious, fakeParts) @@ -306,8 +306,8 @@ describe('Lookahead', () => { allPieces: partInstancesInfo.current.pieceInstances, calculatedTimings: partInstancesInfo.current.calculatedTimings, } - await runJobWithPlayoutCache(context, { playlistId }, null, async (cache) => - getLookeaheadObjects(context, cache, partInstancesInfo) + await runJobWithPlayoutModel(context, { playlistId }, null, async (playoutModel) => + getLookeaheadObjects(context, playoutModel, partInstancesInfo) ) await expectLookaheadForLayerMock(playlistId, [expectedCurrent], expectedPrevious, fakeParts) @@ -326,16 +326,16 @@ describe('Lookahead', () => { allPieces: partInstancesInfo.next.pieceInstances, calculatedTimings: partInstancesInfo.next.calculatedTimings, } - await runJobWithPlayoutCache(context, { playlistId }, null, async (cache) => - getLookeaheadObjects(context, cache, partInstancesInfo) + await runJobWithPlayoutModel(context, { playlistId }, null, async (playoutModel) => + getLookeaheadObjects(context, playoutModel, partInstancesInfo) ) await expectLookaheadForLayerMock(playlistId, [expectedCurrent, expectedNext], expectedPrevious, fakeParts) // current has autonext - partInstancesInfo.current.partInstance.part.autoNext = true + ;(partInstancesInfo.current.partInstance.part as DBPart).autoNext = true expectedNext.onTimeline = true - await runJobWithPlayoutCache(context, { playlistId }, null, async (cache) => - getLookeaheadObjects(context, cache, partInstancesInfo) + await runJobWithPlayoutModel(context, { playlistId }, null, async (playoutModel) => + getLookeaheadObjects(context, playoutModel, partInstancesInfo) ) await expectLookaheadForLayerMock(playlistId, [expectedCurrent, expectedNext], expectedPrevious, fakeParts) }) @@ -382,9 +382,9 @@ describe('Lookahead', () => { // // pieceMap should have come through valid // ;( - // wrapWithCacheForRundownPlaylist(playlist, async (cache) => { - // await getLookeaheadObjects(context, cache, env.studio, playlist, partInstancesInfo) - // expect(cache.Pieces.initialized).toBeFalsy() + // wrapWithCacheForRundownPlaylist(playlist, async (playoutModel) => { + // await getLookeaheadObjects(context, playoutModel, env.studio, playlist, partInstancesInfo) + // expect(playoutModel.Pieces.initialized).toBeFalsy() // }) // ) // await expectLookaheadForLayerMock(playlist, [], undefined, fakeParts, pieceMap) @@ -392,18 +392,18 @@ describe('Lookahead', () => { // // Use the modified cache values // const removedIds: PieceId[] = protectStringArray(['piece_1_0', 'piece_4_0']) // ;( - // wrapWithCacheForRundownPlaylist(playlist, async (cache) => { + // wrapWithCacheForRundownPlaylist(playlist, async (playoutModel) => { // expect( - // cache.Pieces.update(removedIds[0], { + // playoutModel.Pieces.update(removedIds[0], { // $set: { // invalid: true, // }, // }) // ).toEqual(1) - // cache.Pieces.remove(removedIds[1]) - // expect(cache.Pieces.initialized).toBeTruthy() + // playoutModel.Pieces.remove(removedIds[1]) + // expect(playoutModel.Pieces.initialized).toBeTruthy() - // await getLookeaheadObjects(context, cache, env.studio, playlist, partInstancesInfo) + // await getLookeaheadObjects(context, playoutModel, env.studio, playlist, partInstancesInfo) // }) // ) // const pieceMap2 = new Map() diff --git a/packages/job-worker/src/playout/lookahead/__tests__/util.test.ts b/packages/job-worker/src/playout/lookahead/__tests__/util.test.ts index e63e1b5173..862a929fb7 100644 --- a/packages/job-worker/src/playout/lookahead/__tests__/util.test.ts +++ b/packages/job-worker/src/playout/lookahead/__tests__/util.test.ts @@ -6,7 +6,7 @@ import { getCurrentTime } from '../../../lib' import { LookaheadMode, PlaylistTimingType, TSR } from '@sofie-automation/blueprints-integration' import { getOrderedPartsAfterPlayhead } from '../util' import { MockJobContext, setupDefaultJobEnvironment } from '../../../__mocks__/context' -import { runJobWithPlayoutCache } from '../../../playout/lock' +import { runJobWithPlayoutModel } from '../../../playout/lock' import { defaultRundownPlaylist } from '../../../__mocks__/defaultCollectionObjects' import _ = require('underscore') import { wrapPartToTemporaryInstance } from '../../../__mocks__/partinstance' @@ -136,8 +136,8 @@ describe('getOrderedPartsAfterPlayhead', () => { ]) }) test('all parts come back', async () => { - const parts = await runJobWithPlayoutCache(context, { playlistId }, null, async (cache) => - getOrderedPartsAfterPlayhead(context, cache, 100) + const parts = await runJobWithPlayoutModel(context, { playlistId }, null, async (playoutModel) => + getOrderedPartsAfterPlayhead(context, playoutModel, 100) ) expect(parts.map((p) => p._id)).toEqual(partIds) @@ -162,15 +162,15 @@ describe('getOrderedPartsAfterPlayhead', () => { }, }) - const parts = await runJobWithPlayoutCache(context, { playlistId }, null, async (cache) => - getOrderedPartsAfterPlayhead(context, cache, 100) + const parts = await runJobWithPlayoutModel(context, { playlistId }, null, async (playoutModel) => + getOrderedPartsAfterPlayhead(context, playoutModel, 100) ) // Should not have the first expect(parts.map((p) => p._id)).toEqual(partIds.slice(1)) // Try with a limit - const parts2 = await runJobWithPlayoutCache(context, { playlistId }, null, async (cache) => - getOrderedPartsAfterPlayhead(context, cache, 5) + const parts2 = await runJobWithPlayoutModel(context, { playlistId }, null, async (playoutModel) => + getOrderedPartsAfterPlayhead(context, playoutModel, 5) ) // Should not have the first expect(parts2.map((p) => p._id)).toEqual(partIds.slice(1, 6)) @@ -195,15 +195,15 @@ describe('getOrderedPartsAfterPlayhead', () => { }, }) - const parts = await runJobWithPlayoutCache(context, { playlistId }, null, async (cache) => - getOrderedPartsAfterPlayhead(context, cache, 100) + const parts = await runJobWithPlayoutModel(context, { playlistId }, null, async (playoutModel) => + getOrderedPartsAfterPlayhead(context, playoutModel, 100) ) // Should not have the first expect(parts.map((p) => p._id)).toEqual(partIds.slice(1)) // Try with a limit - const parts2 = await runJobWithPlayoutCache(context, { playlistId }, null, async (cache) => - getOrderedPartsAfterPlayhead(context, cache, 5) + const parts2 = await runJobWithPlayoutModel(context, { playlistId }, null, async (playoutModel) => + getOrderedPartsAfterPlayhead(context, playoutModel, 5) ) // Should not have the first expect(parts2.map((p) => p._id)).toEqual(partIds.slice(1, 6)) @@ -228,16 +228,16 @@ describe('getOrderedPartsAfterPlayhead', () => { }, }) - const parts = await runJobWithPlayoutCache(context, { playlistId }, null, async (cache) => - getOrderedPartsAfterPlayhead(context, cache, 100) + const parts = await runJobWithPlayoutModel(context, { playlistId }, null, async (playoutModel) => + getOrderedPartsAfterPlayhead(context, playoutModel, 100) ) // Should be empty expect(parts.map((p) => p._id)).toEqual([]) // Playlist could loop await context.mockCollections.RundownPlaylists.update(playlistId, { $set: { loop: true } }) - const parts2 = await runJobWithPlayoutCache(context, { playlistId }, null, async (cache) => - getOrderedPartsAfterPlayhead(context, cache, 5) + const parts2 = await runJobWithPlayoutModel(context, { playlistId }, null, async (playoutModel) => + getOrderedPartsAfterPlayhead(context, playoutModel, 5) ) // Should be empty expect(parts2.map((p) => p._id)).toEqual(partIds.slice(0, 5)) @@ -251,8 +251,8 @@ describe('getOrderedPartsAfterPlayhead', () => { $set: { invalid: true }, } ) - const parts3 = await runJobWithPlayoutCache(context, { playlistId }, null, async (cache) => - getOrderedPartsAfterPlayhead(context, cache, 5) + const parts3 = await runJobWithPlayoutModel(context, { playlistId }, null, async (playoutModel) => + getOrderedPartsAfterPlayhead(context, playoutModel, 5) ) // Should be empty expect(parts3.map((p) => p._id)).toEqual([partIds[0], ...partIds.slice(2, 4), ...partIds.slice(5, 7)]) @@ -286,8 +286,8 @@ describe('getOrderedPartsAfterPlayhead', () => { }, }) - const parts = await runJobWithPlayoutCache(context, { playlistId }, null, async (cache) => - getOrderedPartsAfterPlayhead(context, cache, 5) + const parts = await runJobWithPlayoutModel(context, { playlistId }, null, async (playoutModel) => + getOrderedPartsAfterPlayhead(context, playoutModel, 5) ) // Should not have the first expect(parts.map((p) => p._id)).toEqual([partIds[5], partIds[6], partIds[8], partIds[9], partIds[10]]) @@ -314,8 +314,8 @@ describe('getOrderedPartsAfterPlayhead', () => { // Change next segment await context.mockCollections.RundownPlaylists.update(playlistId, { $set: { queuedSegmentId: segmentId2 } }) - const parts = await runJobWithPlayoutCache(context, { playlistId }, null, async (cache) => - getOrderedPartsAfterPlayhead(context, cache, 10) + const parts = await runJobWithPlayoutModel(context, { playlistId }, null, async (playoutModel) => + getOrderedPartsAfterPlayhead(context, playoutModel, 10) ) expect(parts.map((p) => p._id)).toEqual([...partIds.slice(1, 5), ...partIds.slice(8)]) @@ -328,8 +328,8 @@ describe('getOrderedPartsAfterPlayhead', () => { $set: { invalid: true }, } ) - const parts2 = await runJobWithPlayoutCache(context, { playlistId }, null, async (cache) => - getOrderedPartsAfterPlayhead(context, cache, 10) + const parts2 = await runJobWithPlayoutModel(context, { playlistId }, null, async (playoutModel) => + getOrderedPartsAfterPlayhead(context, playoutModel, 10) ) expect(parts2.map((p) => p._id)).toEqual([...partIds.slice(1, 5), ...partIds.slice(9)]) @@ -342,8 +342,8 @@ describe('getOrderedPartsAfterPlayhead', () => { $set: { invalid: true }, } ) - const parts3 = await runJobWithPlayoutCache(context, { playlistId }, null, async (cache) => - getOrderedPartsAfterPlayhead(context, cache, 10) + const parts3 = await runJobWithPlayoutModel(context, { playlistId }, null, async (playoutModel) => + getOrderedPartsAfterPlayhead(context, playoutModel, 10) ) expect(parts3.map((p) => p._id)).toEqual(partIds.slice(1, 8)) }) diff --git a/packages/job-worker/src/playout/lookahead/findForLayer.ts b/packages/job-worker/src/playout/lookahead/findForLayer.ts index b097ca61a9..42fbf03958 100644 --- a/packages/job-worker/src/playout/lookahead/findForLayer.ts +++ b/packages/job-worker/src/playout/lookahead/findForLayer.ts @@ -1,5 +1,6 @@ import { PartInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { DBPart, isPartPlayable } from '@sofie-automation/corelib/dist/dataModel/Part' +import { ReadonlyDeep } from 'type-fest' import { JobContext } from '../../jobs' import { sortPieceInstancesByStart } from '../pieces' import { findLookaheadObjectsForPart, LookaheadTimelineObject } from './findObjects' @@ -27,7 +28,7 @@ export function findLookaheadForLayer( } // Track the previous info for checking how the timeline will be built - let previousPart: DBPart | undefined + let previousPart: ReadonlyDeep | undefined if (previousPartInstanceInfo) { previousPart = previousPartInstanceInfo.part.part } diff --git a/packages/job-worker/src/playout/lookahead/findObjects.ts b/packages/job-worker/src/playout/lookahead/findObjects.ts index 91387fba9c..05a13c6288 100644 --- a/packages/job-worker/src/playout/lookahead/findObjects.ts +++ b/packages/job-worker/src/playout/lookahead/findObjects.ts @@ -12,9 +12,9 @@ import { protectString, unprotectString } from '@sofie-automation/corelib/dist/p import { JobContext } from '../../jobs' import { PartAndPieces, PieceInstanceWithObjectMap } from './util' import { deserializePieceTimelineObjectsBlob } from '@sofie-automation/corelib/dist/dataModel/Piece' -import { SetRequired } from 'type-fest' +import { ReadonlyDeep, SetRequired } from 'type-fest' -function getBestPieceInstanceId(piece: PieceInstance): string { +function getBestPieceInstanceId(piece: ReadonlyDeep): string { if (!piece.isTemporary || piece.partInstanceId) { return unprotectString(piece._id) } @@ -25,7 +25,7 @@ function getBestPieceInstanceId(piece: PieceInstance): string { function tryActivateKeyframesForObject( obj: TimelineObjectCoreExt, hasTransition: boolean, - classesFromPreviousPart: string[] | undefined + classesFromPreviousPart: readonly string[] | undefined ): TSR.TSRTimelineContent { // Try and find a keyframe that is used when in a transition if (hasTransition) { @@ -90,7 +90,7 @@ export function findLookaheadObjectsForPart( _context: JobContext, currentPartInstanceId: PartInstanceId | null, layer: string, - previousPart: DBPart | undefined, + previousPart: ReadonlyDeep | undefined, partInfo: PartAndPieces, partInstanceId: PartInstanceId | null ): Array { @@ -123,7 +123,7 @@ export function findLookaheadObjectsForPart( return [] } - let classesFromPreviousPart: string[] = [] + let classesFromPreviousPart: readonly string[] = [] if (previousPart && currentPartInstanceId && partInstanceId) { classesFromPreviousPart = previousPart.classesForNext || [] } diff --git a/packages/job-worker/src/playout/lookahead/index.ts b/packages/job-worker/src/playout/lookahead/index.ts index c4b5e18bde..5cf9761caf 100644 --- a/packages/job-worker/src/playout/lookahead/index.ts +++ b/packages/job-worker/src/playout/lookahead/index.ts @@ -1,6 +1,6 @@ import { getOrderedPartsAfterPlayhead, PartAndPieces, PartInstanceAndPieceInstances } from './util' import { findLookaheadForLayer, LookaheadResult } from './findForLayer' -import { CacheForPlayout, getRundownIDsFromCache } from '../cache' +import { PlayoutModel } from '../model/PlayoutModel' import { sortPieceInstancesByStart } from '../pieces' import { MappingExt } from '@sofie-automation/corelib/dist/dataModel/Studio' import { TSR, LookaheadMode, OnGenerateTimelineObj } from '@sofie-automation/blueprints-integration' @@ -24,6 +24,7 @@ import { LookaheadTimelineObject } from './findObjects' import { hasPieceInstanceDefinitelyEnded } from '../timeline/lib' import { applyAndValidateOverrides } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { ReadonlyDeep } from 'type-fest' const LOOKAHEAD_OBJ_PRIORITY = 0.1 @@ -44,7 +45,7 @@ type ValidLookaheadMode = LookaheadMode.PRELOAD | LookaheadMode.WHEN_CLEAR export async function getLookeaheadObjects( context: JobContext, - cache: CacheForPlayout, + playoutModel: PlayoutModel, partInstancesInfo0: SelectedPartInstancesTimelineInfo ): Promise> { const span = context.startSpan('getLookeaheadObjects') @@ -58,10 +59,10 @@ export async function getLookeaheadObjects( } const maxLookaheadDistance = findLargestLookaheadDistance(mappingsToConsider) - const orderedPartsFollowingPlayhead = getOrderedPartsAfterPlayhead(context, cache, maxLookaheadDistance) + const orderedPartsFollowingPlayhead = getOrderedPartsAfterPlayhead(context, playoutModel, maxLookaheadDistance) const piecesToSearchQuery: FilterQuery = { - startRundownId: { $in: getRundownIDsFromCache(cache) }, + startRundownId: { $in: playoutModel.getRundownIds() }, startPartId: { $in: orderedPartsFollowingPlayhead.map((p) => p._id) }, invalid: { $ne: true }, } @@ -139,7 +140,7 @@ export async function getLookeaheadObjects( } const orderedPartInfos: Array = orderedPartsFollowingPlayhead.map((part, i) => { - const previousPart: DBPart | undefined = + const previousPart: ReadonlyDeep | undefined = i === 0 ? (partInstancesInfo0.next?.partInstance ?? partInstancesInfo0.current?.partInstance)?.part : orderedPartsFollowingPlayhead[i - 1] @@ -164,7 +165,7 @@ export async function getLookeaheadObjects( const lookaheadObjs = findLookaheadForLayer( context, - cache.Playlist.doc.currentPartInfo?.partInstanceId ?? null, + playoutModel.Playlist.currentPartInfo?.partInstanceId ?? null, partInstancesInfo, previousPartInfo, orderedPartInfos, diff --git a/packages/job-worker/src/playout/lookahead/util.ts b/packages/job-worker/src/playout/lookahead/util.ts index 7c0c888163..5cf54c020e 100644 --- a/packages/job-worker/src/playout/lookahead/util.ts +++ b/packages/job-worker/src/playout/lookahead/util.ts @@ -4,27 +4,24 @@ import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartIns import { Piece } from '@sofie-automation/corelib/dist/dataModel/Piece' import { PieceInstance, PieceInstancePiece } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' import { PartCalculatedTimings } from '@sofie-automation/corelib/dist/playout/timings' +import { ReadonlyDeep } from 'type-fest' import { JobContext } from '../../jobs' -import { - CacheForPlayout, - getOrderedSegmentsAndPartsFromPlayoutCache, - getSelectedPartInstancesFromCache, -} from '../cache' +import { PlayoutModel } from '../model/PlayoutModel' import { selectNextPart } from '../selectNextPart' export interface PartInstanceAndPieceInstances { - part: DBPartInstance + part: ReadonlyDeep onTimeline: boolean nowInPart: number - allPieces: PieceInstance[] + allPieces: ReadonlyDeep calculatedTimings: PartCalculatedTimings } -export interface PieceInstanceWithObjectMap extends PieceInstance { +export interface PieceInstanceWithObjectMap extends ReadonlyDeep { /** Cache of objects built by findObjects. */ objectMap?: Map> } export interface PartAndPieces { - part: DBPart + part: ReadonlyDeep /** Whether the inTransition from this part should be considered */ usesInTransition: boolean pieces: PieceInstanceWithObjectMap[] @@ -38,15 +35,21 @@ export function isPieceInstance(piece: Piece | PieceInstance | PieceInstancePiec /** * Excludes the previous, current and next part */ -export function getOrderedPartsAfterPlayhead(context: JobContext, cache: CacheForPlayout, partCount: number): DBPart[] { +export function getOrderedPartsAfterPlayhead( + context: JobContext, + playoutModel: PlayoutModel, + partCount: number +): ReadonlyDeep[] { if (partCount <= 0) { return [] } const span = context.startSpan('getOrderedPartsAfterPlayhead') - const playlist = cache.Playlist.doc - const partsAndSegments = getOrderedSegmentsAndPartsFromPlayoutCache(cache) - const { currentPartInstance, nextPartInstance } = getSelectedPartInstancesFromCache(cache) + const playlist = playoutModel.Playlist + const orderedSegments = playoutModel.getAllOrderedSegments() + const orderedParts = playoutModel.getAllOrderedParts() + const currentPartInstance = playoutModel.CurrentPartInstance?.PartInstance + const nextPartInstance = playoutModel.NextPartInstance?.PartInstance // If the nextPartInstance consumes the const alreadyConsumedQueuedSegmentId = @@ -61,16 +64,17 @@ export function getOrderedPartsAfterPlayhead(context: JobContext, cache: CacheFo strippedPlaylist, nextPartInstance ?? currentPartInstance ?? null, null, - partsAndSegments + orderedSegments, + orderedParts ) if (!nextNextPart) { // We don't know where to begin searching, so we can't do anything return [] } - const playablePartsSlice = partsAndSegments.parts.slice(nextNextPart.index).filter((p) => isPartPlayable(p)) + const playablePartsSlice = orderedParts.slice(nextNextPart.index).filter((p) => isPartPlayable(p)) - const res: DBPart[] = [] + const res: ReadonlyDeep[] = [] const nextSegmentIndex = playablePartsSlice.findIndex((p) => p.segmentId === playlist.queuedSegmentId) if ( @@ -93,7 +97,7 @@ export function getOrderedPartsAfterPlayhead(context: JobContext, cache: CacheFo if (res.length < partCount && playlist.loop) { // The rundown would loop here, so lets run with that - const playableParts = partsAndSegments.parts.filter((p) => isPartPlayable(p)) + const playableParts = orderedParts.filter((p) => isPartPlayable(p)) // Note: We only add it once, as lookahead is unlikely to show anything new in a second pass res.push(...playableParts) diff --git a/packages/job-worker/src/playout/model/PlayoutModel.ts b/packages/job-worker/src/playout/model/PlayoutModel.ts new file mode 100644 index 0000000000..79d6d088b5 --- /dev/null +++ b/packages/job-worker/src/playout/model/PlayoutModel.ts @@ -0,0 +1,328 @@ +import { + PartId, + PartInstanceId, + PieceId, + PieceInstanceId, + RundownId, + RundownPlaylistActivationId, + RundownPlaylistId, + SegmentId, +} from '@sofie-automation/corelib/dist/dataModel/Ids' +import { BaseModel } from '../../modelBase' +import { + ABSessionAssignments, + ABSessionInfo, + DBRundownPlaylist, + RundownHoldState, +} from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { ReadonlyDeep } from 'type-fest' +import { StudioPlayoutModelBase, StudioPlayoutModelBaseReadonly } from '../../studio/model/StudioPlayoutModel' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { PieceInstance, PieceInstancePiece } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' +import { PlaylistLock } from '../../jobs/lock' +import { PlayoutRundownModel } from './PlayoutRundownModel' +import { PlayoutSegmentModel } from './PlayoutSegmentModel' +import { PlayoutPartInstanceModel } from './PlayoutPartInstanceModel' +import { PeripheralDevice } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' +import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' +import { PlayoutPieceInstanceModel } from './PlayoutPieceInstanceModel' + +export type DeferredFunction = (playoutModel: PlayoutModel) => void | Promise +export type DeferredAfterSaveFunction = (playoutModel: PlayoutModelReadonly) => void | Promise + +/** + * A lightweight version of the `PlayoutModel`, used to perform some pre-checks before loading the full model + * + * This represents a `RundownPlaylist` in a `Studio`, in a minimal readonly fashion + */ +export interface PlayoutModelPreInit { + /** + * The Id of the RundownPlaylist this PlayoutModel operates for + */ + readonly PlaylistId: RundownPlaylistId + /** + * Reference to the lock for the RundownPlaylist + */ + readonly PlaylistLock: PlaylistLock + + /** + * All of the PeripheralDevices that belong to the Studio of this RundownPlaylist + */ + readonly PeripheralDevices: ReadonlyDeep + + /** + * The RundownPlaylist this PlayoutModel operates for + */ + readonly Playlist: ReadonlyDeep + /** + * The unwrapped Rundowns in this RundownPlaylist + */ + readonly Rundowns: ReadonlyDeep + + /** + * Get a Rundown which belongs to this RundownPlaylist + * @param id Id of the Rundown + */ + getRundown(id: RundownId): DBRundown | undefined +} + +/** + * A readonly version of the `PlayoutModel` + * + * This represents a `RundownPlaylist` and its content in a `Studio`, in a readonly fashion + */ +export interface PlayoutModelReadonly extends StudioPlayoutModelBaseReadonly { + /** + * The Id of the RundownPlaylist this PlayoutModel operates for + */ + readonly PlaylistId: RundownPlaylistId + /** + * Reference to the lock for the RundownPlaylist + */ + readonly PlaylistLock: PlaylistLock + + /** + * The RundownPlaylist this PlayoutModel operates for + */ + get Playlist(): ReadonlyDeep + /** + * The Rundowns in this RundownPlaylist + */ + get Rundowns(): readonly PlayoutRundownModel[] + + /** + * All of the loaded PartInstances which are not one of the Previous, Current or Next + * This may or may not contain all PartInstances from the RundownPlaylist, depending on implementation. + * At a minimum it will contain all PartInstances from the Segments of the previous, current and next PartInstances + */ + get OlderPartInstances(): PlayoutPartInstanceModel[] + /** + * The PartInstance previously played, if any + */ + get PreviousPartInstance(): PlayoutPartInstanceModel | null + /** + * The PartInstance currently being played, if any + */ + get CurrentPartInstance(): PlayoutPartInstanceModel | null + /** + * The PartInstance which is next to be played, if any + */ + get NextPartInstance(): PlayoutPartInstanceModel | null + /** + * Ids of the previous, current and next PartInstances + */ + get SelectedPartInstanceIds(): PartInstanceId[] + /** + * The previous, current and next PartInstances + */ + get SelectedPartInstances(): PlayoutPartInstanceModel[] + /** + * All of the loaded PartInstances + * This may or may not contain all PartInstances from the RundownPlaylist, depending on implementation. + * At a minimum it will contain all PartInstances from the Segments of the previous, current and next PartInstances + */ + get LoadedPartInstances(): PlayoutPartInstanceModel[] + /** + * All of the loaded PartInstances, sorted by order of playback + */ + get SortedLoadedPartInstances(): PlayoutPartInstanceModel[] + /** + * Get a PartInstance which belongs to this RundownPlaylist + * @param id Id of the PartInstance + */ + getPartInstance(partInstanceId: PartInstanceId): PlayoutPartInstanceModel | undefined + + /** + * Search for a Part through the whole RundownPlaylist + * @param id Id of the Part + */ + findPart(id: PartId): ReadonlyDeep | undefined + /** + * Collect all Parts in the RundownPlaylist, and return them sorted by the Segment and Part ranks + */ + getAllOrderedParts(): ReadonlyDeep[] + + /** + * Search for a Segment through the whole RundownPlaylist + * @param id Id of the Segment + */ + findSegment(id: SegmentId): ReadonlyDeep | undefined + /** + * Collect all Segments in the RundownPlaylist, and return them sorted by their ranks + */ + getAllOrderedSegments(): ReadonlyDeep[] + + /** + * Get a Rundown which belongs to this RundownPlaylist + * @param id Id of the Rundown + */ + getRundown(id: RundownId): PlayoutRundownModel | undefined + /** + * Get the Ids of the Rundowns in this RundownPlaylist + */ + getRundownIds(): RundownId[] + + /** + * Search for a PieceInstance in the RundownPlaylist + * @param id Id of the PieceInstance + * @returns The found PieceInstance and its parent PartInstance + */ + findPieceInstance( + id: PieceInstanceId + ): { partInstance: PlayoutPartInstanceModel; pieceInstance: PlayoutPieceInstanceModel } | undefined +} + +/** + * A view of a `RundownPlaylist` and its content in a `Studio` + */ +export interface PlayoutModel extends PlayoutModelReadonly, StudioPlayoutModelBase, BaseModel { + /** + * Temporary hack for debug logging + */ + get HackDeletedPartInstanceIds(): PartInstanceId[] + + /** + * Set the RundownPlaylist as activated (or reactivate) + * @param rehearsal Whether to activate in rehearsal mode + * @returns Id of this activation + */ + activatePlaylist(rehearsal: boolean): RundownPlaylistActivationId + + /** + * Clear the currently selected PartInstances, so that nothing is selected for playback + */ + clearSelectedPartInstances(): void + + /** + * Insert an adlibbed PartInstance into the RundownPlaylist + * @param part Part to insert + * @param pieces Planned Pieces to insert into Part + * @param fromAdlibId Id of the source Adlib, if any + * @param infinitePieceInstances Infinite PieceInstances to be continued + * @returns The inserted PlayoutPartInstanceModel + */ + createAdlibbedPartInstance( + part: Omit, + pieces: Omit[], + fromAdlibId: PieceId | undefined, + infinitePieceInstances: PieceInstance[] + ): PlayoutPartInstanceModel + + /** + * Insert a planned PartInstance into the RundownPlaylist + * Future: This needs refactoring to take Pieces not PieceInstances + * @param nextPart Part to insert + * @param pieceInstances All the PieceInstances to insert + * @returns The inserted PlayoutPartInstanceModel + */ + createInstanceForPart(nextPart: ReadonlyDeep, pieceInstances: PieceInstance[]): PlayoutPartInstanceModel + + /** + * Insert an adlibbed PartInstance into the Scratchpad Segment of a Rundown in this RundownPlaylist + * @param rundown Rundown to insert for + * @param part Part to insert + * @returns The inserted PlayoutPartInstanceModel + */ + createScratchpadPartInstance( + rundown: PlayoutRundownModel, + part: Omit + ): PlayoutPartInstanceModel + + /** + * Cycle the selected PartInstances + * The current will become the previous, the next will become the current, and there will be no next PartInstance. + */ + cycleSelectedPartInstances(): void + + /** + * Set the RundownPlaylist as deactivated + */ + deactivatePlaylist(): void + + /** + * Queue a `PartInstanceTimingEvent` to be performed upon completion of this Playout operation + * @param partInstanceId Id of the PartInstance the event is in relation to + */ + queuePartInstanceTimingEvent(partInstanceId: PartInstanceId): void + + /** + * Queue a `NotifyCurrentlyPlayingPart` operation to be performed upon completion of this Playout operation + * @param rundownId The Rundown to report the notification to + * @param partInstance The PartInstance the event is in relation to + */ + queueNotifyCurrentlyPlayingPartEvent(rundownId: RundownId, partInstance: PlayoutPartInstanceModel | null): void + + /** + * Remove all loaded PartInstances marked as `rehearsal` from this RundownPlaylist + */ + removeAllRehearsalPartInstances(): void + + /** + * Remove any untaken PartInstances from this RundownPlaylist + * This ignores any which are a selected PartInstance + */ + removeUntakenPartInstances(): void + + /** + * Reset the playlist for playout + */ + resetPlaylist(regenerateActivationId: boolean): void + + /** + * Update the HOLD state of the RundownPlaylist + * @param newState New HOLD state + */ + setHoldState(newState: RundownHoldState): void + + /** + * Store the persistent results of the AB playback resolving and onTimelineGenerate + * @param persistentState Blueprint owned state from onTimelineGenerate + * @param assignedAbSessions The applied AB sessions + * @param trackedAbSessions The known AB sessions + */ + setOnTimelineGenerateResult( + persistentState: unknown | undefined, + assignedAbSessions: Record, + trackedAbSessions: ABSessionInfo[] + ): void + + /** + * Set a PartInstance as the nexted PartInstance + * @param partInstance PartInstance to be set as next, or none + * @param setManually Whether this was specified by the user + * @param consumesQueuedSegmentId Whether this consumes the `queuedSegment` property of the RundownPlaylist + * @param nextTimeOffset The time offset of the next line + */ + setPartInstanceAsNext( + partInstance: PlayoutPartInstanceModel | null, + setManually: boolean, + consumesQueuedSegmentId: boolean, + nextTimeOffset?: number + ): void + + /** + * Set a Segment as queued, indicating it should be played after the current Segment + * @param segment Segment to set as queued, or none + */ + setQueuedSegment(segment: PlayoutSegmentModel | null): void + + /** + * Track a Rundown as having started playback + * @param rundownId If of the Rundown + * @param timestamp Timestamp playback started + */ + setRundownStartedPlayback(rundownId: RundownId, timestamp: number): void + + /** Lifecycle */ + + /** + * @deprecated + * Defer some code to be run before the data is saved + */ + deferBeforeSave(fcn: DeferredFunction): void + /** + * @deprecated + * Defer some code to be run after the data is saved + */ + deferAfterSave(fcn: DeferredAfterSaveFunction): void +} diff --git a/packages/job-worker/src/playout/model/PlayoutPartInstanceModel.ts b/packages/job-worker/src/playout/model/PlayoutPartInstanceModel.ts new file mode 100644 index 0000000000..1fb3b38b92 --- /dev/null +++ b/packages/job-worker/src/playout/model/PlayoutPartInstanceModel.ts @@ -0,0 +1,215 @@ +import { PieceId, PieceInstanceId, RundownPlaylistActivationId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { ReadonlyDeep } from 'type-fest' +import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' +import { PieceInstance, PieceInstancePiece } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' +import { PartNote } from '@sofie-automation/corelib/dist/dataModel/Notes' +import { IBlueprintMutatablePart, PieceLifespan, Time } from '@sofie-automation/blueprints-integration' +import { PartCalculatedTimings } from '@sofie-automation/corelib/dist/playout/timings' +import { PlayoutPieceInstanceModel } from './PlayoutPieceInstanceModel' + +/** + * Token returned when making a backup copy of a PlayoutPartInstanceModel + * The contents of this type is opaque and will vary fully across implementations + */ +export interface PlayoutPartInstanceModelSnapshot { + __isPlayoutPartInstanceModelBackup: true +} + +export interface PlayoutPartInstanceModel { + /** + * The PartInstance properties + */ + readonly PartInstance: ReadonlyDeep + + /** + * All the PieceInstances in the PartInstance + */ + readonly PieceInstances: PlayoutPieceInstanceModel[] + + /** + * Take a snapshot of the current state of this PlayoutPartInstanceModel + * This can be restored with `snapshotRestore` to rollback to a previous state of the model + */ + snapshotMakeCopy(): PlayoutPartInstanceModelSnapshot + + /** + * Restore a snapshot of this PlayoutPartInstanceModel, to rollback to a previous state + * Note: It is only possible to restore each snapshot once. + * Note: Any references to child `PlayoutPieceInstanceModel` or `DBPartInstance` may no longer be valid after this operation + * @param snapshot Snapshot to restore + */ + snapshotRestore(snapshot: PlayoutPartInstanceModelSnapshot): void + + /** + * Add some user notes for this PartInstance + * Future: it is only possible to add these, there is no way to 'replace' or remove them + * @param notes New notes to add + */ + appendNotes(notes: PartNote[]): void + + /** + * Block a take out of this PartInstance from happening until the specified timestamp + * This can be necessary when an uninteruptable Piece is being played out + * @param timestamp Timestampt to block until + */ + blockTakeUntil(timestamp: Time | null): void + + /** + * Get a PieceInstance which belongs to this PartInstance + * @param id Id of the PieceInstance + */ + getPieceInstance(id: PieceInstanceId): PlayoutPieceInstanceModel | undefined + + /** + * Insert a Piece into this PartInstance as an adlibbed PieceInstance + * @param piece Piece to insert + * @param fromAdlibId Id of the source Adlib, if any + * @returns The inserted PlayoutPieceInstanceModel + */ + insertAdlibbedPiece( + piece: Omit, + fromAdlibId: PieceId | undefined + ): PlayoutPieceInstanceModel + + /** + * Extend a PieceInstance into this PartInstance as a Piece extended by HOLD + * The PieceInstance being extended must have been prepared with `prepareForHold` before calling this + * @param extendPieceInstance Piece to extend + * @returns The inserted PlayoutPieceInstanceModel + */ + insertHoldPieceInstance(extendPieceInstance: PlayoutPieceInstanceModel): PlayoutPieceInstanceModel + + /** + * Insert a Piece as if it were originally planned at the time of ingest + * This is a weird operation to have for playout, but it is a needed part of the SyncIngestChanges flow + * @param piece Piece to insert into this PartInstance + * @returns The inserted PlayoutPieceInstanceModel + */ + insertPlannedPiece(piece: Omit): PlayoutPieceInstanceModel + + /** + * Insert a virtual adlib Piece into this PartInstance + * This will stop another piece following the infinite rules, but has no content and will not be visible in the UI + * @param start Start time of the Piece, relative to the start of the PartInstance + * @param lifespan Infinite lifespan to use + * @param sourceLayerId Id of the SourceLayer the Piece should play on + * @param outputLayerId Id of the OutputLayer the Piece should play on + * @returns The inserted PlayoutPieceInstanceModel + */ + insertVirtualPiece( + start: number, + lifespan: PieceLifespan, + sourceLayerId: string, + outputLayerId: string + ): PlayoutPieceInstanceModel + + /** + * Mark this PartInstance as 'reset' + * This will unload it from memory at the end of the operation from both the backend and UI. + * Any UI's will ignore this PartInstance and will use the original Part instead + */ + markAsReset(): void + + /** + * Recalculate the `expectedDurationWithPreroll` property for this PartInstance + * Future: is this needed? should this be handled internally? + */ + recalculateExpectedDurationWithPreroll(): void + + /** + * Remove a PieceInstance from the model. + * This is a slightly dangerous operation to have, as it could remove a PieceInstance which will be readded by the ingest or SyncIngestChanges logic + * @param id Piece to remove from this PartInstance + * @returns Whether the PieceInstance was found and removed + */ + removePieceInstance(id: PieceInstanceId): boolean + + /** + * Replace the infinite PieceInstances inherited from the previous playhead + * These PieceInstances are not supposed to be modified directly, as they are 'extensions'. + * This allows them to be replaced without embedding the infinite logic inside the model + * @param pieceInstances New infinite pieces from previous playhead + */ + replaceInfinitesFromPreviousPlayhead(pieceInstances: PieceInstance[]): void + + /** + * Merge a PieceInstance with a new version, or insert as a new PieceInstance. + * If there is an existing PieceInstance with the same id, it will be merged onto that + * Note: this can replace any playout owned properties too + * @param pieceInstance Replacement PieceInstance to use + * @returns The inserted PlayoutPieceInstanceModel + */ + mergeOrInsertPieceInstance(pieceInstance: ReadonlyDeep): PlayoutPieceInstanceModel + + /** + * Mark this PartInstance as being orphaned + * @param orphaned New orphaned state + */ + setOrphaned(orphaned: 'adlib-part' | 'deleted' | undefined): void + + /** + * Update the activation id of this PartInstance + * This can be done to move this PartInstance when resetting the Playlist, if some previous PartInstances want to be kept + * @param id New activation id + */ + setPlaylistActivationId(id: RundownPlaylistActivationId): void + + /** + * Set the Planned started playback time + * This will clear the Planned stopped playback time + * @param time Planned started time + */ + setPlannedStartedPlayback(time: Time | undefined): void + /** + * Set the Planned stopped playback time + * @param time Planned stopped time + */ + setPlannedStoppedPlayback(time: Time | undefined): void + /** + * Set the Reported (from playout-gateway) started playback time + * This will clear the Reported stopped playback time + * @param time Reported started time + */ + setReportedStartedPlayback(time: Time): boolean + /** + * Set the Reported (from playout-gateway) stopped playback time + * @param time Reported stopped time + */ + setReportedStoppedPlayback(time: Time): boolean + + /** + * Set the rank of this PartInstance, to update it's position in the Segment + * @param rank New rank + */ + setRank(rank: number): void + + /** + * Set the PartInstance as having been taken + * @param takeTime The timestamp to record as when it was taken + * @param playOffset The offset into the PartInstance to start playback from + */ + setTaken(takeTime: number, playOffset: number): void + + /** + * Define some cached values, to be done when taking the PartInstance + * @param partPlayoutTimings Timings used for Playout, these depend on the previous PartInstance and should not change once playback is started + * @param previousPartEndState A state compiled by the Blueprints + */ + storePlayoutTimingsAndPreviousEndState( + partPlayoutTimings: PartCalculatedTimings, + previousPartEndState: unknown + ): void + + /** + * Update some properties for the wrapped Part + * Note: This is missing a lot of validation, and will become stricter later + * @param props New properties for the Part being wrapped + * @returns True if any valid properties were provided + */ + updatePartProps(props: Partial): boolean + + /** + * Ensure that this PartInstance is setup correctly for being in the Scratchpad Segment + */ + validateScratchpadSegmentProperties(): void +} diff --git a/packages/job-worker/src/playout/model/PlayoutPieceInstanceModel.ts b/packages/job-worker/src/playout/model/PlayoutPieceInstanceModel.ts new file mode 100644 index 0000000000..a1323b15b9 --- /dev/null +++ b/packages/job-worker/src/playout/model/PlayoutPieceInstanceModel.ts @@ -0,0 +1,60 @@ +import { PieceInstanceInfiniteId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { ReadonlyDeep } from 'type-fest' +import { PieceInstance, PieceInstancePiece } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' +import { Time } from '@sofie-automation/blueprints-integration' + +export interface PlayoutPieceInstanceModel { + /** + * The PieceInstance properties + */ + readonly PieceInstance: ReadonlyDeep + + /** + * Prepare this PieceInstance to be continued during HOLD + * This sets the PieceInstance up as an infinite, to allow the Timeline to be generated correctly + */ + prepareForHold(): PieceInstanceInfiniteId + + /** + * Set the PieceInstance as disabled/enabled + * If disabled, it will be ignored by the Timeline and infinites logic + * @param disabled Whether the PieceInstance should be disabled + */ + setDisabled(disabled: boolean): void + + /** + * Give the PieceInstance a new end point/duration which has been decided by Playout operations + * @param duration New duration/end point + */ + setDuration(duration: Required['userDuration']): void + + /** + * Set the Planned started playback time + * This will clear the Planned stopped playback time + * @param time Planned started time + */ + setPlannedStartedPlayback(time: Time): boolean + /** + * Set the Planned stopped playback time + * @param time Planned stopped time + */ + setPlannedStoppedPlayback(time: Time | undefined): boolean + /** + * Set the Reported (from playout-gateway) started playback time + * This will clear the Reported stopped playback time + * @param time Reported started time + */ + setReportedStartedPlayback(time: Time): boolean + /** + * Set the Reported (from playout-gateway) stopped playback time + * @param time Reported stopped time + */ + setReportedStoppedPlayback(time: Time): boolean + + /** + * Update some properties for the wrapped Piece + * Note: This is missing a lot of validation, and will become stricter later + * @param props New properties for the Piece being wrapped + */ + updatePieceProps(props: Partial): void +} diff --git a/packages/job-worker/src/playout/model/PlayoutRundownModel.ts b/packages/job-worker/src/playout/model/PlayoutRundownModel.ts new file mode 100644 index 0000000000..6339df9043 --- /dev/null +++ b/packages/job-worker/src/playout/model/PlayoutRundownModel.ts @@ -0,0 +1,71 @@ +import { PartId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' +import { ReadonlyDeep } from 'type-fest' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { RundownBaselineObj } from '@sofie-automation/corelib/dist/dataModel/RundownBaselineObj' +import { PlayoutSegmentModel } from './PlayoutSegmentModel' + +/** + * Wrap a Rundown and its Segments in a readonly and simplified view for Playout operations + */ +export interface PlayoutRundownModel { + /** + * The Rundown properties + */ + readonly Rundown: ReadonlyDeep + /** + * All the Segments in the Rundown + * Sorted by their rank + */ + readonly Segments: readonly PlayoutSegmentModel[] + + /** + * The RundownBaselineObjs for this Rundown + */ + readonly BaselineObjects: ReadonlyDeep + + /** + * Get a Segment which belongs to this Rundown + * @param id Id of the Segment + */ + getSegment(id: SegmentId): PlayoutSegmentModel | undefined + + /** + * Get all the SegmentIds in this Rundown + * Sorted by the Segment ranks + */ + getSegmentIds(): SegmentId[] + + /** + * Get all the PartIds in this Rundown + * Sorted by the Segment and Part ranks + */ + getAllPartIds(): PartId[] + + /** + * All the Parts in the Rundown + * Sorted by the Segment and Part ranks + */ + getAllOrderedParts(): ReadonlyDeep[] + + /** + * Insert the Scratchpad Segment for this Rundown + * Throws if the segment already exists + */ + insertScratchpadSegment(): SegmentId + /** + * Remove the Scratchpad Segment for this Rundown + * @returns true if the Segment was found + */ + removeScratchpadSegment(): boolean + /** + * Get the Scratchpad Segment for this Rundown, if it exists + */ + getScratchpadSegment(): PlayoutSegmentModel | undefined + /** + * Set the rank of the Scratchpad Segment in this Rundown + * Throws if the segment does not exists + * @param rank New rank + */ + setScratchpadSegmentRank(rank: number): void +} diff --git a/packages/job-worker/src/playout/model/PlayoutSegmentModel.ts b/packages/job-worker/src/playout/model/PlayoutSegmentModel.ts new file mode 100644 index 0000000000..1063993471 --- /dev/null +++ b/packages/job-worker/src/playout/model/PlayoutSegmentModel.ts @@ -0,0 +1,32 @@ +import { PartId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { ReadonlyDeep } from 'type-fest' +import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' + +/** + * Wrap a Segment and its Parts in a readonly and simplified view for Playout operations + */ +export interface PlayoutSegmentModel { + /** + * The Segment properties + */ + readonly Segment: ReadonlyDeep + + /** + * All the Parts in the Segment + * Sorted by their rank + */ + readonly Parts: ReadonlyDeep + + /** + * Get a Part which belongs to this Segment + * @param id Id of the Part + */ + getPart(id: PartId): ReadonlyDeep | undefined + + /** + * Get all the PartIds in this Segment + * Sorted by the Part ranks + */ + getPartIds(): PartId[] +} diff --git a/packages/job-worker/src/playout/model/implementation/LoadPlayoutModel.ts b/packages/job-worker/src/playout/model/implementation/LoadPlayoutModel.ts new file mode 100644 index 0000000000..4628791d75 --- /dev/null +++ b/packages/job-worker/src/playout/model/implementation/LoadPlayoutModel.ts @@ -0,0 +1,305 @@ +import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { ReadOnlyCache } from '../../../cache/CacheBase' +import { DatabasePersistedModel } from '../../../modelBase' +import { CacheForIngest } from '../../../ingest/cache' +import { PlaylistLock } from '../../../jobs/lock' +import { ReadonlyDeep } from 'type-fest' +import { JobContext } from '../../../jobs' +import { PlayoutModelImpl } from './PlayoutModelImpl' +import { PlayoutRundownModelImpl } from './PlayoutRundownModelImpl' +import { RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' +import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' +import { SegmentOrphanedReason } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { TimelineComplete } from '@sofie-automation/corelib/dist/dataModel/Timeline' +import { MongoQuery } from '@sofie-automation/corelib/dist/mongo' +import _ = require('underscore') +import { clone, groupByToMap, groupByToMapFunc } from '@sofie-automation/corelib/dist/lib' +import { PlayoutSegmentModelImpl } from './PlayoutSegmentModelImpl' +import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' +import { PlayoutPartInstanceModelImpl } from './PlayoutPartInstanceModelImpl' +import { PeripheralDevice } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' +import { PlayoutModel, PlayoutModelPreInit } from '../PlayoutModel' + +/** + * Load a PlayoutModelPreInit for the given RundownPlaylist + * @param context Context from the job queue + * @param playlistLock Lock for the RundownPlaylist to load + * @param tmpPlaylist Temporary copy of the RundownPlaylist to load + * @param reloadPlaylist Whether to reload the RundownPlaylist, or use the temporary copy + * @returns Loaded PlayoutModelPreInit + */ +export async function loadPlayoutModelPreInit( + context: JobContext, + playlistLock: PlaylistLock, + tmpPlaylist: ReadonlyDeep, + reloadPlaylist = true +): Promise { + const span = context.startSpan('CacheForPlayoutPreInit.createPreInit') + if (span) span.setLabel('playlistId', unprotectString(tmpPlaylist._id)) + + if (!playlistLock.isLocked) { + throw new Error('Cannot create cache with released playlist lock') + } + + const [PeripheralDevices, Playlist, Rundowns] = await Promise.all([ + context.directCollections.PeripheralDevices.findFetch({ studioId: tmpPlaylist.studioId }), + reloadPlaylist ? context.directCollections.RundownPlaylists.findOne(tmpPlaylist._id) : clone(tmpPlaylist), + context.directCollections.Rundowns.findFetch({ playlistId: tmpPlaylist._id }), + ]) + + if (!Playlist) throw new Error(`Playlist "${tmpPlaylist._id}" not found!`) + + const res: PlayoutModelPreInit = { + PlaylistId: playlistLock.playlistId, + PlaylistLock: playlistLock, + + PeripheralDevices, + + Playlist, + Rundowns, + + getRundown: (id: RundownId) => Rundowns.find((rd) => rd._id === id), + } + if (span) span.end() + return res +} + +/** + * Load a PlayoutModel partially from the database, partially from an IngestModel. + * Anything belonging to the Rundown of the IngestModel will be taken from there, as it is assumed to be the most up to date copy of the data + * @param context Context from the job queue + * @param playlistLock Lock for the RundownPlaylist to load + * @param loadedPlaylist Preloaded copy of the RundownPlaylist + * @param newRundowns Preloaded copy of the Rundowns belonging to the RundownPlaylist + * @param ingestCache IngestModel to take data from + * @returns Loaded PlayoutModel + */ +export async function createPlayoutCachefromIngestCache( + context: JobContext, + playlistLock: PlaylistLock, + loadedPlaylist: ReadonlyDeep, + newRundowns: ReadonlyDeep>, + ingestCache: ReadOnlyCache +): Promise { + const [peripheralDevices, playlist, rundowns] = await loadInitData(context, loadedPlaylist, false, newRundowns) + const rundownIds = rundowns.map((r) => r._id) + + const [partInstances, rundownsWithContent, timeline] = await Promise.all([ + loadPartInstances(context, loadedPlaylist, rundownIds), + loadRundowns(context, ingestCache, rundowns), + loadTimeline(context), + ]) + + const res = new PlayoutModelImpl( + context, + playlistLock, + loadedPlaylist._id, + peripheralDevices, + playlist, + partInstances, + rundownsWithContent, + timeline + ) + + return res +} + +async function loadTimeline(context: JobContext): Promise { + // Future: This could be defered until we get to updateTimeline. It could be a small performance boost + return context.directCollections.Timelines.findOne(context.studioId) +} + +async function loadInitData( + context: JobContext, + tmpPlaylist: ReadonlyDeep, + reloadPlaylist: boolean, + existingRundowns: ReadonlyDeep | undefined +): Promise<[ReadonlyDeep, DBRundownPlaylist, ReadonlyDeep]> { + const [peripheralDevices, reloadedPlaylist, rundowns] = await Promise.all([ + context.directCollections.PeripheralDevices.findFetch({ studioId: tmpPlaylist.studioId }), + reloadPlaylist + ? await context.directCollections.RundownPlaylists.findOne(tmpPlaylist._id) + : clone(tmpPlaylist), + existingRundowns ?? context.directCollections.Rundowns.findFetch({ playlistId: tmpPlaylist._id }), + ]) + + if (!reloadedPlaylist) throw new Error(`RundownPlaylist went missing!`) + + return [peripheralDevices, reloadedPlaylist, rundowns] +} + +/** + * Load a PlayoutModel from a PlayoutModelPreInit + * @param context Context from the job queue + * @param initModel Preloaded PlayoutModelPreInit describing the RundownPlaylist to load + * @returns Loaded PlayoutModel + */ +export async function createPlayoutModelfromInitModel( + context: JobContext, + initModel: PlayoutModelPreInit +): Promise { + const span = context.startSpan('CacheForPlayout.fromInit') + if (span) span.setLabel('playlistId', unprotectString(initModel.PlaylistId)) + + if (!initModel.PlaylistLock.isLocked) { + throw new Error('Cannot create cache with released playlist lock') + } + + const rundownIds = initModel.Rundowns.map((r) => r._id) + + const [partInstances, rundownsWithContent, timeline] = await Promise.all([ + loadPartInstances(context, initModel.Playlist, rundownIds), + loadRundowns(context, null, initModel.Rundowns), + loadTimeline(context), + ]) + + const res = new PlayoutModelImpl( + context, + initModel.PlaylistLock, + initModel.PlaylistId, + initModel.PeripheralDevices, + clone(initModel.Playlist), + partInstances, + rundownsWithContent, + timeline + ) + + if (span) span.end() + return res +} + +async function loadRundowns( + context: JobContext, + ingestCache: ReadOnlyCache | null, + rundowns: ReadonlyDeep +): Promise { + const rundownIds = rundowns.map((rd) => rd._id) + + // If there is an ingestCache, then avoid loading some bits from the db for that rundown + const loadRundownIds = ingestCache ? rundownIds.filter((id) => id !== ingestCache.RundownId) : rundownIds + const baselineFromIngest = ingestCache?.RundownBaselineObjs.getIfLoaded() + const loadBaselineIds = baselineFromIngest ? loadRundownIds : rundownIds + + const [segments, parts, baselineObjects] = await Promise.all([ + context.directCollections.Segments.findFetch({ + $or: [ + { + // In a different rundown + rundownId: { $in: loadRundownIds }, + }, + { + // Is the scratchpad + rundownId: { $in: rundownIds }, + orphaned: SegmentOrphanedReason.SCRATCHPAD, + }, + ], + }), + context.directCollections.Parts.findFetch({ rundownId: { $in: loadRundownIds } }), + context.directCollections.RundownBaselineObjects.findFetch({ rundownId: { $in: loadBaselineIds } }), + ]) + + if (ingestCache) { + // Populate the collections with the cached data instead + segments.push(...ingestCache.Segments.findAll(null)) + parts.push(...ingestCache.Parts.findAll(null)) + if (baselineFromIngest) { + baselineObjects.push(...baselineFromIngest.findAll(null)) + } + } + + const groupedParts = groupByToMap(parts, 'segmentId') + const segmentsWithParts = segments.map( + (segment) => new PlayoutSegmentModelImpl(segment, groupedParts.get(segment._id) ?? []) + ) + const groupedSegmentsWithParts = groupByToMapFunc(segmentsWithParts, (s) => s.Segment.rundownId) + + const groupedBaselineObjects = groupByToMap(baselineObjects, 'rundownId') + + return rundowns.map( + (rundown) => + new PlayoutRundownModelImpl( + rundown, + groupedSegmentsWithParts.get(rundown._id) ?? [], + groupedBaselineObjects.get(rundown._id) ?? [] + ) + ) +} + +/** + * Intitialise the full content of the cache + * @param ingestCache A CacheForIngest that is pending saving, if this is following an ingest operation + */ +async function loadPartInstances( + context: JobContext, + playlist: ReadonlyDeep, + rundownIds: RundownId[] +): Promise { + const selectedPartInstanceIds = _.compact([ + playlist.currentPartInfo?.partInstanceId, + playlist.nextPartInfo?.partInstanceId, + playlist.previousPartInfo?.partInstanceId, + ]) + + const partInstancesCollection = Promise.resolve().then(async () => { + // Future: We could optimise away this query if we tracked the segmentIds of these PartInstances on the playlist + const segmentIds = _.uniq( + ( + await context.directCollections.PartInstances.findFetch( + { + _id: { $in: selectedPartInstanceIds }, + }, + { + projection: { + segmentId: 1, + }, + } + ) + ).map((p) => p.segmentId) + ) + + const partInstancesSelector: MongoQuery = { + rundownId: { $in: rundownIds }, + $or: [ + { + segmentId: { $in: segmentIds }, + reset: { $ne: true }, + }, + { + _id: { $in: selectedPartInstanceIds }, + }, + ], + } + // Filter the PieceInstances to the activationId, if possible + pieceInstancesSelector.playlistActivationId = playlist.activationId || { $exists: false } + + return context.directCollections.PartInstances.findFetch(partInstancesSelector) + }) + + const pieceInstancesSelector: MongoQuery = { + rundownId: { $in: rundownIds }, + partInstanceId: { $in: selectedPartInstanceIds }, + } + // Filter the PieceInstances to the activationId, if possible + pieceInstancesSelector.playlistActivationId = playlist.activationId || { $exists: false } + + const [partInstances, pieceInstances] = await Promise.all([ + partInstancesCollection, + context.directCollections.PieceInstances.findFetch(pieceInstancesSelector), + ]) + + const groupedPieceInstances = groupByToMap(pieceInstances, 'partInstanceId') + + const allPartInstances: PlayoutPartInstanceModelImpl[] = [] + for (const partInstance of partInstances) { + const wrappedPartInstance = new PlayoutPartInstanceModelImpl( + partInstance, + groupedPieceInstances.get(partInstance._id) ?? [], + false + ) + allPartInstances.push(wrappedPartInstance) + } + + return allPartInstances +} diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts new file mode 100644 index 0000000000..89b8a8f7a3 --- /dev/null +++ b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts @@ -0,0 +1,763 @@ +import { + PartId, + PartInstanceId, + PieceId, + PieceInstanceId, + RundownId, + RundownPlaylistActivationId, + RundownPlaylistId, + SegmentId, + SegmentPlayoutId, +} from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PeripheralDevice, PeripheralDeviceType } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' +import { + ABSessionAssignments, + ABSessionInfo, + DBRundownPlaylist, + RundownHoldState, + SelectedPartInstance, +} from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { ReadonlyDeep } from 'type-fest' +import { JobContext } from '../../../jobs' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' +import { + getPieceInstanceIdForPiece, + PieceInstance, + PieceInstancePiece, +} from '@sofie-automation/corelib/dist/dataModel/PieceInstance' +import { + serializeTimelineBlob, + TimelineComplete, + TimelineCompleteGenerationVersions, + TimelineObjGeneric, +} from '@sofie-automation/corelib/dist/dataModel/Timeline' +import _ = require('underscore') +import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' +import { PlaylistLock } from '../../../jobs/lock' +import { logger } from '../../../logging' +import { clone, getRandomId, literal, normalizeArrayToMapFunc, sleep } from '@sofie-automation/corelib/dist/lib' +import { sortRundownIDsInPlaylist } from '@sofie-automation/corelib/dist/playout/playlist' +import { PlayoutRundownModel } from '../PlayoutRundownModel' +import { PlayoutRundownModelImpl } from './PlayoutRundownModelImpl' +import { PlayoutSegmentModel } from '../PlayoutSegmentModel' +import { PlayoutPartInstanceModelImpl } from './PlayoutPartInstanceModelImpl' +import { PlayoutPartInstanceModel } from '../PlayoutPartInstanceModel' +import { getCurrentTime } from '../../../lib' +import { protectString } from '@sofie-automation/shared-lib/dist/lib/protectedString' +import { queuePartInstanceTimingEvent } from '../../timings/events' +import { IS_PRODUCTION } from '../../../environment' +import { DeferredAfterSaveFunction, DeferredFunction, PlayoutModel, PlayoutModelReadonly } from '../PlayoutModel' +import { writePartInstancesAndPieceInstances, writeScratchpadSegments } from './SavePlayoutModel' +import { PlayoutPieceInstanceModel } from '../PlayoutPieceInstanceModel' +import { DatabasePersistedModel } from '../../../modelBase' +import { ExpectedPackageDBFromStudioBaselineObjects } from '@sofie-automation/corelib/dist/dataModel/ExpectedPackages' +import { ExpectedPlayoutItemStudio } from '@sofie-automation/corelib/dist/dataModel/ExpectedPlayoutItem' +import { StudioBaselineHelper } from '../../../studio/model/StudioBaselineHelper' +import { EventsJobs } from '@sofie-automation/corelib/dist/worker/events' + +export class PlayoutModelReadonlyImpl implements PlayoutModelReadonly { + public readonly PlaylistId: RundownPlaylistId + + public readonly PlaylistLock: PlaylistLock + + public readonly PeripheralDevices: ReadonlyDeep + + protected readonly PlaylistImpl: DBRundownPlaylist + public get Playlist(): ReadonlyDeep { + return this.PlaylistImpl + } + + protected readonly RundownsImpl: readonly PlayoutRundownModelImpl[] + public get Rundowns(): readonly PlayoutRundownModel[] { + return this.RundownsImpl + } + + protected TimelineImpl: TimelineComplete | null + public get Timeline(): TimelineComplete | null { + return this.TimelineImpl + } + + protected AllPartInstances: Map + + public constructor( + protected readonly context: JobContext, + playlistLock: PlaylistLock, + playlistId: RundownPlaylistId, + peripheralDevices: ReadonlyDeep, + playlist: DBRundownPlaylist, + partInstances: PlayoutPartInstanceModelImpl[], + rundowns: PlayoutRundownModelImpl[], + timeline: TimelineComplete | undefined + ) { + this.PlaylistId = playlistId + this.PlaylistLock = playlistLock + + this.PeripheralDevices = peripheralDevices + this.PlaylistImpl = playlist + + this.RundownsImpl = rundowns + + this.TimelineImpl = timeline ?? null + + this.AllPartInstances = normalizeArrayToMapFunc(partInstances, (p) => p.PartInstance._id) + } + + public get OlderPartInstances(): PlayoutPartInstanceModel[] { + const allPartInstances = this.LoadedPartInstances + + const ignoreIds = new Set(this.SelectedPartInstanceIds) + + return allPartInstances.filter((partInstance) => !ignoreIds.has(partInstance.PartInstance._id)) + } + public get PreviousPartInstance(): PlayoutPartInstanceModel | null { + if (!this.Playlist.previousPartInfo?.partInstanceId) return null + const partInstance = this.AllPartInstances.get(this.Playlist.previousPartInfo.partInstanceId) + if (!partInstance) return null // throw new Error('PreviousPartInstance is missing') + return partInstance + } + public get CurrentPartInstance(): PlayoutPartInstanceModel | null { + if (!this.Playlist.currentPartInfo?.partInstanceId) return null + const partInstance = this.AllPartInstances.get(this.Playlist.currentPartInfo.partInstanceId) + if (!partInstance) return null // throw new Error('CurrentPartInstance is missing') + return partInstance + } + public get NextPartInstance(): PlayoutPartInstanceModel | null { + if (!this.Playlist.nextPartInfo?.partInstanceId) return null + const partInstance = this.AllPartInstances.get(this.Playlist.nextPartInfo.partInstanceId) + if (!partInstance) return null // throw new Error('NextPartInstance is missing') + return partInstance + } + + public get SelectedPartInstanceIds(): PartInstanceId[] { + return _.compact([ + this.Playlist.previousPartInfo?.partInstanceId, + this.Playlist.currentPartInfo?.partInstanceId, + this.Playlist.nextPartInfo?.partInstanceId, + ]) + } + + public get SelectedPartInstances(): PlayoutPartInstanceModel[] { + return _.compact([this.CurrentPartInstance, this.PreviousPartInstance, this.NextPartInstance]) + } + + public get LoadedPartInstances(): PlayoutPartInstanceModel[] { + return Array.from(this.AllPartInstances.values()).filter((v): v is PlayoutPartInstanceModelImpl => v !== null) + } + + public get SortedLoadedPartInstances(): PlayoutPartInstanceModel[] { + const allInstances = this.LoadedPartInstances + allInstances.sort((a, b) => a.PartInstance.takeCount - b.PartInstance.takeCount) + + return allInstances + } + + public getPartInstance(partInstanceId: PartInstanceId): PlayoutPartInstanceModel | undefined { + return this.AllPartInstances.get(partInstanceId) ?? undefined + } + + /** + * Search for a Part through the whole Playlist + * @param id + */ + findPart(id: PartId): ReadonlyDeep | undefined { + for (const rundown of this.Rundowns) { + for (const segment of rundown.Segments) { + const part = segment.getPart(id) + if (part) return part + } + } + + return undefined + } + getAllOrderedParts(): ReadonlyDeep[] { + return this.Rundowns.flatMap((rundown) => rundown.getAllOrderedParts()) + } + + findSegment(id: SegmentId): ReadonlyDeep | undefined { + for (const rundown of this.Rundowns) { + const segment = rundown.getSegment(id) + if (segment) return segment + } + + return undefined + } + getAllOrderedSegments(): ReadonlyDeep[] { + return this.Rundowns.flatMap((rundown) => rundown.Segments) + } + + getRundown(id: RundownId): PlayoutRundownModel | undefined { + return this.Rundowns.find((rundown) => rundown.Rundown._id === id) + } + getRundownIds(): RundownId[] { + return sortRundownIDsInPlaylist( + this.Playlist.rundownIdsInOrder, + this.Rundowns.map((rd) => rd.Rundown._id) + ) + } + + findPieceInstance( + id: PieceInstanceId + ): { partInstance: PlayoutPartInstanceModel; pieceInstance: PlayoutPieceInstanceModel } | undefined { + for (const partInstance of this.LoadedPartInstances) { + const pieceInstance = partInstance.getPieceInstance(id) + if (pieceInstance) return { partInstance, pieceInstance } + } + + return undefined + } + + #isMultiGatewayMode: boolean | undefined = undefined + public get isMultiGatewayMode(): boolean { + if (this.#isMultiGatewayMode === undefined) { + if (this.context.studio.settings.forceMultiGatewayMode) { + this.#isMultiGatewayMode = true + } else { + const playoutDevices = this.PeripheralDevices.filter( + (device) => device.type === PeripheralDeviceType.PLAYOUT + ) + this.#isMultiGatewayMode = playoutDevices.length > 1 + } + } + return this.#isMultiGatewayMode + } +} + +/** + * This is a cache used for playout operations. + * It contains everything that is needed to generate the timeline, and everything except for pieces needed to update the partinstances. + * Anything not in this cache should not be needed often, and only for specific operations (eg, AdlibActions needed to run one). + */ +export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements PlayoutModel, DatabasePersistedModel { + readonly #baselineHelper: StudioBaselineHelper + + #deferredBeforeSaveFunctions: DeferredFunction[] = [] + #deferredAfterSaveFunctions: DeferredAfterSaveFunction[] = [] + #disposed = false + + #PlaylistHasChanged = false + #TimelineHasChanged = false + + #PendingPartInstanceTimingEvents = new Set() + #PendingNotifyCurrentlyPlayingPartEvent = new Map() + + get HackDeletedPartInstanceIds(): PartInstanceId[] { + const result: PartInstanceId[] = [] + for (const [id, doc] of this.AllPartInstances) { + if (!doc) result.push(id) + } + return result + } + + public constructor( + context: JobContext, + playlistLock: PlaylistLock, + playlistId: RundownPlaylistId, + peripheralDevices: ReadonlyDeep, + playlist: DBRundownPlaylist, + partInstances: PlayoutPartInstanceModelImpl[], + rundowns: PlayoutRundownModelImpl[], + timeline: TimelineComplete | undefined + ) { + super(context, playlistLock, playlistId, peripheralDevices, playlist, partInstances, rundowns, timeline) + context.trackCache(this) + + this.#baselineHelper = new StudioBaselineHelper(context) + } + + public get DisplayName(): string { + return `PlayoutModel "${this.PlaylistId}"` + } + + activatePlaylist(rehearsal: boolean): RundownPlaylistActivationId { + this.PlaylistImpl.activationId = getRandomId() + this.PlaylistImpl.rehearsal = rehearsal + + this.#PlaylistHasChanged = true + + return this.PlaylistImpl.activationId + } + + clearSelectedPartInstances(): void { + this.PlaylistImpl.currentPartInfo = null + this.PlaylistImpl.nextPartInfo = null + this.PlaylistImpl.previousPartInfo = null + this.PlaylistImpl.holdState = RundownHoldState.NONE + + delete this.PlaylistImpl.lastTakeTime + delete this.PlaylistImpl.queuedSegmentId + + this.#PlaylistHasChanged = true + } + + #fixupPieceInstancesForPartInstance(partInstance: DBPartInstance, pieceInstances: PieceInstance[]): void { + for (const pieceInstance of pieceInstances) { + // Future: should these be PieceInstance already, or should that be handled here? + pieceInstance._id = getPieceInstanceIdForPiece(partInstance._id, pieceInstance.piece._id) + pieceInstance.partInstanceId = partInstance._id + } + } + + createAdlibbedPartInstance( + part: Omit, + pieces: Omit[], + fromAdlibId: PieceId | undefined, + infinitePieceInstances: PieceInstance[] + ): PlayoutPartInstanceModel { + const currentPartInstance = this.CurrentPartInstance + if (!currentPartInstance) throw new Error('No currentPartInstance') + + const newPartInstance: DBPartInstance = { + _id: getRandomId(), + rundownId: currentPartInstance.PartInstance.rundownId, + segmentId: currentPartInstance.PartInstance.segmentId, + playlistActivationId: currentPartInstance.PartInstance.playlistActivationId, + segmentPlayoutId: currentPartInstance.PartInstance.segmentPlayoutId, + takeCount: currentPartInstance.PartInstance.takeCount + 1, + rehearsal: currentPartInstance.PartInstance.rehearsal, + orphaned: 'adlib-part', + part: { + ...part, + rundownId: currentPartInstance.PartInstance.rundownId, + segmentId: currentPartInstance.PartInstance.segmentId, + }, + } + + this.#fixupPieceInstancesForPartInstance(newPartInstance, infinitePieceInstances) + + const partInstance = new PlayoutPartInstanceModelImpl(newPartInstance, infinitePieceInstances, true) + + for (const piece of pieces) { + partInstance.insertAdlibbedPiece(piece, fromAdlibId) + } + + partInstance.recalculateExpectedDurationWithPreroll() + + this.AllPartInstances.set(newPartInstance._id, partInstance) + + return partInstance + } + + createInstanceForPart(nextPart: ReadonlyDeep, pieceInstances: PieceInstance[]): PlayoutPartInstanceModel { + const playlistActivationId = this.Playlist.activationId + if (!playlistActivationId) throw new Error(`Playlist is not active`) + + const currentPartInstance = this.CurrentPartInstance + + const newTakeCount = currentPartInstance ? currentPartInstance.PartInstance.takeCount + 1 : 0 // Increment + const segmentPlayoutId: SegmentPlayoutId = + currentPartInstance && nextPart.segmentId === currentPartInstance.PartInstance.segmentId + ? currentPartInstance.PartInstance.segmentPlayoutId + : getRandomId() + + const newPartInstance: DBPartInstance = { + _id: protectString(`${nextPart._id}_${getRandomId()}`), + rundownId: nextPart.rundownId, + segmentId: nextPart.segmentId, + playlistActivationId: playlistActivationId, + segmentPlayoutId, + takeCount: newTakeCount, + rehearsal: !!this.Playlist.rehearsal, + part: clone(nextPart), + timings: { + setAsNext: getCurrentTime(), + }, + } + + this.#fixupPieceInstancesForPartInstance(newPartInstance, pieceInstances) + + const partInstance = new PlayoutPartInstanceModelImpl(newPartInstance, pieceInstances, true) + partInstance.recalculateExpectedDurationWithPreroll() + + this.AllPartInstances.set(newPartInstance._id, partInstance) + + return partInstance + } + + createScratchpadPartInstance( + rundown: PlayoutRundownModel, + part: Omit + ): PlayoutPartInstanceModel { + const currentPartInstance = this.CurrentPartInstance + if (!currentPartInstance) throw new Error('No currentPartInstance') + + const scratchpadSegment = rundown.getScratchpadSegment() + if (!scratchpadSegment) throw new Error('No scratchpad segment') + if (this.LoadedPartInstances.find((p) => p.PartInstance.segmentId === scratchpadSegment.Segment._id)) + throw new Error('Scratchpad segment already has content') + + const activationId = this.Playlist.activationId + if (!activationId) throw new Error('Playlist is not active') + + const newPartInstance: DBPartInstance = { + _id: getRandomId(), + rundownId: rundown.Rundown._id, + segmentId: scratchpadSegment.Segment._id, + playlistActivationId: activationId, + segmentPlayoutId: getRandomId(), + takeCount: 1, + rehearsal: !!this.Playlist.rehearsal, + orphaned: 'adlib-part', + part: { + ...part, + rundownId: rundown.Rundown._id, + segmentId: scratchpadSegment.Segment._id, + }, + } + + const partInstance = new PlayoutPartInstanceModelImpl(newPartInstance, [], true) + partInstance.recalculateExpectedDurationWithPreroll() + + this.AllPartInstances.set(newPartInstance._id, partInstance) + + return partInstance + } + + cycleSelectedPartInstances(): void { + this.PlaylistImpl.previousPartInfo = this.PlaylistImpl.currentPartInfo + this.PlaylistImpl.currentPartInfo = this.PlaylistImpl.nextPartInfo + this.PlaylistImpl.nextPartInfo = null + this.PlaylistImpl.lastTakeTime = getCurrentTime() + + if (!this.PlaylistImpl.holdState || this.PlaylistImpl.holdState === RundownHoldState.COMPLETE) { + this.PlaylistImpl.holdState = RundownHoldState.NONE + } else { + this.PlaylistImpl.holdState = this.PlaylistImpl.holdState + 1 + } + + this.#PlaylistHasChanged = true + } + + deactivatePlaylist(): void { + delete this.PlaylistImpl.activationId + + this.clearSelectedPartInstances() + + this.#PlaylistHasChanged = true + } + + queuePartInstanceTimingEvent(partInstanceId: PartInstanceId): void { + this.#PendingPartInstanceTimingEvents.add(partInstanceId) + } + + queueNotifyCurrentlyPlayingPartEvent(rundownId: RundownId, partInstance: PlayoutPartInstanceModel | null): void { + if (partInstance && partInstance.PartInstance.part.shouldNotifyCurrentPlayingPart) { + this.#PendingNotifyCurrentlyPlayingPartEvent.set(rundownId, partInstance.PartInstance.part.externalId) + } else if (!partInstance) { + this.#PendingNotifyCurrentlyPlayingPartEvent.set(rundownId, null) + } + } + + removeAllRehearsalPartInstances(): void { + const partInstancesToRemove: PartInstanceId[] = [] + + for (const [id, partInstance] of this.AllPartInstances.entries()) { + if (partInstance?.PartInstance.rehearsal) { + this.AllPartInstances.set(id, null) + partInstancesToRemove.push(id) + } + } + + // Defer ones which arent loaded + this.deferAfterSave(async (playoutModel) => { + const rundownIds = playoutModel.getRundownIds() + // We need to keep any for PartInstances which are still existent in the cache (as they werent removed) + const partInstanceIdsInCache = playoutModel.LoadedPartInstances.map((p) => p.PartInstance._id) + + // Find all the partInstances which are not loaded, but should be removed + const removeFromDb = await this.context.directCollections.PartInstances.findFetch( + { + // Not any which are in the cache, as they have already been done if needed + _id: { $nin: partInstanceIdsInCache }, + rundownId: { $in: rundownIds }, + rehearsal: true, + }, + { projection: { _id: 1 } } + ).then((ps) => ps.map((p) => p._id)) + + // Do the remove + const allToRemove = [...removeFromDb, ...partInstancesToRemove] + await Promise.all([ + removeFromDb.length > 0 + ? this.context.directCollections.PartInstances.remove({ + _id: { $in: removeFromDb }, + rundownId: { $in: rundownIds }, + }) + : undefined, + allToRemove.length > 0 + ? this.context.directCollections.PieceInstances.remove({ + partInstanceId: { $in: allToRemove }, + rundownId: { $in: rundownIds }, + }) + : undefined, + ]) + }) + } + + removeUntakenPartInstances(): void { + for (const partInstance of this.OlderPartInstances) { + if (!partInstance.PartInstance.isTaken) { + this.AllPartInstances.set(partInstance.PartInstance._id, null) + } + } + } + + /** + * Reset the playlist for playout + */ + resetPlaylist(regenerateActivationId: boolean): void { + this.PlaylistImpl.previousPartInfo = null + this.PlaylistImpl.currentPartInfo = null + this.PlaylistImpl.nextPartInfo = null + this.PlaylistImpl.holdState = RundownHoldState.NONE + this.PlaylistImpl.resetTime = getCurrentTime() + + delete this.PlaylistImpl.lastTakeTime + delete this.PlaylistImpl.startedPlayback + delete this.PlaylistImpl.rundownsStartedPlayback + delete this.PlaylistImpl.previousPersistentState + delete this.PlaylistImpl.trackedAbSessions + delete this.PlaylistImpl.queuedSegmentId + + if (regenerateActivationId) this.PlaylistImpl.activationId = getRandomId() + + this.#PlaylistHasChanged = true + } + + async saveAllToDatabase(): Promise { + if (this.#disposed) { + throw new Error('Cannot save disposed PlayoutModel') + } + + // TODO - ideally we should make sure to preserve the lock during this operation + if (!this.PlaylistLock.isLocked) { + throw new Error('Cannot save changes with released playlist lock') + } + + const span = this.context.startSpan('PlayoutModelImpl.saveAllToDatabase') + + // Execute cache.deferBeforeSave()'s + for (const fn of this.#deferredBeforeSaveFunctions) { + await fn(this as any) + } + this.#deferredBeforeSaveFunctions.length = 0 // clear the array + + // Prioritise the timeline for publication reasons + if (this.#TimelineHasChanged && this.TimelineImpl) { + await this.context.directCollections.Timelines.replace(this.TimelineImpl) + if (!process.env.JEST_WORKER_ID) { + // Wait a little bit before saving the rest. + // The idea is that this allows for the high priority publications to update (such as the Timeline), + // sending the updated timeline to Playout-gateway + await sleep(2) + } + } + this.#TimelineHasChanged = false + + await Promise.all([ + this.#PlaylistHasChanged + ? this.context.directCollections.RundownPlaylists.replace(this.PlaylistImpl) + : undefined, + ...writePartInstancesAndPieceInstances(this.context, this.AllPartInstances), + writeScratchpadSegments(this.context, this.RundownsImpl), + this.#baselineHelper.saveAllToDatabase(), + ]) + + this.#PlaylistHasChanged = false + + // Execute cache.deferAfterSave()'s + for (const fn of this.#deferredAfterSaveFunctions) { + await fn(this as any) + } + this.#deferredAfterSaveFunctions.length = 0 // clear the array + + for (const partInstanceId of this.#PendingPartInstanceTimingEvents) { + // Run in the background, we don't want to hold onto the lock to do this + queuePartInstanceTimingEvent(this.context, this.PlaylistId, partInstanceId) + } + this.#PendingPartInstanceTimingEvents.clear() + + for (const [rundownId, partExternalId] of this.#PendingNotifyCurrentlyPlayingPartEvent) { + // This is low-prio, defer so that it's executed well after publications has been updated, + // so that the playout gateway has had the chance to learn about the timeline changes + this.context + .queueEventJob(EventsJobs.NotifyCurrentlyPlayingPart, { + rundownId: rundownId, + isRehearsal: !!this.Playlist.rehearsal, + partExternalId: partExternalId, + }) + .catch((e) => { + logger.warn(`Failed to queue NotifyCurrentlyPlayingPart job: ${e}`) + }) + } + this.#PendingNotifyCurrentlyPlayingPartEvent.clear() + + if (span) span.end() + } + + setHoldState(newState: RundownHoldState): void { + this.PlaylistImpl.holdState = newState + + this.#PlaylistHasChanged = true + } + + setOnTimelineGenerateResult( + persistentState: unknown | undefined, + assignedAbSessions: Record, + trackedAbSessions: ABSessionInfo[] + ): void { + this.PlaylistImpl.previousPersistentState = persistentState + this.PlaylistImpl.assignedAbSessions = assignedAbSessions + this.PlaylistImpl.trackedAbSessions = trackedAbSessions + + this.#PlaylistHasChanged = true + } + + setPartInstanceAsNext( + partInstance: PlayoutPartInstanceModel | null, + setManually: boolean, + consumesQueuedSegmentId: boolean, + nextTimeOffset?: number + ): void { + if (partInstance) { + const storedPartInstance = this.AllPartInstances.get(partInstance.PartInstance._id) + if (!storedPartInstance) throw new Error(`PartInstance being set as next was not constructed correctly`) + // Make sure we were given the exact same object + if (storedPartInstance !== partInstance) throw new Error(`PartInstance being set as next is not current`) + } + + if (partInstance) { + this.PlaylistImpl.nextPartInfo = literal({ + partInstanceId: partInstance.PartInstance._id, + rundownId: partInstance.PartInstance.rundownId, + manuallySelected: !!(setManually || partInstance.PartInstance.orphaned), + consumesQueuedSegmentId, + }) + this.PlaylistImpl.nextTimeOffset = nextTimeOffset || null + } else { + this.PlaylistImpl.nextPartInfo = null + this.PlaylistImpl.nextTimeOffset = null + } + + this.#PlaylistHasChanged = true + } + + setQueuedSegment(segment: PlayoutSegmentModel | null): void { + this.PlaylistImpl.queuedSegmentId = segment?.Segment?._id ?? undefined + + this.#PlaylistHasChanged = true + } + + setRundownStartedPlayback(rundownId: RundownId, timestamp: number): void { + if (!this.PlaylistImpl.rundownsStartedPlayback) { + this.PlaylistImpl.rundownsStartedPlayback = {} + } + + // If the partInstance is "untimed", it will not update the playlist's startedPlayback and will not count time in the GUI: + const rundownIdStr = unprotectString(rundownId) + if (!this.PlaylistImpl.rundownsStartedPlayback[rundownIdStr]) { + this.PlaylistImpl.rundownsStartedPlayback[rundownIdStr] = timestamp + } + + if (!this.PlaylistImpl.startedPlayback) { + this.PlaylistImpl.startedPlayback = timestamp + } + + this.#PlaylistHasChanged = true + } + + setTimeline(timelineObjs: TimelineObjGeneric[], generationVersions: TimelineCompleteGenerationVersions): void { + this.TimelineImpl = { + _id: this.context.studioId, + timelineHash: getRandomId(), // randomized on every timeline change + generated: getCurrentTime(), + timelineBlob: serializeTimelineBlob(timelineObjs), + generationVersions: generationVersions, + } + this.#TimelineHasChanged = true + } + + setExpectedPackagesForStudioBaseline(packages: ExpectedPackageDBFromStudioBaselineObjects[]): void { + this.#baselineHelper.setExpectedPackages(packages) + } + setExpectedPlayoutItemsForStudioBaseline(playoutItems: ExpectedPlayoutItemStudio[]): void { + this.#baselineHelper.setExpectedPlayoutItems(playoutItems) + } + + /** Lifecycle */ + + /** @deprecated */ + deferBeforeSave(fcn: DeferredFunction): void { + this.#deferredBeforeSaveFunctions.push(fcn) + } + /** @deprecated */ + deferAfterSave(fcn: DeferredAfterSaveFunction): void { + this.#deferredAfterSaveFunctions.push(fcn) + } + + /** ICacheBase */ + + /** + * Assert that no changes should have been made to the cache, will throw an Error otherwise. This can be used in + * place of `saveAllToDatabase()`, when the code controlling the cache expects no changes to have been made and any + * changes made are an error and will cause issues. + */ + assertNoChanges(): void { + const span = this.context.startSpan('Cache.assertNoChanges') + + function logOrThrowError(error: Error) { + if (!IS_PRODUCTION) { + throw error + } else { + logger.error(error) + } + } + + if (this.#deferredBeforeSaveFunctions.length > 0) + logOrThrowError( + new Error( + `Failed no changes in cache assertion, there were ${ + this.#deferredBeforeSaveFunctions.length + } deferred functions` + ) + ) + + if (this.#deferredAfterSaveFunctions.length > 0) + logOrThrowError( + new Error( + `Failed no changes in cache assertion, there were ${ + this.#deferredAfterSaveFunctions.length + } after-save deferred functions` + ) + ) + + if (this.#TimelineHasChanged) + logOrThrowError(new Error(`Failed no changes in cache assertion, Timeline has been changed`)) + + if (this.#PlaylistHasChanged) + logOrThrowError(new Error(`Failed no changes in cache assertion, Playlist has been changed`)) + + if (this.RundownsImpl.find((rd) => rd.ScratchPadSegmentHasChanged)) + logOrThrowError(new Error(`Failed no changes in cache assertion, a scratchpad Segment has been changed`)) + + if ( + Array.from(this.AllPartInstances.values()).find( + (part) => !part || part.PartInstanceHasChanges || part.ChangedPieceInstanceIds().length > 0 + ) + ) + logOrThrowError(new Error(`Failed no changes in cache assertion, a PartInstance has been changed`)) + + if (span) span.end() + } + + /** + * Discards all documents in this cache, and marks it as unusable + */ + dispose(): void { + this.#disposed = true + + // Discard any hooks too + this.#deferredAfterSaveFunctions.length = 0 + this.#deferredBeforeSaveFunctions.length = 0 + } +} diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts new file mode 100644 index 0000000000..b3d729259b --- /dev/null +++ b/packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts @@ -0,0 +1,528 @@ +import { PieceId, PieceInstanceId, RundownPlaylistActivationId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { ReadonlyDeep } from 'type-fest' +import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' +import { + getPieceInstanceIdForPiece, + omitPiecePropertiesForInstance, + PieceInstance, + PieceInstancePiece, +} from '@sofie-automation/corelib/dist/dataModel/PieceInstance' +import { clone, getRandomId } from '@sofie-automation/corelib/dist/lib' +import { getCurrentTime } from '../../../lib' +import { setupPieceInstanceInfiniteProperties } from '../../pieces' +import { + calculatePartExpectedDurationWithPreroll, + PartCalculatedTimings, +} from '@sofie-automation/corelib/dist/playout/timings' +import { PartNote } from '@sofie-automation/corelib/dist/dataModel/Notes' +import { + IBlueprintMutatablePart, + IBlueprintPieceType, + PieceLifespan, + Time, +} from '@sofie-automation/blueprints-integration' +import { PlayoutPartInstanceModel, PlayoutPartInstanceModelSnapshot } from '../PlayoutPartInstanceModel' +import { protectString } from '@sofie-automation/corelib/dist/protectedString' +import { PlayoutPieceInstanceModel } from '../PlayoutPieceInstanceModel' +import { PlayoutPieceInstanceModelImpl } from './PlayoutPieceInstanceModelImpl' +import { EmptyPieceTimelineObjectsBlob } from '@sofie-automation/corelib/dist/dataModel/Piece' +import _ = require('underscore') +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { IBlueprintMutatablePartSampleKeys } from '../../../blueprints/context/lib' + +interface PlayoutPieceInstanceModelSnapshotImpl { + PieceInstance: PieceInstance + HasChanges: boolean +} +class PlayoutPartInstanceModelSnapshotImpl implements PlayoutPartInstanceModelSnapshot { + readonly __isPlayoutPartInstanceModelBackup = true + + isRestored = false + + readonly PartInstance: DBPartInstance + readonly PartInstanceHasChanges: boolean + readonly PieceInstances: ReadonlyMap + + constructor(copyFrom: PlayoutPartInstanceModelImpl) { + this.PartInstance = clone(copyFrom.PartInstanceImpl) + this.PartInstanceHasChanges = copyFrom.PartInstanceHasChanges + + const pieceInstances = new Map() + for (const [pieceInstanceId, pieceInstance] of copyFrom.PieceInstancesImpl) { + if (pieceInstance) { + pieceInstances.set(pieceInstanceId, { + PieceInstance: clone(pieceInstance.PieceInstanceImpl), + HasChanges: pieceInstance.HasChanges, + }) + } else { + pieceInstances.set(pieceInstanceId, null) + } + } + this.PieceInstances = pieceInstances + } +} +export class PlayoutPartInstanceModelImpl implements PlayoutPartInstanceModel { + PartInstanceImpl: DBPartInstance + PieceInstancesImpl: Map + + #setPartInstanceValue(key: T, newValue: DBPartInstance[T]): void { + if (newValue === undefined) { + delete this.PartInstanceImpl[key] + } else { + this.PartInstanceImpl[key] = newValue + } + + this.#PartInstanceHasChanges = true + } + #compareAndSetPartInstanceValue( + key: T, + newValue: DBPartInstance[T], + deepEqual = false + ): boolean { + const oldValue = this.PartInstanceImpl[key] + + const areEqual = deepEqual ? _.isEqual(oldValue, newValue) : oldValue === newValue + + if (!areEqual) { + this.#setPartInstanceValue(key, newValue) + + return true + } else { + return false + } + } + + #setPartValue(key: T, newValue: DBPart[T]): void { + if (newValue === undefined) { + delete this.PartInstanceImpl.part[key] + } else { + this.PartInstanceImpl.part[key] = newValue + } + + this.#PartInstanceHasChanges = true + } + #compareAndSetPartValue(key: T, newValue: DBPart[T], deepEqual = false): boolean { + const oldValue = this.PartInstanceImpl.part[key] + + const areEqual = deepEqual ? _.isEqual(oldValue, newValue) : oldValue === newValue + + if (!areEqual) { + this.#setPartValue(key, newValue) + + return true + } else { + return false + } + } + + #PartInstanceHasChanges = false + get PartInstanceHasChanges(): boolean { + return this.#PartInstanceHasChanges + } + ChangedPieceInstanceIds(): PieceInstanceId[] { + const result: PieceInstanceId[] = [] + for (const [id, pieceInstance] of this.PieceInstancesImpl.entries()) { + if (!pieceInstance || pieceInstance.HasChanges) result.push(id) + } + return result + } + HasAnyChanges(): boolean { + return this.#PartInstanceHasChanges || this.ChangedPieceInstanceIds().length > 0 + } + clearChangedFlags(): void { + this.#PartInstanceHasChanges = false + + for (const [id, value] of this.PieceInstancesImpl) { + if (!value) { + this.PieceInstancesImpl.delete(id) + } else if (value.HasChanges) { + value.clearChangedFlag() + } + } + } + + get PartInstance(): ReadonlyDeep { + return this.PartInstanceImpl + } + get PieceInstances(): PlayoutPieceInstanceModel[] { + const result: PlayoutPieceInstanceModel[] = [] + + for (const pieceWrapped of this.PieceInstancesImpl.values()) { + if (pieceWrapped) result.push(pieceWrapped) + } + + return result + } + + constructor(partInstance: DBPartInstance, pieceInstances: PieceInstance[], hasChanges: boolean) { + this.PartInstanceImpl = partInstance + this.#PartInstanceHasChanges = hasChanges + + this.PieceInstancesImpl = new Map() + for (const pieceInstance of pieceInstances) { + this.PieceInstancesImpl.set(pieceInstance._id, new PlayoutPieceInstanceModelImpl(pieceInstance, hasChanges)) + } + } + + snapshotMakeCopy(): PlayoutPartInstanceModelSnapshot { + return new PlayoutPartInstanceModelSnapshotImpl(this) + } + + snapshotRestore(snapshot: PlayoutPartInstanceModelSnapshot): void { + if (!(snapshot instanceof PlayoutPartInstanceModelSnapshotImpl)) + throw new Error(`Cannot restore a Snapshot from an different Model`) + + if (snapshot.PartInstance._id !== this.PartInstance._id) + throw new Error(`Cannot restore a Snapshot from an different PartInstance`) + + if (snapshot.isRestored) throw new Error(`Cannot restore a Snapshot which has already been restored`) + snapshot.isRestored = true + + this.PartInstanceImpl = snapshot.PartInstance + this.#PartInstanceHasChanges = snapshot.PartInstanceHasChanges + this.PieceInstancesImpl.clear() + for (const [pieceInstanceId, pieceInstance] of snapshot.PieceInstances) { + if (pieceInstance) { + this.PieceInstancesImpl.set( + pieceInstanceId, + new PlayoutPieceInstanceModelImpl(pieceInstance.PieceInstance, pieceInstance.HasChanges) + ) + } else { + this.PieceInstancesImpl.set(pieceInstanceId, null) + } + } + } + + appendNotes(notes: PartNote[]): void { + this.#setPartValue('notes', [...(this.PartInstanceImpl.part.notes ?? []), ...clone(notes)]) + } + + blockTakeUntil(timestamp: Time | null): void { + this.#compareAndSetPartInstanceValue('blockTakeUntil', timestamp ?? undefined) + } + + getPieceInstance(id: PieceInstanceId): PlayoutPieceInstanceModel | undefined { + return this.PieceInstancesImpl.get(id) ?? undefined + } + + insertAdlibbedPiece( + piece: Omit, + fromAdlibId: PieceId | undefined + ): PlayoutPieceInstanceModel { + const pieceInstance: PieceInstance = { + _id: protectString(`${this.PartInstance._id}_${piece._id}`), + rundownId: this.PartInstance.rundownId, + playlistActivationId: this.PartInstance.playlistActivationId, + partInstanceId: this.PartInstance._id, + piece: clone( + omitPiecePropertiesForInstance({ + ...piece, + startPartId: this.PartInstanceImpl.part._id, + }) + ), + } + + // Ensure it is labelled as dynamic + pieceInstance.partInstanceId = this.PartInstance._id + pieceInstance.piece.startPartId = this.PartInstance.part._id + pieceInstance.adLibSourceId = fromAdlibId + + if (this.PartInstance.isTaken) pieceInstance.dynamicallyInserted = getCurrentTime() + + setupPieceInstanceInfiniteProperties(pieceInstance) + + const pieceInstanceModel = new PlayoutPieceInstanceModelImpl(pieceInstance, true) + this.PieceInstancesImpl.set(pieceInstance._id, pieceInstanceModel) + + return pieceInstanceModel + } + + insertHoldPieceInstance(extendPieceInstance: PlayoutPieceInstanceModel): PlayoutPieceInstanceModel { + const extendPieceInfinite = extendPieceInstance.PieceInstance.infinite + if (!extendPieceInfinite) throw new Error('Piece being extended is not infinite!') + if (extendPieceInfinite.infiniteInstanceIndex !== 0 || extendPieceInfinite.fromPreviousPart) + throw new Error('Piece being extended is not infinite due to HOLD!') + + const infiniteInstanceId = extendPieceInfinite.infiniteInstanceId + + // make the extension + const newInstance: PieceInstance = { + _id: protectString(extendPieceInstance.PieceInstance._id + '_hold'), + playlistActivationId: extendPieceInstance.PieceInstance.playlistActivationId, + rundownId: extendPieceInstance.PieceInstance.rundownId, + partInstanceId: this.PartInstance._id, + dynamicallyInserted: getCurrentTime(), + piece: { + ...clone(extendPieceInstance.PieceInstance.piece), + enable: { start: 0 }, + extendOnHold: false, + }, + infinite: { + infiniteInstanceId: infiniteInstanceId, + infiniteInstanceIndex: 1, + infinitePieceId: extendPieceInstance.PieceInstance.piece._id, + fromPreviousPart: true, + fromHold: true, + }, + // Preserve the timings from the playing instance + reportedStartedPlayback: extendPieceInstance.PieceInstance.reportedStartedPlayback, + reportedStoppedPlayback: extendPieceInstance.PieceInstance.reportedStoppedPlayback, + plannedStartedPlayback: extendPieceInstance.PieceInstance.plannedStartedPlayback, + plannedStoppedPlayback: extendPieceInstance.PieceInstance.plannedStoppedPlayback, + } + + const pieceInstanceModel = new PlayoutPieceInstanceModelImpl(newInstance, true) + this.PieceInstancesImpl.set(newInstance._id, pieceInstanceModel) + + return pieceInstanceModel + } + + insertPlannedPiece(piece: Omit): PlayoutPieceInstanceModel { + const pieceInstanceId = getPieceInstanceIdForPiece(this.PartInstance._id, piece._id) + if (this.PieceInstancesImpl.has(pieceInstanceId)) + throw new Error(`PieceInstance "${pieceInstanceId}" already exists`) + + const newPieceInstance: PieceInstance = { + _id: getPieceInstanceIdForPiece(this.PartInstance._id, piece._id), + rundownId: this.PartInstance.rundownId, + playlistActivationId: this.PartInstance.playlistActivationId, + partInstanceId: this.PartInstance._id, + piece: { + ...piece, + startPartId: this.PartInstance.part._id, + }, + } + + // Ensure the infinite-ness is setup correctly + setupPieceInstanceInfiniteProperties(newPieceInstance) + + const pieceInstanceModel = new PlayoutPieceInstanceModelImpl(newPieceInstance, true) + this.PieceInstancesImpl.set(pieceInstanceId, pieceInstanceModel) + + return pieceInstanceModel + } + + insertVirtualPiece( + start: number, + lifespan: PieceLifespan, + sourceLayerId: string, + outputLayerId: string + ): PlayoutPieceInstanceModel { + const pieceId: PieceId = getRandomId() + const newPieceInstance: PieceInstance = { + _id: protectString(`${this.PartInstance._id}_${pieceId}`), + rundownId: this.PartInstance.rundownId, + playlistActivationId: this.PartInstance.playlistActivationId, + partInstanceId: this.PartInstance._id, + piece: { + _id: pieceId, + externalId: '-', + enable: { start: start }, + lifespan: lifespan, + sourceLayerId: sourceLayerId, + outputLayerId: outputLayerId, + invalid: false, + name: '', + startPartId: this.PartInstance.part._id, + pieceType: IBlueprintPieceType.Normal, + virtual: true, + content: {}, + timelineObjectsString: EmptyPieceTimelineObjectsBlob, + }, + + dynamicallyInserted: getCurrentTime(), + } + setupPieceInstanceInfiniteProperties(newPieceInstance) + + const pieceInstanceModel = new PlayoutPieceInstanceModelImpl(newPieceInstance, true) + this.PieceInstancesImpl.set(newPieceInstance._id, pieceInstanceModel) + + return pieceInstanceModel + } + + markAsReset(): void { + this.#compareAndSetPartInstanceValue('reset', true) + + for (const pieceInstance of this.PieceInstancesImpl.values()) { + if (!pieceInstance) continue + + pieceInstance.compareAndSetPieceInstanceValue('reset', true) + } + } + + recalculateExpectedDurationWithPreroll(): void { + const newDuration = calculatePartExpectedDurationWithPreroll( + this.PartInstanceImpl.part, + this.PieceInstances.map((p) => p.PieceInstance.piece) + ) + + this.#compareAndSetPartValue('expectedDurationWithPreroll', newDuration) + } + + removePieceInstance(id: PieceInstanceId): boolean { + // Future: should this limit what can be removed based on type/infinite + + const pieceInstanceWrapped = this.PieceInstancesImpl.get(id) + if (pieceInstanceWrapped) { + this.PieceInstancesImpl.set(id, null) + return true + } + + return false + } + + replaceInfinitesFromPreviousPlayhead(pieceInstances: PieceInstance[]): void { + // Future: this should do some of the wrapping from a Piece into a PieceInstance + + // Remove old infinite pieces + for (const [id, piece] of this.PieceInstancesImpl.entries()) { + if (!piece) continue + + if (piece.PieceInstance.infinite?.fromPreviousPlayhead) { + this.PieceInstancesImpl.set(id, null) + } + } + + for (const pieceInstance of pieceInstances) { + if (this.PieceInstancesImpl.has(pieceInstance._id)) + throw new Error( + `Cannot replace infinite PieceInstance "${pieceInstance._id}" as it replaces a non-infinite` + ) + + if (!pieceInstance.infinite?.fromPreviousPlayhead) + throw new Error(`Cannot insert non-infinite PieceInstance "${pieceInstance._id}" as an infinite`) + + // Future: should this do any deeper validation of the PieceInstances? + + this.PieceInstancesImpl.set(pieceInstance._id, new PlayoutPieceInstanceModelImpl(pieceInstance, true)) + } + } + + mergeOrInsertPieceInstance(doc: ReadonlyDeep): PlayoutPieceInstanceModel { + // Future: this should do some validation of the new PieceInstance + + const existingPieceInstance = this.PieceInstancesImpl.get(doc._id) + if (existingPieceInstance) { + existingPieceInstance.mergeProperties(doc) + + return existingPieceInstance + } else { + const newPieceInstance = new PlayoutPieceInstanceModelImpl(clone(doc), true) + this.PieceInstancesImpl.set(newPieceInstance.PieceInstance._id, newPieceInstance) + return newPieceInstance + } + } + + setOrphaned(orphaned: 'adlib-part' | 'deleted' | undefined): void { + this.#compareAndSetPartInstanceValue('orphaned', orphaned) + } + + setPlaylistActivationId(id: RundownPlaylistActivationId): void { + this.#compareAndSetPartInstanceValue('playlistActivationId', id) + + for (const pieceInstance of this.PieceInstancesImpl.values()) { + if (!pieceInstance) continue + pieceInstance.compareAndSetPieceInstanceValue('playlistActivationId', id) + } + } + + setPlannedStartedPlayback(time: Time | undefined): void { + const timings = { ...this.PartInstanceImpl.timings } + timings.plannedStartedPlayback = time + delete timings.plannedStoppedPlayback + + this.#compareAndSetPartInstanceValue('timings', timings, true) + } + setPlannedStoppedPlayback(time: Time | undefined): void { + const timings = { ...this.PartInstanceImpl.timings } + if (timings?.plannedStartedPlayback && !timings.plannedStoppedPlayback) { + if (time) { + timings.plannedStoppedPlayback = time + timings.duration = time - timings.plannedStartedPlayback + } else { + delete timings.plannedStoppedPlayback + delete timings.duration + } + + this.#compareAndSetPartInstanceValue('timings', timings, true) + } + } + setReportedStartedPlayback(time: Time): boolean { + const timings = { ...this.PartInstanceImpl.timings } + + if (!timings.reportedStartedPlayback) { + timings.reportedStartedPlayback = time + delete timings.plannedStoppedPlayback + delete timings.duration + + return this.#compareAndSetPartInstanceValue('timings', timings, true) + } + + return false + } + setReportedStoppedPlayback(time: number): boolean { + const timings = { ...this.PartInstanceImpl.timings } + + if (!timings.reportedStoppedPlayback) { + timings.reportedStoppedPlayback = time + timings.duration = time - (timings.reportedStartedPlayback || time) + + return this.#compareAndSetPartInstanceValue('timings', timings, true) + } + return false + } + + setRank(rank: number): void { + this.#compareAndSetPartValue('_rank', rank) + } + + setTaken(takeTime: number, playOffset: number): void { + this.#compareAndSetPartInstanceValue('isTaken', true) + + const timings = { ...this.PartInstanceImpl.timings } + timings.take = takeTime + timings.playOffset = playOffset + + this.#compareAndSetPartInstanceValue('timings', timings, true) + } + + storePlayoutTimingsAndPreviousEndState( + partPlayoutTimings: PartCalculatedTimings, + previousPartEndState: unknown + ): void { + this.#compareAndSetPartInstanceValue('isTaken', true) + + // TODO: should this do a comparison? + this.#setPartInstanceValue('partPlayoutTimings', partPlayoutTimings) + this.#setPartInstanceValue('previousPartEndState', previousPartEndState) + } + + updatePartProps(props: Partial): boolean { + // Future: this could do some better validation + + // filter the submission to the allowed ones + const trimmedProps: Partial = _.pick(props, [...IBlueprintMutatablePartSampleKeys]) + if (Object.keys(trimmedProps).length === 0) return false + + this.#compareAndSetPartInstanceValue( + 'part', + { + ...this.PartInstanceImpl.part, + ...trimmedProps, + }, + true + ) + + return true + } + + validateScratchpadSegmentProperties(): void { + this.#compareAndSetPartInstanceValue('orphaned', 'adlib-part') + + // Autonext isn't allowed to begin with, to avoid accidentally exiting the scratchpad + this.#compareAndSetPartValue('autoNext', undefined) + + // Force this to not affect rundown timing + this.#compareAndSetPartValue('untimed', true) + } +} diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutPieceInstanceModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutPieceInstanceModelImpl.ts new file mode 100644 index 0000000000..d41ecfe42a --- /dev/null +++ b/packages/job-worker/src/playout/model/implementation/PlayoutPieceInstanceModelImpl.ts @@ -0,0 +1,139 @@ +import { PieceInstanceInfiniteId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { ReadonlyDeep } from 'type-fest' +import { PieceInstance, PieceInstancePiece } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' +import { clone, getRandomId } from '@sofie-automation/corelib/dist/lib' +import { Time } from '@sofie-automation/blueprints-integration' +import { PlayoutPieceInstanceModel } from '../PlayoutPieceInstanceModel' +import _ = require('underscore') + +export class PlayoutPieceInstanceModelImpl implements PlayoutPieceInstanceModel { + /** + * The raw mutable PieceInstance + * Danger: This should not be modified externally, this is exposed for cloning and saving purposes + */ + PieceInstanceImpl: PieceInstance + + /** + * Set/delete a value for this PieceInstance, and track that there are changes + * @param key Property key + * @param newValue Property value + */ + setPieceInstanceValue(key: T, newValue: PieceInstance[T]): void { + if (newValue === undefined) { + delete this.PieceInstanceImpl[key] + } else { + this.PieceInstanceImpl[key] = newValue + } + + this.#HasChanges = true + } + + /** + * Set/delete a value for this PieceInstance if the value has cahnged, and track that there are changes + * @param key Property key + * @param newValue Property value + * @param deepEqual Perform a deep equality check + */ + compareAndSetPieceInstanceValue( + key: T, + newValue: PieceInstance[T], + deepEqual = false + ): boolean { + const oldValue = this.PieceInstanceImpl[key] + + const areEqual = deepEqual ? _.isEqual(oldValue, newValue) : oldValue === newValue + + if (!areEqual) { + this.setPieceInstanceValue(key, newValue) + + return true + } else { + return false + } + } + + #HasChanges = false + /** + * Whether this PieceInstance has unsaved changes + */ + get HasChanges(): boolean { + return this.#HasChanges + } + + /** + * Clear the `HasChanges` flag + */ + clearChangedFlag(): void { + this.#HasChanges = false + } + + get PieceInstance(): ReadonlyDeep { + return this.PieceInstanceImpl + } + + constructor(pieceInstances: PieceInstance, hasChanges: boolean) { + this.PieceInstanceImpl = pieceInstances + this.#HasChanges = hasChanges + } + + /** + * Merge properties from another PieceInstance onto this one + * @param pieceInstance PieceInstance to merge properties from + */ + mergeProperties(pieceInstance: ReadonlyDeep): void { + this.PieceInstanceImpl = { + ...this.PieceInstanceImpl, + ...clone(pieceInstance), + } + + this.#HasChanges = true + } + + prepareForHold(): PieceInstanceInfiniteId { + const infiniteInstanceId: PieceInstanceInfiniteId = getRandomId() + this.setPieceInstanceValue('infinite', { + infiniteInstanceId: infiniteInstanceId, + infiniteInstanceIndex: 0, + infinitePieceId: this.PieceInstanceImpl.piece._id, + fromPreviousPart: false, + }) + + return infiniteInstanceId + } + + setDisabled(disabled: boolean): void { + this.compareAndSetPieceInstanceValue('disabled', disabled) + } + + setDuration(duration: Required['userDuration']): void { + this.compareAndSetPieceInstanceValue('userDuration', duration, true) + } + + setPlannedStartedPlayback(time: Time): boolean { + this.compareAndSetPieceInstanceValue('plannedStoppedPlayback', undefined) + return this.compareAndSetPieceInstanceValue('plannedStartedPlayback', time) + } + setPlannedStoppedPlayback(time: Time | undefined): boolean { + return this.compareAndSetPieceInstanceValue('plannedStoppedPlayback', time) + } + setReportedStartedPlayback(time: Time): boolean { + this.compareAndSetPieceInstanceValue('reportedStoppedPlayback', undefined) + return this.compareAndSetPieceInstanceValue('reportedStartedPlayback', time) + } + setReportedStoppedPlayback(time: Time): boolean { + return this.compareAndSetPieceInstanceValue('reportedStoppedPlayback', time) + } + + updatePieceProps(props: Partial): void { + // TODO - this is missing a lot of validation + + this.compareAndSetPieceInstanceValue( + 'piece', + { + ...this.PieceInstanceImpl.piece, + ...props, + }, + true + ) + } +} diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutRundownModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutRundownModelImpl.ts new file mode 100644 index 0000000000..2406fdecb9 --- /dev/null +++ b/packages/job-worker/src/playout/model/implementation/PlayoutRundownModelImpl.ts @@ -0,0 +1,117 @@ +import { PartId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' +import { ReadonlyDeep } from 'type-fest' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { RundownBaselineObj } from '@sofie-automation/corelib/dist/dataModel/RundownBaselineObj' +import { PlayoutRundownModel } from '../PlayoutRundownModel' +import { PlayoutSegmentModel } from '../PlayoutSegmentModel' +import { UserError, UserErrorMessage } from '@sofie-automation/corelib/dist/error' +import { SegmentOrphanedReason } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { getRandomId } from '@sofie-automation/corelib/dist/lib' +import { getCurrentTime } from '../../../lib' +import { PlayoutSegmentModelImpl } from './PlayoutSegmentModelImpl' + +export class PlayoutRundownModelImpl implements PlayoutRundownModel { + readonly Rundown: ReadonlyDeep + readonly #segments: PlayoutSegmentModelImpl[] + + readonly BaselineObjects: ReadonlyDeep + + #scratchPadSegmentHasChanged = false + /** + * Check if the Scratchpad Segment has unsaved changes + */ + get ScratchPadSegmentHasChanged(): boolean { + return this.#scratchPadSegmentHasChanged + } + /** + * Clear the `ScratchPadSegmentHasChanged` flag + */ + clearScratchPadSegmentChangedFlag(): void { + this.#scratchPadSegmentHasChanged = false + } + + constructor( + rundown: ReadonlyDeep, + segments: PlayoutSegmentModelImpl[], + baselineObjects: ReadonlyDeep + ) { + segments.sort((a, b) => a.Segment._rank - b.Segment._rank) + + this.Rundown = rundown + this.#segments = segments + this.BaselineObjects = baselineObjects + } + + get Segments(): readonly PlayoutSegmentModel[] { + return this.#segments + } + + getSegment(id: SegmentId): PlayoutSegmentModel | undefined { + return this.Segments.find((segment) => segment.Segment._id === id) + } + + getSegmentIds(): SegmentId[] { + return this.Segments.map((segment) => segment.Segment._id) + } + + getAllPartIds(): PartId[] { + return this.getAllOrderedParts().map((p) => p._id) + } + + getAllOrderedParts(): ReadonlyDeep[] { + return this.Segments.flatMap((segment) => segment.Parts) + } + + insertScratchpadSegment(): SegmentId { + const existingSegment = this.Segments.find((s) => s.Segment.orphaned === SegmentOrphanedReason.SCRATCHPAD) + if (existingSegment) throw UserError.create(UserErrorMessage.ScratchpadAlreadyActive) + + const minSegmentRank = Math.min(0, ...this.Segments.map((s) => s.Segment._rank)) + + const segmentId: SegmentId = getRandomId() + this.#segments.unshift( + new PlayoutSegmentModelImpl( + { + _id: segmentId, + _rank: minSegmentRank - 1, + externalId: '__scratchpad__', + externalModified: getCurrentTime(), + rundownId: this.Rundown._id, + orphaned: SegmentOrphanedReason.SCRATCHPAD, + name: '', + }, + [] + ) + ) + + this.#scratchPadSegmentHasChanged = true + + return segmentId + } + + removeScratchpadSegment(): boolean { + const index = this.#segments.findIndex((s) => s.Segment.orphaned === SegmentOrphanedReason.SCRATCHPAD) + if (index === -1) return false + + this.#segments.splice(index, 1) + this.#scratchPadSegmentHasChanged = true + + return true + } + + getScratchpadSegment(): PlayoutSegmentModel | undefined { + // Note: this assumes there will be up to one per rundown + return this.#segments.find((s) => s.Segment.orphaned === SegmentOrphanedReason.SCRATCHPAD) + } + + setScratchpadSegmentRank(rank: number): void { + const segment = this.#segments.find((s) => s.Segment.orphaned === SegmentOrphanedReason.SCRATCHPAD) + if (!segment) throw new Error('Scratchpad segment does not exist!') + + segment.setScratchpadRank(rank) + this.#segments.sort((a, b) => a.Segment._rank - b.Segment._rank) + + this.#scratchPadSegmentHasChanged = true + } +} diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutSegmentModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutSegmentModelImpl.ts new file mode 100644 index 0000000000..8482015137 --- /dev/null +++ b/packages/job-worker/src/playout/model/implementation/PlayoutSegmentModelImpl.ts @@ -0,0 +1,41 @@ +import { PartId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { ReadonlyDeep } from 'type-fest' +import { DBSegment, SegmentOrphanedReason } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { PlayoutSegmentModel } from '../PlayoutSegmentModel' + +export class PlayoutSegmentModelImpl implements PlayoutSegmentModel { + readonly #Segment: DBSegment + readonly Parts: ReadonlyDeep + + get Segment(): ReadonlyDeep { + return this.#Segment + } + + constructor(segment: DBSegment, parts: DBPart[]) { + parts.sort((a, b) => a._rank - b._rank) + + this.#Segment = segment + this.Parts = parts + } + + getPart(id: PartId): ReadonlyDeep | undefined { + return this.Parts.find((part) => part._id === id) + } + + getPartIds(): PartId[] { + return this.Parts.map((part) => part._id) + } + + /** + * Internal mutation 'hack' to modify the rank of the ScratchPad segment + * This segment belongs to Playout, so is allowed to be modified in this way + * @param rank New rank for the segment + */ + setScratchpadRank(rank: number): void { + if (this.#Segment.orphaned !== SegmentOrphanedReason.SCRATCHPAD) + throw new Error('setScratchpadRank can only be used on a SCRATCHPAD segment') + + this.#Segment._rank = rank + } +} diff --git a/packages/job-worker/src/playout/model/implementation/SavePlayoutModel.ts b/packages/job-worker/src/playout/model/implementation/SavePlayoutModel.ts new file mode 100644 index 0000000000..2e7f9ad791 --- /dev/null +++ b/packages/job-worker/src/playout/model/implementation/SavePlayoutModel.ts @@ -0,0 +1,137 @@ +import { PartInstanceId, PieceInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' +import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' +import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { protectString } from '@sofie-automation/corelib/dist/protectedString' +import { AnyBulkWriteOperation } from 'mongodb' +import { JobContext } from '../../../jobs' +import { PlayoutPartInstanceModelImpl } from './PlayoutPartInstanceModelImpl' +import { PlayoutRundownModelImpl } from './PlayoutRundownModelImpl' + +/** + * Save any changed Scratchpad Segments + * @param context Context from the job queue + * @param rundowns Rundowns whose Scratchpad Segment may need saving + */ +export async function writeScratchpadSegments( + context: JobContext, + rundowns: readonly PlayoutRundownModelImpl[] +): Promise { + const writeOps: AnyBulkWriteOperation[] = [] + + for (const rundown of rundowns) { + if (rundown.ScratchPadSegmentHasChanged) { + rundown.clearScratchPadSegmentChangedFlag() + const scratchpadSegment = rundown.getScratchpadSegment()?.Segment + + // Delete a removed scratchpad, and any with the non-current id (just in case) + writeOps.push({ + deleteMany: { + filter: { + rundownId: rundown.Rundown._id, + _id: { $ne: scratchpadSegment?._id ?? protectString('') }, + }, + }, + }) + + // Update/insert the segment + if (scratchpadSegment) { + writeOps.push({ + replaceOne: { + filter: { _id: scratchpadSegment._id }, + replacement: scratchpadSegment as DBSegment, + upsert: true, + }, + }) + } + } + } + + if (writeOps.length) { + await context.directCollections.Segments.bulkWrite(writeOps) + } +} + +/** + * Save any changed or deleted PartInstances and their PieceInstances + * @param context Context from the job queue + * @param partInstances Map of PartInstances to check for changes or deletion + */ +export function writePartInstancesAndPieceInstances( + context: JobContext, + partInstances: Map +): [Promise, Promise] { + const partInstanceOps: AnyBulkWriteOperation[] = [] + const pieceInstanceOps: AnyBulkWriteOperation[] = [] + + const deletedPartInstanceIds: PartInstanceId[] = [] + const deletedPieceInstanceIds: PieceInstanceId[] = [] + + for (const [partInstanceId, partInstance] of partInstances.entries()) { + if (!partInstance) { + deletedPartInstanceIds.push(partInstanceId) + } else { + if (partInstance.PartInstanceHasChanges) { + partInstanceOps.push({ + replaceOne: { + filter: { _id: partInstanceId }, + replacement: partInstance.PartInstanceImpl, + upsert: true, + }, + }) + } + + for (const [pieceInstanceId, pieceInstance] of partInstance.PieceInstancesImpl.entries()) { + if (!pieceInstance) { + deletedPieceInstanceIds.push(pieceInstanceId) + } else if (pieceInstance.HasChanges) { + pieceInstanceOps.push({ + replaceOne: { + filter: { _id: pieceInstanceId }, + replacement: pieceInstance.PieceInstanceImpl, + upsert: true, + }, + }) + } + } + + partInstance.clearChangedFlags() + } + } + + // Delete any removed PartInstances + if (deletedPartInstanceIds.length) { + partInstanceOps.push({ + deleteMany: { + filter: { + _id: { $in: deletedPartInstanceIds }, + }, + }, + }) + pieceInstanceOps.push({ + deleteMany: { + filter: { + partInstanceId: { $in: deletedPartInstanceIds }, + }, + }, + }) + } + + // Delete any removed PieceInstances + if (deletedPieceInstanceIds.length) { + pieceInstanceOps.push({ + deleteMany: { + filter: { + _id: { $in: deletedPieceInstanceIds }, + }, + }, + }) + } + + return [ + partInstanceOps.length ? context.directCollections.PartInstances.bulkWrite(partInstanceOps) : Promise.resolve(), + pieceInstanceOps.length + ? context.directCollections.PieceInstances.bulkWrite(pieceInstanceOps) + : Promise.resolve(), + ] +} diff --git a/packages/job-worker/src/playout/moveNextPart.ts b/packages/job-worker/src/playout/moveNextPart.ts index a3a998cbe9..7b66e5a154 100644 --- a/packages/job-worker/src/playout/moveNextPart.ts +++ b/packages/job-worker/src/playout/moveNextPart.ts @@ -2,35 +2,41 @@ import { groupByToMap } from '@sofie-automation/corelib/dist/lib' import { DBPart, isPartPlayable } from '@sofie-automation/corelib/dist/dataModel/Part' import { JobContext } from '../jobs' import { PartId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { CacheForPlayout, getOrderedSegmentsAndPartsFromPlayoutCache, getSelectedPartInstancesFromCache } from './cache' +import { PlayoutModel } from './model/PlayoutModel' import { sortPartsInSortedSegments } from '@sofie-automation/corelib/dist/playout/playlist' import { setNextPartFromPart } from './setNext' import { logger } from '../logging' import { SegmentOrphanedReason } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { ReadonlyDeep } from 'type-fest' export async function moveNextPart( context: JobContext, - cache: CacheForPlayout, + playoutModel: PlayoutModel, partDelta: number, segmentDelta: number ): Promise { - const playlist = cache.Playlist.doc + const playlist = playoutModel.Playlist - const { currentPartInstance, nextPartInstance } = getSelectedPartInstancesFromCache(cache) + const currentPartInstance = playoutModel.CurrentPartInstance?.PartInstance + const nextPartInstance = playoutModel.NextPartInstance?.PartInstance const refPartInstance = nextPartInstance ?? currentPartInstance const refPart = refPartInstance?.part if (!refPart || !refPartInstance) throw new Error(`RundownPlaylist "${playlist._id}" has no next and no current part!`) - const { segments: rawSegments, parts: rawParts } = getOrderedSegmentsAndPartsFromPlayoutCache(cache) + const rawSegments = playoutModel.getAllOrderedSegments() + const rawParts = playoutModel.getAllOrderedParts() if (segmentDelta) { // Ignores horizontalDelta const considerSegments = rawSegments.filter( - (s) => s._id === refPart.segmentId || !s.isHidden || s.orphaned === SegmentOrphanedReason.SCRATCHPAD + (s) => + s.Segment._id === refPart.segmentId || + !s.Segment.isHidden || + s.Segment.orphaned === SegmentOrphanedReason.SCRATCHPAD ) - const refSegmentIndex = considerSegments.findIndex((s) => s._id === refPart.segmentId) + const refSegmentIndex = considerSegments.findIndex((s) => s.Segment._id === refPart.segmentId) if (refSegmentIndex === -1) throw new Error(`Segment "${refPart.segmentId}" not found!`) const targetSegmentIndex = refSegmentIndex + segmentDelta @@ -49,9 +55,9 @@ export async function moveNextPart( ) // Iterate through segments and find the first part - let selectedPart: DBPart | undefined + let selectedPart: ReadonlyDeep | undefined for (const segment of allowedSegments) { - const parts = playablePartsBySegment.get(segment._id) ?? [] + const parts = playablePartsBySegment.get(segment.Segment._id) ?? [] // Cant go to the current part (yet) const filteredParts = parts.filter((p) => p._id !== currentPartInstance?.part._id) if (filteredParts.length > 0) { @@ -63,7 +69,7 @@ export async function moveNextPart( // TODO - looping playlists if (selectedPart) { // Switch to that part - await setNextPartFromPart(context, cache, selectedPart, true) + await setNextPartFromPart(context, playoutModel, selectedPart, true) return selectedPart._id } else { // Nothing looked valid so do nothing @@ -72,11 +78,14 @@ export async function moveNextPart( return null } } else if (partDelta) { - let playabaleParts: DBPart[] = rawParts.filter((p) => refPart._id === p._id || isPartPlayable(p)) + let playabaleParts: ReadonlyDeep[] = rawParts.filter((p) => refPart._id === p._id || isPartPlayable(p)) let refPartIndex = playabaleParts.findIndex((p) => p._id === refPart._id) if (refPartIndex === -1) { const tmpRefPart = { ...refPart, invalid: true } // make sure it won't be found as playable - playabaleParts = sortPartsInSortedSegments([...playabaleParts, tmpRefPart], rawSegments) + playabaleParts = sortPartsInSortedSegments( + [...playabaleParts, tmpRefPart], + rawSegments.map((s) => s.Segment) + ) refPartIndex = playabaleParts.findIndex((p) => p._id === refPart._id) if (refPartIndex === -1) throw new Error(`Part "${refPart._id}" not found after insert!`) } @@ -92,7 +101,7 @@ export async function moveNextPart( if (targetPart) { // Switch to that part - await setNextPartFromPart(context, cache, targetPart, true) + await setNextPartFromPart(context, playoutModel, targetPart, true) return targetPart._id } else { // Nothing looked valid so do nothing diff --git a/packages/job-worker/src/playout/pieces.ts b/packages/job-worker/src/playout/pieces.ts index 6af2c60b38..a30af7f8c2 100644 --- a/packages/job-worker/src/playout/pieces.ts +++ b/packages/job-worker/src/playout/pieces.ts @@ -1,22 +1,26 @@ -import { PieceId, RundownPlaylistActivationId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PieceId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { PieceInstance, PieceInstancePiece } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' import { protectString } from '@sofie-automation/corelib/dist/protectedString' import { PieceLifespan, IBlueprintPieceType } from '@sofie-automation/blueprints-integration/dist' import { getRandomId, literal } from '@sofie-automation/corelib/dist/lib' -import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { JobContext } from '../jobs' import { AdLibPiece } from '@sofie-automation/corelib/dist/dataModel/AdLibPiece' import _ = require('underscore') import { Piece } from '@sofie-automation/corelib/dist/dataModel/Piece' import { BucketAdLib } from '@sofie-automation/corelib/dist/dataModel/BucketAdLibPiece' -import { getCurrentTime } from '../lib' +import { ReadonlyDeep } from 'type-fest' + /** * Approximate compare Piece start times (for use in .sort()) * @param a First Piece * @param b Second Piece * @param nowInPart Approximate time to substitute for 'now' */ -function comparePieceStart(a: T, b: T, nowInPart: number): 0 | 1 | -1 { +export function comparePieceStart>( + a: T, + b: T, + nowInPart: number +): 0 | 1 | -1 { if (a.pieceType === IBlueprintPieceType.OutTransition && b.pieceType !== IBlueprintPieceType.OutTransition) { return 1 } else if (a.pieceType !== IBlueprintPieceType.OutTransition && b.pieceType === IBlueprintPieceType.OutTransition) { @@ -55,9 +59,13 @@ function comparePieceStart(a: T, b: T, nowInPart: * @param nowInPart Approximate time to substitute for 'now' * @returns Sorted PieceInstances */ -export function sortPieceInstancesByStart(pieces: PieceInstance[], nowInPart: number): PieceInstance[] { - pieces.sort((a, b) => comparePieceStart(a.piece, b.piece, nowInPart)) - return pieces +export function sortPieceInstancesByStart( + pieces: ReadonlyDeep, + nowInPart: number +): ReadonlyDeep[] { + const pieces2 = [...pieces] + pieces2.sort((a, b) => comparePieceStart(a.piece, b.piece, nowInPart)) + return pieces2 } /** @@ -98,17 +106,13 @@ export function convertPieceToAdLibPiece(context: JobContext, piece: PieceInstan * @param playlistActivationId ActivationId for the active current playlist * @param adLibPiece The piece or AdLibPiece to convert * @param partInstance The PartInstance the Adlibbed PieceInstance will belong to - * @param queue Whether this is being queued as a new PartInstance, or adding to the already playing PartInstance + * @param isBeingQueued Whether this is being queued as a new PartInstance, or adding to the already playing PartInstance * @returns The PieceInstance that was constructed */ -export function convertAdLibToPieceInstance( - context: JobContext, - playlistActivationId: RundownPlaylistActivationId, +export function convertAdLibToGenericPiece( adLibPiece: AdLibPiece | Piece | BucketAdLib | PieceInstancePiece, - partInstance: DBPartInstance, - queue: boolean -): PieceInstance { - const span = context.startSpan('convertAdLibToPieceInstance') + isBeingQueued: boolean +): Omit { let duration: number | undefined = undefined if ('expectedDuration' in adLibPiece && adLibPiece['expectedDuration']) { duration = adLibPiece['expectedDuration'] @@ -117,29 +121,16 @@ export function convertAdLibToPieceInstance( } const newPieceId: PieceId = getRandomId() - const newPieceInstance = literal({ - _id: protectString(`${partInstance._id}_${newPieceId}`), - rundownId: partInstance.rundownId, - partInstanceId: partInstance._id, - playlistActivationId, - adLibSourceId: adLibPiece._id, - dynamicallyInserted: queue ? undefined : getCurrentTime(), - piece: literal({ - ...(_.omit(adLibPiece, '_rank', 'expectedDuration', 'partId', 'rundownId') as PieceInstancePiece), // TODO - this could be typed stronger - _id: newPieceId, - startPartId: partInstance.part._id, - pieceType: IBlueprintPieceType.Normal, - enable: { - start: queue ? 0 : 'now', - duration: !queue && adLibPiece.lifespan === PieceLifespan.WithinPart ? duration : undefined, - }, - }), - }) - - setupPieceInstanceInfiniteProperties(newPieceInstance) - - if (span) span.end() - return newPieceInstance + return { + ...(_.omit(adLibPiece, '_rank', 'expectedDuration', 'partId', 'rundownId') as PieceInstancePiece), // TODO - this could be typed stronger + _id: newPieceId, + // startPartId: partInstance.part._id, + pieceType: IBlueprintPieceType.Normal, + enable: { + start: isBeingQueued ? 0 : 'now', + duration: !isBeingQueued && adLibPiece.lifespan === PieceLifespan.WithinPart ? duration : undefined, + }, + } } /** diff --git a/packages/job-worker/src/playout/resolvedPieces.ts b/packages/job-worker/src/playout/resolvedPieces.ts index 90858d21b1..c4d2b3d157 100644 --- a/packages/job-worker/src/playout/resolvedPieces.ts +++ b/packages/job-worker/src/playout/resolvedPieces.ts @@ -1,16 +1,14 @@ import { PieceInstanceInfiniteId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { ResolvedPieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' -import { CacheForPlayout } from './cache' import { SourceLayers } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' -import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { JobContext } from '../jobs' import { getCurrentTime } from '../lib' import { processAndPrunePieceInstanceTimings, resolvePrunedPieceInstance, } from '@sofie-automation/corelib/dist/playout/processAndPrune' -import { ReadOnlyCache } from '../cache/CacheBase' import { SelectedPartInstancesTimelineInfo } from './timeline/generate' +import { PlayoutPartInstanceModel } from './model/PlayoutPartInstanceModel' /** * Resolve the PieceInstances for a PartInstance @@ -23,19 +21,20 @@ import { SelectedPartInstancesTimelineInfo } from './timeline/generate' */ export function getResolvedPiecesForCurrentPartInstance( _context: JobContext, - cache: ReadOnlyCache, sourceLayers: SourceLayers, - partInstance: Pick, + partInstance: PlayoutPartInstanceModel, now?: number ): ResolvedPieceInstance[] { - const pieceInstances = cache.PieceInstances.findAll((p) => p.partInstanceId === partInstance._id) - if (now === undefined) now = getCurrentTime() - const partStarted = partInstance.timings?.plannedStartedPlayback + const partStarted = partInstance.PartInstance.timings?.plannedStartedPlayback const nowInPart = partStarted ? now - partStarted : 0 - const preprocessedPieces = processAndPrunePieceInstanceTimings(sourceLayers, pieceInstances, nowInPart) + const preprocessedPieces = processAndPrunePieceInstanceTimings( + sourceLayers, + partInstance.PieceInstances.map((p) => p.PieceInstance), + nowInPart + ) return preprocessedPieces.map((instance) => resolvePrunedPieceInstance(nowInPart, instance)) } diff --git a/packages/job-worker/src/playout/scratchpad.ts b/packages/job-worker/src/playout/scratchpad.ts index 5af179927b..7e37f0b6f5 100644 --- a/packages/job-worker/src/playout/scratchpad.ts +++ b/packages/job-worker/src/playout/scratchpad.ts @@ -1,92 +1,52 @@ -import { DBSegment, SegmentOrphanedReason } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { SegmentOrphanedReason } from '@sofie-automation/corelib/dist/dataModel/Segment' import { UserError, UserErrorMessage } from '@sofie-automation/corelib/dist/error' import { getRandomId } from '@sofie-automation/corelib/dist/lib' import { ActivateScratchpadProps } from '@sofie-automation/corelib/dist/worker/studio' -import { literal } from '@sofie-automation/shared-lib/dist/lib/lib' import { getCurrentTime } from '../lib' import { JobContext } from '../jobs' -import { runJobWithPlayoutCache } from './lock' -import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' +import { runJobWithPlayoutModel } from './lock' import { performTakeToNextedPart } from './take' -import { PartInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { CacheForPlayout } from './cache' +import { PlayoutModel } from './model/PlayoutModel' +import { PlayoutPartInstanceModel } from './model/PlayoutPartInstanceModel' export async function handleActivateScratchpad(context: JobContext, data: ActivateScratchpadProps): Promise { if (!context.studio.settings.allowScratchpad) throw UserError.create(UserErrorMessage.ScratchpadNotAllowed) - return runJobWithPlayoutCache( + return runJobWithPlayoutModel( context, data, - async (cache) => { - const playlist = cache.Playlist.doc + async (playoutModel) => { + const playlist = playoutModel.Playlist if (!playlist.activationId) throw UserError.create(UserErrorMessage.InactiveRundown) if (playlist.currentPartInfo) throw UserError.create(UserErrorMessage.RundownAlreadyActive) }, - async (cache) => { - let playlist = cache.Playlist.doc + async (playoutModel) => { + const playlist = playoutModel.Playlist if (!playlist.activationId) throw new Error(`Playlist has no activationId!`) - const rundown = cache.Rundowns.findOne(data.rundownId) + const rundown = playoutModel.getRundown(data.rundownId) if (!rundown) throw new Error(`Rundown "${data.rundownId}" not found!`) - const segment = cache.Segments.findOne( - (s) => s.orphaned === SegmentOrphanedReason.SCRATCHPAD && s.rundownId === data.rundownId - ) - if (segment) throw UserError.create(UserErrorMessage.ScratchpadAlreadyActive) + // Create the segment + rundown.insertScratchpadSegment() - const minSegmentRank = Math.min(0, ...cache.Segments.findAll(null).map((s) => s._rank)) - - const segmentId = cache.Segments.insert( - literal({ - _id: getRandomId(), - _rank: minSegmentRank - 1, - externalId: '__scratchpad__', - externalModified: getCurrentTime(), - rundownId: data.rundownId, - orphaned: SegmentOrphanedReason.SCRATCHPAD, - name: '', - }) - ) - - const newPartInstance: DBPartInstance = { + // Create the first PartInstance for the segment + const newPartInstance = playoutModel.createScratchpadPartInstance(rundown, { _id: getRandomId(), - rundownId: data.rundownId, - segmentId: segmentId, - playlistActivationId: playlist.activationId, - segmentPlayoutId: getRandomId(), - takeCount: 1, - rehearsal: !!playlist.rehearsal, - orphaned: 'adlib-part', - part: { - _id: getRandomId(), - _rank: 0, - externalId: '', - rundownId: data.rundownId, - segmentId: segmentId, - title: 'Scratchpad', - expectedDuration: 0, - expectedDurationWithPreroll: 0, // Filled in later - untimed: true, - }, - } - cache.PartInstances.insert(newPartInstance) + _rank: 0, + externalId: '', + title: 'Scratchpad', + expectedDuration: 0, + expectedDurationWithPreroll: 0, // Filled in later + untimed: true, + }) // Set the part as next - cache.Playlist.update((playlist) => { - playlist.nextPartInfo = { - partInstanceId: newPartInstance._id, - rundownId: newPartInstance.rundownId, - manuallySelected: true, - consumesQueuedSegmentId: false, - } - - return playlist - }) - playlist = cache.Playlist.doc + playoutModel.setPartInstanceAsNext(newPartInstance, true, false) // Take into the newly created Part - await performTakeToNextedPart(context, cache, getCurrentTime()) + await performTakeToNextedPart(context, playoutModel, getCurrentTime()) } ) } @@ -97,28 +57,23 @@ export async function handleActivateScratchpad(context: JobContext, data: Activa */ export function validateScratchpartPartInstanceProperties( _context: JobContext, - cache: CacheForPlayout, - partInstanceId: PartInstanceId + playoutModel: PlayoutModel, + partInstance: PlayoutPartInstanceModel ): void { - const partInstance = cache.PartInstances.findOne(partInstanceId) - if (!partInstance) return + const rundown = playoutModel.getRundown(partInstance.PartInstance.rundownId) + if (!rundown) + throw new Error( + `Failed to find Rundown "${partInstance.PartInstance.rundownId}" for PartInstance "${partInstance.PartInstance._id}"` + ) - const segment = cache.Segments.findOne(partInstance.segmentId) + const segment = rundown.getSegment(partInstance.PartInstance.segmentId) if (!segment) - throw new Error(`Failed to find Segment "${partInstance.segmentId}" for PartInstance "${partInstance._id}"`) + throw new Error( + `Failed to find Segment "${partInstance.PartInstance.segmentId}" for PartInstance "${partInstance.PartInstance._id}"` + ) // Check if this applies - if (segment.orphaned !== SegmentOrphanedReason.SCRATCHPAD) return - - cache.PartInstances.updateOne(partInstance._id, (partInstance) => { - partInstance.orphaned = 'adlib-part' - - // Autonext isn't allowed to begin with, to avoid accidentally exiting the scratchpad - delete partInstance.part.autoNext - - // Force this to not affect rundown timing - partInstance.part.untimed = true + if (segment.Segment.orphaned !== SegmentOrphanedReason.SCRATCHPAD) return - return partInstance - }) + partInstance.validateScratchpadSegmentProperties() } diff --git a/packages/job-worker/src/playout/selectNextPart.ts b/packages/job-worker/src/playout/selectNextPart.ts index 0eecbb562a..3d85fb0a6d 100644 --- a/packages/job-worker/src/playout/selectNextPart.ts +++ b/packages/job-worker/src/playout/selectNextPart.ts @@ -3,7 +3,8 @@ import { JobContext } from '../jobs' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { ReadonlyDeep } from 'type-fest' +import { PlayoutSegmentModel } from './model/PlayoutSegmentModel' /** * This wraps a Part which has been selected to be next, to include some additional data about that choice @@ -12,7 +13,7 @@ export interface SelectNextPartResult { /** * The Part selected to be nexted */ - part: DBPart + part: ReadonlyDeep /** * The index of the Part in the provided list of all sorted Parts @@ -25,10 +26,6 @@ export interface SelectNextPartResult { */ consumesQueuedSegmentId: boolean } -export interface PartsAndSegments { - segments: DBSegment[] - parts: DBPart[] -} /** * Select the Part in the Playlist which should be set as next @@ -37,9 +34,10 @@ export interface PartsAndSegments { export function selectNextPart( context: JobContext, rundownPlaylist: Pick, - previousPartInstance: DBPartInstance | null, - currentlySelectedPartInstance: DBPartInstance | null, - { parts: parts0, segments }: PartsAndSegments, + previousPartInstance: ReadonlyDeep | null, + currentlySelectedPartInstance: ReadonlyDeep | null, + segments: readonly PlayoutSegmentModel[], + parts0: ReadonlyDeep[], ignoreUnplayabale = true ): SelectNextPartResult | null { const span = context.startSpan('selectNextPart') @@ -57,7 +55,7 @@ export function selectNextPart( */ const findFirstPlayablePart = ( offset: number, - condition?: (part: DBPart) => boolean, + condition?: (part: ReadonlyDeep) => boolean, length?: number ): SelectNextPartResult | undefined => { // Filter to after and find the first playabale @@ -99,12 +97,12 @@ export function selectNextPart( searchFromIndex = nextInSegmentIndex ?? segmentStartIndex } else { // If we didn't find the segment in the list of parts, then look for segments after this one. - const segmentIndex = segments.findIndex((s) => s._id === previousPartInstance.segmentId) + const segmentIndex = segments.findIndex((s) => s.Segment._id === previousPartInstance.segmentId) let followingSegmentStart: number | undefined if (segmentIndex !== -1) { // Find the first segment with parts that lies after this for (let i = segmentIndex + 1; i < segments.length; i++) { - const segmentStart = segmentStarts.get(segments[i]._id) + const segmentStart = segmentStarts.get(segments[i].Segment._id) if (segmentStart !== undefined) { followingSegmentStart = segmentStart break diff --git a/packages/job-worker/src/playout/setNext.ts b/packages/job-worker/src/playout/setNext.ts index e40df96f19..b8ee8f6c97 100644 --- a/packages/job-worker/src/playout/setNext.ts +++ b/packages/job-worker/src/playout/setNext.ts @@ -1,99 +1,96 @@ -import { assertNever, getRandomId, literal } from '@sofie-automation/corelib/dist/lib' -import { DBSegment, SegmentOrphanedReason } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { assertNever } from '@sofie-automation/corelib/dist/lib' +import { SegmentOrphanedReason } from '@sofie-automation/corelib/dist/dataModel/Segment' import { DBPart, isPartPlayable } from '@sofie-automation/corelib/dist/dataModel/Part' import { JobContext } from '../jobs' -import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' -import { - PartId, - PartInstanceId, - RundownId, - RundownPlaylistActivationId, - SegmentId, - SegmentPlayoutId, -} from '@sofie-automation/corelib/dist/dataModel/Ids' -import { CacheForPlayout, getRundownIDsFromCache, getSelectedPartInstancesFromCache } from './cache' +import { PartId, PartInstanceId, RundownId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PlayoutModel } from './model/PlayoutModel' +import { PlayoutPartInstanceModel } from './model/PlayoutPartInstanceModel' +import { PlayoutSegmentModel } from './model/PlayoutSegmentModel' import { fetchPiecesThatMayBeActiveForPart, getPieceInstancesForPart, syncPlayheadInfinitesForNextPartInstance, } from './infinites' -import { protectString } from '@sofie-automation/corelib/dist/protectedString' import { PRESERVE_UNSYNCED_PLAYING_SEGMENT_CONTENTS } from '@sofie-automation/shared-lib/dist/core/constants' -import { getCurrentTime } from '../lib' import { IngestJobs } from '@sofie-automation/corelib/dist/worker/ingest' import _ = require('underscore') import { resetPartInstancesWithPieceInstances } from './lib' -import { RundownHoldState, SelectedPartInstance } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { RundownHoldState } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { UserError, UserErrorMessage } from '@sofie-automation/corelib/dist/error' import { SelectNextPartResult } from './selectNextPart' -import { sortPartsInSortedSegments } from '@sofie-automation/corelib/dist/playout/playlist' +import { ReadonlyDeep } from 'type-fest' import { QueueNextSegmentResult } from '@sofie-automation/corelib/dist/worker/studio' +import { protectString } from '@sofie-automation/corelib/dist/protectedString' /** * Set or clear the nexted part, from a given PartInstance, or SelectNextPartResult * @param context Context for the running job - * @param cache The playout cache of the playlist + * @param playoutModel The playout cache of the playlist * @param rawNextPart The Part to set as next * @param setManually Whether this was manually chosen by the user * @param nextTimeOffset The offset into the Part to start playback */ export async function setNextPart( context: JobContext, - cache: CacheForPlayout, - rawNextPart: Omit | DBPartInstance | null, + playoutModel: PlayoutModel, + rawNextPart: ReadonlyDeep> | PlayoutPartInstanceModel | null, setManually: boolean, nextTimeOffset?: number | undefined ): Promise { const span = context.startSpan('setNextPart') - const rundownIds = getRundownIDsFromCache(cache) - const { currentPartInstance, nextPartInstance } = getSelectedPartInstancesFromCache(cache) + const rundownIds = playoutModel.getRundownIds() + const currentPartInstance = playoutModel.CurrentPartInstance + const nextPartInstance = playoutModel.NextPartInstance if (rawNextPart) { - if (!cache.Playlist.doc.activationId) - throw new Error(`RundownPlaylist "${cache.Playlist.doc._id}" is not active`) + if (!playoutModel.Playlist.activationId) + throw new Error(`RundownPlaylist "${playoutModel.Playlist._id}" is not active`) // create new instance - let newPartInstance: DBPartInstance + let newPartInstance: PlayoutPartInstanceModel let consumesQueuedSegmentId: boolean - if ('playlistActivationId' in rawNextPart) { - const inputPartInstance: DBPartInstance = rawNextPart - if (inputPartInstance.part.invalid) { + if ('PartInstance' in rawNextPart) { + const inputPartInstance: PlayoutPartInstanceModel = rawNextPart + if (inputPartInstance.PartInstance.part.invalid) { throw new Error('Part is marked as invalid, cannot set as next.') } - if (!rundownIds.includes(inputPartInstance.rundownId)) { + if (!rundownIds.includes(inputPartInstance.PartInstance.rundownId)) { throw new Error( - `PartInstance "${inputPartInstance._id}" of rundown "${inputPartInstance.rundownId}" not part of RundownPlaylist "${cache.Playlist.doc._id}"` + `PartInstance "${inputPartInstance.PartInstance._id}" of rundown "${inputPartInstance.PartInstance.rundownId}" not part of RundownPlaylist "${playoutModel.Playlist._id}"` ) } consumesQueuedSegmentId = false - newPartInstance = await prepareExistingPartInstanceForBeingNexted(context, cache, inputPartInstance) + newPartInstance = await prepareExistingPartInstanceForBeingNexted(context, playoutModel, inputPartInstance) } else { - const selectedPart: Omit = rawNextPart + const selectedPart: ReadonlyDeep> = rawNextPart if (selectedPart.part.invalid) { throw new Error('Part is marked as invalid, cannot set as next.') } if (!rundownIds.includes(selectedPart.part.rundownId)) { throw new Error( - `Part "${selectedPart.part._id}" of rundown "${selectedPart.part.rundownId}" not part of RundownPlaylist "${cache.Playlist.doc._id}"` + `Part "${selectedPart.part._id}" of rundown "${selectedPart.part.rundownId}" not part of RundownPlaylist "${playoutModel.Playlist._id}"` ) } consumesQueuedSegmentId = selectedPart.consumesQueuedSegmentId ?? false - if (nextPartInstance && nextPartInstance.part._id === selectedPart.part._id) { + if (nextPartInstance && nextPartInstance.PartInstance.part._id === selectedPart.part._id) { // Re-use existing - newPartInstance = await prepareExistingPartInstanceForBeingNexted(context, cache, nextPartInstance) + newPartInstance = await prepareExistingPartInstanceForBeingNexted( + context, + playoutModel, + nextPartInstance + ) } else { // Create new instance newPartInstance = await preparePartInstanceForPartBeingNexted( context, - cache, - cache.Playlist.doc.activationId, + playoutModel, currentPartInstance, selectedPart.part ) @@ -101,138 +98,90 @@ export async function setNextPart( } const selectedPartInstanceIds = _.compact([ - newPartInstance._id, - cache.Playlist.doc.currentPartInfo?.partInstanceId, - cache.Playlist.doc.previousPartInfo?.partInstanceId, + newPartInstance.PartInstance._id, + playoutModel.Playlist.currentPartInfo?.partInstanceId, + playoutModel.Playlist.previousPartInfo?.partInstanceId, ]) // reset any previous instances of this part - resetPartInstancesWithPieceInstances(context, cache, { + resetPartInstancesWithPieceInstances(context, playoutModel, { _id: { $nin: selectedPartInstanceIds }, - rundownId: newPartInstance.rundownId, - 'part._id': newPartInstance.part._id, + rundownId: newPartInstance.PartInstance.rundownId, + 'part._id': newPartInstance.PartInstance.part._id, }) - cache.Playlist.update((p) => { - p.nextPartInfo = literal({ - partInstanceId: newPartInstance._id, - rundownId: newPartInstance.rundownId, - manuallySelected: !!(setManually || newPartInstance.orphaned), - consumesQueuedSegmentId, - }) - p.nextTimeOffset = nextTimeOffset || null - return p - }) + playoutModel.setPartInstanceAsNext(newPartInstance, setManually, consumesQueuedSegmentId, nextTimeOffset) } else { // Set to null - cache.Playlist.update((p) => { - p.nextPartInfo = null - p.nextTimeOffset = null - return p - }) + playoutModel.setPartInstanceAsNext(null, setManually, false, nextTimeOffset) } - discardUntakenPartInstances(cache) + playoutModel.removeUntakenPartInstances() - resetPartInstancesWhenChangingSegment(context, cache) + resetPartInstancesWhenChangingSegment(context, playoutModel) - await cleanupOrphanedItems(context, cache) + await cleanupOrphanedItems(context, playoutModel) if (span) span.end() } async function prepareExistingPartInstanceForBeingNexted( context: JobContext, - cache: CacheForPlayout, - instance: DBPartInstance -): Promise { - await syncPlayheadInfinitesForNextPartInstance(context, cache) + playoutModel: PlayoutModel, + instance: PlayoutPartInstanceModel +): Promise { + await syncPlayheadInfinitesForNextPartInstance(context, playoutModel, playoutModel.CurrentPartInstance, instance) return instance } async function preparePartInstanceForPartBeingNexted( context: JobContext, - cache: CacheForPlayout, - playlistActivationId: RundownPlaylistActivationId, - currentPartInstance: DBPartInstance | undefined, - nextPart: DBPart -): Promise { - const partInstanceId = protectString(`${nextPart._id}_${getRandomId()}`) - - const newTakeCount = currentPartInstance ? currentPartInstance.takeCount + 1 : 0 // Increment - const segmentPlayoutId: SegmentPlayoutId = - currentPartInstance && nextPart.segmentId === currentPartInstance.segmentId - ? currentPartInstance.segmentPlayoutId - : getRandomId() - - const instance: DBPartInstance = { - _id: partInstanceId, - takeCount: newTakeCount, - playlistActivationId: playlistActivationId, - rundownId: nextPart.rundownId, - segmentId: nextPart.segmentId, - segmentPlayoutId, - part: nextPart, - rehearsal: !!cache.Playlist.doc.rehearsal, - timings: { - setAsNext: getCurrentTime(), - }, - } - cache.PartInstances.insert(instance) - - const rundown = cache.Rundowns.findOne(nextPart.rundownId) + playoutModel: PlayoutModel, + currentPartInstance: PlayoutPartInstanceModel | null, + nextPart: ReadonlyDeep +): Promise { + const rundown = playoutModel.getRundown(nextPart.rundownId) if (!rundown) throw new Error(`Could not find rundown ${nextPart.rundownId}`) - const possiblePieces = await fetchPiecesThatMayBeActiveForPart(context, cache, undefined, nextPart) + const possiblePieces = await fetchPiecesThatMayBeActiveForPart(context, playoutModel, undefined, nextPart) const newPieceInstances = getPieceInstancesForPart( context, - cache, + playoutModel, currentPartInstance, rundown, nextPart, possiblePieces, - partInstanceId + protectString('') // Replaced inside playoutModel.createInstanceForPart ) - for (const pieceInstance of newPieceInstances) { - cache.PieceInstances.insert(pieceInstance) - } - return instance -} - -function discardUntakenPartInstances(cache: CacheForPlayout) { - const instancesIdsToRemove = cache.PartInstances.remove( - (p) => - !p.isTaken && - p._id !== cache.Playlist.doc.nextPartInfo?.partInstanceId && - p._id !== cache.Playlist.doc.currentPartInfo?.partInstanceId - ) - cache.PieceInstances.remove((p) => instancesIdsToRemove.includes(p.partInstanceId)) + return playoutModel.createInstanceForPart(nextPart, newPieceInstances) } /** * When entering a segment, or moving backwards in a segment, reset any partInstances in that window * In theory the new segment should already be reset, as we do that upon leaving, but it wont be if jumping to earlier in the same segment or maybe if the rundown wasnt reset */ -function resetPartInstancesWhenChangingSegment(context: JobContext, cache: CacheForPlayout) { - const { currentPartInstance, nextPartInstance } = getSelectedPartInstancesFromCache(cache) +function resetPartInstancesWhenChangingSegment(context: JobContext, playoutModel: PlayoutModel) { + const currentPartInstance = playoutModel.CurrentPartInstance?.PartInstance + const nextPartInstance = playoutModel.NextPartInstance?.PartInstance + if (nextPartInstance) { const resetPartInstanceIds = new Set() if (currentPartInstance) { // Always clean the current segment, anything after the current part (except the next part) - const trailingInOldSegment = cache.PartInstances.findAll( + const trailingInOldSegment = playoutModel.LoadedPartInstances.filter( (p) => - !p.reset && - p._id !== currentPartInstance._id && - p._id !== nextPartInstance._id && - p.segmentId === currentPartInstance.segmentId && - p.part._rank > currentPartInstance.part._rank + !p.PartInstance.reset && + p.PartInstance._id !== currentPartInstance._id && + p.PartInstance._id !== nextPartInstance._id && + p.PartInstance.segmentId === currentPartInstance.segmentId && + p.PartInstance.part._rank > currentPartInstance.part._rank ) for (const part of trailingInOldSegment) { - resetPartInstanceIds.add(part._id) + resetPartInstanceIds.add(part.PartInstance._id) } } @@ -243,20 +192,20 @@ function resetPartInstancesWhenChangingSegment(context: JobContext, cache: Cache nextPartInstance.part._rank < currentPartInstance.part._rank) ) { // clean the whole segment if new, or jumping backwards - const newSegmentParts = cache.PartInstances.findAll( + const newSegmentParts = playoutModel.LoadedPartInstances.filter( (p) => - !p.reset && - p._id !== nextPartInstance._id && - p._id !== currentPartInstance?._id && - p.segmentId === nextPartInstance.segmentId + !p.PartInstance.reset && + p.PartInstance._id !== nextPartInstance._id && + p.PartInstance._id !== currentPartInstance?._id && + p.PartInstance.segmentId === nextPartInstance.segmentId ) for (const part of newSegmentParts) { - resetPartInstanceIds.add(part._id) + resetPartInstanceIds.add(part.PartInstance._id) } } if (resetPartInstanceIds.size > 0) { - resetPartInstancesWithPieceInstances(context, cache, { + resetPartInstancesWithPieceInstances(context, playoutModel, { _id: { $in: Array.from(resetPartInstanceIds) }, }) } @@ -265,40 +214,41 @@ function resetPartInstancesWhenChangingSegment(context: JobContext, cache: Cache /** * Cleanup any orphaned (deleted) segments and partinstances once they are no longer being played - * @param cache + * @param playoutModel */ -async function cleanupOrphanedItems(context: JobContext, cache: CacheForPlayout) { - const playlist = cache.Playlist.doc +async function cleanupOrphanedItems(context: JobContext, playoutModel: PlayoutModel) { + const playlist = playoutModel.Playlist const selectedPartInstancesSegmentIds = new Set() - const selectedPartInstances = getSelectedPartInstancesFromCache(cache) - if (selectedPartInstances.currentPartInstance) - selectedPartInstancesSegmentIds.add(selectedPartInstances.currentPartInstance.segmentId) - if (selectedPartInstances.nextPartInstance) - selectedPartInstancesSegmentIds.add(selectedPartInstances.nextPartInstance.segmentId) + + const currentPartInstance = playoutModel.CurrentPartInstance?.PartInstance + const nextPartInstance = playoutModel.NextPartInstance?.PartInstance + + if (currentPartInstance) selectedPartInstancesSegmentIds.add(currentPartInstance.segmentId) + if (nextPartInstance) selectedPartInstancesSegmentIds.add(nextPartInstance.segmentId) // Cleanup any orphaned segments once they are no longer being played. This also cleans up any adlib-parts, that have been marked as deleted as a deferred cleanup operation - const segments = cache.Segments.findAll((s) => !!s.orphaned) - const orphanedSegmentIds = new Set(segments.map((s) => s._id)) + const segments = playoutModel.getAllOrderedSegments().filter((s) => !!s.Segment.orphaned) + const orphanedSegmentIds = new Set(segments.map((s) => s.Segment._id)) const alterSegmentsFromRundowns = new Map() for (const segment of segments) { // If the segment is orphaned and not the segment for the next or current partinstance - if (!selectedPartInstancesSegmentIds.has(segment._id)) { - let rundownSegments = alterSegmentsFromRundowns.get(segment.rundownId) + if (!selectedPartInstancesSegmentIds.has(segment.Segment._id)) { + let rundownSegments = alterSegmentsFromRundowns.get(segment.Segment.rundownId) if (!rundownSegments) { rundownSegments = { deleted: [], hidden: [] } - alterSegmentsFromRundowns.set(segment.rundownId, rundownSegments) + alterSegmentsFromRundowns.set(segment.Segment.rundownId, rundownSegments) } // The segment is finished with. Queue it for attempted removal or reingest - switch (segment.orphaned) { + switch (segment.Segment.orphaned) { case SegmentOrphanedReason.DELETED: { - rundownSegments.deleted.push(segment._id) + rundownSegments.deleted.push(segment.Segment._id) break } case SegmentOrphanedReason.HIDDEN: { // The segment is finished with. Queue it for attempted resync - rundownSegments.hidden.push(segment._id) + rundownSegments.hidden.push(segment.Segment._id) break } case SegmentOrphanedReason.SCRATCHPAD: @@ -308,7 +258,7 @@ async function cleanupOrphanedItems(context: JobContext, cache: CacheForPlayout) // Not orphaned break default: - assertNever(segment.orphaned) + assertNever(segment.Segment.orphaned) break } } @@ -316,13 +266,13 @@ async function cleanupOrphanedItems(context: JobContext, cache: CacheForPlayout) // We need to run this outside of the current lock, and within an ingest lock, so defer to the work queue for (const [rundownId, candidateSegmentIds] of alterSegmentsFromRundowns) { - const rundown = cache.Rundowns.findOne(rundownId) - if (rundown?.restoredFromSnapshotId) { + const rundown = playoutModel.getRundown(rundownId) + if (rundown?.Rundown?.restoredFromSnapshotId) { // This is not valid as the rundownId won't match the externalId, so ingest will fail // For now do nothing } else if (rundown) { await context.queueIngestJob(IngestJobs.RemoveOrphanedSegments, { - rundownExternalId: rundown.externalId, + rundownExternalId: rundown.Rundown.externalId, peripheralDeviceId: null, orphanedHiddenSegmentIds: candidateSegmentIds.hidden, orphanedDeletedSegmentIds: candidateSegmentIds.deleted, @@ -332,60 +282,60 @@ async function cleanupOrphanedItems(context: JobContext, cache: CacheForPlayout) const removePartInstanceIds: PartInstanceId[] = [] // Cleanup any orphaned partinstances once they are no longer being played (and the segment isnt orphaned) - const orphanedInstances = cache.PartInstances.findAll((p) => p.orphaned === 'deleted' && !p.reset) + const orphanedInstances = playoutModel.LoadedPartInstances.filter( + (p) => p.PartInstance.orphaned === 'deleted' && !p.PartInstance.reset + ) for (const partInstance of orphanedInstances) { - if (PRESERVE_UNSYNCED_PLAYING_SEGMENT_CONTENTS && orphanedSegmentIds.has(partInstance.segmentId)) { + if (PRESERVE_UNSYNCED_PLAYING_SEGMENT_CONTENTS && orphanedSegmentIds.has(partInstance.PartInstance.segmentId)) { // If the segment is also orphaned, then don't delete it until it is clear continue } if ( - partInstance._id !== playlist.currentPartInfo?.partInstanceId && - partInstance._id !== playlist.nextPartInfo?.partInstanceId + partInstance.PartInstance._id !== playlist.currentPartInfo?.partInstanceId && + partInstance.PartInstance._id !== playlist.nextPartInfo?.partInstanceId ) { - removePartInstanceIds.push(partInstance._id) + removePartInstanceIds.push(partInstance.PartInstance._id) } } // Cleanup any instances from above if (removePartInstanceIds.length > 0) { - resetPartInstancesWithPieceInstances(context, cache, { _id: { $in: removePartInstanceIds } }) + resetPartInstancesWithPieceInstances(context, playoutModel, { _id: { $in: removePartInstanceIds } }) } } /** * Set or clear the queued segment. * @param context Context for the running job - * @param cache The playout cache of the playlist + * @param playoutModel The playout cache of the playlist * @param queuedSegment The segment to queue, or null to clear it */ export async function queueNextSegment( context: JobContext, - cache: CacheForPlayout, - queuedSegment: DBSegment | null + playoutModel: PlayoutModel, + queuedSegment: PlayoutSegmentModel | null ): Promise { const span = context.startSpan('queueNextSegment') if (queuedSegment) { - if (queuedSegment.orphaned === SegmentOrphanedReason.SCRATCHPAD) - throw new Error(`Segment "${queuedSegment._id}" is a scratchpad, and cannot be queued!`) + if (queuedSegment.Segment.orphaned === SegmentOrphanedReason.SCRATCHPAD) + throw new Error(`Segment "${queuedSegment.Segment._id}" is a scratchpad, and cannot be queued!`) // Just run so that errors will be thrown if something wrong: - const firstPlayablePart = findFirstPlayablePartOrThrow(cache, queuedSegment) + const firstPlayablePart = findFirstPlayablePartOrThrow(queuedSegment) - const { nextPartInstance, currentPartInstance } = getSelectedPartInstancesFromCache(cache) + const currentPartInstance = playoutModel.CurrentPartInstance?.PartInstance + const nextPartInstance = playoutModel.NextPartInstance?.PartInstance // if there is not currentPartInstance or the nextPartInstance is not in the current segment // behave as if user chose SetNextPart on the first playable part of the segment if (currentPartInstance === undefined || currentPartInstance.segmentId !== nextPartInstance?.segmentId) { // Clear any existing nextSegment, as this call 'replaces' it - cache.Playlist.update((p) => { - delete p.queuedSegmentId - return p - }) + playoutModel.setQueuedSegment(null) await setNextPart( context, - cache, + playoutModel, { part: firstPlayablePart, consumesQueuedSegmentId: false, @@ -397,43 +347,34 @@ export async function queueNextSegment( return { nextPartId: firstPlayablePart._id } } - cache.Playlist.update((p) => { - p.queuedSegmentId = queuedSegment._id - return p - }) + playoutModel.setQueuedSegment(queuedSegment) } else { - cache.Playlist.update((p) => { - delete p.queuedSegmentId - return p - }) + playoutModel.setQueuedSegment(null) } span?.end() - return { queuedSegmentId: queuedSegment?._id ?? null } + return { queuedSegmentId: queuedSegment?.Segment?._id ?? null } } /** * Set the first playable part of a given segment as next. * @param context Context for the running job - * @param cache The playout cache of the playlist + * @param playoutModel The playout cache of the playlist * @param nextSegment The segment, whose first part is to be set as next */ export async function setNextSegment( context: JobContext, - cache: CacheForPlayout, - nextSegment: DBSegment + playoutModel: PlayoutModel, + nextSegment: PlayoutSegmentModel ): Promise { const span = context.startSpan('setNextSegment') // Just run so that errors will be thrown if something wrong: - const firstPlayablePart = findFirstPlayablePartOrThrow(cache, nextSegment) + const firstPlayablePart = findFirstPlayablePartOrThrow(nextSegment) - cache.Playlist.update((p) => { - delete p.queuedSegmentId - return p - }) + playoutModel.setQueuedSegment(null) await setNextPart( context, - cache, + playoutModel, { part: firstPlayablePart, consumesQueuedSegmentId: false, @@ -445,12 +386,8 @@ export async function setNextSegment( return firstPlayablePart._id } -function findFirstPlayablePartOrThrow(cache: CacheForPlayout, segment: DBSegment): DBPart { - const partsInSegment = sortPartsInSortedSegments( - cache.Parts.findAll((p) => p.segmentId === segment._id), - [segment] - ) - const firstPlayablePart = partsInSegment.find((p) => isPartPlayable(p)) +function findFirstPlayablePartOrThrow(segment: PlayoutSegmentModel): ReadonlyDeep { + const firstPlayablePart = segment.Parts.find((p) => isPartPlayable(p)) if (!firstPlayablePart) { throw new Error('Segment contains no valid parts') } @@ -460,33 +397,34 @@ function findFirstPlayablePartOrThrow(cache: CacheForPlayout, segment: DBSegment /** * Set the nexted part, from a given DBPart * @param context Context for the running job - * @param cache The playout cache of the playlist + * @param playoutModel The playout cache of the playlist * @param nextPart The Part to set as next * @param setManually Whether this was manually chosen by the user * @param nextTimeOffset The offset into the Part to start playback */ export async function setNextPartFromPart( context: JobContext, - cache: CacheForPlayout, - nextPart: DBPart, + playoutModel: PlayoutModel, + nextPart: ReadonlyDeep, setManually: boolean, nextTimeOffset?: number | undefined ): Promise { - const playlist = cache.Playlist.doc + const playlist = playoutModel.Playlist if (!playlist.activationId) throw UserError.create(UserErrorMessage.InactiveRundown) if (playlist.holdState === RundownHoldState.ACTIVE || playlist.holdState === RundownHoldState.PENDING) { throw UserError.create(UserErrorMessage.DuringHold) } - const consumesQueuedSegmentId = doesPartConsumeQueuedSegmentId(cache, nextPart) + const consumesQueuedSegmentId = doesPartConsumeQueuedSegmentId(playoutModel, nextPart) - await setNextPart(context, cache, { part: nextPart, consumesQueuedSegmentId }, setManually, nextTimeOffset) + await setNextPart(context, playoutModel, { part: nextPart, consumesQueuedSegmentId }, setManually, nextTimeOffset) } -function doesPartConsumeQueuedSegmentId(cache: CacheForPlayout, nextPart: DBPart) { +function doesPartConsumeQueuedSegmentId(playoutModel: PlayoutModel, nextPart: ReadonlyDeep) { // If we're setting the next point to somewhere other than the current segment, and in the queued segment, clear the queued segment - const playlist = cache.Playlist.doc - const { currentPartInstance } = getSelectedPartInstancesFromCache(cache) + const playlist = playoutModel.Playlist + const currentPartInstance = playoutModel.CurrentPartInstance?.PartInstance + return !!( currentPartInstance && currentPartInstance.segmentId !== nextPart.segmentId && diff --git a/packages/job-worker/src/playout/setNextJobs.ts b/packages/job-worker/src/playout/setNextJobs.ts index 5fa99166f2..9f6f6f7df4 100644 --- a/packages/job-worker/src/playout/setNextJobs.ts +++ b/packages/job-worker/src/playout/setNextJobs.ts @@ -1,7 +1,6 @@ import { PartId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { isPartPlayable } from '@sofie-automation/corelib/dist/dataModel/Part' import { RundownHoldState } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' import { UserError, UserErrorMessage } from '@sofie-automation/corelib/dist/error' import { SetNextPartProps, @@ -11,35 +10,37 @@ import { QueueNextSegmentResult, } from '@sofie-automation/corelib/dist/worker/studio' import { JobContext } from '../jobs' -import { runJobWithPlayoutCache } from './lock' +import { runJobWithPlayoutModel } from './lock' import { setNextPartFromPart, setNextSegment, queueNextSegment } from './setNext' import { moveNextPart } from './moveNextPart' import { updateTimeline } from './timeline/generate' +import { PlayoutSegmentModel } from './model/PlayoutSegmentModel' +import { ReadonlyDeep } from 'type-fest' /** * Set the next Part to a specified id */ export async function handleSetNextPart(context: JobContext, data: SetNextPartProps): Promise { - return runJobWithPlayoutCache( + return runJobWithPlayoutModel( context, data, - async (cache) => { - const playlist = cache.Playlist.doc + async (playoutModel) => { + const playlist = playoutModel.Playlist if (!playlist.activationId) throw UserError.create(UserErrorMessage.InactiveRundown, undefined, 412) if (playlist.holdState && playlist.holdState !== RundownHoldState.COMPLETE) { throw UserError.create(UserErrorMessage.DuringHold, undefined, 412) } }, - async (cache) => { + async (playoutModel) => { // Ensure the part is playable and found - const nextPart = cache.Parts.findOne(data.nextPartId) + const nextPart = playoutModel.findPart(data.nextPartId) if (!nextPart) throw UserError.create(UserErrorMessage.PartNotFound, undefined, 404) if (!isPartPlayable(nextPart)) throw UserError.create(UserErrorMessage.PartNotPlayable, undefined, 412) - await setNextPartFromPart(context, cache, nextPart, data.setManually ?? false, data.nextTimeOffset) + await setNextPartFromPart(context, playoutModel, nextPart, data.setManually ?? false, data.nextTimeOffset) - await updateTimeline(context, cache) + await updateTimeline(context, playoutModel) } ) } @@ -48,14 +49,14 @@ export async function handleSetNextPart(context: JobContext, data: SetNextPartPr * Move which Part is nexted by a Part(horizontal) or Segment (vertical) delta */ export async function handleMoveNextPart(context: JobContext, data: MoveNextPartProps): Promise { - return runJobWithPlayoutCache( + return runJobWithPlayoutModel( context, data, - async (cache) => { + async (playoutModel) => { if (!data.partDelta && !data.segmentDelta) throw new Error(`rundownMoveNext: invalid delta: (${data.partDelta}, ${data.segmentDelta})`) - const playlist = cache.Playlist.doc + const playlist = playoutModel.Playlist if (!playlist.activationId) throw UserError.create(UserErrorMessage.InactiveRundown, undefined, 412) if (playlist.holdState === RundownHoldState.ACTIVE || playlist.holdState === RundownHoldState.PENDING) { @@ -66,10 +67,10 @@ export async function handleMoveNextPart(context: JobContext, data: MoveNextPart throw UserError.create(UserErrorMessage.NoCurrentOrNextPart, undefined, 412) } }, - async (cache) => { - const newPartId = await moveNextPart(context, cache, data.partDelta, data.segmentDelta) + async (playoutModel) => { + const newPartId = await moveNextPart(context, playoutModel, data.partDelta, data.segmentDelta) - if (newPartId) await updateTimeline(context, cache) + if (newPartId) await updateTimeline(context, playoutModel) return newPartId } @@ -80,25 +81,25 @@ export async function handleMoveNextPart(context: JobContext, data: MoveNextPart * Set the next part to the first part of a Segment with given id */ export async function handleSetNextSegment(context: JobContext, data: SetNextSegmentProps): Promise { - return runJobWithPlayoutCache( + return runJobWithPlayoutModel( context, data, - async (cache) => { - const playlist = cache.Playlist.doc + async (playoutModel) => { + const playlist = playoutModel.Playlist if (!playlist.activationId) throw UserError.create(UserErrorMessage.InactiveRundown, undefined, 412) if (playlist.holdState && playlist.holdState !== RundownHoldState.COMPLETE) { throw UserError.create(UserErrorMessage.DuringHold, undefined, 412) } }, - async (cache) => { - const nextSegment = cache.Segments.findOne(data.nextSegmentId) || null + async (playoutModel) => { + const nextSegment = playoutModel.findSegment(data.nextSegmentId) if (!nextSegment) throw new Error(`Segment "${data.nextSegmentId}" not found!`) - const nextedPartId = await setNextSegment(context, cache, nextSegment) + const nextedPartId = await setNextSegment(context, playoutModel, nextSegment) // Update any future lookaheads - await updateTimeline(context, cache) + await updateTimeline(context, playoutModel) return nextedPartId } @@ -112,28 +113,28 @@ export async function handleQueueNextSegment( context: JobContext, data: QueueNextSegmentProps ): Promise { - return runJobWithPlayoutCache( + return runJobWithPlayoutModel( context, data, - async (cache) => { - const playlist = cache.Playlist.doc + async (playoutModel) => { + const playlist = playoutModel.Playlist if (!playlist.activationId) throw UserError.create(UserErrorMessage.InactiveRundown, undefined, 412) if (playlist.holdState && playlist.holdState !== RundownHoldState.COMPLETE) { throw UserError.create(UserErrorMessage.DuringHold, undefined, 412) } }, - async (cache) => { - let queuedSegment: DBSegment | null = null + async (playoutModel) => { + let queuedSegment: ReadonlyDeep | null = null if (data.queuedSegmentId) { - queuedSegment = cache.Segments.findOne(data.queuedSegmentId) || null + queuedSegment = playoutModel.findSegment(data.queuedSegmentId) ?? null if (!queuedSegment) throw new Error(`Segment "${data.queuedSegmentId}" not found!`) } - const result = await queueNextSegment(context, cache, queuedSegment) + const result = await queueNextSegment(context, playoutModel, queuedSegment) // Update any future lookaheads - await updateTimeline(context, cache) + await updateTimeline(context, playoutModel) return result } diff --git a/packages/job-worker/src/playout/take.ts b/packages/job-worker/src/playout/take.ts index 8c88b68b81..9504a26bc8 100644 --- a/packages/job-worker/src/playout/take.ts +++ b/packages/job-worker/src/playout/take.ts @@ -1,10 +1,15 @@ import { PeripheralDeviceType } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' -import { RundownHoldState, SelectedPartInstance } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { + DBRundownPlaylist, + RundownHoldState, + SelectedPartInstance, +} from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { UserError, UserErrorMessage } from '@sofie-automation/corelib/dist/error' import { logger } from '../logging' import { JobContext, ProcessedShowStyleCompound } from '../jobs' -import { CacheForPlayout, getOrderedSegmentsAndPartsFromPlayoutCache, getSelectedPartInstancesFromCache } from './cache' +import { PlayoutModel } from './model/PlayoutModel' +import { PlayoutPartInstanceModel } from './model/PlayoutPartInstanceModel' import { isTooCloseToAutonext } from './lib' import { selectNextPart } from './selectNextPart' import { setNextPart } from './setNext' @@ -13,26 +18,18 @@ import { PartEndState, VTContent } from '@sofie-automation/blueprints-integratio import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { ReadonlyDeep } from 'type-fest' import { getResolvedPiecesForCurrentPartInstance } from './resolvedPieces' -import { clone, getRandomId, literal } from '@sofie-automation/corelib/dist/lib' +import { clone, getRandomId } from '@sofie-automation/corelib/dist/lib' import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' -import { protectString } from '@sofie-automation/corelib/dist/protectedString' import { updateTimeline } from './timeline/generate' -import { - PieceInstanceId, - PieceInstanceInfiniteId, - RundownPlaylistActivationId, -} from '@sofie-automation/corelib/dist/dataModel/Ids' -import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' import { PartEventContext, RundownContext } from '../blueprints/context' import { WrappedShowStyleBlueprint } from '../blueprints/cache' import { innerStopPieces } from './adlibUtils' import { reportPartInstanceHasStarted, reportPartInstanceHasStopped } from './timings/partPlayback' -import { EventsJobs } from '@sofie-automation/corelib/dist/worker/events' import { calculatePartTimings } from '@sofie-automation/corelib/dist/playout/timings' import { convertPartInstanceToBlueprints, convertResolvedPieceInstanceToBlueprints } from '../blueprints/context/lib' import { processAndPrunePieceInstanceTimings } from '@sofie-automation/corelib/dist/playout/processAndPrune' import { TakeNextPartProps } from '@sofie-automation/corelib/dist/worker/studio' -import { runJobWithPlayoutCache } from './lock' +import { runJobWithPlayoutModel } from './lock' import _ = require('underscore') /** @@ -41,11 +38,11 @@ import _ = require('underscore') export async function handleTakeNextPart(context: JobContext, data: TakeNextPartProps): Promise { const now = getCurrentTime() - return runJobWithPlayoutCache( + return runJobWithPlayoutModel( context, data, - async (cache) => { - const playlist = cache.Playlist.doc + async (playoutModel) => { + const playlist = playoutModel.Playlist if (!playlist.activationId) throw UserError.create(UserErrorMessage.InactiveRundown, undefined, 412) @@ -55,13 +52,13 @@ export async function handleTakeNextPart(context: JobContext, data: TakeNextPart if ((playlist.currentPartInfo?.partInstanceId ?? null) !== data.fromPartInstanceId) throw UserError.create(UserErrorMessage.TakeFromIncorrectPart, undefined, 412) }, - async (cache) => { - const playlist = cache.Playlist.doc + async (playoutModel) => { + const playlist = playoutModel.Playlist let lastTakeTime = playlist.lastTakeTime ?? 0 if (playlist.currentPartInfo) { - const currentPartInstance = cache.PartInstances.findOne(playlist.currentPartInfo.partInstanceId) + const currentPartInstance = playoutModel.CurrentPartInstance?.PartInstance if (currentPartInstance?.timings?.plannedStartedPlayback) { lastTakeTime = Math.max(lastTakeTime, currentPartInstance.timings.plannedStartedPlayback) } else { @@ -83,7 +80,7 @@ export async function handleTakeNextPart(context: JobContext, data: TakeNextPart }) } - return performTakeToNextedPart(context, cache, now) + return performTakeToNextedPart(context, playoutModel, now) } ) } @@ -91,41 +88,42 @@ export async function handleTakeNextPart(context: JobContext, data: TakeNextPart /** * Perform a Take into the nexted Part, and prepare a new nexted Part * @param context Context for current job - * @param cache Cache for the active Playlist + * @param playoutModel Cache for the active Playlist * @param now Current timestamp */ -export async function performTakeToNextedPart(context: JobContext, cache: CacheForPlayout, now: number): Promise { +export async function performTakeToNextedPart( + context: JobContext, + playoutModel: PlayoutModel, + now: number +): Promise { const span = context.startSpan('takeNextPartInner') - if (!cache.Playlist.doc.activationId) throw new Error(`Rundown Playlist "${cache.Playlist.doc._id}" is not active!`) - const playlistActivationId = cache.Playlist.doc.activationId + if (!playoutModel.Playlist.activationId) + throw new Error(`Rundown Playlist "${playoutModel.Playlist._id}" is not active!`) - const timeOffset: number | null = cache.Playlist.doc.nextTimeOffset || null + const timeOffset: number | null = playoutModel.Playlist.nextTimeOffset || null - const { currentPartInstance, nextPartInstance, previousPartInstance } = getSelectedPartInstancesFromCache(cache) + const currentPartInstance = playoutModel.CurrentPartInstance + const nextPartInstance = playoutModel.NextPartInstance + const previousPartInstance = playoutModel.PreviousPartInstance const currentOrNextPartInstance = nextPartInstance ?? currentPartInstance if (!currentOrNextPartInstance) { // Some temporary logging to diagnose some cases where this route is hit - logger.warn(`No partinstance was found during take`, JSON.stringify(cache.Playlist.doc)) + logger.warn(`No partinstance was found during take`, JSON.stringify(playoutModel.Playlist)) logger.warn( 'All PartInstances in cache', - cache.PartInstances.findAll(null).map((p) => p._id) - ) - logger.warn( - 'Deleted PartInstances in cache', - Array.from(cache.PartInstances.documents.entries()) - .filter((d) => d[1] === null) - .map((d) => d[0]) + playoutModel.SortedLoadedPartInstances.map((p) => p.PartInstance._id) ) + logger.warn('Deleted PartInstances in cache', playoutModel.HackDeletedPartInstanceIds) logger.warn( 'Parts in cache', - cache.Parts.findAll(null).map((d) => d._id) + playoutModel.getAllOrderedParts().map((d) => d._id) ) const validIds = _.compact([ - cache.Playlist.doc.currentPartInfo?.partInstanceId, - cache.Playlist.doc.nextPartInfo?.partInstanceId, + playoutModel.Playlist.currentPartInfo?.partInstanceId, + playoutModel.Playlist.nextPartInfo?.partInstanceId, ]) if (validIds.length) { const mongoDocs = await context.directCollections.PartInstances.findFetch({ _id: { $in: validIds } }) @@ -135,46 +133,50 @@ export async function performTakeToNextedPart(context: JobContext, cache: CacheF throw new Error(`No partInstance could be found!`) } const currentRundown = currentOrNextPartInstance - ? cache.Rundowns.findOne(currentOrNextPartInstance.rundownId) + ? playoutModel.getRundown(currentOrNextPartInstance.PartInstance.rundownId) : undefined - if (!currentRundown) throw new Error(`Rundown "${currentOrNextPartInstance?.rundownId ?? ''}" could not be found!`) + if (!currentRundown) + throw new Error(`Rundown "${currentOrNextPartInstance?.PartInstance?.rundownId ?? ''}" could not be found!`) - const pShowStyle = context.getShowStyleCompound(currentRundown.showStyleVariantId, currentRundown.showStyleBaseId) + const pShowStyle = context.getShowStyleCompound( + currentRundown.Rundown.showStyleVariantId, + currentRundown.Rundown.showStyleBaseId + ) if (currentPartInstance) { const now = getCurrentTime() - if (currentPartInstance.blockTakeUntil && currentPartInstance.blockTakeUntil > now) { - const remainingTime = currentPartInstance.blockTakeUntil - now + if (currentPartInstance.PartInstance.blockTakeUntil && currentPartInstance.PartInstance.blockTakeUntil > now) { + const remainingTime = currentPartInstance.PartInstance.blockTakeUntil - now // Adlib-actions can arbitrarily block takes from being done - logger.debug(`Take is blocked until ${currentPartInstance.blockTakeUntil}. Which is in: ${remainingTime}`) + logger.debug( + `Take is blocked until ${currentPartInstance.PartInstance.blockTakeUntil}. Which is in: ${remainingTime}` + ) throw UserError.create(UserErrorMessage.TakeBlockedDuration, { duration: remainingTime }) } // If there was a transition from the previous Part, then ensure that has finished before another take is permitted - const allowTransition = previousPartInstance && !previousPartInstance.part.disableNextInTransition - const start = currentPartInstance.timings?.plannedStartedPlayback + const allowTransition = previousPartInstance && !previousPartInstance.PartInstance.part.disableNextInTransition + const start = currentPartInstance.PartInstance.timings?.plannedStartedPlayback if ( allowTransition && - currentPartInstance.part.inTransition && + currentPartInstance.PartInstance.part.inTransition && start && - now < start + currentPartInstance.part.inTransition.blockTakeDuration + now < start + currentPartInstance.PartInstance.part.inTransition.blockTakeDuration ) { throw UserError.create(UserErrorMessage.TakeDuringTransition) } - if (isTooCloseToAutonext(currentPartInstance, true)) { + if (isTooCloseToAutonext(currentPartInstance.PartInstance, true)) { throw UserError.create(UserErrorMessage.TakeCloseToAutonext) } } - if (cache.Playlist.doc.holdState === RundownHoldState.COMPLETE) { - cache.Playlist.update((p) => { - p.holdState = RundownHoldState.NONE - return p - }) + if (playoutModel.Playlist.holdState === RundownHoldState.COMPLETE) { + playoutModel.setHoldState(RundownHoldState.NONE) + // If hold is active, then this take is to clear it - } else if (cache.Playlist.doc.holdState === RundownHoldState.ACTIVE) { - await completeHold(context, cache, await pShowStyle, currentPartInstance) + } else if (playoutModel.Playlist.holdState === RundownHoldState.ACTIVE) { + await completeHold(context, playoutModel, await pShowStyle, currentPartInstance) if (span) span.end() @@ -183,32 +185,26 @@ export async function performTakeToNextedPart(context: JobContext, cache: CacheF const takePartInstance = nextPartInstance if (!takePartInstance) throw new Error('takePart not found!') - const takeRundown: DBRundown | undefined = cache.Rundowns.findOne(takePartInstance.rundownId) - if (!takeRundown) throw new Error(`takeRundown: takeRundown not found! ("${takePartInstance.rundownId}")`) + const takeRundown = playoutModel.getRundown(takePartInstance.PartInstance.rundownId) + if (!takeRundown) + throw new Error(`takeRundown: takeRundown not found! ("${takePartInstance.PartInstance.rundownId}")`) // Autonext may have setup the plannedStartedPlayback. Clear it so that a new value is generated - cache.PartInstances.updateOne(takePartInstance._id, (p) => { - if (p.timings?.plannedStartedPlayback) { - delete p.timings.plannedStartedPlayback - delete p.timings.plannedStoppedPlayback - return p - } else { - // No change - return false - } - }) + takePartInstance.setPlannedStartedPlayback(undefined) + takePartInstance.setPlannedStoppedPlayback(undefined) // it is only a first take if the Playlist has no startedPlayback and the taken PartInstance is not untimed - const isFirstTake = !cache.Playlist.doc.startedPlayback && !takePartInstance.part.untimed + const isFirstTake = !playoutModel.Playlist.startedPlayback && !takePartInstance.PartInstance.part.untimed - clearQueuedSegmentId(cache, takePartInstance, cache.Playlist.doc.nextPartInfo) + clearQueuedSegmentId(playoutModel, takePartInstance.PartInstance, playoutModel.Playlist.nextPartInfo) const nextPart = selectNextPart( context, - cache.Playlist.doc, - takePartInstance, + playoutModel.Playlist, + takePartInstance.PartInstance, null, - getOrderedSegmentsAndPartsFromPlayoutCache(cache) + playoutModel.getAllOrderedSegments(), + playoutModel.getAllOrderedParts() ) const showStyle = await pShowStyle @@ -223,8 +219,8 @@ export async function performTakeToNextedPart(context: JobContext, cache: CacheF context.getStudioBlueprintConfig(), showStyle, context.getShowStyleBlueprintConfig(showStyle), - takeRundown, - takePartInstance + takeRundown.Rundown, + takePartInstance.PartInstance ) ) } catch (err) { @@ -233,53 +229,37 @@ export async function performTakeToNextedPart(context: JobContext, cache: CacheF if (span) span.end() } - updatePartInstanceOnTake(context, cache, showStyle, blueprint, takeRundown, takePartInstance, currentPartInstance) - - cache.Playlist.update((p) => { - p.previousPartInfo = p.currentPartInfo - p.currentPartInfo = { - partInstanceId: takePartInstance._id, - rundownId: takePartInstance.rundownId, - manuallySelected: p.nextPartInfo?.manuallySelected ?? false, - consumesQueuedSegmentId: p.nextPartInfo?.consumesQueuedSegmentId ?? false, - } - p.lastTakeTime = getCurrentTime() - - if (!p.holdState || p.holdState === RundownHoldState.COMPLETE) { - p.holdState = RundownHoldState.NONE - } else { - p.holdState = p.holdState + 1 - } - return p - }) - - cache.PartInstances.updateOne(takePartInstance._id, (instance) => { - instance.isTaken = true + updatePartInstanceOnTake( + context, + playoutModel.Playlist, + showStyle, + blueprint, + takeRundown.Rundown, + takePartInstance, + currentPartInstance + ) - if (!instance.timings) instance.timings = {} - instance.timings.take = now - instance.timings.playOffset = timeOffset || 0 + playoutModel.cycleSelectedPartInstances() - return instance - }) + takePartInstance.setTaken(now, timeOffset ?? 0) - resetPreviousSegment(cache) + resetPreviousSegment(playoutModel) // Once everything is synced, we can choose the next part - await setNextPart(context, cache, nextPart, false) + await setNextPart(context, playoutModel, nextPart, false) // Setup the parts for the HOLD we are starting if ( - cache.Playlist.doc.previousPartInfo && - (cache.Playlist.doc.holdState as RundownHoldState) === RundownHoldState.ACTIVE + playoutModel.Playlist.previousPartInfo && + (playoutModel.Playlist.holdState as RundownHoldState) === RundownHoldState.ACTIVE ) { - startHold(context, cache, playlistActivationId, currentPartInstance, nextPartInstance) + startHold(context, currentPartInstance, nextPartInstance) } - await afterTake(context, cache, takePartInstance, timeOffset) + await afterTake(context, playoutModel, takePartInstance, timeOffset) // Last: const takeDoneTime = getCurrentTime() - cache.deferBeforeSave(async (cache2) => { + playoutModel.deferBeforeSave(async (cache2) => { await afterTakeUpdateTimingsAndEvents(context, cache2, showStyle, blueprint, isFirstTake, takeDoneTime) }) @@ -288,98 +268,86 @@ export async function performTakeToNextedPart(context: JobContext, cache: CacheF /** * Clear the nexted Segment, if taking into a PartInstance that consumes it - * @param cache Cache for the active Playlist + * @param playoutModel Cache for the active Playlist * @param takenPartInstance PartInstance to check */ export function clearQueuedSegmentId( - cache: CacheForPlayout, - takenPartInstance: DBPartInstance | undefined, + playoutModel: PlayoutModel, + takenPartInstance: ReadonlyDeep | undefined, takenPartInfo: ReadonlyDeep | null ): void { if ( takenPartInfo?.consumesQueuedSegmentId && takenPartInstance && - cache.Playlist.doc.queuedSegmentId === takenPartInstance.segmentId + playoutModel.Playlist.queuedSegmentId === takenPartInstance.segmentId ) { // clear the queuedSegmentId if the newly taken partInstance says it was selected because of it - cache.Playlist.update((p) => { - delete p.queuedSegmentId - return p - }) + playoutModel.setQueuedSegment(null) } } /** * Reset the Segment of the previousPartInstance, if playback has left that Segment and the Rundown is looping - * @param cache Cache for the active Playlist + * @param playoutModel Cache for the active Playlist */ -export function resetPreviousSegment(cache: CacheForPlayout): void { - const { previousPartInstance, currentPartInstance } = getSelectedPartInstancesFromCache(cache) +export function resetPreviousSegment(playoutModel: PlayoutModel): void { + const previousPartInstance = playoutModel.PreviousPartInstance + const currentPartInstance = playoutModel.CurrentPartInstance // If the playlist is looping and // If the previous and current part are not in the same segment, then we have just left a segment if ( - cache.Playlist.doc.loop && + playoutModel.Playlist.loop && previousPartInstance && - previousPartInstance.segmentId !== currentPartInstance?.segmentId + previousPartInstance.PartInstance.segmentId !== currentPartInstance?.PartInstance?.segmentId ) { // Reset the old segment - const segmentId = previousPartInstance.segmentId - const resetIds = new Set( - cache.PartInstances.updateAll((p) => { - if (!p.reset && p.segmentId === segmentId) { - p.reset = true - return p - } else { - return false - } - }) - ) - cache.PieceInstances.updateAll((p) => { - if (resetIds.has(p.partInstanceId)) { - p.reset = true - return p - } else { - return false + const segmentId = previousPartInstance.PartInstance.segmentId + for (const partInstance of playoutModel.LoadedPartInstances) { + if (partInstance.PartInstance.segmentId === segmentId) { + partInstance.markAsReset() } - }) + } } } async function afterTakeUpdateTimingsAndEvents( context: JobContext, - cache: CacheForPlayout, + playoutModel: PlayoutModel, showStyle: ReadonlyDeep, blueprint: ReadonlyDeep, isFirstTake: boolean, takeDoneTime: number ): Promise { - const { currentPartInstance: takePartInstance, previousPartInstance } = getSelectedPartInstancesFromCache(cache) + const takePartInstance = playoutModel.CurrentPartInstance + const previousPartInstance = playoutModel.PreviousPartInstance if (takePartInstance) { // Simulate playout, if no gateway - const playoutDevices = cache.PeripheralDevices.findAll((d) => d.type === PeripheralDeviceType.PLAYOUT) + const playoutDevices = playoutModel.PeripheralDevices.filter((d) => d.type === PeripheralDeviceType.PLAYOUT) if (playoutDevices.length === 0) { logger.info( `No Playout gateway attached to studio, reporting PartInstance "${ - takePartInstance._id + takePartInstance.PartInstance._id }" to have started playback on timestamp ${new Date(takeDoneTime).toISOString()}` ) - reportPartInstanceHasStarted(context, cache, takePartInstance, takeDoneTime) + reportPartInstanceHasStarted(context, playoutModel, takePartInstance, takeDoneTime) if (previousPartInstance) { logger.info( `Also reporting PartInstance "${ - previousPartInstance._id + previousPartInstance.PartInstance._id }" to have stopped playback on timestamp ${new Date(takeDoneTime).toISOString()}` ) - reportPartInstanceHasStopped(context, cache, previousPartInstance, takeDoneTime) + reportPartInstanceHasStopped(context, playoutModel, previousPartInstance, takeDoneTime) } // Future: is there anything we can do for simulating autoNext? } - const takeRundown = takePartInstance ? cache.Rundowns.findOne(takePartInstance.rundownId) : undefined + const takeRundown = takePartInstance + ? playoutModel.getRundown(takePartInstance.PartInstance.rundownId) + : undefined if (isFirstTake && takeRundown) { if (blueprint.blueprint.onRundownFirstTake) { @@ -392,8 +360,8 @@ async function afterTakeUpdateTimingsAndEvents( context.getStudioBlueprintConfig(), showStyle, context.getShowStyleBlueprintConfig(showStyle), - takeRundown, - takePartInstance + takeRundown.Rundown, + takePartInstance.PartInstance ) ) } catch (err) { @@ -413,8 +381,8 @@ async function afterTakeUpdateTimingsAndEvents( context.getStudioBlueprintConfig(), showStyle, context.getShowStyleBlueprintConfig(showStyle), - takeRundown, - takePartInstance + takeRundown.Rundown, + takePartInstance.PartInstance ) ) } catch (err) { @@ -427,15 +395,13 @@ async function afterTakeUpdateTimingsAndEvents( export function updatePartInstanceOnTake( context: JobContext, - cache: CacheForPlayout, + playlist: ReadonlyDeep, showStyle: ReadonlyDeep, blueprint: ReadonlyDeep, - takeRundown: DBRundown, - takePartInstance: DBPartInstance, - currentPartInstance: DBPartInstance | undefined + takeRundown: ReadonlyDeep, + takePartInstance: PlayoutPartInstanceModel, + currentPartInstance: PlayoutPartInstanceModel | null ): void { - const playlist = cache.Playlist.doc - // TODO - the state could change after this sampling point. This should be handled properly let previousPartEndState: PartEndState | undefined = undefined if (blueprint.blueprint.getEndStateForPart && currentPartInstance) { @@ -444,7 +410,6 @@ export function updatePartInstanceOnTake( const resolvedPieces = getResolvedPiecesForCurrentPartInstance( context, - cache, showStyle.sourceLayers, currentPartInstance ) @@ -454,7 +419,7 @@ export function updatePartInstanceOnTake( { name: `${playlist.name}`, identifier: `playlist=${playlist._id},currentPartInstance=${ - currentPartInstance._id + currentPartInstance.PartInstance._id },execution=${getRandomId()}`, }, context.studio, @@ -466,7 +431,7 @@ export function updatePartInstanceOnTake( previousPartEndState = blueprint.blueprint.getEndStateForPart( context2, playlist.previousPersistentState, - convertPartInstanceToBlueprints(currentPartInstance), + convertPartInstanceToBlueprints(currentPartInstance.PartInstance), resolvedPieces.map(convertResolvedPieceInstanceToBlueprints), time ) @@ -481,56 +446,33 @@ export function updatePartInstanceOnTake( // calculate and cache playout timing properties, so that we don't depend on the previousPartInstance: const tmpTakePieces = processAndPrunePieceInstanceTimings( showStyle.sourceLayers, - cache.PieceInstances.findAll((p) => p.partInstanceId === takePartInstance._id), + takePartInstance.PieceInstances.map((p) => p.PieceInstance), 0 ) const partPlayoutTimings = calculatePartTimings( - cache.Playlist.doc.holdState, - currentPartInstance?.part, - cache.PieceInstances.findAll((p) => p.partInstanceId === currentPartInstance?._id).map((p) => p.piece), - takePartInstance.part, + playlist.holdState, + currentPartInstance?.PartInstance?.part, + currentPartInstance?.PieceInstances?.map((p) => p.PieceInstance.piece) ?? [], + takePartInstance.PartInstance.part, tmpTakePieces.filter((p) => !p.infinite || p.infinite.infiniteInstanceIndex === 0).map((p) => p.piece) ) - cache.PartInstances.updateOne(takePartInstance._id, (p) => { - p.isTaken = true - p.partPlayoutTimings = partPlayoutTimings - - if (previousPartEndState) { - p.previousPartEndState = previousPartEndState - } - - return p - }) + takePartInstance.storePlayoutTimingsAndPreviousEndState(partPlayoutTimings, previousPartEndState) } export async function afterTake( context: JobContext, - cache: CacheForPlayout, - takePartInstance: DBPartInstance, + playoutModel: PlayoutModel, + takePartInstance: PlayoutPartInstanceModel, timeOffsetIntoPart: number | null = null ): Promise { const span = context.startSpan('afterTake') // This function should be called at the end of a "take" event (when the Parts have been updated) // or after a new part has started playing - await updateTimeline(context, cache, timeOffsetIntoPart || undefined) - - cache.deferAfterSave(async () => { - // This is low-prio, defer so that it's executed well after publications has been updated, - // so that the playout gateway has haf the chance to learn about the timeline changes - if (takePartInstance.part.shouldNotifyCurrentPlayingPart) { - context - .queueEventJob(EventsJobs.NotifyCurrentlyPlayingPart, { - rundownId: takePartInstance.rundownId, - isRehearsal: !!cache.Playlist.doc.rehearsal, - partExternalId: takePartInstance.part.externalId, - }) - .catch((e) => { - logger.warn(`Failed to queue NotifyCurrentlyPlayingPart job: ${e}`) - }) - } - }) + await updateTimeline(context, playoutModel, timeOffsetIntoPart || undefined) + + playoutModel.queueNotifyCurrentlyPlayingPartEvent(takePartInstance.PartInstance.rundownId, takePartInstance) if (span) span.end() } @@ -540,65 +482,31 @@ export async function afterTake( */ function startHold( context: JobContext, - cache: CacheForPlayout, - activationId: RundownPlaylistActivationId, - holdFromPartInstance: DBPartInstance | undefined, - holdToPartInstance: DBPartInstance | undefined + holdFromPartInstance: PlayoutPartInstanceModel | null, + holdToPartInstance: PlayoutPartInstanceModel | undefined ) { if (!holdFromPartInstance) throw new Error('previousPart not found!') if (!holdToPartInstance) throw new Error('currentPart not found!') const span = context.startSpan('startHold') // Make a copy of any item which is flagged as an 'infinite' extension - const itemsToCopy = cache.PieceInstances.findAll( - (p) => p.partInstanceId === holdFromPartInstance._id && !!p.piece.extendOnHold - ) - itemsToCopy.forEach((instance) => { - if (!instance.infinite) { - const infiniteInstanceId: PieceInstanceInfiniteId = getRandomId() + const pieceInstancesToCopy = holdFromPartInstance.PieceInstances.filter((p) => !!p.PieceInstance.piece.extendOnHold) + pieceInstancesToCopy.forEach((instance) => { + if (!instance.PieceInstance.infinite) { // mark current one as infinite - cache.PieceInstances.updateOne(instance._id, (p) => { - p.infinite = { - infiniteInstanceId: infiniteInstanceId, - infiniteInstanceIndex: 0, - infinitePieceId: instance.piece._id, - fromPreviousPart: false, - } - return p - }) - - // make the extension - const newInstance = literal({ - _id: protectString(instance._id + '_hold'), - playlistActivationId: activationId, - rundownId: instance.rundownId, - partInstanceId: holdToPartInstance._id, - dynamicallyInserted: getCurrentTime(), - piece: { - ...clone(instance.piece), - enable: { start: 0 }, - extendOnHold: false, - }, - infinite: { - infiniteInstanceId: infiniteInstanceId, - infiniteInstanceIndex: 1, - infinitePieceId: instance.piece._id, - fromPreviousPart: true, - fromHold: true, - }, - // Preserve the timings from the playing instance - reportedStartedPlayback: instance.reportedStartedPlayback, - reportedStoppedPlayback: instance.reportedStoppedPlayback, - plannedStartedPlayback: instance.plannedStartedPlayback, - plannedStoppedPlayback: instance.plannedStoppedPlayback, - }) - const content = newInstance.piece.content as VTContent | undefined - if (content?.fileName && content.sourceDuration && instance.plannedStartedPlayback) { - content.seek = Math.min(content.sourceDuration, getCurrentTime() - instance.plannedStartedPlayback) - } + instance.prepareForHold() // This gets deleted once the nextpart is activated, so it doesnt linger for long - cache.PieceInstances.replace(newInstance) + const extendedPieceInstance = holdToPartInstance.insertHoldPieceInstance(instance) + + const content = clone(instance.PieceInstance.piece.content) as VTContent | undefined + if (content?.fileName && content.sourceDuration && instance.PieceInstance.plannedStartedPlayback) { + content.seek = Math.min( + content.sourceDuration, + getCurrentTime() - instance.PieceInstance.plannedStartedPlayback + ) + } + extendedPieceInstance.updatePieceProps({ content }) } }) if (span) span.end() @@ -606,22 +514,19 @@ function startHold( async function completeHold( context: JobContext, - cache: CacheForPlayout, + playoutModel: PlayoutModel, showStyleCompound: ReadonlyDeep, - currentPartInstance: DBPartInstance | undefined + currentPartInstance: PlayoutPartInstanceModel | null ): Promise { - cache.Playlist.update((p) => { - p.holdState = RundownHoldState.COMPLETE - return p - }) + playoutModel.setHoldState(RundownHoldState.COMPLETE) - if (cache.Playlist.doc.currentPartInfo) { + if (playoutModel.Playlist.currentPartInfo) { if (!currentPartInstance) throw new Error('currentPart not found!') // Clear the current extension line innerStopPieces( context, - cache, + playoutModel, showStyleCompound.sourceLayers, currentPartInstance, (p) => !!p.infinite?.fromHold, @@ -629,5 +534,5 @@ async function completeHold( ) } - await updateTimeline(context, cache) + await updateTimeline(context, playoutModel) } diff --git a/packages/job-worker/src/playout/timeline/generate.ts b/packages/job-worker/src/playout/timeline/generate.ts index e758bbbfe6..0bd5c86568 100644 --- a/packages/job-worker/src/playout/timeline/generate.ts +++ b/packages/job-worker/src/playout/timeline/generate.ts @@ -22,8 +22,7 @@ import { import { RundownBaselineObj } from '@sofie-automation/corelib/dist/dataModel/RundownBaselineObj' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { applyToArray, clone, getRandomId, literal, normalizeArray, omit } from '@sofie-automation/corelib/dist/lib' -import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' -import { CacheForPlayout, getSelectedPartInstancesFromCache } from '../cache' +import { PlayoutModel } from '../model/PlayoutModel' import { logger } from '../../logging' import { getCurrentTime, getSystemVersion } from '../../lib' import { getResolvedPiecesForPartInstancesOnTimeline } from '../resolvedPieces' @@ -31,7 +30,7 @@ import { processAndPrunePieceInstanceTimings, PieceInstanceWithTimings, } from '@sofie-automation/corelib/dist/playout/processAndPrune' -import { CacheForStudio, CacheForStudioBase } from '../../studio/cache' +import { StudioPlayoutModel, StudioPlayoutModelBase } from '../../studio/model/StudioPlayoutModel' import { getLookeaheadObjects } from '../lookahead' import { StudioBaselineContext, OnTimelineGenerateContext } from '../../blueprints/context' import { ExpectedPackageDBType } from '@sofie-automation/corelib/dist/dataModel/ExpectedPackages' @@ -52,9 +51,11 @@ import { PartCalculatedTimings, } from '@sofie-automation/corelib/dist/playout/timings' import { applyAbPlaybackForTimeline } from '../abPlayback' +import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' +import { PlayoutPartInstanceModel } from '../model/PlayoutPartInstanceModel' -function isCacheForStudio(cache: CacheForStudioBase): cache is CacheForStudio { - const tmp = cache as CacheForStudio +function isModelForStudio(model: StudioPlayoutModelBase): model is StudioPlayoutModel { + const tmp = model as StudioPlayoutModel return !!tmp.isStudio } @@ -73,19 +74,19 @@ function generateTimelineVersions( export async function updateStudioTimeline( context: JobContext, - cache: CacheForStudio | CacheForPlayout + playoutModel: StudioPlayoutModel | PlayoutModel ): Promise { const span = context.startSpan('updateStudioTimeline') logger.debug('updateStudioTimeline running...') const studio = context.studio // Ensure there isn't a playlist active, as that should be using a different function call - if (isCacheForStudio(cache)) { - const activePlaylists = cache.getActiveRundownPlaylists() + if (isModelForStudio(playoutModel)) { + const activePlaylists = playoutModel.getActiveRundownPlaylists() if (activePlaylists.length > 0) { throw new Error(`Studio has an active playlist`) } } else { - if (cache.Playlist.doc.activationId) { + if (playoutModel.Playlist.activationId) { throw new Error(`Studio has an active playlist`) } } @@ -126,16 +127,16 @@ export async function updateStudioTimeline( flattenAndProcessTimelineObjects(context, baselineObjects) // Future: We should handle any 'now' objects that are at the root of this timeline - preserveOrReplaceNowTimesInObjects(cache, baselineObjects) + preserveOrReplaceNowTimesInObjects(playoutModel, baselineObjects) - if (cache.isMultiGatewayMode) { + if (playoutModel.isMultiGatewayMode) { logAnyRemainingNowTimes(context, baselineObjects) } - saveTimeline(context, cache, baselineObjects, versions) + saveTimeline(context, playoutModel, baselineObjects, versions) if (studioBaseline) { - updateBaselineExpectedPackagesOnStudio(context, cache, studioBaseline) + updateBaselineExpectedPackagesOnStudio(context, playoutModel, studioBaseline) } logger.debug('updateStudioTimeline done!') @@ -144,37 +145,40 @@ export async function updateStudioTimeline( export async function updateTimeline( context: JobContext, - cache: CacheForPlayout, + playoutModel: PlayoutModel, timeOffsetIntoPart?: Time ): Promise { const span = context.startSpan('updateTimeline') logger.debug('updateTimeline running...') - if (!cache.Playlist.doc.activationId) { - throw new Error(`RundownPlaylist ("${cache.Playlist.doc._id}") is not active")`) + if (!playoutModel.Playlist.activationId) { + throw new Error(`RundownPlaylist ("${playoutModel.Playlist._id}") is not active")`) } - const { versions, objs: timelineObjs, timingContext: timingInfo } = await getTimelineRundown(context, cache) + const { versions, objs: timelineObjs, timingContext: timingInfo } = await getTimelineRundown(context, playoutModel) flattenAndProcessTimelineObjects(context, timelineObjs) - preserveOrReplaceNowTimesInObjects(cache, timelineObjs) + preserveOrReplaceNowTimesInObjects(playoutModel, timelineObjs) - if (cache.isMultiGatewayMode) { - deNowifyMultiGatewayTimeline(context, cache, timelineObjs, timeOffsetIntoPart, timingInfo) + if (playoutModel.isMultiGatewayMode) { + deNowifyMultiGatewayTimeline(context, playoutModel, timelineObjs, timeOffsetIntoPart, timingInfo) logAnyRemainingNowTimes(context, timelineObjs) } - saveTimeline(context, cache, timelineObjs, versions) + saveTimeline(context, playoutModel, timelineObjs, versions) logger.debug('updateTimeline done!') if (span) span.end() } -function preserveOrReplaceNowTimesInObjects(cache: CacheForStudioBase, timelineObjs: Array) { - const timeline = cache.Timeline.doc +function preserveOrReplaceNowTimesInObjects( + studioPlayoutModel: StudioPlayoutModelBase, + timelineObjs: Array +) { + const timeline = studioPlayoutModel.Timeline const oldTimelineObjsMap = normalizeArray( (timeline?.timelineBlob !== undefined && deserializeTimelineBlob(timeline.timelineBlob)) || [], 'id' @@ -234,7 +238,7 @@ function logAnyRemainingNowTimes(_context: JobContext, timelineObjs: Array pieceInstances: PieceInstanceWithTimings[] calculatedTimings: PartCalculatedTimings } function getPartInstanceTimelineInfo( - cache: CacheForPlayout, currentTime: Time, sourceLayers: SourceLayers, - partInstance: DBPartInstance | undefined + partInstance: PlayoutPartInstanceModel | null ): SelectedPartInstanceTimelineInfo | undefined { if (partInstance) { - const partStarted = partInstance.timings?.plannedStartedPlayback + const partStarted = partInstance.PartInstance.timings?.plannedStartedPlayback const nowInPart = partStarted === undefined ? 0 : currentTime - partStarted - const currentPieces = cache.PieceInstances.findAll((p) => p.partInstanceId === partInstance._id) - const pieceInstances = processAndPrunePieceInstanceTimings(sourceLayers, currentPieces, nowInPart) + const pieceInstances = processAndPrunePieceInstanceTimings( + sourceLayers, + partInstance.PieceInstances.map((p) => p.PieceInstance), + nowInPart + ) return { - partInstance, + partInstance: partInstance.PartInstance, pieceInstances, nowInPart, partStarted, // Approximate `calculatedTimings`, for the partInstances which already have it cached - calculatedTimings: getPartTimingsOrDefaults(partInstance, pieceInstances), + calculatedTimings: getPartTimingsOrDefaults(partInstance.PartInstance, pieceInstances), } } else { return undefined @@ -295,7 +301,7 @@ function getPartInstanceTimelineInfo( */ async function getTimelineRundown( context: JobContext, - cache: CacheForPlayout + playoutModel: PlayoutModel ): Promise<{ objs: Array versions: TimelineCompleteGenerationVersions @@ -305,34 +311,36 @@ async function getTimelineRundown( try { let timelineObjs: Array = [] - const { currentPartInstance, nextPartInstance, previousPartInstance } = getSelectedPartInstancesFromCache(cache) + const currentPartInstance = playoutModel.CurrentPartInstance + const nextPartInstance = playoutModel.NextPartInstance + const previousPartInstance = playoutModel.PreviousPartInstance const partForRundown = currentPartInstance || nextPartInstance - const activeRundown = partForRundown && cache.Rundowns.findOne(partForRundown.rundownId) + const activeRundown = partForRundown && playoutModel.getRundown(partForRundown.PartInstance.rundownId) let timelineVersions: TimelineCompleteGenerationVersions | undefined if (activeRundown) { // Fetch showstyle blueprint: const showStyle = await context.getShowStyleCompound( - activeRundown.showStyleVariantId, - activeRundown.showStyleBaseId + activeRundown.Rundown.showStyleVariantId, + activeRundown.Rundown.showStyleBaseId ) if (!showStyle) { throw new Error( - `ShowStyleBase "${activeRundown.showStyleBaseId}" not found! (referenced by Rundown "${activeRundown._id}")` + `ShowStyleBase "${activeRundown.Rundown.showStyleBaseId}" not found! (referenced by Rundown "${activeRundown.Rundown._id}")` ) } const currentTime = getCurrentTime() const partInstancesInfo: SelectedPartInstancesTimelineInfo = { - current: getPartInstanceTimelineInfo(cache, currentTime, showStyle.sourceLayers, currentPartInstance), - next: getPartInstanceTimelineInfo(cache, currentTime, showStyle.sourceLayers, nextPartInstance), - previous: getPartInstanceTimelineInfo(cache, currentTime, showStyle.sourceLayers, previousPartInstance), + current: getPartInstanceTimelineInfo(currentTime, showStyle.sourceLayers, currentPartInstance), + next: getPartInstanceTimelineInfo(currentTime, showStyle.sourceLayers, nextPartInstance), + previous: getPartInstanceTimelineInfo(currentTime, showStyle.sourceLayers, previousPartInstance), } if (partInstancesInfo.next) { // the nextPartInstance doesn't have accurate cached `calculatedTimings` yet, so calculate a prediction partInstancesInfo.next.calculatedTimings = calculatePartTimings( - cache.Playlist.doc.holdState, + playoutModel.Playlist.holdState, partInstancesInfo.current?.partInstance?.part, partInstancesInfo.current?.pieceInstances?.map?.((p) => p.piece), partInstancesInfo.next.partInstance.part, @@ -343,15 +351,20 @@ async function getTimelineRundown( } // next (on pvw (or on pgm if first)) - const pLookaheadObjs = getLookeaheadObjects(context, cache, partInstancesInfo) - const rawBaselineItems = cache.BaselineObjects.findAll((o) => o.rundownId === activeRundown._id) + const pLookaheadObjs = getLookeaheadObjects(context, playoutModel, partInstancesInfo) + const rawBaselineItems = activeRundown.BaselineObjects if (rawBaselineItems.length > 0) { timelineObjs = timelineObjs.concat(transformBaselineItemsIntoTimeline(rawBaselineItems)) } else { - logger.warn(`Missing Baseline objects for Rundown "${activeRundown._id}"`) + logger.warn(`Missing Baseline objects for Rundown "${activeRundown.Rundown._id}"`) } - const rundownTimelineResult = buildTimelineObjsForRundown(context, cache, activeRundown, partInstancesInfo) + const rundownTimelineResult = buildTimelineObjsForRundown( + context, + playoutModel, + activeRundown.Rundown, + partInstancesInfo + ) timelineObjs = timelineObjs.concat(rundownTimelineResult.timeline) timelineObjs = timelineObjs.concat(await pLookaheadObjs) @@ -374,11 +387,11 @@ async function getTimelineRundown( context.getStudioBlueprintConfig(), showStyle, context.getShowStyleBlueprintConfig(showStyle), - cache.Playlist.doc, - activeRundown, - previousPartInstance, - currentPartInstance, - nextPartInstance, + playoutModel.Playlist, + activeRundown.Rundown, + previousPartInstance?.PartInstance, + currentPartInstance?.PartInstance, + nextPartInstance?.PartInstance, resolvedPieces ) try { @@ -388,7 +401,7 @@ async function getTimelineRundown( abHelper, blueprint, showStyle, - cache.Playlist.doc, + playoutModel.Playlist, resolvedPieces, timelineObjs ) @@ -400,8 +413,8 @@ async function getTimelineRundown( tlGenRes = await blueprint.blueprint.onTimelineGenerate( blueprintContext, timelineObjs, - clone(cache.Playlist.doc.previousPersistentState), - clone(currentPartInstance?.previousPartEndState), + clone(playoutModel.Playlist.previousPersistentState), + clone(currentPartInstance?.PartInstance?.previousPartEndState), resolvedPieces.map(convertResolvedPieceInstanceToBlueprints) ) sendTrace(endTrace(influxTrace)) @@ -415,12 +428,11 @@ async function getTimelineRundown( }) } - cache.Playlist.update((p) => { - p.previousPersistentState = tlGenRes?.persistentState - p.assignedAbSessions = newAbSessionsResult - p.trackedAbSessions = blueprintContext.abSessionsHelper.knownSessions - return p - }) + playoutModel.setOnTimelineGenerateResult( + tlGenRes?.persistentState, + newAbSessionsResult, + blueprintContext.abSessionsHelper.knownSessions + ) } catch (err) { // TODO - this may not be sufficient? logger.error(`Error in showStyleBlueprint.onTimelineGenerate: ${stringifyError(err)}`) @@ -505,7 +517,7 @@ function flattenAndProcessTimelineObjects(context: JobContext, timelineObjs: Arr * Convert RundownBaselineObj into TimelineObjects for the timeline */ function transformBaselineItemsIntoTimeline( - objs: RundownBaselineObj[] + objs: ReadonlyDeep ): Array { const timelineObjs: Array = [] for (const obj of objs) { diff --git a/packages/job-worker/src/playout/timeline/multi-gateway.ts b/packages/job-worker/src/playout/timeline/multi-gateway.ts index c02e4c1359..a4cb38eb75 100644 --- a/packages/job-worker/src/playout/timeline/multi-gateway.ts +++ b/packages/job-worker/src/playout/timeline/multi-gateway.ts @@ -1,18 +1,18 @@ import { Time } from '@sofie-automation/blueprints-integration' import { PieceInstanceInfiniteId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { PeripheralDeviceType } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' -import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' import { TimelineObjRundown } from '@sofie-automation/corelib/dist/dataModel/Timeline' import { normalizeArray } from '@sofie-automation/corelib/dist/lib' import { PieceTimelineMetadata } from './pieceGroup' -import { CacheForStudioBase } from '../../studio/cache' +import { StudioPlayoutModelBase } from '../../studio/model/StudioPlayoutModel' import { JobContext } from '../../jobs' import { getCurrentTime } from '../../lib' -import { CacheForPlayout, getSelectedPartInstancesFromCache } from '../cache' +import { PlayoutModel } from '../model/PlayoutModel' import { RundownTimelineTimingContext, getInfinitePartGroupId } from './rundown' import { getExpectedLatency } from '@sofie-automation/corelib/dist/studio/playout' -import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { getPieceControlObjectId } from '@sofie-automation/corelib/dist/playout/ids' +import { PlayoutPartInstanceModel } from '../model/PlayoutPartInstanceModel' +import { PlayoutPieceInstanceModel } from '../model/PlayoutPieceInstanceModel' /** * We want it to be possible to generate a timeline without it containing any `start: 'now'`. @@ -23,7 +23,7 @@ import { getPieceControlObjectId } from '@sofie-automation/corelib/dist/playout/ */ export function deNowifyMultiGatewayTimeline( context: JobContext, - cache: CacheForPlayout, + playoutModel: PlayoutModel, timelineObjs: TimelineObjRundown[], timeOffsetIntoPart: Time | undefined, timingContext: RundownTimelineTimingContext | undefined @@ -32,23 +32,22 @@ export function deNowifyMultiGatewayTimeline( const timelineObjsMap = normalizeArray(timelineObjs, 'id') - const nowOffsetLatency = calculateNowOffsetLatency(context, cache, timeOffsetIntoPart) + const nowOffsetLatency = calculateNowOffsetLatency(context, playoutModel, timeOffsetIntoPart) const targetNowTime = getCurrentTime() + (nowOffsetLatency ?? 0) // Replace `start: 'now'` in currentPartInstance on timeline - const { currentPartInstance, nextPartInstance } = getSelectedPartInstancesFromCache(cache) + const currentPartInstance = playoutModel.CurrentPartInstance if (!currentPartInstance) return const partGroupTimings = updatePartInstancePlannedTimes( - cache, + playoutModel, targetNowTime, timingContext, currentPartInstance, - nextPartInstance + playoutModel.NextPartInstance ) deNowifyCurrentPieces( - cache, targetNowTime, timingContext, currentPartInstance, @@ -56,19 +55,21 @@ export function deNowifyMultiGatewayTimeline( timelineObjsMap ) - updatePlannedTimingsForPieceInstances(cache, currentPartInstance, partGroupTimings, timelineObjsMap) + updatePlannedTimingsForPieceInstances(playoutModel, currentPartInstance, partGroupTimings, timelineObjsMap) } export function calculateNowOffsetLatency( context: JobContext, - cache: CacheForStudioBase, + studioPlayoutModel: StudioPlayoutModelBase, timeOffsetIntoPart: Time | undefined ): Time | undefined { /** The timestamp that "now" was set to */ let nowOffsetLatency: Time | undefined - if (cache.isMultiGatewayMode) { - const playoutDevices = cache.PeripheralDevices.findAll((device) => device.type === PeripheralDeviceType.PLAYOUT) + if (studioPlayoutModel.isMultiGatewayMode) { + const playoutDevices = studioPlayoutModel.PeripheralDevices.filter( + (device) => device.type === PeripheralDeviceType.PLAYOUT + ) const worstLatency = Math.max(0, ...playoutDevices.map((device) => getExpectedLatency(device).safe)) /** Add a little more latency, to account for network latency variability */ const ADD_SAFE_LATENCY = context.studio.settings.multiGatewayNowSafeLatency || 30 @@ -90,55 +91,29 @@ interface PartGroupTimings { } function updatePartInstancePlannedTimes( - cache: CacheForPlayout, + playoutModel: PlayoutModel, targetNowTime: number, timingContext: RundownTimelineTimingContext, - currentPartInstance: DBPartInstance, - nextPartInstance: DBPartInstance | undefined + currentPartInstance: PlayoutPartInstanceModel, + nextPartInstance: PlayoutPartInstanceModel | null ): PartGroupTimings { let currentPartGroupStartTime: number - if (!currentPartInstance.timings?.plannedStartedPlayback) { + if (!currentPartInstance.PartInstance.timings?.plannedStartedPlayback) { // Looks like the part is just being taken - cache.PartInstances.updateOne( - currentPartInstance._id, - (instance) => { - if (instance.timings?.plannedStartedPlayback !== targetNowTime) { - if (!instance.timings) instance.timings = {} - instance.timings.plannedStartedPlayback = targetNowTime - return instance - } else { - return false - } - }, - true - ) + currentPartInstance.setPlannedStartedPlayback(targetNowTime) // Reflect this in the timeline timingContext.currentPartGroup.enable.start = targetNowTime currentPartGroupStartTime = targetNowTime } else { - currentPartGroupStartTime = currentPartInstance.timings.plannedStartedPlayback + currentPartGroupStartTime = currentPartInstance.PartInstance.timings.plannedStartedPlayback } // Also mark the previous as ended - if (cache.Playlist.doc.previousPartInfo) { + const previousPartInstance = playoutModel.PreviousPartInstance + if (previousPartInstance) { const previousPartEndTime = currentPartGroupStartTime + (timingContext.previousPartOverlap ?? 0) - cache.PartInstances.updateOne( - cache.Playlist.doc.previousPartInfo.partInstanceId, - (instance) => { - if ( - instance.timings?.plannedStartedPlayback && - instance.timings?.plannedStoppedPlayback !== previousPartEndTime - ) { - if (!instance.timings) instance.timings = {} - instance.timings.plannedStoppedPlayback = previousPartEndTime - return instance - } else { - return false - } - }, - true - ) + previousPartInstance.setPlannedStoppedPlayback(previousPartEndTime) } const currentPartGroupEndTime = timingContext.currentPartDuration @@ -154,34 +129,10 @@ function updatePartInstancePlannedTimes( timingContext.nextPartGroup.enable.start = nextPartGroupStartTime - cache.PartInstances.updateOne( - nextPartInstance._id, - (instance) => { - if (instance.timings?.plannedStartedPlayback !== nextPartGroupStartTime) { - if (!instance.timings) instance.timings = {} - instance.timings.plannedStartedPlayback = nextPartGroupStartTime - delete instance.timings.plannedStoppedPlayback - return instance - } else { - return false - } - }, - true - ) + nextPartInstance.setPlannedStartedPlayback(nextPartGroupStartTime) } else if (nextPartInstance) { // Make sure the next partInstance doesnt have a start time - cache.PartInstances.updateOne( - nextPartInstance._id, - (instance) => { - if (instance.timings?.plannedStartedPlayback) { - delete instance.timings.plannedStartedPlayback - return instance - } else { - return false - } - }, - true - ) + nextPartInstance.setPlannedStartedPlayback(undefined) } return { @@ -192,10 +143,9 @@ function updatePartInstancePlannedTimes( } function deNowifyCurrentPieces( - cache: CacheForPlayout, targetNowTime: number, timingContext: RundownTimelineTimingContext, - currentPartInstance: DBPartInstance, + currentPartInstance: PlayoutPartInstanceModel, currentPartGroupStartTime: number, timelineObjsMap: Record ) { @@ -203,14 +153,16 @@ function deNowifyCurrentPieces( const nowInPart = targetNowTime - currentPartGroupStartTime // Ensure any pieces in the currentPartInstance have their now replaced - cache.PieceInstances.updateAll((p) => { - if (p.partInstanceId === currentPartInstance._id && p.piece.enable.start === 'now') { - p.piece.enable.start = nowInPart - return p + for (const pieceInstance of currentPartInstance.PieceInstances) { + if (pieceInstance.PieceInstance.piece.enable.start === 'now') { + pieceInstance.updatePieceProps({ + enable: { + ...pieceInstance.PieceInstance.piece.enable, + start: nowInPart, + }, + }) } - - return false - }, true) + } // Pieces without concrete times will add some special 'now' objects to the timeline that they can reference // Make sure that the all have concrete times attached @@ -226,158 +178,133 @@ function deNowifyCurrentPieces( } // Ensure any pieces with an unconfirmed userDuration is confirmed - cache.PieceInstances.updateAll((p) => { - if (p.partInstanceId === currentPartInstance._id && p.userDuration && 'endRelativeToNow' in p.userDuration) { - const relativeToNow = p.userDuration.endRelativeToNow - p.userDuration = { - endRelativeToPart: relativeToNow + nowInPart, - } + for (const pieceInstance of currentPartInstance.PieceInstances) { + if ( + pieceInstance.PieceInstance.userDuration && + 'endRelativeToNow' in pieceInstance.PieceInstance.userDuration + ) { + const relativeToNow = pieceInstance.PieceInstance.userDuration.endRelativeToNow + const endRelativeToPart = relativeToNow + nowInPart + pieceInstance.setDuration({ endRelativeToPart }) // Update the piece control obj - const controlObj = timelineObjsMap[getPieceControlObjectId(p)] + const controlObj = timelineObjsMap[getPieceControlObjectId(pieceInstance.PieceInstance)] if (controlObj && !Array.isArray(controlObj.enable) && controlObj.enable.end === 'now') { - controlObj.enable.end = p.userDuration.endRelativeToPart + controlObj.enable.end = endRelativeToPart } // If the piece is an infinite, there may be a now in the parent group - const infiniteGroup = timelineObjsMap[getInfinitePartGroupId(p._id)] + const infiniteGroup = timelineObjsMap[getInfinitePartGroupId(pieceInstance.PieceInstance._id)] if (infiniteGroup && !Array.isArray(infiniteGroup.enable) && infiniteGroup.enable.end === 'now') { infiniteGroup.enable.end = targetNowTime + relativeToNow } - - return p } - - return false - }) + } } function updatePlannedTimingsForPieceInstances( - cache: CacheForPlayout, - currentPartInstance: DBPartInstance, + playoutModel: PlayoutModel, + currentPartInstance: PlayoutPartInstanceModel, partGroupTimings: PartGroupTimings, timelineObjsMap: Record ) { const existingInfiniteTimings = new Map() - if (cache.Playlist.doc.previousPartInfo) { - const previousPartInstanceId = cache.Playlist.doc.previousPartInfo.partInstanceId - const pieceInstances = cache.PieceInstances.findAll((p) => p.partInstanceId === previousPartInstanceId) + const previousPartInstance = playoutModel.PreviousPartInstance + if (previousPartInstance) { + const pieceInstances = previousPartInstance.PieceInstances for (const pieceInstance of pieceInstances) { // Track the timings for the infinites - const plannedStartedPlayback = pieceInstance.plannedStartedPlayback - if (pieceInstance.infinite && plannedStartedPlayback) { - existingInfiniteTimings.set(pieceInstance.infinite.infiniteInstanceId, plannedStartedPlayback) + const plannedStartedPlayback = pieceInstance.PieceInstance.plannedStartedPlayback + if (pieceInstance.PieceInstance.infinite && plannedStartedPlayback) { + existingInfiniteTimings.set( + pieceInstance.PieceInstance.infinite.infiniteInstanceId, + plannedStartedPlayback + ) } } } // Ensure any pieces have up to date timings - cache.PieceInstances.updateAll((p) => { - if (p.partInstanceId === currentPartInstance._id) { - let res = setPlannedTimingsOnPieceInstance( - p, - partGroupTimings.currentStartTime, - partGroupTimings.currentEndTime - ) - res = preserveOrTrackInfiniteTimings(existingInfiniteTimings, timelineObjsMap, res || p) || res - - return res - } else { - return false - } - }, true) + for (const pieceInstance of currentPartInstance.PieceInstances) { + setPlannedTimingsOnPieceInstance( + pieceInstance, + partGroupTimings.currentStartTime, + partGroupTimings.currentEndTime + ) + preserveOrTrackInfiniteTimings(existingInfiniteTimings, timelineObjsMap, pieceInstance) + } - if (cache.Playlist.doc.nextPartInfo && partGroupTimings.nextStartTime) { + const nextPartInstance = playoutModel.NextPartInstance + if (nextPartInstance && partGroupTimings.nextStartTime) { const nextPartGroupStartTime0 = partGroupTimings.nextStartTime - cache.PieceInstances.updateAll((p) => { - if (p.partInstanceId === currentPartInstance._id) { - let res = setPlannedTimingsOnPieceInstance(p, nextPartGroupStartTime0, undefined) - res = preserveOrTrackInfiniteTimings(existingInfiniteTimings, timelineObjsMap, res || p) || res - - return res - } else { - return false - } - }, true) + for (const pieceInstance of nextPartInstance.PieceInstances) { + setPlannedTimingsOnPieceInstance(pieceInstance, nextPartGroupStartTime0, undefined) + preserveOrTrackInfiniteTimings(existingInfiniteTimings, timelineObjsMap, pieceInstance) + } } } function setPlannedTimingsOnPieceInstance( - pieceInstance: PieceInstance, + pieceInstance: PlayoutPieceInstanceModel, partPlannedStart: Time, partPlannedEnd: Time | undefined -): PieceInstance | false { +): void { if ( - pieceInstance.infinite && - pieceInstance.infinite.infiniteInstanceIndex > 0 && - pieceInstance.plannedStartedPlayback + pieceInstance.PieceInstance.infinite && + pieceInstance.PieceInstance.infinite.infiniteInstanceIndex > 0 && + pieceInstance.PieceInstance.plannedStartedPlayback ) { // If not the start of an infinite chain, then the plannedStartedPlayback flows differently - return false + return } - let changed = false - - if (typeof pieceInstance.piece.enable.start === 'number') { - const plannedStart = partPlannedStart + pieceInstance.piece.enable.start - if (pieceInstance.plannedStartedPlayback !== plannedStart) { - pieceInstance.plannedStartedPlayback = plannedStart - changed = true - } + if (typeof pieceInstance.PieceInstance.piece.enable.start === 'number') { + const plannedStart = partPlannedStart + pieceInstance.PieceInstance.piece.enable.start + pieceInstance.setPlannedStartedPlayback(plannedStart) const userDurationEnd = - pieceInstance.userDuration && 'endRelativeToPart' in pieceInstance.userDuration - ? pieceInstance.userDuration.endRelativeToPart + pieceInstance.PieceInstance.userDuration && 'endRelativeToPart' in pieceInstance.PieceInstance.userDuration + ? pieceInstance.PieceInstance.userDuration.endRelativeToPart : null const plannedEnd = userDurationEnd ?? - (pieceInstance.piece.enable.duration ? plannedStart + pieceInstance.piece.enable.duration : partPlannedEnd) + (pieceInstance.PieceInstance.piece.enable.duration + ? plannedStart + pieceInstance.PieceInstance.piece.enable.duration + : partPlannedEnd) - if (pieceInstance.plannedStoppedPlayback !== plannedEnd) { - pieceInstance.plannedStoppedPlayback = plannedEnd - changed = true - } + pieceInstance.setPlannedStoppedPlayback(plannedEnd) } - - return changed ? pieceInstance : false } function preserveOrTrackInfiniteTimings( existingInfiniteTimings: Map, timelineObjsMap: Record, - pieceInstance: PieceInstance -): PieceInstance | false { - let changed = false - if (pieceInstance.infinite) { - const plannedStartedPlayback = existingInfiniteTimings.get(pieceInstance.infinite.infiniteInstanceId) + pieceInstance: PlayoutPieceInstanceModel +): void { + if (!pieceInstance.PieceInstance.infinite) return + + const plannedStartedPlayback = existingInfiniteTimings.get(pieceInstance.PieceInstance.infinite.infiniteInstanceId) + if (plannedStartedPlayback) { + // Found a value from the previousPartInstance, lets preserve it + pieceInstance.setPlannedStartedPlayback(plannedStartedPlayback) + } else { + const plannedStartedPlayback = pieceInstance.PieceInstance.plannedStartedPlayback if (plannedStartedPlayback) { - // Found a value from the previousPartInstance, lets preserve it - if (pieceInstance.plannedStartedPlayback !== plannedStartedPlayback) { - pieceInstance.plannedStartedPlayback = plannedStartedPlayback - changed = true - } - } else { - const plannedStartedPlayback = pieceInstance.plannedStartedPlayback - if (plannedStartedPlayback) { - existingInfiniteTimings.set(pieceInstance.infinite.infiniteInstanceId, plannedStartedPlayback) - } + existingInfiniteTimings.set(pieceInstance.PieceInstance.infinite.infiniteInstanceId, plannedStartedPlayback) } + } - // Update the timeline group - const startedPlayback = plannedStartedPlayback ?? pieceInstance.plannedStartedPlayback - if (startedPlayback) { - const infinitePartGroupId = getInfinitePartGroupId(pieceInstance._id) - const infinitePartGroupObj = timelineObjsMap[infinitePartGroupId] - if ( - infinitePartGroupObj && - !Array.isArray(infinitePartGroupObj.enable) && - typeof infinitePartGroupObj.enable.start === 'string' - ) { - infinitePartGroupObj.enable.start = startedPlayback - changed = true - } + // Update the timeline group + const startedPlayback = plannedStartedPlayback ?? pieceInstance.PieceInstance.plannedStartedPlayback + if (startedPlayback) { + const infinitePartGroupId = getInfinitePartGroupId(pieceInstance.PieceInstance._id) + const infinitePartGroupObj = timelineObjsMap[infinitePartGroupId] + if ( + infinitePartGroupObj && + !Array.isArray(infinitePartGroupObj.enable) && + typeof infinitePartGroupObj.enable.start === 'string' + ) { + infinitePartGroupObj.enable.start = startedPlayback } } - - return changed ? pieceInstance : false } diff --git a/packages/job-worker/src/playout/timeline/part.ts b/packages/job-worker/src/playout/timeline/part.ts index bb9ddf385e..745e529fdf 100644 --- a/packages/job-worker/src/playout/timeline/part.ts +++ b/packages/job-worker/src/playout/timeline/part.ts @@ -96,7 +96,7 @@ export interface PartEnable { } export function createPartGroup( - partInstance: DBPartInstance, + partInstance: ReadonlyDeep, enable: PartEnable ): TimelineObjGroupPart & OnGenerateTimelineObjExt { const partGrp = literal({ @@ -122,9 +122,9 @@ export function createPartGroup( export function createPartGroupFirstObject( playlistId: RundownPlaylistId, - partInstance: DBPartInstance, + partInstance: ReadonlyDeep, partGroup: TimelineObjRundown & OnGenerateTimelineObjExt, - previousPart?: DBPartInstance + previousPart?: ReadonlyDeep ): TimelineObjPartAbstract & OnGenerateTimelineObjExt { return literal({ id: getPartFirstObjectId(partInstance), diff --git a/packages/job-worker/src/playout/timeline/rundown.ts b/packages/job-worker/src/playout/timeline/rundown.ts index 16b0941dec..7a5d38a2f3 100644 --- a/packages/job-worker/src/playout/timeline/rundown.ts +++ b/packages/job-worker/src/playout/timeline/rundown.ts @@ -26,10 +26,9 @@ import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { literal, normalizeArrayToMapFunc } from '@sofie-automation/corelib/dist/lib' import { getCurrentTime } from '../../lib' import _ = require('underscore') -import { CacheForPlayout } from '../cache' +import { PlayoutModel } from '../model/PlayoutModel' import { getPieceEnableInsidePart, transformPieceGroupAndObjects } from './piece' import { logger } from '../../logging' -import { ReadOnlyCache } from '../../cache/CacheBase' /** * Some additional data used by the timeline generation process @@ -51,14 +50,14 @@ export interface RundownTimelineResult { export function buildTimelineObjsForRundown( context: JobContext, - cache: ReadOnlyCache, - _activeRundown: DBRundown, + playoutModel: PlayoutModel, + _activeRundown: ReadonlyDeep, partInstancesInfo: SelectedPartInstancesTimelineInfo ): RundownTimelineResult { const span = context.startSpan('buildTimelineObjsForRundown') const timelineObjs: Array = [] - const activePlaylist = cache.Playlist.doc + const activePlaylist = playoutModel.Playlist const currentTime = getCurrentTime() timelineObjs.push( diff --git a/packages/job-worker/src/playout/timelineJobs.ts b/packages/job-worker/src/playout/timelineJobs.ts index dbc8c54aee..d23f85fb24 100644 --- a/packages/job-worker/src/playout/timelineJobs.ts +++ b/packages/job-worker/src/playout/timelineJobs.ts @@ -1,36 +1,38 @@ import { UpdateTimelineAfterIngestProps } from '@sofie-automation/corelib/dist/worker/studio' import { JobContext } from '../jobs' -import { getSelectedPartInstancesFromCache } from './cache' -import { runJobWithPlaylistLock, runWithPlaylistCache } from './lock' +import { runJobWithPlaylistLock, runWithPlayoutModel } from './lock' import { updateStudioTimeline, updateTimeline } from './timeline/generate' import { getSystemVersion } from '../lib' -import { runJobWithStudioCache } from '../studio/lock' +import { runJobWithStudioPlayoutModel } from '../studio/lock' import { shouldUpdateStudioBaselineInner as libShouldUpdateStudioBaselineInner } from '@sofie-automation/corelib/dist/studio/baseline' -import { CacheForStudio } from '../studio/cache' +import { StudioPlayoutModel } from '../studio/model/StudioPlayoutModel' /** * Update the timeline with a regenerated Studio Baseline * Has no effect if a Playlist is active */ export async function handleUpdateStudioBaseline(context: JobContext, _data: void): Promise { - return runJobWithStudioCache(context, async (cache) => { - const activePlaylists = cache.getActiveRundownPlaylists() + return runJobWithStudioPlayoutModel(context, async (studioPlayoutModel) => { + const activePlaylists = studioPlayoutModel.getActiveRundownPlaylists() if (activePlaylists.length === 0) { - await updateStudioTimeline(context, cache) - return shouldUpdateStudioBaselineInner(context, cache) + await updateStudioTimeline(context, studioPlayoutModel) + return shouldUpdateStudioBaselineInner(context, studioPlayoutModel) } else { - return shouldUpdateStudioBaselineInner(context, cache) + return shouldUpdateStudioBaselineInner(context, studioPlayoutModel) } }) } -async function shouldUpdateStudioBaselineInner(context: JobContext, cache: CacheForStudio): Promise { +async function shouldUpdateStudioBaselineInner( + context: JobContext, + playoutModel: StudioPlayoutModel +): Promise { const studio = context.studio - if (cache.getActiveRundownPlaylists().length > 0) return false + if (playoutModel.getActiveRundownPlaylists().length > 0) return false - const timeline = cache.Timeline.doc + const timeline = playoutModel.Timeline const blueprint = studio.blueprintId ? await context.directCollections.Blueprints.findOne(studio.blueprintId) : null if (!blueprint) return 'missingBlueprint' @@ -48,19 +50,19 @@ export async function handleUpdateTimelineAfterIngest( await runJobWithPlaylistLock(context, data, async (playlist, lock) => { if (playlist?.activationId && (playlist.currentPartInfo || playlist.nextPartInfo)) { // TODO - r37 added a retry mechanic to this. should that be kept? - await runWithPlaylistCache(context, playlist, lock, null, async (cache) => { - const { currentPartInstance } = getSelectedPartInstancesFromCache(cache) + await runWithPlayoutModel(context, playlist, lock, null, async (playoutModel) => { + const currentPartInstance = playoutModel.CurrentPartInstance if ( - !cache.isMultiGatewayMode && + !playoutModel.isMultiGatewayMode && currentPartInstance && - !currentPartInstance.timings?.reportedStartedPlayback + !currentPartInstance.PartInstance.timings?.reportedStartedPlayback ) { // HACK: The current PartInstance doesn't have a start time yet, so we know an updateTimeline is coming as part of onPartPlaybackStarted // We mustn't run before that does, or we will get the timings in playout-gateway confused. } else { // It is safe enough (except adlibs) to update the timeline directly // If the playlist is active, then updateTimeline as lookahead could have been affected - await updateTimeline(context, cache) + await updateTimeline(context, playoutModel) } }) } diff --git a/packages/job-worker/src/playout/timings/index.ts b/packages/job-worker/src/playout/timings/index.ts index f0e918d2fd..17bfb8e17b 100644 --- a/packages/job-worker/src/playout/timings/index.ts +++ b/packages/job-worker/src/playout/timings/index.ts @@ -1,7 +1,7 @@ import { OnPlayoutPlaybackChangedProps } from '@sofie-automation/corelib/dist/worker/studio' import { logger } from '../../logging' import { JobContext } from '../../jobs' -import { runJobWithPlayoutCache } from '../lock' +import { runJobWithPlayoutModel } from '../lock' import { assertNever } from '@sofie-automation/corelib/dist/lib' import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' import { PlayoutChangedType } from '@sofie-automation/shared-lib/dist/peripheralDevice/peripheralDeviceAPI' @@ -17,26 +17,27 @@ export async function handleOnPlayoutPlaybackChanged( context: JobContext, data: OnPlayoutPlaybackChangedProps ): Promise { - return runJobWithPlayoutCache(context, data, null, async (cache) => { + return runJobWithPlayoutModel(context, data, null, async (playoutModel) => { for (const change of data.changes) { try { if (change.type === PlayoutChangedType.PART_PLAYBACK_STARTED) { - await onPartPlaybackStarted(context, cache, { + await onPartPlaybackStarted(context, playoutModel, { partInstanceId: change.data.partInstanceId, startedPlayback: change.data.time, }) } else if (change.type === PlayoutChangedType.PART_PLAYBACK_STOPPED) { - onPartPlaybackStopped(context, cache, { + onPartPlaybackStopped(context, playoutModel, { partInstanceId: change.data.partInstanceId, stoppedPlayback: change.data.time, }) } else if (change.type === PlayoutChangedType.PIECE_PLAYBACK_STARTED) { - onPiecePlaybackStarted(context, cache, { + onPiecePlaybackStarted(context, playoutModel, { + partInstanceId: change.data.partInstanceId, pieceInstanceId: change.data.pieceInstanceId, startedPlayback: change.data.time, }) } else if (change.type === PlayoutChangedType.PIECE_PLAYBACK_STOPPED) { - onPiecePlaybackStopped(context, cache, { + onPiecePlaybackStopped(context, playoutModel, { partInstanceId: change.data.partInstanceId, pieceInstanceId: change.data.pieceInstanceId, stoppedPlayback: change.data.time, diff --git a/packages/job-worker/src/playout/timings/partPlayback.ts b/packages/job-worker/src/playout/timings/partPlayback.ts index 987431185e..db2ab543a2 100644 --- a/packages/job-worker/src/playout/timings/partPlayback.ts +++ b/packages/job-worker/src/playout/timings/partPlayback.ts @@ -1,44 +1,39 @@ import { PartInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { RundownHoldState } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { logger } from '../../logging' import { JobContext } from '../../jobs' -import { - CacheForPlayout, - getOrderedSegmentsAndPartsFromPlayoutCache, - getSelectedPartInstancesFromCache, -} from '../cache' +import { PlayoutModel } from '../model/PlayoutModel' +import { PlayoutPartInstanceModel } from '../model/PlayoutPartInstanceModel' import { selectNextPart } from '../selectNextPart' import { setNextPart } from '../setNext' import { updateTimeline } from '../timeline/generate' import { getCurrentTime } from '../../lib' import { afterTake, clearQueuedSegmentId, resetPreviousSegment, updatePartInstanceOnTake } from '../take' -import { queuePartInstanceTimingEvent } from './events' import { INCORRECT_PLAYING_PART_DEBOUNCE, RESET_IGNORE_ERRORS } from '../constants' import { Time } from '@sofie-automation/blueprints-integration' -import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' -import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' /** * Set the playback of a part is confirmed to have started * If the part reported to be playing is not the current part, then make it be the current * @param context Context from the job queue - * @param cache DB cache for the current playlist + * @param playoutModel DB cache for the current playlist * @param data Details on the part start event */ export async function onPartPlaybackStarted( context: JobContext, - cache: CacheForPlayout, + playoutModel: PlayoutModel, data: { partInstanceId: PartInstanceId startedPlayback: Time } ): Promise { - const playingPartInstance = cache.PartInstances.findOne(data.partInstanceId) + const playingPartInstance = playoutModel.getPartInstance(data.partInstanceId) if (!playingPartInstance) - throw new Error(`PartInstance "${data.partInstanceId}" in RundownPlayst "${cache.PlaylistId}" not found!`) + throw new Error( + `PartInstance "${data.partInstanceId}" in RundownPlayst "${playoutModel.PlaylistId}" not found!` + ) // make sure we don't run multiple times, even if TSR calls us multiple times - const hasStartedPlaying = !!playingPartInstance.timings?.reportedStartedPlayback + const hasStartedPlaying = !!playingPartInstance.PartInstance.timings?.reportedStartedPlayback if (!hasStartedPlaying) { logger.debug( `Playout reports PartInstance "${data.partInstanceId}" has started playback on timestamp ${new Date( @@ -46,66 +41,62 @@ export async function onPartPlaybackStarted( ).toISOString()}` ) - const playlist = cache.Playlist.doc + const playlist = playoutModel.Playlist - const rundown = cache.Rundowns.findOne(playingPartInstance.rundownId) - if (!rundown) throw new Error(`Rundown "${playingPartInstance.rundownId}" not found!`) + const rundown = playoutModel.getRundown(playingPartInstance.PartInstance.rundownId) + if (!rundown) throw new Error(`Rundown "${playingPartInstance.PartInstance.rundownId}" not found!`) - const { currentPartInstance } = getSelectedPartInstancesFromCache(cache) + const currentPartInstance = playoutModel.CurrentPartInstance if (playlist.currentPartInfo?.partInstanceId === data.partInstanceId) { // this is the current part, it has just started playback - reportPartInstanceHasStarted(context, cache, playingPartInstance, data.startedPlayback) + reportPartInstanceHasStarted(context, playoutModel, playingPartInstance, data.startedPlayback) // complete the take - await afterTake(context, cache, playingPartInstance) + await afterTake(context, playoutModel, playingPartInstance) } else if (playlist.nextPartInfo?.partInstanceId === data.partInstanceId) { // this is the next part, clearly an autoNext has taken place - cache.Playlist.update((p) => { - p.previousPartInfo = p.currentPartInfo - p.currentPartInfo = playlist.nextPartInfo - p.holdState = RundownHoldState.NONE - return p - }) + playoutModel.cycleSelectedPartInstances() - reportPartInstanceHasStarted(context, cache, playingPartInstance, data.startedPlayback) + reportPartInstanceHasStarted(context, playoutModel, playingPartInstance, data.startedPlayback) // Update generated properties on the newly playing partInstance const currentRundown = currentPartInstance - ? cache.Rundowns.findOne(currentPartInstance.rundownId) + ? playoutModel.getRundown(currentPartInstance.PartInstance.rundownId) : undefined const showStyleRundown = currentRundown ?? rundown const showStyle = await context.getShowStyleCompound( - showStyleRundown.showStyleVariantId, - showStyleRundown.showStyleBaseId + showStyleRundown.Rundown.showStyleVariantId, + showStyleRundown.Rundown.showStyleBaseId ) const blueprint = await context.getShowStyleBlueprint(showStyle._id) updatePartInstanceOnTake( context, - cache, + playoutModel.Playlist, showStyle, blueprint, - rundown, + rundown.Rundown, playingPartInstance, currentPartInstance ) - clearQueuedSegmentId(cache, playingPartInstance, playlist.nextPartInfo) - resetPreviousSegment(cache) + clearQueuedSegmentId(playoutModel, playingPartInstance.PartInstance, playlist.nextPartInfo) + resetPreviousSegment(playoutModel) // Update the next partinstance const nextPart = selectNextPart( context, playlist, - playingPartInstance, + playingPartInstance.PartInstance, null, - getOrderedSegmentsAndPartsFromPlayoutCache(cache) + playoutModel.getAllOrderedSegments(), + playoutModel.getAllOrderedParts() ) - await setNextPart(context, cache, nextPart, false) + await setNextPart(context, playoutModel, nextPart, false) // complete the take - await afterTake(context, cache, playingPartInstance) + await afterTake(context, playoutModel, playingPartInstance) } else { // a part is being played that has not been selected for playback by Core @@ -121,11 +112,11 @@ export async function onPartPlaybackStarted( const previousReported = playlist.lastIncorrectPartPlaybackReported if (previousReported && Date.now() - previousReported > INCORRECT_PLAYING_PART_DEBOUNCE) { // first time this has happened for a while, let's make sure it has the correct timeline - await updateTimeline(context, cache) + await updateTimeline(context, playoutModel) } logger.error( - `PartInstance "${playingPartInstance._id}" has started playback by the playout gateway, but has not been selected for playback!` + `PartInstance "${playingPartInstance.PartInstance._id}" has started playback by the playout gateway, but has not been selected for playback!` ) } } @@ -134,25 +125,26 @@ export async function onPartPlaybackStarted( /** * Set the playback of a part is confirmed to have stopped * @param context Context from the job queue - * @param cache DB cache for the current playlist + * @param playoutModel DB cache for the current playlist * @param data Details on the part stop event */ export function onPartPlaybackStopped( context: JobContext, - cache: CacheForPlayout, + playoutModel: PlayoutModel, data: { partInstanceId: PartInstanceId stoppedPlayback: Time } ): void { - const playlist = cache.Playlist.doc + const playlist = playoutModel.Playlist - const partInstance = cache.PartInstances.findOne(data.partInstanceId) + const partInstance = playoutModel.getPartInstance(data.partInstanceId) if (partInstance) { // make sure we don't run multiple times, even if TSR calls us multiple times const isPlaying = - partInstance.timings?.reportedStartedPlayback && !partInstance.timings?.reportedStoppedPlayback + partInstance.PartInstance.timings?.reportedStartedPlayback && + !partInstance.PartInstance.timings?.reportedStoppedPlayback if (isPlaying) { logger.debug( `onPartPlaybackStopped: Playout reports PartInstance "${ @@ -160,7 +152,7 @@ export function onPartPlaybackStopped( }" has stopped playback on timestamp ${new Date(data.stoppedPlayback).toISOString()}` ) - reportPartInstanceHasStopped(context, cache, partInstance, data.stoppedPlayback) + reportPartInstanceHasStopped(context, playoutModel, partInstance, data.stoppedPlayback) } } else if (!playlist.activationId) { logger.warn(`onPartPlaybackStopped: Received for inactive RundownPlaylist "${playlist._id}"`) @@ -174,84 +166,35 @@ export function onPartPlaybackStopped( /** * Set the playback of a PartInstance is confirmed to have started * @param context Context from the job queue - * @param cache DB cache for the current playlist + * @param playoutModel DB cache for the current playlist * @param partInstance PartInstance to be updated * @param timestamp timestamp the PieceInstance started */ export function reportPartInstanceHasStarted( - context: JobContext, - cache: CacheForPlayout, - partInstance: DBPartInstance, + _context: JobContext, + playoutModel: PlayoutModel, + partInstance: PlayoutPartInstanceModel, timestamp: Time ): void { if (partInstance) { - let timestampUpdated = false - cache.PartInstances.updateOne(partInstance._id, (instance) => { - if (!instance.timings) instance.timings = {} - - // If timings.startedPlayback has already been set, we shouldn't set it to another value: - if (!instance.timings.reportedStartedPlayback) { - timestampUpdated = true - instance.timings.reportedStartedPlayback = timestamp - - if (!cache.isMultiGatewayMode) { - instance.timings.plannedStartedPlayback = timestamp - } - } - - // Unset stoppedPlayback if it is set: - if (instance.timings.reportedStoppedPlayback || instance.timings.duration) { - timestampUpdated = true - delete instance.timings.reportedStoppedPlayback - delete instance.timings.duration - - if (!cache.isMultiGatewayMode) { - delete instance.timings.plannedStoppedPlayback - } - } - - // Save/discard change - return timestampUpdated ? instance : false - }) + const timestampUpdated = partInstance.setReportedStartedPlayback(timestamp) + if (timestamp && !playoutModel.isMultiGatewayMode) { + partInstance.setPlannedStartedPlayback(timestamp) + } - if (timestampUpdated && !cache.isMultiGatewayMode && cache.Playlist.doc.previousPartInfo) { + const previousPartInstance = playoutModel.PreviousPartInstance + if (timestampUpdated && !playoutModel.isMultiGatewayMode && previousPartInstance) { // Ensure the plannedStoppedPlayback is set for the previous partinstance too - cache.PartInstances.updateOne(cache.Playlist.doc.previousPartInfo.partInstanceId, (instance) => { - if (instance.timings && !instance.timings.plannedStoppedPlayback) { - instance.timings.plannedStoppedPlayback = timestamp - return instance - } - - return false - }) + previousPartInstance.setPlannedStoppedPlayback(timestamp) } // Update the playlist: - cache.Playlist.update((playlist) => { - if (!playlist.rundownsStartedPlayback) { - playlist.rundownsStartedPlayback = {} - } - - // If the partInstance is "untimed", it will not update the playlist's startedPlayback and will not count time in the GUI: - if (!partInstance.part.untimed) { - const rundownId = unprotectString(partInstance.rundownId) - if (!playlist.rundownsStartedPlayback[rundownId]) { - playlist.rundownsStartedPlayback[rundownId] = timestamp - } - - if (!playlist.startedPlayback) { - playlist.startedPlayback = timestamp - } - } - - return playlist - }) + if (!partInstance.PartInstance.part.untimed) { + playoutModel.setRundownStartedPlayback(partInstance.PartInstance.rundownId, timestamp) + } if (timestampUpdated) { - cache.deferAfterSave(() => { - // Run in the background, we don't want to hold onto the lock to do this - queuePartInstanceTimingEvent(context, cache.PlaylistId, partInstance._id) - }) + playoutModel.queuePartInstanceTimingEvent(partInstance.PartInstance._id) } } } @@ -259,36 +202,22 @@ export function reportPartInstanceHasStarted( /** * Set the playback of a PartInstance is confirmed to have stopped * @param context Context from the job queue - * @param cache DB cache for the current playlist + * @param playoutModel DB cache for the current playlist * @param partInstance PartInstance to be updated * @param timestamp timestamp the PieceInstance stopped */ export function reportPartInstanceHasStopped( - context: JobContext, - cache: CacheForPlayout, - partInstance: DBPartInstance, + _context: JobContext, + playoutModel: PlayoutModel, + partInstance: PlayoutPartInstanceModel, timestamp: Time ): void { - let timestampUpdated = false - if (!partInstance.timings?.reportedStoppedPlayback) { - cache.PartInstances.updateOne(partInstance._id, (instance) => { - if (!instance.timings) instance.timings = {} - instance.timings.reportedStoppedPlayback = timestamp - instance.timings.duration = timestamp - (instance.timings.reportedStartedPlayback || timestamp) - - if (!cache.isMultiGatewayMode) { - instance.timings.plannedStoppedPlayback = timestamp - } - - return instance - }) - timestampUpdated = true + const timestampUpdated = partInstance.setReportedStoppedPlayback(timestamp) + if (timestampUpdated && !playoutModel.isMultiGatewayMode) { + partInstance.setPlannedStoppedPlayback(timestamp) } if (timestampUpdated) { - cache.deferAfterSave(() => { - // Run in the background, we don't want to hold onto the lock to do this - queuePartInstanceTimingEvent(context, cache.PlaylistId, partInstance._id) - }) + playoutModel.queuePartInstanceTimingEvent(partInstance.PartInstance._id) } } diff --git a/packages/job-worker/src/playout/timings/piecePlayback.ts b/packages/job-worker/src/playout/timings/piecePlayback.ts index 59f684890e..1fe2b1a87c 100644 --- a/packages/job-worker/src/playout/timings/piecePlayback.ts +++ b/packages/job-worker/src/playout/timings/piecePlayback.ts @@ -1,170 +1,176 @@ import { PartInstanceId, PieceInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { logger } from '../../logging' import { JobContext } from '../../jobs' -import { CacheForPlayout } from '../cache' -import { queuePartInstanceTimingEvent } from './events' +import { PlayoutModel } from '../model/PlayoutModel' import { Time } from '@sofie-automation/blueprints-integration' -import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' +import { PlayoutPartInstanceModel } from '../model/PlayoutPartInstanceModel' +import { PlayoutPieceInstanceModel } from '../model/PlayoutPieceInstanceModel' /** * Set the playback of a piece is confirmed to have started * @param context Context from the job queue - * @param cache DB cache for the current playlist + * @param playoutModel DB cache for the current playlist * @param data Details on the piece start event */ export function onPiecePlaybackStarted( context: JobContext, - cache: CacheForPlayout, + playoutModel: PlayoutModel, data: { + partInstanceId: PartInstanceId pieceInstanceId: PieceInstanceId startedPlayback: Time } ): void { - const playlist = cache.Playlist.doc - const pieceInstance = cache.PieceInstances.findOne(data.pieceInstanceId) - - if (pieceInstance) { - const isPlaying = !!(pieceInstance.reportedStartedPlayback && !pieceInstance.reportedStoppedPlayback) - if (!isPlaying) { - logger.debug( - `onPiecePlaybackStarted: Playout reports pieceInstance "${ - data.pieceInstanceId - }" has started playback on timestamp ${new Date(data.startedPlayback).toISOString()}` - ) - reportPieceHasStarted(context, cache, pieceInstance, data.startedPlayback) - - // We don't need to bother with an updateTimeline(), as this hasn't changed anything, but lets us accurately add started items when reevaluating + const playlist = playoutModel.Playlist + + const partInstance = playoutModel.getPartInstance(data.partInstanceId) + if (!partInstance) { + if (!playlist.activationId) { + logger.warn(`onPiecePlaybackStarted: Received for inactive RundownPlaylist "${playlist._id}"`) + } else { + throw new Error(`PartInstance "${data.partInstanceId}" in RundownPlaylist "${playlist._id}" not found!`) + } + return + } + + const pieceInstance = partInstance.getPieceInstance(data.pieceInstanceId) + if (!pieceInstance) { + if (!playlist.activationId) { + logger.warn(`onPiecePlaybackStarted: Received for inactive RundownPlaylist "${playlist._id}"`) + } else { + throw new Error(`PieceInstance "${data.partInstanceId}" in RundownPlaylist "${playlist._id}" not found!`) } - } else if (!playlist.activationId) { - logger.warn(`onPiecePlaybackStarted: Received for inactive RundownPlaylist "${playlist._id}"`) - } else { - throw new Error(`PieceInstance "${data.pieceInstanceId}" in RundownPlaylist "${playlist._id}" not found!`) + return + } + + const isPlaying = !!( + pieceInstance.PieceInstance.reportedStartedPlayback && !pieceInstance.PieceInstance.reportedStoppedPlayback + ) + if (!isPlaying) { + logger.debug( + `onPiecePlaybackStarted: Playout reports pieceInstance "${ + data.pieceInstanceId + }" has started playback on timestamp ${new Date(data.startedPlayback).toISOString()}` + ) + reportPieceHasStarted(context, playoutModel, partInstance, pieceInstance, data.startedPlayback) + + // We don't need to bother with an updateTimeline(), as this hasn't changed anything, but lets us accurately add started items when reevaluating } } /** * Set the playback of a piece is confirmed to have stopped * @param context Context from the job queue - * @param cache DB cache for the current playlist + * @param playoutModel DB cache for the current playlist * @param data Details on the piece stop event */ export function onPiecePlaybackStopped( context: JobContext, - cache: CacheForPlayout, + playoutModel: PlayoutModel, data: { partInstanceId: PartInstanceId pieceInstanceId: PieceInstanceId stoppedPlayback: Time } ): void { - const playlist = cache.Playlist.doc - const pieceInstance = cache.PieceInstances.findOne(data.pieceInstanceId) - - if (pieceInstance) { - const isPlaying = !!(pieceInstance.reportedStartedPlayback && !pieceInstance.reportedStoppedPlayback) - if (isPlaying) { - logger.debug( - `onPiecePlaybackStopped: Playout reports pieceInstance "${ - data.pieceInstanceId - }" has stopped playback on timestamp ${new Date(data.stoppedPlayback).toISOString()}` - ) - - reportPieceHasStopped(context, cache, pieceInstance, data.stoppedPlayback) - } - } else if (!playlist.activationId) { - logger.warn(`onPiecePlaybackStopped: Received for inactive RundownPlaylist "${playlist._id}"`) - } else { - const partInstance = cache.PartInstances.findOne(data.partInstanceId) - if (!partInstance) { - // PartInstance not found, so we can rely on the onPartPlaybackStopped callback erroring + const playlist = playoutModel.Playlist + + const partInstance = playoutModel.getPartInstance(data.partInstanceId) + if (!partInstance) { + // PartInstance not found, so we can rely on the onPartPlaybackStopped callback erroring + return + } + + const pieceInstance = partInstance.getPieceInstance(data.pieceInstanceId) + if (!pieceInstance) { + if (!playlist.activationId) { + logger.warn(`onPiecePlaybackStopped: Received for inactive RundownPlaylist "${playlist._id}"`) } else { - throw new Error(`PieceInstance "${data.pieceInstanceId}" in RundownPlaylist "${playlist._id}" not found!`) + throw new Error(`PieceInstance "${data.partInstanceId}" in RundownPlaylist "${playlist._id}" not found!`) } + return + } + + const isPlaying = !!( + pieceInstance.PieceInstance.reportedStartedPlayback && !pieceInstance.PieceInstance.reportedStoppedPlayback + ) + if (isPlaying) { + logger.debug( + `onPiecePlaybackStopped: Playout reports pieceInstance "${ + data.pieceInstanceId + }" has stopped playback on timestamp ${new Date(data.stoppedPlayback).toISOString()}` + ) + + reportPieceHasStopped(context, playoutModel, pieceInstance, data.stoppedPlayback) } } /** * Set the playback of a PieceInstance is confirmed to have started * @param context Context from the job queue - * @param cache DB cache for the current playlist + * @param playoutModel DB cache for the current playlist * @param pieceInstance PieceInstance to be updated * @param timestamp timestamp the PieceInstance started */ function reportPieceHasStarted( - context: JobContext, - cache: CacheForPlayout, - pieceInstance: PieceInstance, + _context: JobContext, + playoutModel: PlayoutModel, + partInstance: PlayoutPartInstanceModel, + pieceInstance: PlayoutPieceInstanceModel, timestamp: Time ): void { - if (pieceInstance.reportedStartedPlayback !== timestamp) { - cache.PieceInstances.updateOne(pieceInstance._id, (piece) => { - piece.reportedStartedPlayback = timestamp - delete piece.reportedStoppedPlayback - - if (!cache.isMultiGatewayMode) { - piece.plannedStartedPlayback = timestamp - delete piece.plannedStoppedPlayback - } - - return piece - }) + const timestampChanged = pieceInstance.setReportedStartedPlayback(timestamp) + if (timestampChanged) { + if (!playoutModel.isMultiGatewayMode) { + pieceInstance.setPlannedStartedPlayback(timestamp) + } // Update the copy in the next-part if there is one, so that the infinite has the same start after a take - const playlist = cache.Playlist.doc - if (pieceInstance.infinite && playlist.nextPartInfo) { - const infiniteInstanceId = pieceInstance.infinite.infiniteInstanceId - cache.PieceInstances.updateAll((piece) => { + const nextPartInstance = playoutModel.NextPartInstance + if ( + pieceInstance.PieceInstance.infinite && + nextPartInstance && + nextPartInstance.PartInstance._id !== partInstance.PartInstance._id + ) { + const infiniteInstanceId = pieceInstance.PieceInstance.infinite.infiniteInstanceId + for (const nextPieceInstance of nextPartInstance.PieceInstances) { if ( - piece.partInstanceId === playlist.nextPartInfo?.partInstanceId && - !!piece.infinite && - piece.infinite.infiniteInstanceId === infiniteInstanceId + !!nextPieceInstance.PieceInstance.infinite && + nextPieceInstance.PieceInstance.infinite.infiniteInstanceId === infiniteInstanceId ) { - piece.reportedStartedPlayback = timestamp - delete piece.reportedStoppedPlayback + nextPieceInstance.setReportedStartedPlayback(timestamp) - if (!cache.isMultiGatewayMode) { - piece.plannedStartedPlayback = timestamp - delete piece.plannedStoppedPlayback + if (!playoutModel.isMultiGatewayMode) { + nextPieceInstance.setPlannedStartedPlayback(timestamp) } - - return piece - } else { - return false } - }) + } } - cache.deferAfterSave(() => { - queuePartInstanceTimingEvent(context, playlist._id, pieceInstance.partInstanceId) - }) + playoutModel.queuePartInstanceTimingEvent(partInstance.PartInstance._id) } } /** * Set the playback of a PieceInstance is confirmed to have stopped * @param context Context from the job queue - * @param cache DB cache for the current playlist + * @param playoutModel DB cache for the current playlist * @param pieceInstance PieceInstance to be updated * @param timestamp timestamp the PieceInstance stopped */ function reportPieceHasStopped( - context: JobContext, - cache: CacheForPlayout, - pieceInstance: PieceInstance, + _context: JobContext, + playoutModel: PlayoutModel, + pieceInstance: PlayoutPieceInstanceModel, timestamp: Time ): void { - if (pieceInstance.reportedStoppedPlayback !== timestamp) { - cache.PieceInstances.updateOne(pieceInstance._id, (piece) => { - piece.reportedStoppedPlayback = timestamp + const timestampChanged = pieceInstance.setReportedStoppedPlayback(timestamp) - if (!cache.isMultiGatewayMode) { - piece.plannedStoppedPlayback = timestamp - } + if (timestampChanged) { + if (!playoutModel.isMultiGatewayMode) { + pieceInstance.setPlannedStoppedPlayback(timestamp) + } - return piece - }) - cache.deferAfterSave(() => { - queuePartInstanceTimingEvent(context, cache.PlaylistId, pieceInstance.partInstanceId) - }) + playoutModel.queuePartInstanceTimingEvent(pieceInstance.PieceInstance.partInstanceId) } } diff --git a/packages/job-worker/src/playout/timings/timelineTriggerTime.ts b/packages/job-worker/src/playout/timings/timelineTriggerTime.ts index 102f6700f9..81fc11fbba 100644 --- a/packages/job-worker/src/playout/timings/timelineTriggerTime.ts +++ b/packages/job-worker/src/playout/timings/timelineTriggerTime.ts @@ -8,11 +8,12 @@ import { runJobWithPlaylistLock } from '../lock' import { saveTimeline } from '../timeline/generate' import { applyToArray } from '@sofie-automation/corelib/dist/lib' import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' -import { runJobWithStudioCache } from '../../studio/lock' -import { CacheForStudio } from '../../studio/cache' +import { runJobWithStudioPlayoutModel } from '../../studio/lock' +import { StudioPlayoutModel } from '../../studio/model/StudioPlayoutModel' import { DbCacheWriteCollection } from '../../cache/CacheCollection' import { PieceTimelineMetadata } from '../timeline/pieceGroup' import { deserializeTimelineBlob } from '@sofie-automation/corelib/dist/dataModel/Timeline' +import { ReadonlyDeep } from 'type-fest' /** * Called from Playout-gateway when the trigger-time of a timeline object has updated @@ -20,7 +21,7 @@ import { deserializeTimelineBlob } from '@sofie-automation/corelib/dist/dataMode */ export async function handleTimelineTriggerTime(context: JobContext, data: OnTimelineTriggerTimeProps): Promise { if (data.results.length > 0) { - await runJobWithStudioCache(context, async (studioCache) => { + await runJobWithStudioPlayoutModel(context, async (studioCache) => { const activePlaylists = studioCache.getActiveRundownPlaylists() if (studioCache.isMultiGatewayMode) { @@ -67,15 +68,15 @@ export async function handleTimelineTriggerTime(context: JobContext, data: OnTim function timelineTriggerTimeInner( context: JobContext, - cache: CacheForStudio, + studioPlayoutModel: StudioPlayoutModel, results: OnTimelineTriggerTimeProps['results'], pieceInstanceCache: DbCacheWriteCollection | undefined, - activePlaylist: DBRundownPlaylist | undefined + activePlaylist: ReadonlyDeep | undefined ) { let lastTakeTime: number | undefined // ------------------------------ - const timeline = cache.Timeline.doc + const timeline = studioPlayoutModel.Timeline if (timeline) { const timelineObjs = deserializeTimelineBlob(timeline.timelineBlob) let tlChanged = false @@ -140,7 +141,7 @@ function timelineTriggerTimeInner( } } if (tlChanged) { - saveTimeline(context, cache, timelineObjs, timeline.generationVersions) + saveTimeline(context, studioPlayoutModel, timelineObjs, timeline.generationVersions) } } } diff --git a/packages/job-worker/src/rundown.ts b/packages/job-worker/src/rundown.ts index 214c19f299..b30fbaf72f 100644 --- a/packages/job-worker/src/rundown.ts +++ b/packages/job-worker/src/rundown.ts @@ -3,7 +3,7 @@ import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { normalizeArrayToMap, min, groupByToMap } from '@sofie-automation/corelib/dist/lib' +import { normalizeArrayToMap, min, groupByToMap, clone } from '@sofie-automation/corelib/dist/lib' import { AnyBulkWriteOperation } from 'mongodb' import { ReadonlyDeep } from 'type-fest' import _ = require('underscore') @@ -11,7 +11,7 @@ import { CacheForIngest } from './ingest/cache' import { BeforePartMap } from './ingest/commit' import { JobContext } from './jobs' import { logger } from './logging' -import { CacheForPlayout } from './playout/cache' +import { PlayoutModel } from './playout/model/PlayoutModel' /** Return true if the rundown is allowed to be moved out of that playlist */ export function allowedToMoveRundownOutOfPlaylist( @@ -115,32 +115,29 @@ export async function updatePartInstanceRanks( * Update the ranks of all PartInstances in the given segments. * Syncs the ranks from matching Parts to PartInstances. */ -export function updatePartInstanceRanksAfterAdlib(cache: CacheForPlayout, segmentId: SegmentId): void { - const newParts = cache.Parts.findAll((p) => p.segmentId === segmentId) +export function updatePartInstanceRanksAfterAdlib(playoutModel: PlayoutModel, segmentId: SegmentId): void { + const newParts = playoutModel.findSegment(segmentId)?.Parts ?? [] + const segmentPartInstances = _.sortBy( - cache.PartInstances.findAll((p) => p.segmentId === segmentId), + playoutModel.LoadedPartInstances.filter((p) => p.PartInstance.segmentId === segmentId).map((p) => + clone(p.PartInstance) + ), (p) => p.part._rank ) const newRanks = calculateNewRanksForParts(segmentId, null, newParts, segmentPartInstances) for (const [instanceId, info] of newRanks.entries()) { + const partInstance = playoutModel.getPartInstance(instanceId) + if (!partInstance) continue // TODO - should this throw? + if (info.deleted) { - cache.PartInstances.updateOne(instanceId, (p) => { - p.part._rank = info.rank - p.orphaned = 'deleted' - return p - }) + partInstance.setRank(info.rank) + partInstance.setOrphaned('deleted') } else if (info.deleted === undefined) { - cache.PartInstances.updateOne(instanceId, (p) => { - p.part._rank = info.rank - return p - }) + partInstance.setRank(info.rank) } else { - cache.PartInstances.updateOne(instanceId, (p) => { - p.part._rank = info.rank - delete p.orphaned - return p - }) + partInstance.setRank(info.rank) + partInstance.setOrphaned(undefined) } } @@ -150,7 +147,7 @@ export function updatePartInstanceRanksAfterAdlib(cache: CacheForPlayout, segmen function calculateNewRanksForParts( segmentId: SegmentId, oldPartIdsAndRanks0: Array<{ id: PartId; rank: number }> | null, // Null if the Parts havent changed, and so can be loaded locally - newParts: DBPart[], + newParts: ReadonlyDeep, segmentPartInstances: DBPartInstance[] ): Map { const changedRanks = new Map() @@ -179,7 +176,12 @@ function calculateNewRanksForParts( } const orphanedPartInstances = segmentPartInstances - .map((p) => ({ rank: p.part._rank, orphaned: p.orphaned, instanceId: p._id, id: p.part._id })) + .map((p) => ({ + rank: p.part._rank, + orphaned: p.orphaned, + instanceId: p._id, + id: p.part._id, + })) .filter((p) => p.orphaned) if (orphanedPartInstances.length === 0) { diff --git a/packages/job-worker/src/rundownPlaylists.ts b/packages/job-worker/src/rundownPlaylists.ts index 8c7f4efbb9..d05a4877e5 100644 --- a/packages/job-worker/src/rundownPlaylists.ts +++ b/packages/job-worker/src/rundownPlaylists.ts @@ -16,7 +16,7 @@ import { BlueprintResultRundownPlaylist, IBlueprintRundown } from '@sofie-automa import { JobContext } from './jobs' import { logger } from './logging' import { resetRundownPlaylist } from './playout/lib' -import { runJobWithPlaylistLock, runWithPlaylistCache } from './playout/lock' +import { runJobWithPlaylistLock, runWithPlayoutModel } from './playout/lock' import { updateTimeline } from './playout/timeline/generate' import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' import { WrappedStudioBlueprint } from './blueprints/cache' @@ -77,10 +77,10 @@ export async function handleRegenerateRundownPlaylist( ) if (rundowns.length === 0) return [] - await runWithPlaylistCache(context, playlist, playlistLock, null, async (cache) => { + await runWithPlayoutModel(context, playlist, playlistLock, null, async (cache) => { await resetRundownPlaylist(context, cache) - if (cache.Playlist.doc.activationId) { + if (cache.Playlist.activationId) { await updateTimeline(context, cache) } }) diff --git a/packages/job-worker/src/studio/cache.ts b/packages/job-worker/src/studio/cache.ts deleted file mode 100644 index f903806f44..0000000000 --- a/packages/job-worker/src/studio/cache.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { PeripheralDevice, PeripheralDeviceType } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' -import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { TimelineComplete } from '@sofie-automation/corelib/dist/dataModel/Timeline' -import { JobContext } from '../jobs' -import { CacheBase } from '../cache/CacheBase' -import { DbCacheReadCollection } from '../cache/CacheCollection' -import { DbCacheWriteOptionalObject } from '../cache/CacheObject' - -export interface CacheForStudioBase { - readonly PeripheralDevices: DbCacheReadCollection - - readonly Timeline: DbCacheWriteOptionalObject - - readonly isMultiGatewayMode: boolean -} - -/** - * This is a cache used for studio operations. - */ -export class CacheForStudio extends CacheBase implements CacheForStudioBase { - public readonly isStudio = true - - public readonly PeripheralDevices: DbCacheReadCollection - - public readonly RundownPlaylists: DbCacheReadCollection - public readonly Timeline: DbCacheWriteOptionalObject - - private constructor( - context: JobContext, - peripheralDevices: DbCacheReadCollection, - rundownPlaylists: DbCacheReadCollection, - timeline: DbCacheWriteOptionalObject - ) { - super(context) - - this.PeripheralDevices = peripheralDevices - - this.RundownPlaylists = rundownPlaylists - this.Timeline = timeline - } - - public get DisplayName(): string { - return `CacheForStudio` - } - - static async create(context: JobContext): Promise { - const span = context.startSpan('CacheForStudio.create') - - const studioId = context.studioId - - const collections = await Promise.all([ - DbCacheReadCollection.createFromDatabase(context, context.directCollections.PeripheralDevices, { - studioId, - }), - DbCacheReadCollection.createFromDatabase(context, context.directCollections.RundownPlaylists, { studioId }), - DbCacheWriteOptionalObject.createOptionalFromDatabase( - context, - context.directCollections.Timelines, - studioId - ), - ]) - - const res = new CacheForStudio(context, ...collections) - if (span) span.end() - return res - } - - public getActiveRundownPlaylists(excludeRundownPlaylistId?: RundownPlaylistId): DBRundownPlaylist[] { - return this.RundownPlaylists.findAll((p) => !!p.activationId && p._id !== excludeRundownPlaylistId) - } - - #isMultiGatewayMode: boolean | undefined = undefined - public get isMultiGatewayMode(): boolean { - if (this.#isMultiGatewayMode === undefined) { - if (this.context.studio.settings.forceMultiGatewayMode) { - this.#isMultiGatewayMode = true - } else { - const playoutDevices = this.PeripheralDevices.findAll( - (device) => device.type === PeripheralDeviceType.PLAYOUT - ) - this.#isMultiGatewayMode = playoutDevices.length > 1 - } - } - return this.#isMultiGatewayMode - } -} diff --git a/packages/job-worker/src/studio/cleanup.ts b/packages/job-worker/src/studio/cleanup.ts index e9a32df92c..a1d052773f 100644 --- a/packages/job-worker/src/studio/cleanup.ts +++ b/packages/job-worker/src/studio/cleanup.ts @@ -1,15 +1,15 @@ import { runJobWithPlaylistLock } from '../playout/lock' import { JobContext } from '../jobs' -import { runJobWithStudioCache } from './lock' +import { runJobWithStudioPlayoutModel } from './lock' import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' /** * Cleanup any RundownPlaylists that contain no Rundowns */ export async function handleRemoveEmptyPlaylists(context: JobContext, _data: void): Promise { - await runJobWithStudioCache(context, async (cache) => { + await runJobWithStudioPlayoutModel(context, async (studioPlayoutModel) => { // Skip any playlists which are active - const tmpPlaylists = cache.RundownPlaylists.findAll((p) => !p.activationId, { fields: { _id: 1 } }) + const tmpPlaylists = studioPlayoutModel.RundownPlaylists.filter((p) => !p.activationId, { fields: { _id: 1 } }) // We want to run them all in parallel await Promise.allSettled( diff --git a/packages/job-worker/src/studio/lock.ts b/packages/job-worker/src/studio/lock.ts index 30231381ed..b0aa672ca7 100644 --- a/packages/job-worker/src/studio/lock.ts +++ b/packages/job-worker/src/studio/lock.ts @@ -1,24 +1,25 @@ import { JobContext } from '../jobs' -import { CacheForStudio } from './cache' +import { StudioPlayoutModel } from './model/StudioPlayoutModel' +import { loadStudioPlayoutModel } from './model/StudioPlayoutModelImpl' /** * Run a typical studio job - * This means loading the studio cache, doing some calculations and saving the result + * This means loading the studioPlayoutModel, doing some calculations and saving the result */ -export async function runJobWithStudioCache( +export async function runJobWithStudioPlayoutModel( context: JobContext, - fcn: (cache: CacheForStudio) => Promise + fcn: (studioPlayoutModel: StudioPlayoutModel) => Promise ): Promise { - const cache = await CacheForStudio.create(context) + const studioPlayoutModel = await loadStudioPlayoutModel(context) try { - const res = await fcn(cache) + const res = await fcn(studioPlayoutModel) - await cache.saveAllToDatabase() + await studioPlayoutModel.saveAllToDatabase() return res } catch (err) { - cache.dispose() + studioPlayoutModel.dispose() throw err } } diff --git a/packages/job-worker/src/studio/model/StudioBaselineHelper.ts b/packages/job-worker/src/studio/model/StudioBaselineHelper.ts new file mode 100644 index 0000000000..5b41352248 --- /dev/null +++ b/packages/job-worker/src/studio/model/StudioBaselineHelper.ts @@ -0,0 +1,57 @@ +import { JobContext } from '../../jobs' +import { + ExpectedPackageDB, + ExpectedPackageDBFromStudioBaselineObjects, + ExpectedPackageDBType, +} from '@sofie-automation/corelib/dist/dataModel/ExpectedPackages' +import { ExpectedPlayoutItemStudio } from '@sofie-automation/corelib/dist/dataModel/ExpectedPlayoutItem' +import { saveIntoDb } from '../../db/changes' + +export class StudioBaselineHelper { + readonly #context: JobContext + + #pendingExpectedPackages: ExpectedPackageDBFromStudioBaselineObjects[] | undefined + #pendingExpectedPlayoutItems: ExpectedPlayoutItemStudio[] | undefined + + constructor(context: JobContext) { + this.#context = context + } + + hasChanges(): boolean { + return !!this.#pendingExpectedPackages || !!this.#pendingExpectedPlayoutItems + } + + setExpectedPackages(packages: ExpectedPackageDBFromStudioBaselineObjects[]): void { + this.#pendingExpectedPackages = packages + } + setExpectedPlayoutItems(playoutItems: ExpectedPlayoutItemStudio[]): void { + this.#pendingExpectedPlayoutItems = playoutItems + } + + async saveAllToDatabase(): Promise { + await Promise.all([ + this.#pendingExpectedPlayoutItems + ? saveIntoDb( + this.#context, + this.#context.directCollections.ExpectedPlayoutItems, + { studioId: this.#context.studioId, baseline: 'studio' }, + this.#pendingExpectedPlayoutItems + ) + : undefined, + this.#pendingExpectedPackages + ? saveIntoDb( + this.#context, + this.#context.directCollections.ExpectedPackages, + { + studioId: this.#context.studioId, + fromPieceType: ExpectedPackageDBType.STUDIO_BASELINE_OBJECTS, + }, + this.#pendingExpectedPackages + ) + : undefined, + ]) + + this.#pendingExpectedPlayoutItems = undefined + this.#pendingExpectedPackages = undefined + } +} diff --git a/packages/job-worker/src/studio/model/StudioPlayoutModel.ts b/packages/job-worker/src/studio/model/StudioPlayoutModel.ts new file mode 100644 index 0000000000..66359f7ff2 --- /dev/null +++ b/packages/job-worker/src/studio/model/StudioPlayoutModel.ts @@ -0,0 +1,68 @@ +import { RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PeripheralDevice } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { + TimelineComplete, + TimelineCompleteGenerationVersions, + TimelineObjGeneric, +} from '@sofie-automation/corelib/dist/dataModel/Timeline' +import { BaseModel } from '../../modelBase' +import { ReadonlyDeep } from 'type-fest' +import { ExpectedPackageDBFromStudioBaselineObjects } from '@sofie-automation/corelib/dist/dataModel/ExpectedPackages' +import { ExpectedPlayoutItemStudio } from '@sofie-automation/corelib/dist/dataModel/ExpectedPlayoutItem' + +export interface StudioPlayoutModelBaseReadonly { + /** + * All of the PeripheralDevices that belong to the Studio of this RundownPlaylist + */ + readonly PeripheralDevices: ReadonlyDeep + + /** + * Get the Timeline for the current Studio + */ + get Timeline(): TimelineComplete | null + + /** + * Whether this Studio is operating in multi-gateway mode + */ + readonly isMultiGatewayMode: boolean +} + +export interface StudioPlayoutModelBase extends StudioPlayoutModelBaseReadonly { + /** + * Update the ExpectedPackages for the StudioBaseline of the current Studio + * @param packages ExpectedPackages to store + */ + setExpectedPackagesForStudioBaseline(packages: ExpectedPackageDBFromStudioBaselineObjects[]): void + /** + * Update the ExpectedPlayoutItems for the StudioBaseline of the current Studio + * @param playoutItems ExpectedPlayoutItems to store + */ + setExpectedPlayoutItemsForStudioBaseline(playoutItems: ExpectedPlayoutItemStudio[]): void + + /** + * Update the Timeline for the current Studio + * @param timelineObjs Timeline objects to be run in the Studio + * @param generationVersions Details about the versions where these objects were generated + */ + setTimeline(timelineObjs: TimelineObjGeneric[], generationVersions: TimelineCompleteGenerationVersions): void +} + +/** + * A view of a `Studio` and its RundownPlaylists for playout when a RundownPlaylist is not activated + */ +export interface StudioPlayoutModel extends StudioPlayoutModelBase, BaseModel { + readonly isStudio: true + + /** + * The unwrapped RundownPlaylists in this Studio + */ + readonly RundownPlaylists: ReadonlyDeep + + /** + * Get any activated RundownPlaylists in this Studio + * Note: This should return one or none, but could return more if in a bad state + * @param excludeRundownPlaylistId Ignore a given RundownPlaylist, useful to see if any other RundownPlaylists are active + */ + getActiveRundownPlaylists(excludeRundownPlaylistId?: RundownPlaylistId): ReadonlyDeep +} diff --git a/packages/job-worker/src/studio/model/StudioPlayoutModelImpl.ts b/packages/job-worker/src/studio/model/StudioPlayoutModelImpl.ts new file mode 100644 index 0000000000..39adf47aec --- /dev/null +++ b/packages/job-worker/src/studio/model/StudioPlayoutModelImpl.ts @@ -0,0 +1,174 @@ +import { RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PeripheralDevice, PeripheralDeviceType } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { + serializeTimelineBlob, + TimelineComplete, + TimelineCompleteGenerationVersions, + TimelineObjGeneric, +} from '@sofie-automation/corelib/dist/dataModel/Timeline' +import { JobContext } from '../../jobs' +import { ReadonlyDeep } from 'type-fest' +import { getRandomId } from '@sofie-automation/corelib/dist/lib' +import { getCurrentTime } from '../../lib' +import { IS_PRODUCTION } from '../../environment' +import { logger } from '../../logging' +import { StudioPlayoutModel } from './StudioPlayoutModel' +import { DatabasePersistedModel } from '../../modelBase' +import { ExpectedPackageDBFromStudioBaselineObjects } from '@sofie-automation/corelib/dist/dataModel/ExpectedPackages' +import { ExpectedPlayoutItemStudio } from '@sofie-automation/corelib/dist/dataModel/ExpectedPlayoutItem' +import { StudioBaselineHelper } from './StudioBaselineHelper' + +/** + * This is a model used for studio operations. + */ +export class StudioPlayoutModelImpl implements StudioPlayoutModel { + readonly #baselineHelper: StudioBaselineHelper + #disposed = false + + public readonly isStudio = true + + public readonly PeripheralDevices: ReadonlyDeep + + public readonly RundownPlaylists: ReadonlyDeep + + #TimelineHasChanged = false + #Timeline: TimelineComplete | null + public get Timeline(): TimelineComplete | null { + return this.#Timeline + } + + public constructor( + protected readonly context: JobContext, + peripheralDevices: ReadonlyDeep, + rundownPlaylists: ReadonlyDeep, + timeline: TimelineComplete | undefined + ) { + context.trackCache(this) + + this.#baselineHelper = new StudioBaselineHelper(context) + + this.PeripheralDevices = peripheralDevices + + this.RundownPlaylists = rundownPlaylists + this.#Timeline = timeline ?? null + } + + public get DisplayName(): string { + return `CacheForStudio` + } + + public getActiveRundownPlaylists(excludeRundownPlaylistId?: RundownPlaylistId): ReadonlyDeep { + return this.RundownPlaylists.filter((p) => !!p.activationId && p._id !== excludeRundownPlaylistId) + } + + #isMultiGatewayMode: boolean | undefined = undefined + public get isMultiGatewayMode(): boolean { + if (this.#isMultiGatewayMode === undefined) { + if (this.context.studio.settings.forceMultiGatewayMode) { + this.#isMultiGatewayMode = true + } else { + const playoutDevices = this.PeripheralDevices.filter( + (device) => device.type === PeripheralDeviceType.PLAYOUT + ) + this.#isMultiGatewayMode = playoutDevices.length > 1 + } + } + return this.#isMultiGatewayMode + } + + setExpectedPackagesForStudioBaseline(packages: ExpectedPackageDBFromStudioBaselineObjects[]): void { + this.#baselineHelper.setExpectedPackages(packages) + } + setExpectedPlayoutItemsForStudioBaseline(playoutItems: ExpectedPlayoutItemStudio[]): void { + this.#baselineHelper.setExpectedPlayoutItems(playoutItems) + } + + setTimeline(timelineObjs: TimelineObjGeneric[], generationVersions: TimelineCompleteGenerationVersions): void { + this.#Timeline = { + _id: this.context.studioId, + timelineHash: getRandomId(), + generated: getCurrentTime(), + timelineBlob: serializeTimelineBlob(timelineObjs), + generationVersions: generationVersions, + } + this.#TimelineHasChanged = true + } + + /** + * Discards all documents in this cache, and marks it as unusable + */ + dispose(): void { + this.#disposed = true + } + + async saveAllToDatabase(): Promise { + if (this.#disposed) { + throw new Error('Cannot save disposed PlayoutModel') + } + + const span = this.context.startSpan('StudioPlayoutModelImpl.saveAllToDatabase') + + // Prioritise the timeline for publication reasons + if (this.#TimelineHasChanged && this.#Timeline) { + await this.context.directCollections.Timelines.replace(this.#Timeline) + } + this.#TimelineHasChanged = false + + await this.#baselineHelper.saveAllToDatabase() + + if (span) span.end() + } + + /** + * Assert that no changes should have been made to the cache, will throw an Error otherwise. This can be used in + * place of `saveAllToDatabase()`, when the code controlling the cache expects no changes to have been made and any + * changes made are an error and will cause issues. + */ + assertNoChanges(): void { + const span = this.context.startSpan('Cache.assertNoChanges') + + function logOrThrowError(error: Error) { + if (!IS_PRODUCTION) { + throw error + } else { + logger.error(error) + } + } + + if (this.#baselineHelper.hasChanges()) + logOrThrowError( + new Error( + `Failed no changes in cache assertion, baseline ExpectedPackages or ExpectedPlayoutItems has changes` + ) + ) + + if (this.#TimelineHasChanged) + logOrThrowError(new Error(`Failed no changes in cache assertion, Timeline has been changed`)) + + if (span) span.end() + } +} + +/** + * Load a StudioPlayoutModel for the current Studio + * @param context Context from the job queue + * @returns Loaded StudioPlayoutModel + */ +export async function loadStudioPlayoutModel( + context: JobContext +): Promise { + const span = context.startSpan('loadStudioPlayoutModel') + + const studioId = context.studioId + + const collections = await Promise.all([ + context.directCollections.PeripheralDevices.findFetch({ studioId }), + context.directCollections.RundownPlaylists.findFetch({ studioId }), + context.directCollections.Timelines.findOne(studioId), + ]) + + const res = new StudioPlayoutModelImpl(context, ...collections) + if (span) span.end() + return res +} diff --git a/packages/job-worker/src/workers/context.ts b/packages/job-worker/src/workers/context.ts index f1ff14cc31..ba5f8e8dd2 100644 --- a/packages/job-worker/src/workers/context.ts +++ b/packages/job-worker/src/workers/context.ts @@ -34,8 +34,7 @@ import { import { getStudioQueueName, StudioJobFunc } from '@sofie-automation/corelib/dist/worker/studio' import { LockBase, PlaylistLock, RundownLock } from '../jobs/lock' import { logger } from '../logging' -import { ReadOnlyCacheBase } from '../cache/CacheBase' -import { IS_PRODUCTION } from '../environment' +import { BaseModel } from '../modelBase' import { LocksManager } from './locks' import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' import { EventsJobFunc, getEventsQueueName } from '@sofie-automation/corelib/dist/worker/events' @@ -279,7 +278,7 @@ export class StudioCacheContextImpl implements StudioCacheContext { export class JobContextImpl extends StudioCacheContextImpl implements JobContext { private readonly locks: Array = [] - private readonly caches: Array> = [] + private readonly caches: Array = [] constructor( directCollections: Readonly, @@ -292,7 +291,7 @@ export class JobContextImpl extends StudioCacheContextImpl implements JobContext super(directCollections, cacheData) } - trackCache(cache: ReadOnlyCacheBase): void { + trackCache(cache: BaseModel): void { this.caches.push(cache) } @@ -365,11 +364,11 @@ export class JobContextImpl extends StudioCacheContextImpl implements JobContext } // Ensure all caches were saved/aborted - if (!IS_PRODUCTION) { - for (const cache of this.caches) { - if (cache.hasChanges()) { - logger.warn(`Cache has unsaved changes: ${cache.DisplayName}`) - } + for (const cache of this.caches) { + try { + cache.assertNoChanges() + } catch (e) { + logger.warn(`${cache.DisplayName} has unsaved changes: ${stringifyError(e)}`) } } }