Skip to content

fix: prevent a still playing part from disappearing from the timeline#1700

Open
ianshade wants to merge 6 commits into
Sofie-Automation:mainfrom
tv2norge-collab:contribute/EAV-949
Open

fix: prevent a still playing part from disappearing from the timeline#1700
ianshade wants to merge 6 commits into
Sofie-Automation:mainfrom
tv2norge-collab:contribute/EAV-949

Conversation

@ianshade
Copy link
Copy Markdown
Contributor

@ianshade ianshade commented Mar 27, 2026

About the Contributor

This pull request is posted on behalf of TV 2 Norge.

Type of Contribution

This is a:

Bug fix (handling an edge case that probably no one encountered before us)

Current Behavior

PartInstance expected to be kept alive using e.g. previousPartKeepaliveDuration, sometimes would prematurely disappear from the timeline, if two following PartInstances started their playback.
Previous partInstance(s) were not properly considered when generating lookaheads. When a take occurred, and a partinstance became previous while its timed piece was yet to start, there would be no timed lookahead generated from that piece.

There are times when e.g. VT playback should continue into the taken part, so that a transition can safely execute till completion. Due to our transitions being separate Parts, we end up in a scenario when at a certain point three parts need to be active. Unfortunately Sofie supports only two actively playing Parts at any given time (only previous and current + next for lookahads, and briefly during autoNext, which is not enough). In our case, during a transition, the to Part quickly becomes current, whereas the transition Part becomes previous, that means the from Part no longer is considered for timeline generation, yet it is needed for its VB contents to keep playing until the transition finishes. Currently this results in either black frames during a transition, or, in conjunction with preroll applied to some pieces, holes on the PGM layer mapping.

New Behavior

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

Testing

  • I have added one or more unit tests for this PR
  • I have updated the relevant unit tests
  • No unit test changes are needed for this PR

Affected areas

Time Frame

Other Information

Status

  • PR is ready to be reviewed.
  • The functionality has been tested by the author.
  • Relevant unit tests has been added / updated.
  • Relevant documentation (code comments, system documentation) has been added / updated.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 27, 2026

Walkthrough

Refactors RundownPlaylist to track multiple previous part instances: previousPartInfo: SelectedPartInstance | nullpreviousPartsInfo: SelectedPartInstance[]. Updates data model, playout model, lookahead/timeline/resolve logic, device triggers, UI subscriptions, and many tests/mocks to the new array shape and ordering semantics.

Changes

Cohort / File(s) Summary
Core data model
packages/corelib/src/dataModel/RundownPlaylist/RundownPlaylist.ts
Replaced `previousPartInfo: SelectedPartInstance
Mocks & test fixtures
meteor/__mocks__/defaultCollectionObjects.ts, packages/job-worker/src/__mocks__/defaultCollectionObjects.ts, packages/webui/src/__mocks__/defaultCollectionObjects.ts, meteor/server/__tests__/cronjobs.test.ts, meteor/server/api/__tests__/*, packages/*/__tests__/*
Initialized mocks/fixtures to previousPartsInfo: [] and adjusted many tests to pass or assert previous as an array (often using index [0]).
Playout model API & impl
packages/job-worker/src/playout/model/PlayoutModel.ts, .../PlayoutModelImpl.ts, .../LoadPlayoutModel.ts
Added previousPartInstances getter, renamed clearPreviousPartInstance()clearPreviousPartInstances(), added prunePreviousPartInstances(now), and updated selected-part ids/instances to include all previous entries.
Timeline generation & rundown
packages/job-worker/src/playout/timeline/generate.ts, .../rundown.ts
SelectedPartInstancesTimelineInfo.previous → array; generation now iterates all previous parts, chains end/cap timings, and updates overlap/timing context accordingly.
Lookahead engine
packages/job-worker/src/playout/lookahead/findForLayer.ts, .../index.ts
previous becomes an array (oldest-first for processing); lookahead now generates objects for every previous part and merges/prunes continuations across them.
Resolved pieces & merge logic
packages/job-worker/src/playout/resolvedPieces.ts
Resolves and caps piece instances from multiple previous parts, computing per-entry capEnd and merging infinite chains across the accumulated previous pieces.
Device triggers / TagsService
meteor/server/api/deviceTriggers/TagsService.ts, .../reactiveContentCacheForPieceInstances.ts
isPieceInstanceActive updated to accept a Set of previous partInstanceIds; content cache selection/projections updated to request previousPartsInfo.
Playout operations & cleanup
packages/job-worker/src/playout/commit.ts, .../setNext.ts, .../timings/partPlayback.ts
Segment removal and orphan cleanup now consider all previousPartsInfo; pruning triggered on part stop; various calls updated to use plural previous entries.
Blueprint/context & job-worker flows
packages/job-worker/src/blueprints/context/OnTimelineGenerateContext.ts, packages/job-worker/src/ingest/commit.ts, packages/job-worker/src/rundownPlaylists.ts
Constructor/signatures updated to accept arrays of previous part instances; new playlists initialized with previousPartsInfo: []; commit logic adapted for multiple previous parts.
Live-status gateway collections
packages/live-status-gateway/src/collections/partInstancesHandler.ts, .../pieceInstancesHandler.ts
SelectedPartInstances.previousDBPartInstance[]; aggregation and active-piece computations iterate all previous parts; change detection uses shallow element comparison.
Web UI updates
packages/webui/src/client/lib/rundownPlaylistUtil.ts, various packages/webui/src/client/ui/** files
Subscriptions, Picks and lookups updated to use previousPartsInfo?.[0] (or include previousPartsInfo in projections); getSelectedPartInstances signature updated.
Playout lookahead & timeline tests
packages/job-worker/src/playout/lookahead/__tests__/*, packages/job-worker/src/playout/timeline/__tests__/rundown.test.ts
Extensive test changes/additions to validate multiple previous parts, adjust mock call counts, and cover pruning/capping behavior.
Snapshot & migration logic
packages/job-worker/src/playout/snapshot.ts
Migration logic updated to convert legacy previousPartInfo into previousPartsInfo array and remap ids accordingly.
Other integrations & minor updates
packages/job-worker/src/playout/abPlayback/index.ts, packages/job-worker/src/playout/resolvedPieces.test.ts, packages/webui/src/client/__tests__/*, packages/live-status-gateway/src/topics/__tests__/*
Identifiers, tests and auxiliary code updated to refer to previousPartsInfo or previousPartsInfo?.[0] where appropriate.

Sequence Diagram(s)

sequenceDiagram
  participant UI as Client(UI)
  participant API as API / Webserver
  participant DB as DB (RundownPlaylists)
  participant Playout as PlayoutModel
  participant Lookahead as Lookahead/ResolvedPieces
  participant Timeline as Timeline Generator

  UI->>API: request playlist / trigger take
  API->>DB: read `previousPartsInfo`
  DB-->>API: playlist with previousPartsInfo[]
  API->>Playout: loadPartInstances(previousPartsInfo[])
  Playout->>Lookahead: compute lookahead using previousPartsInfo (oldest-first)
  Lookahead->>Timeline: generate timeline objects (chain caps/overlap)
  Timeline-->>API: timeline payload
  API-->>UI: updated timeline / state
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • PeterC89
  • jstarpl
  • Julusian
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.89% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly describes the main change: preventing a still-playing part from disappearing from the timeline, which is the core bug being fixed.
Description check ✅ Passed The PR description is comprehensive and directly related to the changeset, explaining the bug, root cause, practical impact, and the new behavior of tracking multiple previous parts.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 27, 2026

@ianshade ianshade force-pushed the contribute/EAV-949 branch 2 times, most recently from f41537d to b544d85 Compare March 27, 2026 15:32
@ianshade ianshade changed the title fix(EAV-929): prevent a still playing part from disappearing from the timeline fix: prevent a still playing part from disappearing from the timeline Mar 27, 2026
@ianshade ianshade force-pushed the contribute/EAV-949 branch from b544d85 to 739abe8 Compare April 8, 2026 13:25
@ianshade ianshade force-pushed the contribute/EAV-949 branch 2 times, most recently from a06d461 to 5bf5f55 Compare April 21, 2026 09:12
… 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
@ianshade ianshade force-pushed the contribute/EAV-949 branch from 5bf5f55 to e1076ed Compare April 21, 2026 09:27
@ianshade ianshade added the Contribution from TV 2 Norge Contributions sponsored by TV 2 Norge (tv2.no) label Apr 21, 2026
@ianshade ianshade marked this pull request as ready for review April 21, 2026 10:49
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (6)
packages/webui/src/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx (1)

158-178: ⚠️ Potential issue | 🟠 Major

Check every previous part before entering duration settling.

Line 162 only treats previousPartsInfo[0] as previous. With multiple still-active previous parts, older entries will skip isDurationSettling, so their timeline width can stop reflecting ongoing playback before they are pruned.

🐛 Proposed fix
-		const isPrevious = nextProps.playlist.previousPartsInfo?.[0]?.partInstanceId === nextProps.part.instance._id
+		const isPrevious =
+			nextProps.playlist.previousPartsInfo?.some(
+				(previousPartInfo) => previousPartInfo.partInstanceId === nextProps.part.instance._id
+			) ?? false
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/webui/src/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx`
around lines 158 - 178, getDerivedStateFromProps currently only checks
previousPartsInfo[0] to determine if a part is previous, which misses older
still-active previous parts; update the isPrevious calculation in
getDerivedStateFromProps to test the entire nextProps.playlist.previousPartsInfo
array (e.g. using .some(...) to compare partInstanceId to
nextProps.part.instance._id) so that isDurationSettling (which relies on
isPrevious) correctly applies for any previous part entry rather than only the
first one.
packages/webui/src/client/ui/SegmentTimeline/SegmentContextMenu.tsx (1)

122-127: ⚠️ Potential issue | 🟠 Major

Disable edits for all active previous parts.

Line 126 only blocks edits for previousPartsInfo[0]. Older previous parts can still be active after this PR, so user edit operations can become enabled on on-air previous content.

🐛 Proposed fix
+	const partInstanceId = part?.instance._id
+
 	const isPartEditAble =
+		!!partInstanceId &&
 		isSegmentEditAble &&
-		part?.instance._id !== playlist.currentPartInfo?.partInstanceId &&
-		part?.instance._id !== playlist.nextPartInfo?.partInstanceId &&
-		part?.instance._id !== playlist.previousPartsInfo?.[0]?.partInstanceId
+		partInstanceId !== playlist.currentPartInfo?.partInstanceId &&
+		partInstanceId !== playlist.nextPartInfo?.partInstanceId &&
+		!(playlist.previousPartsInfo?.some((previousPartInfo) => previousPartInfo.partInstanceId === partInstanceId) ?? false)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/webui/src/client/ui/SegmentTimeline/SegmentContextMenu.tsx` around
lines 122 - 127, The isPartEditAble check currently only excludes
playlist.previousPartsInfo?.[0] so older previous parts remain editable; update
the condition in the isPartEditAble computation to check against all entries in
playlist.previousPartsInfo (e.g., ensure part?.instance._id is not equal to any
previousPartsInfo[].partInstanceId by using Array.some/Array.find and negating
it) so edits are disabled for any active previous part; reference the
isPartEditAble variable, part?.instance._id, and playlist.previousPartsInfo to
locate and modify the logic.
packages/webui/src/client/ui/RundownView/RundownViewSubscriptions.ts (1)

91-113: ⚠️ Potential issue | 🟠 Major

Subscribe to piece instances for every previous part.

This still includes only previousPartsInfo[0]. With multiple previous parts alive, older previous part instances won’t have their piece instances subscribed on the client, so the rundown/timeline UI can still drop the part this PR is trying to preserve.

Proposed subscription fix
 			meteorSubscribe(
 				CorelibPubSub.pieceInstances,
 				rundownIds,
 				[
 					playlist.currentPartInfo?.partInstanceId,
 					playlist.nextPartInfo?.partInstanceId,
-					playlist.previousPartsInfo?.[0]?.partInstanceId,
-				].filter((p): p is PartInstanceId => p !== null),
+					...(playlist.previousPartsInfo ?? []).map((info) => info.partInstanceId),
+				].filter((p): p is PartInstanceId => p !== null && p !== undefined),
 				{}
 			)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/webui/src/client/ui/RundownView/RundownViewSubscriptions.ts` around
lines 91 - 113, The subscription currently passes only previousPartsInfo[0] as
the previous part instance to meteorSubscribe, so pieceInstances for older
previous parts are not subscribed; update the argument construction passed to
meteorSubscribe (where playlist is read and meteorSubscribe is called) to
include all partInstanceId values from playlist.previousPartsInfo (e.g., map
previousPartsInfo to partInstanceId and filter nulls) instead of only
previousPartsInfo[0], keeping the same filtering to PartInstanceId and
preserving currentPartInfo and nextPartInfo inclusion.
packages/webui/src/client/ui/Shelf/AdLibPanel.tsx (1)

142-179: ⚠️ Potential issue | 🟠 Major

Invalidate the adlib segment cache for the full previous-part chain.

Line 172 watches only previousPartsInfo[0], but fetchAndFilter later builds active part instances from the playlist. If an older previous part is added/pruned while the latest previous entry stays the same, the cached UI segments can be stale.

Proposed cache-key fix
 export function useFetchAndFilter(
 	playlist: DBRundownPlaylist | undefined,
 	showStyleBase: UIShowStyleBase | undefined,
 	filter: RundownLayoutFilterBase | undefined,
 	includeGlobalAdLibs: boolean | undefined
 ): AdLibFetchAndFilterProps {
+	const previousPartInstanceIdsKey = playlist?.previousPartsInfo?.map((info) => info.partInstanceId).join('\0') ?? ''
+
 	return useTracker(
 		() =>
 			playlist && showStyleBase
 				? fetchAndFilter({
@@
 			playlist?.currentPartInfo?.partInstanceId,
 			playlist?.nextPartInfo?.partInstanceId,
-			playlist?.previousPartsInfo?.[0]?.partInstanceId,
+			previousPartInstanceIdsKey,
 			playlist?.rundownIdsInOrder,
 			showStyleBase?._id,

Also include the same key in the memoizedIsolatedAutorun arguments that compute uiSegments, because that block calls getActivePartInstancesMap(props.playlist):

 		(
 			currentPartInstanceId: PartInstanceId | null,
 			nextPartInstanceId: PartInstanceId | null,
+			_previousPartInstanceIdsKey: string,
 			segments: DBSegment[],
 			rundowns: Record<string, MinimalRundown>
 		) => {
@@
 		`uiSegments_${props.filter?._id || 'no_filter'}`,
 		props.playlist.currentPartInfo?.partInstanceId ?? null,
 		props.playlist.nextPartInfo?.partInstanceId ?? null,
+		props.playlist.previousPartsInfo?.map((info) => info.partInstanceId).join('\0') ?? '',
 		segments,
 		rundowns
 	)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/webui/src/client/ui/Shelf/AdLibPanel.tsx` around lines 142 - 179,
The cache key only watches previousPartsInfo[0], causing stale uiSegments when
deeper previous parts change; update useFetchAndFilter to include the full
previousPartsInfo array (e.g. playlist?.previousPartsInfo) in the dependency
list passed to useTracker so fetchAndFilter sees changes across the whole chain,
and also add that same full previousPartsInfo dependency to the
memoizedIsolatedAutorun (the block that computes uiSegments / calls
getActivePartInstancesMap) so the memoized computation invalidates when any
previous part is added/pruned; reference useFetchAndFilter, fetchAndFilter,
memoizedIsolatedAutorun and uiSegments when applying this change.
packages/job-worker/src/ingest/commit.ts (2)

474-485: ⚠️ Potential issue | 🔴 Critical

Preserve active previous PartInstances during ingest cleanup.

Lines 479-480 only protect current/next. With this PR, a still-active previous PartInstance can be reset when its source Part is removed, which can drop the very timeline contribution this change is trying to keep alive.

Proposed fix
 	const instancesToReset: PartInstanceId[] = []
 	const instancesToOrphan: PartInstanceId[] = []
+	const previousPartInstanceIds = new Set((playlist.previousPartsInfo ?? []).map((info) => info.partInstanceId))
 	for (const partInstance of partInstancesToOrphan) {
 		if (
+			!previousPartInstanceIds.has(partInstance._id) &&
 			playlist.currentPartInfo?.partInstanceId !== partInstance._id &&
 			playlist.nextPartInfo?.partInstanceId !== partInstance._id
 		) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/job-worker/src/ingest/commit.ts` around lines 474 - 485, The loop
that classifies partInstancesToOrphan currently only protects
playlist.currentPartInfo?.partInstanceId and
playlist.nextPartInfo?.partInstanceId, so a still-active previous partInstance
can be incorrectly added to instancesToReset; update the condition inside the
for (const partInstance of partInstancesToOrphan) loop to also check and protect
playlist.previousPartInfo?.partInstanceId (i.e., compare partInstance._id
against playlist.previousPartInfo?.partInstanceId alongside current and next) so
active previous PartInstances are pushed to instancesToOrphan rather than
instancesToReset.

598-605: ⚠️ Potential issue | 🟠 Major

Check the full previous-part chain before clearing rundown references.

Line 602 only checks the most recent previous PartInstance. If an older previousPartsInfo entry belongs to rundownIdToForget, it remains referenced after that rundown is removed.

Proposed fix
 		if (
 			rundownIdToForget &&
-			playoutModel.previousPartInstance &&
-			playoutModel.previousPartInstance.partInstance.rundownId === rundownIdToForget
+			playoutModel.previousPartInstances.some(
+				(previousPartInstance) => previousPartInstance.partInstance.rundownId === rundownIdToForget
+			)
 		) {
 			playoutModel.clearPreviousPartInstances()
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/job-worker/src/ingest/commit.ts` around lines 598 - 605, The current
check only inspects playoutModel.previousPartInstance; update the logic to scan
the whole previousPartsInfo chain and clear references if any entry references
rundownIdToForget. Concretely, iterate playoutModel.previousPartsInfo (or
equivalent array on playoutModel) and if any entry.partInstance.rundownId ===
rundownIdToForget then call playoutModel.clearPreviousPartInstances(); otherwise
leave the chain intact; use the existing symbols previousPartInstance,
previousPartsInfo, rundownIdToForget, and
playoutModel.clearPreviousPartInstances() to locate and modify the code.
🧹 Nitpick comments (5)
packages/job-worker/src/blueprints/context/services/__tests__/PartAndPieceInstanceActionService.test.ts (1)

287-291: Consider letting this helper seed multiple previous parts.

The helper always writes a single-element previousPartsInfo, so tests using it can’t cover the new multi-previous-part behavior introduced by this PR.

Proposed helper refactor
-		previousPartInstance?: PlayoutPartInstanceModel | DBPartInstance | PieceInstance | null
+		previousPartInstance?:
+			| PlayoutPartInstanceModel
+			| DBPartInstance
+			| PieceInstance
+			| Array<PlayoutPartInstanceModel | DBPartInstance | PieceInstance>
+			| null
@@
 		if (previousPartInstance !== undefined) {
+			const previousPartInstances = Array.isArray(previousPartInstance)
+				? previousPartInstance
+				: previousPartInstance
+					? [previousPartInstance]
+					: []
 			await jobContext.mockCollections.RundownPlaylists.update(playlistId, {
 				$set: {
-					previousPartsInfo: previousPartInstance ? [convertInfo(previousPartInstance)!] : [],
+					previousPartsInfo: previousPartInstances.map((partInstance) => convertInfo(partInstance)!),
 				},
 			})
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/job-worker/src/blueprints/context/services/__tests__/PartAndPieceInstanceActionService.test.ts`
around lines 287 - 291, The helper currently only seeds a single previous part
by setting previousPartsInfo to previousPartInstance ?
[convertInfo(previousPartInstance)!] : [], so update the helper signature to
accept multiple previous parts (e.g., previousPartInstances or allow an array),
and change the RundownPlaylists.update call (still referenced as
jobContext.mockCollections.RundownPlaylists.update and playlistId) to set
previousPartsInfo to previousPartInstances?.map(convertInfo).filter(Boolean) ||
[] so tests can seed and exercise multi-previous-part behavior; keep convertInfo
usage and preserve the empty-array fallback when none supplied.
packages/job-worker/src/playout/lookahead/__tests__/lookahead.test.ts (1)

296-318: Add coverage for multiple previous parts and ordering.

This test only verifies a single previous entry. Since the fix depends on preserving a chain of previous parts, please add a case with at least two entries and assert that findLookaheadForLayer receives them in the expected oldest-first order.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/job-worker/src/playout/lookahead/__tests__/lookahead.test.ts` around
lines 296 - 318, Add a test case that creates at least two previous entries in
the SelectedPartInstancesTimelineInfo (e.g. duplicate previousEntry with
different partInstance._id and different partTimes.nowInPart values), push them
into partInstancesInfo.previous in chronological order (oldest first), call
getLookeaheadObjects via runJobWithPlayoutModel as done now, and update the
expectedPrevious to be an array of the two mapped objects (using
previousEntry.partInstance, partTimes.nowInPart, pieceInstances,
calculatedTimings) in oldest-first order; finally assert via
expectLookaheadForLayerMock (and indirectly findLookaheadForLayer) that it was
invoked with previous equal to that ordered array to verify ordering and
multiple-previous handling for getLookeaheadObjects.
meteor/server/api/deviceTriggers/__tests__/TagsService.test.ts (1)

244-402: LGTM — coverage matches the contract that pieces from any entry of previousPartsInfo are tallied as on-air. The setup duplicates portions of createAndPopulateMockCache; a small shared helper accepting previousPartsInfo could reduce the repetition, but it's not essential.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@meteor/server/api/deviceTriggers/__tests__/TagsService.test.ts` around lines
244 - 402, Tests duplicate cache setup between the two cases; extract the
repeated setup into a small helper (e.g. createAndPopulateMockCache or
buildTestCache) that accepts previousPartsInfo (array of { partInstanceId }) and
returns the created testee and cache (or at least the cache and showStyleBaseId)
so both tests call that helper, then call testee.updatePieceInstances(cache,
showStyleBaseId) and assert via testee.getTallyStateFromTags; update references
to createTestee, cache construction, ShowStyleBases insert,
PieceInstances/PartInstances inserts to use the helper to remove duplication.
packages/job-worker/src/playout/__tests__/resolvedPieces.test.ts (1)

972-1070: Nice coverage for the multi-previous-parts path.

The two new tests lock in both the cap-at-following-part boundary and the "don't extend a naturally shorter piece" invariant, which is exactly the behavior the implementation in resolvedPieces.ts relies on. It might be worth adding a one-liner comment in the test file (or a JSDoc on SelectedPartInstancesTimelineInfo.previous) stating that previous is most-recent-first, since that ordering is now load-bearing for the cap computation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/job-worker/src/playout/__tests__/resolvedPieces.test.ts` around
lines 972 - 1070, Tests rely on the ordering of
SelectedPartInstancesTimelineInfo.previous being most-recent-first but there's
no explicit comment; add a one-line clarifying comment or JSDoc stating that
SelectedPartInstancesTimelineInfo.previous is ordered most-recent-first (newest
first) so the cap computation in getResolvedPiecesForPartInstancesOnTimeline is
unambiguous. Update either the test file near the test block or the
SelectedPartInstancesTimelineInfo.previous property JSDoc (or type/interface
declaration) to include that single-line clarification.
packages/job-worker/src/playout/resolvedPieces.ts (1)

78-96: Ordering contract is now load-bearing; consider documenting it.

The per-entry capEnd derivation relies on partInstancesInfo.previous being ordered most-recent-firstprevious[0] must start after previous[1], etc. This is consistent with the tests and the UI helper, but the contract is implicit on the type. A short JSDoc on SelectedPartInstancesTimelineInfo.previous (in timeline/generate.ts) stating the ordering would protect future callers from silently producing negative cap intervals.

Two smaller notes on the same block:

  • If previous[i-1] was continue'd (no partStartTime), the fallback to currentPartStarted collapses the cap for the entry at i to the current-part boundary, silently losing the intermediate cap. Unlikely in practice, but worth being aware of.
  • allPreviousResolvedPieces = allPreviousResolvedPieces.concat(resolved) in a loop can be replaced with allPreviousResolvedPieces.push(...resolved) for O(n) growth. Negligible given the 10-previous cap mentioned in the PR description.
♻️ Optional tweak
-	let allPreviousResolvedPieces: ResolvedPieceInstance[] = []
+	const allPreviousResolvedPieces: ResolvedPieceInstance[] = []
 	for (let i = 0; i < partInstancesInfo.previous.length; i++) {
 		const prevInfo = partInstancesInfo.previous[i]
 		if (!prevInfo.partTimes.partStartTime) continue

 		const capEnd =
 			i === 0
 				? currentPartStarted
 				: (partInstancesInfo.previous[i - 1].partTimes.partStartTime ?? currentPartStarted)

 		const resolved = prevInfo.pieceInstances.map((instance) =>
 			resolvePrunedPieceInstance(prevInfo.partTimes, instance)
 		)
 		offsetResolvedStartAndCapDuration(resolved, prevInfo.partTimes.partStartTime, capEnd)
-		allPreviousResolvedPieces = allPreviousResolvedPieces.concat(resolved)
+		allPreviousResolvedPieces.push(...resolved)
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/job-worker/src/playout/resolvedPieces.ts` around lines 78 - 96,
Document the ordering contract on SelectedPartInstancesTimelineInfo.previous in
timeline/generate.ts (JSDoc: array is most-recent-first so previous[0] starts
after previous[1], etc.). In resolvedPieces.ts, make the cap calculation robust:
when computing capEnd for index i, search backward from i-1 to find the nearest
previous entry with partTimes.partStartTime and use that startTime as the cap
(fallback to currentPartStarted only if none found) to avoid collapsing caps
when an earlier entry was skipped; keep using resolvePrunedPieceInstance and
offsetResolvedStartAndCapDuration. Also replace allPreviousResolvedPieces =
allPreviousResolvedPieces.concat(resolved) with
allPreviousResolvedPieces.push(...resolved) for better append performance.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/job-worker/src/playout/__tests__/lib.ts`:
- Around line 31-34: The two thrown errors use misleading labels: when
nextPartInstance is undefined use a message that references the missing next
part instance (e.g., playlist.nextPartInfo?.partInstanceId) and when
previousPartInstance is undefined use a message that references the missing
previous part instance (e.g., playlist.previousPartsInfo?.[0]?.partInstanceId);
update the Error messages in the checks for nextPartInstance and
previousPartInstance to correctly say "Missing nextPartInstance" and "Missing
previousPartInstance" (or equivalent) so they match the variable being
validated.

In
`@packages/job-worker/src/playout/lookahead/__tests__/findForLayer/constants.ts`:
- Around line 25-32: The fixture's "previous" array in constants.ts only
contains one PartInstance, so add at least one more
PartInstanceAndPieceInstances object (use createFakePiece to create pieces) to
simulate multiple previous entries and pass a 2+ previousParts scenario through
findLookaheadForLayer/findForLayer; ensure the additional entries are ordered
most-recent-first (matching DBRundownPlaylist.previousPartsInfo) and set
appropriate onTimeline/nowInPart values to reflect distinct previous parts.

In `@packages/job-worker/src/playout/lookahead/findForLayer.ts`:
- Around line 15-20: Update the JSDoc for
PartInstanceAndPieceInstancesInfos.previous to explicitly state the invariant
that every entry has onTimeline === true (oldest-first), and then simplify
findForLayer by removing the dead defensive branch that checks
prevInfo.onTimeline for entries from previous; instead either assert
prevInfo.onTimeline or unconditionally handle them as on-timeline (push to the
same branch you currently take when onTimeline is true) in the loop that
iterates previous (referencing the symbols
PartInstanceAndPieceInstancesInfos.previous, prevInfo, and the result buckets
like res.future/res.timed used in findForLayer).

In `@packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts`:
- Around line 562-596: prunePreviousPartInstances currently treats a reference
part as "active" when referenceStarted is undefined, which conflicts with
generatePreviousPartInstancesObjects (which skips entries lacking
plannedStartedPlayback) and causes dangling timeline group refs; in
prunePreviousPartInstances (method name) change the active check to treat
entries with missing plannedStartedPlayback as inactive (i.e. require
referenceStarted and referenceTimings to be defined and now <= referenceStarted
+ referenceTimings.fromPartRemaining), remove the forced fallback that always
keeps one entry so the previousPartsInfo array may become empty when nothing is
active, replace the length-only change detection with a direct identity/content
check (e.g. compare pruned !== current or deep-equal) to set
`#playlistHasChanged`, and hoist MAX_PREVIOUS_PARTS out of the function into a
module-level const with a brief comment explaining the cap; this will align
behavior with generatePreviousPartInstancesObjects and avoid leaving unreachable
group.start references.

In `@packages/job-worker/src/playout/timeline/generate.ts`:
- Around line 335-343: The regeneration scheduling misses previous PartInstances
— ensure previousPartInstances are included wherever regenerateTimelineAt is
built (the spots around getPartInstanceTimelineInfo usage that currently only
include current/next). For each place that constructs the list (e.g., where
currentPartInstance and nextPartInstance are considered at lines referenced),
append previousPartInstances.flatMap(pi => { const info =
getPartInstanceTimelineInfo(absolutePiecePrepareTime, targetNowTime,
showStyle.sourceLayers, pi); return info ? [info] : [] }) so previous parts that
need timeline regeneration are scheduled; make sure to merge and filter
undefined entries the same way as for current/next entries (use the same
mapping/filter pattern) so any previously skipped future absolute pieces get the
regeneration trigger.

In `@packages/job-worker/src/playout/timeline/rundown.ts`:
- Around line 505-531: The loop over previousPartsInfo can create dangling
timeline references when a skipped previous part
(previousPartInfo.partInstance.timings?.plannedStartedPlayback is falsy) is
skipped with continue; change that continue to break so once a previous part
lacks plannedStartedPlayback you stop building older groups (affects the logic
that computes nextGroupId using timingContext.currentPartGroup.id and
getPartGroupId(previousPartsInfo[i - 1].partInstance) and the end expressions
that use currentPartInstanceTimings.fromPartRemaining or previousPartsInfo[i -
1].calculatedTimings.fromPartRemaining), preventing references to non-existent
groups created by createPartGroup; also add a unit test in
__tests__/rundown.test.ts that constructs multiple previousPartsInfo entries
where an earlier entry has no plannedStartedPlayback to assert older groups are
not emitted.

In `@packages/live-status-gateway/src/collections/pieceInstancesHandler.ts`:
- Around line 120-131: The code pairs pieces from previousPartsInfo with timing
objects by array index which breaks when this._partInstances.previous has been
filtered; instead build a Map of previous PartInstance timings keyed by
partInstanceId from this._partInstances.previous and then, inside the flatMap
over this._currentPlaylist.previousPartsInfo, look up the timing object by
info.partInstanceId and pass that result to processAndPrunePieceInstanceTimings
(preserving the existing call to collection.find({ partInstanceId:
info.partInstanceId }) and the boolean flag); this ensures
processAndPrunePieceInstanceTimings receives the correct timing object even when
entries were removed from _partInstances.previous.

---

Outside diff comments:
In `@packages/job-worker/src/ingest/commit.ts`:
- Around line 474-485: The loop that classifies partInstancesToOrphan currently
only protects playlist.currentPartInfo?.partInstanceId and
playlist.nextPartInfo?.partInstanceId, so a still-active previous partInstance
can be incorrectly added to instancesToReset; update the condition inside the
for (const partInstance of partInstancesToOrphan) loop to also check and protect
playlist.previousPartInfo?.partInstanceId (i.e., compare partInstance._id
against playlist.previousPartInfo?.partInstanceId alongside current and next) so
active previous PartInstances are pushed to instancesToOrphan rather than
instancesToReset.
- Around line 598-605: The current check only inspects
playoutModel.previousPartInstance; update the logic to scan the whole
previousPartsInfo chain and clear references if any entry references
rundownIdToForget. Concretely, iterate playoutModel.previousPartsInfo (or
equivalent array on playoutModel) and if any entry.partInstance.rundownId ===
rundownIdToForget then call playoutModel.clearPreviousPartInstances(); otherwise
leave the chain intact; use the existing symbols previousPartInstance,
previousPartsInfo, rundownIdToForget, and
playoutModel.clearPreviousPartInstances() to locate and modify the code.

In `@packages/webui/src/client/ui/RundownView/RundownViewSubscriptions.ts`:
- Around line 91-113: The subscription currently passes only
previousPartsInfo[0] as the previous part instance to meteorSubscribe, so
pieceInstances for older previous parts are not subscribed; update the argument
construction passed to meteorSubscribe (where playlist is read and
meteorSubscribe is called) to include all partInstanceId values from
playlist.previousPartsInfo (e.g., map previousPartsInfo to partInstanceId and
filter nulls) instead of only previousPartsInfo[0], keeping the same filtering
to PartInstanceId and preserving currentPartInfo and nextPartInfo inclusion.

In `@packages/webui/src/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx`:
- Around line 158-178: getDerivedStateFromProps currently only checks
previousPartsInfo[0] to determine if a part is previous, which misses older
still-active previous parts; update the isPrevious calculation in
getDerivedStateFromProps to test the entire nextProps.playlist.previousPartsInfo
array (e.g. using .some(...) to compare partInstanceId to
nextProps.part.instance._id) so that isDurationSettling (which relies on
isPrevious) correctly applies for any previous part entry rather than only the
first one.

In `@packages/webui/src/client/ui/SegmentTimeline/SegmentContextMenu.tsx`:
- Around line 122-127: The isPartEditAble check currently only excludes
playlist.previousPartsInfo?.[0] so older previous parts remain editable; update
the condition in the isPartEditAble computation to check against all entries in
playlist.previousPartsInfo (e.g., ensure part?.instance._id is not equal to any
previousPartsInfo[].partInstanceId by using Array.some/Array.find and negating
it) so edits are disabled for any active previous part; reference the
isPartEditAble variable, part?.instance._id, and playlist.previousPartsInfo to
locate and modify the logic.

In `@packages/webui/src/client/ui/Shelf/AdLibPanel.tsx`:
- Around line 142-179: The cache key only watches previousPartsInfo[0], causing
stale uiSegments when deeper previous parts change; update useFetchAndFilter to
include the full previousPartsInfo array (e.g. playlist?.previousPartsInfo) in
the dependency list passed to useTracker so fetchAndFilter sees changes across
the whole chain, and also add that same full previousPartsInfo dependency to the
memoizedIsolatedAutorun (the block that computes uiSegments / calls
getActivePartInstancesMap) so the memoized computation invalidates when any
previous part is added/pruned; reference useFetchAndFilter, fetchAndFilter,
memoizedIsolatedAutorun and uiSegments when applying this change.

---

Nitpick comments:
In `@meteor/server/api/deviceTriggers/__tests__/TagsService.test.ts`:
- Around line 244-402: Tests duplicate cache setup between the two cases;
extract the repeated setup into a small helper (e.g. createAndPopulateMockCache
or buildTestCache) that accepts previousPartsInfo (array of { partInstanceId })
and returns the created testee and cache (or at least the cache and
showStyleBaseId) so both tests call that helper, then call
testee.updatePieceInstances(cache, showStyleBaseId) and assert via
testee.getTallyStateFromTags; update references to createTestee, cache
construction, ShowStyleBases insert, PieceInstances/PartInstances inserts to use
the helper to remove duplication.

In
`@packages/job-worker/src/blueprints/context/services/__tests__/PartAndPieceInstanceActionService.test.ts`:
- Around line 287-291: The helper currently only seeds a single previous part by
setting previousPartsInfo to previousPartInstance ?
[convertInfo(previousPartInstance)!] : [], so update the helper signature to
accept multiple previous parts (e.g., previousPartInstances or allow an array),
and change the RundownPlaylists.update call (still referenced as
jobContext.mockCollections.RundownPlaylists.update and playlistId) to set
previousPartsInfo to previousPartInstances?.map(convertInfo).filter(Boolean) ||
[] so tests can seed and exercise multi-previous-part behavior; keep convertInfo
usage and preserve the empty-array fallback when none supplied.

In `@packages/job-worker/src/playout/__tests__/resolvedPieces.test.ts`:
- Around line 972-1070: Tests rely on the ordering of
SelectedPartInstancesTimelineInfo.previous being most-recent-first but there's
no explicit comment; add a one-line clarifying comment or JSDoc stating that
SelectedPartInstancesTimelineInfo.previous is ordered most-recent-first (newest
first) so the cap computation in getResolvedPiecesForPartInstancesOnTimeline is
unambiguous. Update either the test file near the test block or the
SelectedPartInstancesTimelineInfo.previous property JSDoc (or type/interface
declaration) to include that single-line clarification.

In `@packages/job-worker/src/playout/lookahead/__tests__/lookahead.test.ts`:
- Around line 296-318: Add a test case that creates at least two previous
entries in the SelectedPartInstancesTimelineInfo (e.g. duplicate previousEntry
with different partInstance._id and different partTimes.nowInPart values), push
them into partInstancesInfo.previous in chronological order (oldest first), call
getLookeaheadObjects via runJobWithPlayoutModel as done now, and update the
expectedPrevious to be an array of the two mapped objects (using
previousEntry.partInstance, partTimes.nowInPart, pieceInstances,
calculatedTimings) in oldest-first order; finally assert via
expectLookaheadForLayerMock (and indirectly findLookaheadForLayer) that it was
invoked with previous equal to that ordered array to verify ordering and
multiple-previous handling for getLookeaheadObjects.

In `@packages/job-worker/src/playout/resolvedPieces.ts`:
- Around line 78-96: Document the ordering contract on
SelectedPartInstancesTimelineInfo.previous in timeline/generate.ts (JSDoc: array
is most-recent-first so previous[0] starts after previous[1], etc.). In
resolvedPieces.ts, make the cap calculation robust: when computing capEnd for
index i, search backward from i-1 to find the nearest previous entry with
partTimes.partStartTime and use that startTime as the cap (fallback to
currentPartStarted only if none found) to avoid collapsing caps when an earlier
entry was skipped; keep using resolvePrunedPieceInstance and
offsetResolvedStartAndCapDuration. Also replace allPreviousResolvedPieces =
allPreviousResolvedPieces.concat(resolved) with
allPreviousResolvedPieces.push(...resolved) for better append performance.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0abdf53a-d511-410e-9490-c67dff22ec45

📥 Commits

Reviewing files that changed from the base of the PR and between 72e40f6 and e1076ed.

⛔ Files ignored due to path filters (2)
  • packages/job-worker/src/ingest/mosDevice/__tests__/__snapshots__/mosIngest.test.ts.snap is excluded by !**/*.snap
  • packages/job-worker/src/playout/__tests__/__snapshots__/playout.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (58)
  • meteor/__mocks__/defaultCollectionObjects.ts
  • meteor/server/__tests__/cronjobs.test.ts
  • meteor/server/api/__tests__/externalMessageQueue.test.ts
  • meteor/server/api/__tests__/peripheralDevice.test.ts
  • meteor/server/api/deviceTriggers/TagsService.ts
  • meteor/server/api/deviceTriggers/__tests__/TagsService.test.ts
  • meteor/server/api/deviceTriggers/reactiveContentCacheForPieceInstances.ts
  • packages/corelib/src/dataModel/RundownPlaylist/RundownPlaylist.ts
  • packages/job-worker/src/__mocks__/defaultCollectionObjects.ts
  • packages/job-worker/src/blueprints/context/OnTimelineGenerateContext.ts
  • packages/job-worker/src/blueprints/context/services/__tests__/PartAndPieceInstanceActionService.test.ts
  • packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts
  • packages/job-worker/src/ingest/__tests__/ingest.test.ts
  • packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts
  • packages/job-worker/src/ingest/__tests__/updateNext.test.ts
  • packages/job-worker/src/ingest/commit.ts
  • packages/job-worker/src/playout/__tests__/lib.ts
  • packages/job-worker/src/playout/__tests__/resolvedPieces.test.ts
  • packages/job-worker/src/playout/__tests__/tTimersJobs.test.ts
  • packages/job-worker/src/playout/abPlayback/index.ts
  • packages/job-worker/src/playout/lookahead/__tests__/findForLayer/basicBehavior.test.ts
  • packages/job-worker/src/playout/lookahead/__tests__/findForLayer/constants.ts
  • packages/job-worker/src/playout/lookahead/__tests__/findForLayer/orderedParts.test.ts
  • packages/job-worker/src/playout/lookahead/__tests__/findForLayer/playoutStatePropagation.test.ts
  • packages/job-worker/src/playout/lookahead/__tests__/findForLayer/searchDistance.test.ts
  • packages/job-worker/src/playout/lookahead/__tests__/findForLayer/timing.test.ts
  • packages/job-worker/src/playout/lookahead/__tests__/lookahead.test.ts
  • packages/job-worker/src/playout/lookahead/__tests__/lookaheadOffset/lookaheadOffset.test.ts
  • packages/job-worker/src/playout/lookahead/findForLayer.ts
  • packages/job-worker/src/playout/lookahead/index.ts
  • packages/job-worker/src/playout/model/PlayoutModel.ts
  • packages/job-worker/src/playout/model/implementation/LoadPlayoutModel.ts
  • packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts
  • packages/job-worker/src/playout/model/implementation/__tests__/PlayoutModelImpl.spec.ts
  • packages/job-worker/src/playout/resolvedPieces.ts
  • packages/job-worker/src/playout/setNext.ts
  • packages/job-worker/src/playout/snapshot.ts
  • packages/job-worker/src/playout/timeline/__tests__/rundown.test.ts
  • packages/job-worker/src/playout/timeline/generate.ts
  • packages/job-worker/src/playout/timeline/rundown.ts
  • packages/job-worker/src/playout/timings/partPlayback.ts
  • packages/job-worker/src/rundownPlaylists.ts
  • packages/live-status-gateway/src/collections/partInstancesHandler.ts
  • packages/live-status-gateway/src/collections/pieceInstancesHandler.ts
  • packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts
  • packages/live-status-gateway/src/topics/__tests__/utils.ts
  • packages/webui/src/__mocks__/defaultCollectionObjects.ts
  • packages/webui/src/client/lib/__tests__/rundownTiming.test.ts
  • packages/webui/src/client/lib/rundownPlaylistUtil.ts
  • packages/webui/src/client/ui/ClockView/DirectorScreen/DirectorScreen.tsx
  • packages/webui/src/client/ui/ClockView/PresenterScreen.tsx
  • packages/webui/src/client/ui/MediaStatus/MediaStatus.tsx
  • packages/webui/src/client/ui/RundownView.tsx
  • packages/webui/src/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx
  • packages/webui/src/client/ui/RundownView/RundownViewSubscriptions.ts
  • packages/webui/src/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx
  • packages/webui/src/client/ui/SegmentTimeline/SegmentContextMenu.tsx
  • packages/webui/src/client/ui/Shelf/AdLibPanel.tsx

Comment thread packages/job-worker/src/playout/__tests__/lib.ts Outdated
Comment on lines 15 to 20
export interface PartInstanceAndPieceInstancesInfos {
previous?: PartInstanceAndPieceInstances
/** Oldest-first. Each entry is "on timeline" and contributes timed lookahead data. */
previous: PartInstanceAndPieceInstances[]
current?: PartInstanceAndPieceInstances
next?: PartInstanceAndPieceInstances
}
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm caller ordering: previous should be oldest-first when passed to findLookaheadForLayer.
rg -nP --type=ts -C5 '\bfindLookaheadForLayer\s*\('
echo '---'
# Inspect how lookahead/index.ts builds the `previous` array.
fd -a 'index.ts' packages/job-worker/src/playout/lookahead
rg -nPU --type=ts -C5 '(?s)previous\s*:\s*\[' packages/job-worker/src/playout/lookahead/index.ts || true
rg -nP --type=ts -C3 '\bpreviousPartsInfo\b' packages/job-worker/src/playout/lookahead/

Repository: Sofie-Automation/sofie-core

Length of output: 17596


🏁 Script executed:

# Read the relevant section of findForLayer.ts to see the branching logic
sed -n '1,80p' packages/job-worker/src/playout/lookahead/findForLayer.ts | cat -n

Repository: Sofie-Automation/sofie-core

Length of output: 3235


🏁 Script executed:

# Read index.ts around the call site to see how partInstancesInfo is constructed
sed -n '180,240p' packages/job-worker/src/playout/lookahead/index.ts | cat -n

Repository: Sofie-Automation/sofie-core

Length of output: 2721


🏁 Script executed:

# Search for where partInstancesInfo (specifically previous) is built
rg -n 'partInstancesInfo\s*=' packages/job-worker/src/playout/lookahead/index.ts -B 5 -A 10

Repository: Sofie-Automation/sofie-core

Length of output: 53


🏁 Script executed:

# Search for previousPartsInfo to understand ordering context
rg -n 'previousPartsInfo' packages/job-worker/src/playout/lookahead/ -A 2 -B 2

Repository: Sofie-Automation/sofie-core

Length of output: 53


🏁 Script executed:

# Find the function that contains the findLookaheadForLayer call and see the full context
rg -n 'partInstancesInfo' packages/job-worker/src/playout/lookahead/index.ts | head -20

Repository: Sofie-Automation/sofie-core

Length of output: 1263


🏁 Script executed:

# Get more context around the partInstancesInfo0 variable to see construction
sed -n '100,220p' packages/job-worker/src/playout/lookahead/index.ts | cat -n

Repository: Sofie-Automation/sofie-core

Length of output: 5686


🏁 Script executed:

# Search for previousPartsInfo with broader context to understand ordering
rg -n 'previousPartsInfo' packages/job-worker/src --type=ts -B 3 -A 3 | head -60

Repository: Sofie-Automation/sofie-core

Length of output: 5560


JSDoc is accurate but the defensive branch for off-timeline entries is dead code in practice.

The interface's JSDoc assertion is correct: all entries in previous are hardcoded with onTimeline: true at index.ts line 27. However, the loop at lines 49–52 in findForLayer.ts still branches defensively on prevInfo.onTimeline, even though the else case (routing to res.future) can never occur for the previous array. This pattern makes sense for current and next (which have dynamic onTimeline values), but is dead code for previous.

Consider adding a note in the JSDoc to clarify that this field maintains the invariant that all entries are on-timeline, distinguishing it from the current and next fields:

✏️ Suggested JSDoc clarification
-	/** Oldest-first. Each entry is "on timeline" and contributes timed lookahead data. */
+	/**
+	 * Previous PartInstances ordered oldest-first to chain `previousPart` forward through each
+	 * generated lookahead. All entries are on-timeline (never contribute future objects).
+	 */

Regarding ordering: this is already well-documented in index.ts lines 10–17, which explains the oldest-first requirement for timed-lookahead chain correctness.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/job-worker/src/playout/lookahead/findForLayer.ts` around lines 15 -
20, Update the JSDoc for PartInstanceAndPieceInstancesInfos.previous to
explicitly state the invariant that every entry has onTimeline === true
(oldest-first), and then simplify findForLayer by removing the dead defensive
branch that checks prevInfo.onTimeline for entries from previous; instead either
assert prevInfo.onTimeline or unconditionally handle them as on-timeline (push
to the same branch you currently take when onTimeline is true) in the loop that
iterates previous (referencing the symbols
PartInstanceAndPieceInstancesInfos.previous, prevInfo, and the result buckets
like res.future/res.timed used in findForLayer).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wasn't it hardcoded for the if (partInstancesInfo.current) block too?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

Good question — let me check how current.onTimeline is set in index.ts.


🏁 Script executed:

#!/bin/bash
# Check how onTimeline is set for current, previous, and next in index.ts
rg -n 'onTimeline' packages/job-worker/src/playout/lookahead/index.ts -B 3 -A 2

Length of output: 1225


@ianshade, you're right — current is also hardcoded with onTimeline: true (line 138 in index.ts):

Field onTimeline value
previous true (hardcoded)
current true (hardcoded)
next !!partInstancesInfo0.current?.partInstance?.part?.autoNext (dynamic)

So the defensive onTimeline branch is dead code for both previous and current — only next has a meaningful dynamic value that drives the res.timed vs res.future split. My original comment was incomplete in flagging only previous. The suggested JSDoc clarification still holds, but the simplification opportunity extends to the current block as well.

Comment thread packages/job-worker/src/playout/snapshot.ts
Comment thread packages/job-worker/src/playout/timeline/generate.ts
Comment thread packages/job-worker/src/playout/timeline/rundown.ts
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/job-worker/src/playout/timeline/rundown.ts (1)

189-194: ⚠️ Potential issue | 🟠 Major

Link the current first object to the first emitted previous part.

Line 510 skips previous entries that never started, but Line 193 still uses raw previous[0]. In the previous[0] skipped / previous[1] active case, the current first object points at a PartInstance that was not emitted into the timeline.

Proposed fix
+	const previousPartForCurrentFirstObject = partInstancesInfo.previous.find(
+		(previousPartInfo) => previousPartInfo.partInstance.timings?.plannedStartedPlayback
+	)?.partInstance
+
 	const groupClasses: string[] = ['current_part']
 	timelineObjs.push(
 		currentPartGroup,
 		createPartGroupFirstObject(
 			activePlaylist._id,
 			partInstancesInfo.current.partInstance,
 			currentPartGroup,
-			partInstancesInfo.previous[0]?.partInstance
+			previousPartForCurrentFirstObject
 		),

Also applies to: 507-510

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/job-worker/src/playout/timeline/rundown.ts` around lines 189 - 194,
The call to createPartGroupFirstObject currently uses
partInstancesInfo.previous[0]?.partInstance which can reference a previous
PartInstance that was never emitted/started; change it to locate the first
previous entry that was actually emitted/started (the same selection logic used
at lines ~507-510) and pass that PartInstance instead. In other words, replace
the raw previous[0] usage with a search over partInstancesInfo.previous for the
first emitted/started entry (same predicate as used elsewhere in this file) and
supply its .partInstance to createPartGroupFirstObject while leaving
partInstancesInfo.current.partInstance and currentPartGroup as-is. Ensure the
same fix is applied to the other occurrences noted (around lines 507–510).
🧹 Nitpick comments (1)
packages/job-worker/src/playout/lookahead/__tests__/findForLayer/searchDistance.test.ts (1)

23-46: Consider mocking the 4th (nextFuture) call explicitly.

With mockReset() in beforeEach, the default mockImplementation(() => []) set at module load is cleared between tests. The chain here only defines 3 mockReturnValueOnce values, so the 4th call (for nextFuture) returns undefined rather than []. It happens to work because searchDistance = 0 causes the result to be discarded via res.future, but adding an explicit .mockReturnValueOnce([] as any) (or a .mockReturnValue([]) at the head of the chain) would make intent clearer and guard against future regressions in findLookaheadForLayer that might dereference the returned array.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/job-worker/src/playout/lookahead/__tests__/findForLayer/searchDistance.test.ts`
around lines 23 - 46, The test for searchDistance = 0 only sets three
mockReturnValueOnce values for findLookaheadObjectsForPartMock but the
beforeEach mockReset clears the module-level default, so the 4th call (used for
nextFuture) returns undefined; update the test to explicitly mock the 4th call
by adding another .mockReturnValueOnce([] as any) (or replace the chain with a
leading .mockReturnValue([]) plus specific .mockReturnValueOnce overrides) so
findLookaheadObjectsForPartMock always returns an array for the nextFuture
invocation when calling findLookaheadForLayer.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/job-worker/src/playout/snapshot.ts`:
- Around line 719-735: The migration code builds migratedInfo from fullOldKey
but leaves the legacy key on snapshot.playlist, risking stale data overwriting
modern fields later; update the logic in snapshot migration (the block using
fullOldKey, snapshot.playlist, partInstanceOldRundownIdMap) to first check for
and prefer existing modern fields (previousPartsInfo, nextPartInfo,
currentPartInfo) — only migrate if the modern field is absent — and then remove
the legacy key (delete (snapshot.playlist as any)[fullOldKey]) after migrating
so the old property is not persisted.

---

Outside diff comments:
In `@packages/job-worker/src/playout/timeline/rundown.ts`:
- Around line 189-194: The call to createPartGroupFirstObject currently uses
partInstancesInfo.previous[0]?.partInstance which can reference a previous
PartInstance that was never emitted/started; change it to locate the first
previous entry that was actually emitted/started (the same selection logic used
at lines ~507-510) and pass that PartInstance instead. In other words, replace
the raw previous[0] usage with a search over partInstancesInfo.previous for the
first emitted/started entry (same predicate as used elsewhere in this file) and
supply its .partInstance to createPartGroupFirstObject while leaving
partInstancesInfo.current.partInstance and currentPartGroup as-is. Ensure the
same fix is applied to the other occurrences noted (around lines 507–510).

---

Nitpick comments:
In
`@packages/job-worker/src/playout/lookahead/__tests__/findForLayer/searchDistance.test.ts`:
- Around line 23-46: The test for searchDistance = 0 only sets three
mockReturnValueOnce values for findLookaheadObjectsForPartMock but the
beforeEach mockReset clears the module-level default, so the 4th call (used for
nextFuture) returns undefined; update the test to explicitly mock the 4th call
by adding another .mockReturnValueOnce([] as any) (or replace the chain with a
leading .mockReturnValue([]) plus specific .mockReturnValueOnce overrides) so
findLookaheadObjectsForPartMock always returns an array for the nextFuture
invocation when calling findLookaheadForLayer.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c3f34eff-cb32-4348-852b-457e0ddc1bf4

📥 Commits

Reviewing files that changed from the base of the PR and between e1076ed and 44ff075.

📒 Files selected for processing (8)
  • packages/job-worker/src/playout/__tests__/lib.ts
  • packages/job-worker/src/playout/lookahead/__tests__/findForLayer/constants.ts
  • packages/job-worker/src/playout/lookahead/__tests__/findForLayer/searchDistance.test.ts
  • packages/job-worker/src/playout/lookahead/__tests__/findForLayer/timing.test.ts
  • packages/job-worker/src/playout/snapshot.ts
  • packages/job-worker/src/playout/timeline/__tests__/rundown.test.ts
  • packages/job-worker/src/playout/timeline/rundown.ts
  • packages/live-status-gateway/src/collections/pieceInstancesHandler.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/job-worker/src/playout/lookahead/tests/findForLayer/constants.ts
  • packages/job-worker/src/playout/tests/lib.ts
  • packages/live-status-gateway/src/collections/pieceInstancesHandler.ts
  • packages/job-worker/src/playout/lookahead/tests/findForLayer/timing.test.ts

Comment on lines 719 to 735
const fullOldKey = `${property}PartInstanceId`
if (fullOldKey in snapshot.playlist) {
const oldId = (snapshot.playlist as any)[fullOldKey] as PartInstanceId
snapshot.playlist.currentPartInfo = {
const migratedInfo = {
partInstanceId: oldId,
rundownId: partInstanceOldRundownIdMap.get(oldId) || protectString(''),
manuallySelected: false,
consumesQueuedSegmentId: false,
}
if (property === 'previous') {
snapshot.playlist.previousPartsInfo = [migratedInfo]
} else if (property === 'next') {
snapshot.playlist.nextPartInfo = migratedInfo
} else {
snapshot.playlist.currentPartInfo = migratedInfo
}
}
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Drop consumed legacy selection ids before saving the restored playlist.

fullOldKey is migrated but never deleted, and snapshot.playlist is saved as-is later. A restored legacy snapshot can keep a stale previousPartInstanceId; on a later restore, Line 729 can then overwrite valid previousPartsInfo with that stale single id. Prefer existing modern fields when both shapes exist, then delete the legacy key.

Proposed fix
 	const fullOldKey = `${property}PartInstanceId`
 	if (fullOldKey in snapshot.playlist) {
 		const oldId = (snapshot.playlist as any)[fullOldKey] as PartInstanceId
 		const migratedInfo = {
 			partInstanceId: oldId,
 			rundownId: partInstanceOldRundownIdMap.get(oldId) || protectString(''),
 			manuallySelected: false,
 			consumesQueuedSegmentId: false,
 		}
 		if (property === 'previous') {
-			snapshot.playlist.previousPartsInfo = [migratedInfo]
+			if (!Array.isArray(snapshot.playlist.previousPartsInfo) || snapshot.playlist.previousPartsInfo.length === 0) {
+				snapshot.playlist.previousPartsInfo = [migratedInfo]
+			}
 		} else if (property === 'next') {
-			snapshot.playlist.nextPartInfo = migratedInfo
+			if (!snapshot.playlist.nextPartInfo) snapshot.playlist.nextPartInfo = migratedInfo
 		} else {
-			snapshot.playlist.currentPartInfo = migratedInfo
+			if (!snapshot.playlist.currentPartInfo) snapshot.playlist.currentPartInfo = migratedInfo
 		}
+		delete (snapshot.playlist as any)[fullOldKey]
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/job-worker/src/playout/snapshot.ts` around lines 719 - 735, The
migration code builds migratedInfo from fullOldKey but leaves the legacy key on
snapshot.playlist, risking stale data overwriting modern fields later; update
the logic in snapshot migration (the block using fullOldKey, snapshot.playlist,
partInstanceOldRundownIdMap) to first check for and prefer existing modern
fields (previousPartsInfo, nextPartInfo, currentPartInfo) — only migrate if the
modern field is absent — and then remove the legacy key (delete
(snapshot.playlist as any)[fullOldKey]) after migrating so the old property is
not persisted.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this a regression?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ianshade — not a regression in the strict sense. The "legacy key never deleted" behaviour was already present before this PR. However, the pre-existing code had a separate, arguably worse bug: the migration block always wrote to snapshot.playlist.currentPartInfo regardless of which property was being handled, so the previous branch was essentially broken. Because previousPartsInfo didn't exist yet, there was nothing to overwrite.

This PR correctly introduces the if/else branching and populates previousPartsInfo, but because the legacy key (previousPartInstanceId) is still not removed from snapshot.playlist, a second restore of a snapshot that was itself produced by a prior restore could overwrite a valid previousPartsInfo with the migrated single-entry value. That scenario wasn't reachable before.

So in short:

  • The root cause (not deleting the legacy key) is pre-existing.
  • The PR makes it newly observable by finally doing something meaningful with the previous migration path.
  • It's worth fixing regardless, since the delete is cheap and prevents a subtle double-restore footgun.

🧠 Learnings used
Learnt from: Julusian
Repo: Sofie-Automation/sofie-core PR: 1698
File: packages/webui/src/client/ui/SegmentTimeline/SegmentContextMenu.tsx:266-290
Timestamp: 2026-03-24T16:58:20.370Z
Learning: In `packages/webui/src/client/ui/SegmentTimeline/SegmentContextMenu.tsx`, the `UserEditOperationMenuItems` for parts and pieces are intentionally rendered outside the `!part.instance.part.invalid` guard. This is by design (SOFIE-283): the properties editor must be accessible for invalid parts so users can edit their properties to fix the invalid state. Only the "Set part as Next" / "play from here" menu items are suppressed for invalid parts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Contribution from TV 2 Norge Contributions sponsored by TV 2 Norge (tv2.no)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant