Skip to content

Commit a06d461

Browse files
committed
fix(EAV-929): prevent a still playing part from disappearing from the timeline when more than two parts overlap
keeps track of more than one previous part, so that they are included in the timeline and ab-logic, until stopped playback; timing properties are used for pruning, but an arbitrary limit of 10 is in place, to avoid too much data piling up if blueprints did something very wrong
1 parent 59aff41 commit a06d461

60 files changed

Lines changed: 1451 additions & 372 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

meteor/__mocks__/defaultCollectionObjects.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export function defaultRundownPlaylist(_id: RundownPlaylistId, studioId: StudioI
4747
rehearsal: false,
4848
currentPartInfo: null,
4949
nextPartInfo: null,
50-
previousPartInfo: null,
50+
previousPartsInfo: [],
5151
timing: {
5252
type: 'none' as any,
5353
},

meteor/server/__tests__/cronjobs.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -615,7 +615,7 @@ describe('cronjobs', () => {
615615
externalId: '',
616616
modified: Date.now(),
617617
name: 'Rundown',
618-
previousPartInfo: null,
618+
previousPartsInfo: [],
619619
rundownIdsInOrder: [],
620620
studioId,
621621
timing: {

meteor/server/api/__tests__/externalMessageQueue.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ describe('Test external message queue static methods', () => {
3535
manuallySelected: false,
3636
consumesQueuedSegmentId: false,
3737
},
38-
previousPartInfo: null,
38+
previousPartsInfo: [],
3939
activationId: protectString('active'),
4040
timing: {
4141
type: PlaylistTimingType.None,

meteor/server/api/__tests__/peripheralDevice.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ describe('test peripheralDevice general API methods', () => {
7272
modified: 0,
7373
currentPartInfo: null,
7474
nextPartInfo: null,
75-
previousPartInfo: null,
75+
previousPartsInfo: [],
7676
activationId: protectString('active'),
7777
timing: {
7878
type: PlaylistTimingType.None,

meteor/server/api/deviceTriggers/TagsService.ts

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export class TagsService {
5656
return false
5757
}
5858

59-
const previousPartInstanceId = rundownPlaylist?.previousPartInfo?.partInstanceId
59+
const previousPartInstanceIds = (rundownPlaylist?.previousPartsInfo ?? []).map((info) => info.partInstanceId)
6060
const currentPartInstanceId = rundownPlaylist?.currentPartInfo?.partInstanceId
6161
const nextPartInstanceId = rundownPlaylist?.nextPartInfo?.partInstanceId
6262

@@ -66,13 +66,13 @@ export class TagsService {
6666

6767
const resolvedSourceLayers = applyAndValidateOverrides(showStyleBase.sourceLayersWithOverrides).obj
6868

69-
const inPreviousPartInstance = previousPartInstanceId
70-
? this.processAndPrunePieceInstanceTimings(
71-
cache.PartInstances.findOne(previousPartInstanceId)?.timings,
72-
cache.PieceInstances.find({ partInstanceId: previousPartInstanceId }).fetch(),
73-
resolvedSourceLayers
74-
)
75-
: []
69+
const inPreviousPartInstances = previousPartInstanceIds.flatMap((previousPartInstanceId) =>
70+
this.processAndPrunePieceInstanceTimings(
71+
cache.PartInstances.findOne(previousPartInstanceId)?.timings,
72+
cache.PieceInstances.find({ partInstanceId: previousPartInstanceId }).fetch(),
73+
resolvedSourceLayers
74+
)
75+
)
7676
const inCurrentPartInstance = currentPartInstanceId
7777
? this.processAndPrunePieceInstanceTimings(
7878
cache.PartInstances.findOne(currentPartInstanceId)?.timings,
@@ -88,8 +88,9 @@ export class TagsService {
8888
)
8989
: []
9090

91-
const activePieceInstances = [...inPreviousPartInstance, ...inCurrentPartInstance].filter((pieceInstance) =>
92-
this.isPieceInstanceActive(pieceInstance, previousPartInstanceId, currentPartInstanceId)
91+
const previousPartInstanceIdSet = new Set(previousPartInstanceIds)
92+
const activePieceInstances = [...inPreviousPartInstances, ...inCurrentPartInstance].filter((pieceInstance) =>
93+
this.isPieceInstanceActive(pieceInstance, previousPartInstanceIdSet, currentPartInstanceId)
9394
)
9495

9596
const activePieceInstancesTags = new Set<string>()
@@ -144,14 +145,14 @@ export class TagsService {
144145

145146
private isPieceInstanceActive(
146147
pieceInstance: PieceInstanceWithTimings,
147-
previousPartInstanceId: PartInstanceId | undefined,
148+
previousPartInstanceIds: Set<PartInstanceId>,
148149
currentPartInstanceId: PartInstanceId | undefined
149150
) {
150151
return (
151152
pieceInstance.reportedStoppedPlayback == null &&
152153
pieceInstance.piece.virtual !== true &&
153154
pieceInstance.disabled !== true &&
154-
(pieceInstance.partInstanceId === previousPartInstanceId || // a piece from previous part instance may be active during transition
155+
(previousPartInstanceIds.has(pieceInstance.partInstanceId) || // a piece from a previous part instance may be active during transition/overlap
155156
pieceInstance.partInstanceId === currentPartInstanceId) &&
156157
(pieceInstance.reportedStartedPlayback != null || // has been reported to have started by the Playout Gateway
157158
pieceInstance.plannedStartedPlayback != null || // a time to start playing has been set by Core

meteor/server/api/deviceTriggers/__tests__/TagsService.test.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ const tag2 = 'tag2'
4040
const tag3 = 'tag3'
4141

4242
const tag4 = 'tag4'
43+
const tag5 = 'tag5'
44+
const tag6 = 'tag6'
45+
46+
const partInstanceId3 = protectString<PartInstanceId>('partInstance3')
47+
const partInstanceId4 = protectString<PartInstanceId>('partInstance4')
48+
const pieceInstanceId4 = protectString<PieceInstanceId>('pieceInstance4')
49+
const pieceInstanceId5 = protectString<PieceInstanceId>('pieceInstance5')
4350

4451
function createAndPopulateMockCache(): ContentCache {
4552
const newCache: ContentCache = {
@@ -233,4 +240,164 @@ describe('TagsService', () => {
233240

234241
expect(result).toEqual(true)
235242
})
243+
244+
test('piece in previousPartsInfo[0] (most-recent previous) is treated as on-air', () => {
245+
// partInstanceId3 = previous (index 0), partInstanceId0 = current
246+
const testee = createTestee()
247+
const cache: ContentCache = {
248+
RundownPlaylists: new ReactiveCacheCollection('rundownPlaylists'),
249+
ShowStyleBases: new ReactiveCacheCollection('showStyleBases'),
250+
PieceInstances: new ReactiveCacheCollection('pieceInstances'),
251+
PartInstances: new ReactiveCacheCollection('partInstances'),
252+
}
253+
cache.RundownPlaylists.insert({
254+
_id: playlistId,
255+
activationId,
256+
previousPartsInfo: [{ partInstanceId: partInstanceId3 }],
257+
currentPartInfo: { partInstanceId: partInstanceId0 },
258+
nextPartInfo: { partInstanceId: partInstanceId1 },
259+
} as DBRundownPlaylist)
260+
cache.ShowStyleBases.insert({
261+
_id: showStyleBaseId,
262+
sourceLayersWithOverrides: wrapDefaultObject(
263+
normalizeArray(
264+
[
265+
literal<ISourceLayer>({
266+
_id: sourceLayerId0,
267+
_rank: 0,
268+
name: 'Camera',
269+
type: SourceLayerType.CAMERA,
270+
}),
271+
],
272+
'_id'
273+
)
274+
),
275+
} as DBShowStyleBase)
276+
// Piece in the previous part — started playback, not yet stopped
277+
cache.PieceInstances.insert({
278+
_id: pieceInstanceId4,
279+
piece: {
280+
tags: [tag5],
281+
sourceLayerId: sourceLayerId0,
282+
enable: { start: 0 },
283+
lifespan: PieceLifespan.WithinPart,
284+
},
285+
partInstanceId: partInstanceId3,
286+
plannedStartedPlayback: 1000,
287+
} as PieceInstance)
288+
// Piece in the current part
289+
cache.PieceInstances.insert({
290+
_id: pieceInstanceId0,
291+
piece: {
292+
tags: [tag0],
293+
sourceLayerId: sourceLayerId0,
294+
enable: { start: 0 },
295+
lifespan: PieceLifespan.WithinPart,
296+
},
297+
partInstanceId: partInstanceId0,
298+
} as PieceInstance)
299+
cache.PartInstances.insert({ _id: partInstanceId3 } as DBPartInstance)
300+
cache.PartInstances.insert({ _id: partInstanceId0 } as DBPartInstance)
301+
cache.PartInstances.insert({ _id: partInstanceId1 } as DBPartInstance)
302+
303+
testee.updatePieceInstances(cache, showStyleBaseId)
304+
305+
// tag5 is from previous part → on-air; tag0 is from current → on-air; neither is next
306+
expect(testee.getTallyStateFromTags({ currentPieceTags: [tag5] } as IWrappedAdLib)).toEqual({
307+
isActive: true,
308+
isNext: false,
309+
})
310+
expect(testee.getTallyStateFromTags({ currentPieceTags: [tag0] } as IWrappedAdLib)).toEqual({
311+
isActive: true,
312+
isNext: false,
313+
})
314+
})
315+
316+
test('pieces in all entries of previousPartsInfo are treated as on-air', () => {
317+
// partInstanceId4 = older previous (index 1), partInstanceId3 = recent previous (index 0), partInstanceId0 = current
318+
const testee = createTestee()
319+
const cache: ContentCache = {
320+
RundownPlaylists: new ReactiveCacheCollection('rundownPlaylists'),
321+
ShowStyleBases: new ReactiveCacheCollection('showStyleBases'),
322+
PieceInstances: new ReactiveCacheCollection('pieceInstances'),
323+
PartInstances: new ReactiveCacheCollection('partInstances'),
324+
}
325+
cache.RundownPlaylists.insert({
326+
_id: playlistId,
327+
activationId,
328+
// most-recent-first: index 0 = partInstanceId3, index 1 = partInstanceId4
329+
previousPartsInfo: [{ partInstanceId: partInstanceId3 }, { partInstanceId: partInstanceId4 }],
330+
currentPartInfo: { partInstanceId: partInstanceId0 },
331+
} as DBRundownPlaylist)
332+
cache.ShowStyleBases.insert({
333+
_id: showStyleBaseId,
334+
sourceLayersWithOverrides: wrapDefaultObject(
335+
normalizeArray(
336+
[
337+
literal<ISourceLayer>({
338+
_id: sourceLayerId0,
339+
_rank: 0,
340+
name: 'Camera',
341+
type: SourceLayerType.CAMERA,
342+
}),
343+
],
344+
'_id'
345+
)
346+
),
347+
} as DBShowStyleBase)
348+
// Piece in the most-recent previous part (index 0)
349+
cache.PieceInstances.insert({
350+
_id: pieceInstanceId4,
351+
piece: {
352+
tags: [tag5],
353+
sourceLayerId: sourceLayerId0,
354+
enable: { start: 0 },
355+
lifespan: PieceLifespan.WithinPart,
356+
},
357+
partInstanceId: partInstanceId3,
358+
plannedStartedPlayback: 1000,
359+
} as PieceInstance)
360+
// Piece in the older previous part (index 1) — still has started playback, not stopped
361+
cache.PieceInstances.insert({
362+
_id: pieceInstanceId5,
363+
piece: {
364+
tags: [tag6],
365+
sourceLayerId: sourceLayerId0,
366+
enable: { start: 0 },
367+
lifespan: PieceLifespan.WithinPart,
368+
},
369+
partInstanceId: partInstanceId4,
370+
plannedStartedPlayback: 500,
371+
} as PieceInstance)
372+
// Piece in the current part
373+
cache.PieceInstances.insert({
374+
_id: pieceInstanceId0,
375+
piece: {
376+
tags: [tag0],
377+
sourceLayerId: sourceLayerId0,
378+
enable: { start: 0 },
379+
lifespan: PieceLifespan.WithinPart,
380+
},
381+
partInstanceId: partInstanceId0,
382+
} as PieceInstance)
383+
cache.PartInstances.insert({ _id: partInstanceId4 } as DBPartInstance)
384+
cache.PartInstances.insert({ _id: partInstanceId3 } as DBPartInstance)
385+
cache.PartInstances.insert({ _id: partInstanceId0 } as DBPartInstance)
386+
387+
testee.updatePieceInstances(cache, showStyleBaseId)
388+
389+
// All three tags should be on-air
390+
expect(testee.getTallyStateFromTags({ currentPieceTags: [tag5] } as IWrappedAdLib)).toEqual({
391+
isActive: true,
392+
isNext: false,
393+
})
394+
expect(testee.getTallyStateFromTags({ currentPieceTags: [tag6] } as IWrappedAdLib)).toEqual({
395+
isActive: true,
396+
isNext: false,
397+
})
398+
expect(testee.getTallyStateFromTags({ currentPieceTags: [tag0] } as IWrappedAdLib)).toEqual({
399+
isActive: true,
400+
isNext: false,
401+
})
402+
})
236403
})

meteor/server/api/deviceTriggers/reactiveContentCacheForPieceInstances.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export type RundownPlaylistFields =
1414
| 'activationId'
1515
| 'currentPartInfo'
1616
| 'nextPartInfo'
17-
| 'previousPartInfo'
17+
| 'previousPartsInfo'
1818
export const rundownPlaylistFieldSpecifier = literal<
1919
MongoFieldSpecifierOnesStrict<Pick<DBRundownPlaylist, RundownPlaylistFields>>
2020
>({
@@ -23,7 +23,7 @@ export const rundownPlaylistFieldSpecifier = literal<
2323
activationId: 1,
2424
currentPartInfo: 1,
2525
nextPartInfo: 1,
26-
previousPartInfo: 1,
26+
previousPartsInfo: 1,
2727
})
2828

2929
export type PieceInstanceFields =

packages/corelib/src/dataModel/RundownPlaylist/RundownPlaylist.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,13 @@ export interface DBRundownPlaylist {
135135
nextPartInfo: SelectedPartInstance | null
136136
/** The time offset of the next line */
137137
nextTimeOffset?: number | null
138-
/** the id of the Previous Part */
139-
previousPartInfo: SelectedPartInstance | null
138+
/**
139+
* Previously played PartInstances, ordered most-recent-first (index 0 = the one taken from most recently).
140+
* There may be more than one entry when keepalive/postroll/preroll cause PartInstances to overlap:
141+
* e.g. if Part A is still audible due to postroll when Part C is taken, both A and B are retained here
142+
* until their timeline contribution has fully ended.
143+
*/
144+
previousPartsInfo: SelectedPartInstance[]
140145

141146
/**
142147
* The id of the Queued Segment. If set, the Next point will jump to that segment when reaching the end of the currently playing segment.

packages/job-worker/src/__mocks__/defaultCollectionObjects.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export function defaultRundownPlaylist(_id: RundownPlaylistId, studioId: StudioI
3838
rehearsal: false,
3939
currentPartInfo: null,
4040
nextPartInfo: null,
41-
previousPartInfo: null,
41+
previousPartsInfo: [],
4242

4343
timing: {
4444
type: PlaylistTimingType.None,

packages/job-worker/src/blueprints/context/OnTimelineGenerateContext.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,15 @@ export class OnTimelineGenerateContext extends RundownContext implements ITimeli
4343
showStyleBlueprintConfig: ProcessedShowStyleConfig,
4444
playlist: ReadonlyDeep<DBRundownPlaylist>,
4545
rundown: ReadonlyDeep<DBRundown>,
46-
previousPartInstance: ReadonlyDeep<DBPartInstance> | undefined,
46+
previousPartInstances: ReadonlyDeep<DBPartInstance>[],
4747
currentPartInstance: ReadonlyDeep<DBPartInstance> | undefined,
4848
nextPartInstance: ReadonlyDeep<DBPartInstance> | undefined,
4949
pieceInstances: ReadonlyDeep<ResolvedPieceInstance[]>
5050
) {
5151
super(
5252
{
5353
name: playlist.name,
54-
identifier: `playlistId=${playlist._id},previousPartInstance=${previousPartInstance?._id},currentPartInstance=${currentPartInstance?._id},nextPartInstance=${nextPartInstance?._id}`,
54+
identifier: `playlistId=${playlist._id},previousPartInstance=${previousPartInstances[0]?._id},currentPartInstance=${currentPartInstance?._id},nextPartInstance=${nextPartInstance?._id}`,
5555
},
5656
studio,
5757
studioBlueprintConfig,
@@ -62,11 +62,12 @@ export class OnTimelineGenerateContext extends RundownContext implements ITimeli
6262

6363
this.currentPartInstance = currentPartInstance && convertPartInstanceToBlueprints(currentPartInstance)
6464
this.nextPartInstance = nextPartInstance && convertPartInstanceToBlueprints(nextPartInstance)
65-
this.previousPartInstance = previousPartInstance && convertPartInstanceToBlueprints(previousPartInstance)
65+
this.previousPartInstance =
66+
previousPartInstances[0] && convertPartInstanceToBlueprints(previousPartInstances[0])
6667

6768
this.quickLoopInfo = createBlueprintQuickLoopInfo(playlist)
6869

69-
const partInstances = _.compact([previousPartInstance, currentPartInstance, nextPartInstance])
70+
const partInstances = _.compact([...previousPartInstances, currentPartInstance, nextPartInstance])
7071

7172
for (const pieceInstance of pieceInstances) {
7273
this.#pieceInstanceCache.set(pieceInstance.instance._id, pieceInstance.instance)

0 commit comments

Comments
 (0)