Skip to content

Commit 38f7333

Browse files
committed
Merge branch 'main' into bbc-main
2 parents e3e85f8 + c965434 commit 38f7333

10 files changed

Lines changed: 260 additions & 31 deletions

File tree

meteor/server/api/deviceTriggers/StudioObserver.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -212,14 +212,16 @@ export class StudioObserver extends EventEmitter {
212212
this.nextProps = undefined
213213

214214
const { activePlaylistId, activationId } = this.currentProps
215+
const rundownContentChanged = this.#rundownContentChanged
216+
const pieceInstancesChanged = this.#pieceInstancesChanged
215217

216218
this.showStyleBaseId = showStyleBaseId
217219

218220
this.#rundownsLiveQuery = await RundownsObserver.create(activePlaylistId, async (rundownIds) => {
219221
logger.silly(`Creating new RundownContentObserver`)
220222

221223
const obs1 = await RundownContentObserver.create(activePlaylistId, showStyleBaseId, rundownIds, (cache) => {
222-
return this.#rundownContentChanged(showStyleBaseId, cache)
224+
return rundownContentChanged(showStyleBaseId, cache)
223225
})
224226

225227
return () => {
@@ -228,11 +230,9 @@ export class StudioObserver extends EventEmitter {
228230
})
229231

230232
this.#pieceInstancesLiveQuery = await PieceInstancesObserver.create(activationId, showStyleBaseId, (cache) => {
231-
const cleanupChanges = this.#pieceInstancesChanged(showStyleBaseId, cache)
233+
const cleanupChanges = pieceInstancesChanged(showStyleBaseId, cache)
232234

233-
return () => {
234-
cleanupChanges?.()
235-
}
235+
return () => cleanupChanges?.()
236236
})
237237

238238
if (this.#disposed) {
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { protectString } from '@sofie-automation/corelib/dist/protectedString'
2+
import {
3+
RundownId,
4+
RundownPlaylistActivationId,
5+
RundownPlaylistId,
6+
ShowStyleBaseId,
7+
StudioId,
8+
} from '@sofie-automation/corelib/dist/dataModel/Ids'
9+
10+
import type { ContentCache as RundownContentCache } from '../reactiveContentCache'
11+
import type { ContentCache as PieceInstancesContentCache } from '../reactiveContentCacheForPieceInstances'
12+
import { runAllTimers } from '../../../../__mocks__/helpers/jest'
13+
14+
type OnChangedRundown = (cache: RundownContentCache) => () => void
15+
type OnChangedPieceInstances = (cache: PieceInstancesContentCache) => () => void
16+
17+
let capturedRundownContentOnChanged: OnChangedRundown | undefined
18+
let capturedPieceInstancesOnChanged: OnChangedPieceInstances | undefined
19+
20+
jest.mock('../../../publications/lib/observerChain', () => {
21+
const fakeHandle = { stop: jest.fn() }
22+
const chain: any = {
23+
next: jest.fn(() => chain),
24+
end: jest.fn(() => fakeHandle),
25+
}
26+
return {
27+
observerChain: jest.fn(() => chain),
28+
}
29+
})
30+
31+
jest.mock('../RundownsObserver', () => {
32+
return {
33+
RundownsObserver: {
34+
create: jest.fn(
35+
async (_playlistId: RundownPlaylistId, onChanged: (ids: RundownId[]) => Promise<() => void>) => {
36+
// Immediately drive the callback once, to emulate initial observer execution
37+
await onChanged([protectString<RundownId>('r0')])
38+
return { stop: jest.fn() }
39+
}
40+
),
41+
},
42+
}
43+
})
44+
45+
jest.mock('../RundownContentObserver', () => {
46+
return {
47+
RundownContentObserver: {
48+
create: jest.fn(
49+
async (
50+
_playlistId: RundownPlaylistId,
51+
_showStyleBaseId: ShowStyleBaseId,
52+
_rundownIds: RundownId[],
53+
onChanged: OnChangedRundown
54+
) => {
55+
capturedRundownContentOnChanged = onChanged
56+
return { stop: jest.fn() }
57+
}
58+
),
59+
},
60+
}
61+
})
62+
63+
jest.mock('../PieceInstancesObserver', () => {
64+
return {
65+
PieceInstancesObserver: {
66+
create: jest.fn(
67+
async (
68+
_activationId: RundownPlaylistActivationId,
69+
_showStyleBaseId: ShowStyleBaseId,
70+
onChanged: OnChangedPieceInstances
71+
) => {
72+
capturedPieceInstancesOnChanged = onChanged
73+
return { stop: jest.fn() }
74+
}
75+
),
76+
},
77+
}
78+
})
79+
80+
describe('StudioObserver', () => {
81+
beforeEach(() => {
82+
jest.useFakeTimers()
83+
capturedRundownContentOnChanged = undefined
84+
capturedPieceInstancesOnChanged = undefined
85+
})
86+
87+
test('rundown deactivation regression: observer callbacks must not depend on `this` (private fields)', async () => {
88+
// Import after mocks are in place
89+
const { StudioObserver } = await import('../StudioObserver')
90+
91+
const studioId = protectString<StudioId>('studio0')
92+
const playlistId = protectString<RundownPlaylistId>('playlist0')
93+
const activationId = protectString<RundownPlaylistActivationId>('activation0')
94+
const rundownId = protectString<RundownId>('rundown0')
95+
const showStyleBaseId = protectString<ShowStyleBaseId>('showStyleBase0')
96+
97+
const rundownCleanup = jest.fn()
98+
const pieceCleanup = jest.fn()
99+
100+
const onRundownContentChanged = jest.fn(
101+
(_ssbId: ShowStyleBaseId, _cache: RundownContentCache) => rundownCleanup
102+
)
103+
const onPieceInstancesChanged = jest.fn(
104+
(_ssbId: ShowStyleBaseId, _cache: PieceInstancesContentCache) => pieceCleanup
105+
)
106+
107+
const observer = new StudioObserver(studioId, onRundownContentChanged, onPieceInstancesChanged)
108+
109+
// Prime state so updateShowStyle goes down the creation path
110+
;(observer as any).nextProps = {
111+
activePlaylistId: playlistId,
112+
activationId,
113+
currentRundownId: rundownId,
114+
}
115+
116+
const state = {
117+
currentRundown: { _id: rundownId, showStyleBaseId },
118+
showStyleBase: { _id: showStyleBaseId },
119+
}
120+
121+
// Trigger the debounced execution
122+
const ps: Promise<void> = (observer as any).updateShowStyle.call(state)
123+
124+
// Flush debounce timers and any queued promises
125+
await jest.advanceTimersByTimeAsync(25)
126+
await runAllTimers()
127+
await ps
128+
129+
// Ensure we captured callbacks from the two observers
130+
expect(capturedRundownContentOnChanged).toBeTruthy()
131+
expect(capturedPieceInstancesOnChanged).toBeTruthy()
132+
133+
const mockRundownCache = {} as any as RundownContentCache
134+
const mockPieceInstancesCache = {} as any as PieceInstancesContentCache
135+
136+
// Regression: invoke callbacks without a bound `this` (simulates lost context)
137+
expect(() => capturedRundownContentOnChanged!(mockRundownCache)).not.toThrow()
138+
expect(() => capturedPieceInstancesOnChanged!(mockPieceInstancesCache)).not.toThrow()
139+
140+
// They should return cleanup fns
141+
const cleanup1 = capturedRundownContentOnChanged!(mockRundownCache)
142+
const cleanup2 = capturedPieceInstancesOnChanged!(mockPieceInstancesCache)
143+
expect(typeof cleanup1).toBe('function')
144+
expect(typeof cleanup2).toBe('function')
145+
146+
// Ensure our handlers were called with expected args
147+
expect(onRundownContentChanged).toHaveBeenCalledWith(showStyleBaseId, mockRundownCache)
148+
expect(onPieceInstancesChanged).toHaveBeenCalledWith(showStyleBaseId, mockPieceInstancesCache)
149+
150+
// Ensure returned cleanup fns are callable
151+
expect(() => cleanup1()).not.toThrow()
152+
expect(() => cleanup2()).not.toThrow()
153+
154+
observer.stop()
155+
})
156+
})

packages/job-worker/src/ingest/__tests__/updateNext.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { protectString } from '@sofie-automation/corelib/dist/protectedString'
77
import { saveIntoDb } from '../../db/changes.js'
88
import { ensureNextPartIsValid as ensureNextPartIsValidRaw } from '../updateNext.js'
99
import { MockJobContext, setupDefaultJobEnvironment } from '../../__mocks__/context.js'
10+
import { setupMockShowStyleCompound } from '../../__mocks__/presetCollections.js'
1011
import { runJobWithPlayoutModel } from '../../playout/lock.js'
1112

1213
jest.mock('../../playout/setNext')
@@ -538,6 +539,47 @@ describe('ensureNextPartIsValid', () => {
538539
false
539540
)
540541
})
542+
test('Next part instance is orphaned: "deleted" and `syncIngestUpdateToPartInstance` exists', async () => {
543+
const showStyleCompound = await setupMockShowStyleCompound(context)
544+
await context.mockCollections.Rundowns.update(rundownId, {
545+
$set: {
546+
showStyleBaseId: showStyleCompound._id,
547+
showStyleVariantId: showStyleCompound.showStyleVariantId,
548+
},
549+
})
550+
context.updateShowStyleBlueprint({
551+
syncIngestUpdateToPartInstance: jest.fn(),
552+
})
553+
554+
const instanceId: PartInstanceId = protectString('orphaned_first_part_with_callback')
555+
await context.mockCollections.PartInstances.insertOne(
556+
literal<DBPartInstance>({
557+
_id: instanceId,
558+
rundownId: rundownId,
559+
segmentId: protectString('mock_segment1'),
560+
playlistActivationId: protectString('active'),
561+
segmentPlayoutId: protectString(''),
562+
takeCount: 0,
563+
rehearsal: false,
564+
part: literal<DBPart>({
565+
_id: protectString('orphan_with_callback_1'),
566+
_rank: 1.5,
567+
rundownId: rundownId,
568+
segmentId: protectString('mock_segment1'),
569+
externalId: 'o1-callback',
570+
title: 'Orphan 1 Callback',
571+
expectedDurationWithTransition: undefined,
572+
}),
573+
orphaned: 'deleted',
574+
})
575+
)
576+
577+
await resetPartIds(null, instanceId, false)
578+
579+
await expect(ensureNextPartIsValid()).resolves.toBeFalsy()
580+
581+
expect(setNextPartMock).not.toHaveBeenCalled()
582+
})
541583
test('Next part is invalid, but instance is not', async () => {
542584
// Insert a temporary instance
543585
const instanceId: PartInstanceId = protectString('orphaned_first_part')

packages/job-worker/src/ingest/updateNext.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,13 @@ import { setNextPart } from '../playout/setNext.js'
55
import { isPartPlayable } from '@sofie-automation/corelib/dist/dataModel/Part'
66

77
/**
8-
* Make sure that the nextPartInstance for the current Playlist is still correct
9-
* This will often change the nextPartInstance
8+
* Make sure that the nextPartInstance for the current Playlist is still correct.
9+
* This will often change the nextPartInstance.
10+
*
11+
* If the selected next part has been deleted by ingest, the default behavior is to
12+
* drop it and autoselect a replacement. If the show-style blueprint defines
13+
* `syncIngestUpdateToPartInstance`, the deleted next part is kept selected here so
14+
* the blueprint can decide whether to remove it or preserve it.
1015
* @param context Context of the job being run
1116
* @param playoutModel Playout Model to operate on
1217
* @returns Whether the timeline should be updated following this operation
@@ -43,14 +48,22 @@ export async function ensureNextPartIsValid(context: JobContext, playoutModel: P
4348

4449
const orderedSegments = playoutModel.getAllOrderedSegments()
4550
const orderedParts = playoutModel.getAllOrderedParts()
51+
const nextPartIsDeleted = nextPartInstance?.partInstance.orphaned === 'deleted'
4652

47-
if (!nextPartInstance || nextPartInstance.partInstance.orphaned === 'deleted') {
48-
// Don't have a nextPart or it has been deleted, so autoselect something
53+
if (nextPartIsDeleted && (await hasSyncIngestUpdateToPartInstance(context, playoutModel, nextPartInstance))) {
54+
// Source has deleted this part, but it is selected as next.
55+
// Keep it selected, and let blueprint function `syncIngestUpdateToPartInstance` decide what to do with it.
56+
span?.end()
57+
return false
58+
}
59+
60+
if (!nextPartInstance || nextPartIsDeleted) {
61+
// Don't have a nextPart, so autoselect something
4962
const newNextPart = selectNextPart(
5063
context,
5164
playlist,
5265
currentPartInstance?.partInstance ?? null,
53-
nextPartInstance?.partInstance ?? null,
66+
null,
5467
orderedSegments,
5568
orderedParts,
5669
{ ignoreUnplayable: true, ignoreQuickLoop: false }
@@ -97,3 +110,26 @@ export async function ensureNextPartIsValid(context: JobContext, playoutModel: P
97110
span?.end()
98111
return false
99112
}
113+
114+
async function hasSyncIngestUpdateToPartInstance(
115+
context: JobContext,
116+
playoutModel: PlayoutModel,
117+
nextPartInstance: PlayoutModel['nextPartInstance']
118+
): Promise<boolean> {
119+
if (!nextPartInstance) return false
120+
const rundown = playoutModel.getRundown(nextPartInstance.partInstance.part.rundownId)
121+
if (!rundown) return false
122+
if (!rundown.rundown.showStyleVariantId) return false
123+
124+
try {
125+
const showStyle = await context.getShowStyleCompound(
126+
rundown.rundown.showStyleVariantId,
127+
rundown.rundown.showStyleBaseId
128+
)
129+
const blueprint = await context.getShowStyleBlueprint(showStyle._id)
130+
131+
return !!blueprint.blueprint.syncIngestUpdateToPartInstance
132+
} catch {
133+
return false
134+
}
135+
}

packages/webui/src/client/lib/collapseJSON.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export function CollapseJSON({ json }: { json: string }): JSX.Element {
5959
<Button
6060
variant="light"
6161
size="sm"
62-
key={'collapse'}
62+
key={'copy'}
6363
className="collapse-json__copy"
6464
tabIndex={0}
6565
onClick={(e) => {

packages/webui/src/client/ui/ClockView/TTimerDisplay.tsx

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,31 +4,22 @@ import { calculateTTimerDiff, calculateTTimerOverUnder } from '../../lib/tTimerU
44
import { useTiming } from '../RundownView/RundownTiming/withTiming.js'
55
import { OverUnderChip } from '../../lib/Components/OverUnderChip.js'
66
import { Countdown } from '../RundownView/RundownHeader/Countdown.js'
7+
import { getCurrentTime } from '../../lib/systemTime.js'
78

89
interface TTimerDisplayProps {
910
timer: RundownTTimer
1011
}
1112

1213
export function TTimerDisplay({ timer }: Readonly<TTimerDisplayProps>): JSX.Element | null {
13-
useTiming()
14+
const timing = useTiming()
1415

1516
if (!timer.mode) return null
1617

17-
const now = Date.now()
18+
const now = timing.currentTime ?? getCurrentTime()
1819

1920
const diff = calculateTTimerDiff(timer, now)
2021
const overUnder = calculateTTimerOverUnder(timer, now)
21-
const timeStr = RundownUtils.formatDiffToTimecode(
22-
Math.abs(diff),
23-
false,
24-
true,
25-
true,
26-
false,
27-
true,
28-
undefined,
29-
true,
30-
true
31-
)
22+
const timeStr = RundownUtils.formatDiffToTimecode(Math.abs(diff), false, true, true, false, true)
3223
const timerSign = diff >= 0 ? '' : '-'
3324

3425
return (

packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderDurations.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Countdown } from './Countdown'
44
import { useTiming } from '../RundownTiming/withTiming'
55
import { RundownUtils } from '../../../lib/rundown.js'
66
import { PlaylistTiming } from '@sofie-automation/corelib/dist/playout/rundownTiming'
7+
import { getCurrentTime } from '../../../lib/systemTime'
78

89
export function RundownHeaderDurations({
910
playlist,
@@ -21,7 +22,7 @@ export function RundownHeaderDurations({
2122

2223
const estDuration = PlaylistTiming.getRemainingDuration(
2324
playlist.timing,
24-
timingDurations.currentTime ?? Date.now(),
25+
timingDurations.currentTime ?? getCurrentTime(),
2526
timingDurations.remainingPlaylistDuration,
2627
startedPlayback
2728
)

packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderExpectedEnd.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { PlaylistTiming } from '@sofie-automation/corelib/dist/playout/rundownTi
33
import { useTranslation } from 'react-i18next'
44
import { Countdown } from './Countdown'
55
import { useTiming } from '../RundownTiming/withTiming'
6+
import { getCurrentTime } from '../../../lib/systemTime'
67

78
export function RundownHeaderExpectedEnd({
89
playlist,
@@ -14,7 +15,7 @@ export function RundownHeaderExpectedEnd({
1415
const { t } = useTranslation()
1516
const timingDurations = useTiming()
1617

17-
const now = timingDurations.currentTime ?? Date.now()
18+
const now = timingDurations.currentTime ?? getCurrentTime()
1819
const expectedEnd = PlaylistTiming.getExpectedEnd(playlist.timing)
1920
const startedPlayback = playlist.activationId ? playlist.startedPlayback : undefined
2021
const estEnd = PlaylistTiming.getEstimatedEnd(

0 commit comments

Comments
 (0)