From 2177b467cbf800e9e61cc821f4ee9c69899df073 Mon Sep 17 00:00:00 2001 From: ianshade Date: Fri, 29 Sep 2023 13:36:27 +0200 Subject: [PATCH 1/8] feat(api): allow setting segment as "immediate" next effectively setting its first part as next, as opposed to queuing until after the current segment --- meteor/lib/api/rest.ts | 4 +++- meteor/server/api/rest/v1/index.ts | 10 +++++++--- packages/corelib/src/worker/studio.ts | 1 + packages/job-worker/src/playout/setNext.ts | 10 ++++++++-- packages/job-worker/src/playout/setNextJobs.ts | 2 +- packages/openapi/api/definitions/playlists.yaml | 3 +++ 6 files changed, 23 insertions(+), 7 deletions(-) diff --git a/meteor/lib/api/rest.ts b/meteor/lib/api/rest.ts index 4484121933..4f72f2e3ea 100644 --- a/meteor/lib/api/rest.ts +++ b/meteor/lib/api/rest.ts @@ -171,12 +171,14 @@ export interface RestAPI { * @param event User event string * @param rundownPlaylistId Target Playlist. * @param segmentId Segment to set as next. + * @param immediate Whether given Segment should be the first thing to be taken right after current part, or after the last part of the current segment (aka queued). */ setNextSegment( connection: Meteor.Connection, event: string, rundownPlaylistId: RundownPlaylistId, - segmentId: SegmentId + segmentId: SegmentId, + immediate: boolean ): Promise> /** * Performs a take in the given Playlist. diff --git a/meteor/server/api/rest/v1/index.ts b/meteor/server/api/rest/v1/index.ts index 49b7d321ca..0e6bf05903 100644 --- a/meteor/server/api/rest/v1/index.ts +++ b/meteor/server/api/rest/v1/index.ts @@ -401,7 +401,8 @@ class ServerRestAPI implements RestAPI { connection: Meteor.Connection, event: string, rundownPlaylistId: RundownPlaylistId, - segmentId: SegmentId + segmentId: SegmentId, + immediate: boolean ): Promise> { return ServerClientAPI.runUserActionInLogForPlaylistOnWorker( ServerRestAPI.getMethodContext(connection), @@ -416,6 +417,7 @@ class ServerRestAPI implements RestAPI { { playlistId: rundownPlaylistId, nextSegmentId: segmentId, + immediate, } ) } @@ -1430,7 +1432,7 @@ sofieAPIRequest<{ playlistId: string }, { partId: string }, void>( } ) -sofieAPIRequest<{ playlistId: string }, { segmentId: string }, void>( +sofieAPIRequest<{ playlistId: string }, { segmentId: string; immediate?: boolean }, void>( 'put', '/playlists/:playlistId/set-next-segment', new Map([ @@ -1440,11 +1442,13 @@ sofieAPIRequest<{ playlistId: string }, { segmentId: string }, void>( async (serverAPI, connection, event, params, body) => { const rundownPlaylistId = protectString(params.playlistId) const segmentId = protectString(body.segmentId) + const immediate = body.immediate ?? false logger.info(`API PUT: set-next-segment ${rundownPlaylistId} ${segmentId}`) check(rundownPlaylistId, String) check(segmentId, String) - return await serverAPI.setNextSegment(connection, event, rundownPlaylistId, segmentId) + check(immediate, Boolean) + return await serverAPI.setNextSegment(connection, event, rundownPlaylistId, segmentId, immediate) } ) diff --git a/packages/corelib/src/worker/studio.ts b/packages/corelib/src/worker/studio.ts index f483a214f2..802ee5ad9c 100644 --- a/packages/corelib/src/worker/studio.ts +++ b/packages/corelib/src/worker/studio.ts @@ -208,6 +208,7 @@ export interface SetNextPartProps extends RundownPlayoutPropsBase { } export interface SetNextSegmentProps extends RundownPlayoutPropsBase { nextSegmentId: SegmentId | null + immediate: boolean } export interface ExecuteActionProps extends RundownPlayoutPropsBase { actionDocId: AdLibActionId | RundownBaselineAdLibActionId | null diff --git a/packages/job-worker/src/playout/setNext.ts b/packages/job-worker/src/playout/setNext.ts index 89b904a1d0..c68a56ca31 100644 --- a/packages/job-worker/src/playout/setNext.ts +++ b/packages/job-worker/src/playout/setNext.ts @@ -347,11 +347,13 @@ async function cleanupOrphanedItems(context: JobContext, cache: CacheForPlayout) * @param context Context for the running job * @param cache The playout cache of the playlist * @param nextSegment The segment to set as next, or null to clear it + * @param immediate Whether given Segment should be the first thing to be taken right after current part, or after the last part of the current segment (aka queued). */ export async function setNextSegment( context: JobContext, cache: CacheForPlayout, - nextSegment: DBSegment | null + nextSegment: DBSegment | null, + immediate: boolean ): Promise { const span = context.startSpan('setNextSegment') if (nextSegment) { @@ -369,7 +371,11 @@ export async function setNextSegment( // if there is not currentPartInstance or the nextPartInstance is not in the current segment // behave as if user chose SetNextPart on the first playable part of the segment - if (currentPartInstance === undefined || currentPartInstance.segmentId !== nextPartInstance?.segmentId) { + if ( + currentPartInstance === undefined || + currentPartInstance.segmentId !== nextPartInstance?.segmentId || + immediate + ) { // Clear any existing nextSegment, as this call 'replaces' it cache.Playlist.update((p) => { delete p.nextSegmentId diff --git a/packages/job-worker/src/playout/setNextJobs.ts b/packages/job-worker/src/playout/setNextJobs.ts index 6eb04302d1..8fe2a14a82 100644 --- a/packages/job-worker/src/playout/setNextJobs.ts +++ b/packages/job-worker/src/playout/setNextJobs.ts @@ -92,7 +92,7 @@ export async function handleSetNextSegment(context: JobContext, data: SetNextSeg if (!nextSegment) throw new Error(`Segment "${data.nextSegmentId}" not found!`) } - await setNextSegment(context, cache, nextSegment) + await setNextSegment(context, cache, nextSegment, data.immediate) // Update any future lookaheads await updateTimeline(context, cache) diff --git a/packages/openapi/api/definitions/playlists.yaml b/packages/openapi/api/definitions/playlists.yaml index fef7cd6379..e3a846f940 100644 --- a/packages/openapi/api/definitions/playlists.yaml +++ b/packages/openapi/api/definitions/playlists.yaml @@ -372,6 +372,9 @@ resources: segmentId: type: string description: Segment to set as next. + immediate: + type: boolean + description: Whether given Segment should be the first thing to be taken right after current part (effectively setting the first part of given segment as Next), as opposed to being taken after the last part of the current segment (queued until after the current segment ends). required: - segmentId responses: From 358fba79cae07b6250851af2df5b4382bca0c6cb Mon Sep 17 00:00:00 2001 From: ianshade Date: Tue, 3 Oct 2023 12:51:37 +0200 Subject: [PATCH 2/8] fix(api): type issues --- meteor/lib/api/userActions.ts | 3 ++- meteor/server/api/rest/v1/index.ts | 2 +- meteor/server/api/userActions.ts | 5 ++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/meteor/lib/api/userActions.ts b/meteor/lib/api/userActions.ts index e2a94897e3..fa5cab3de8 100644 --- a/meteor/lib/api/userActions.ts +++ b/meteor/lib/api/userActions.ts @@ -44,7 +44,8 @@ export interface NewUserActionAPI extends MethodContext { userEvent: string, eventTime: Time, rundownPlaylistId: RundownPlaylistId, - segmentId: SegmentId | null + segmentId: SegmentId | null, + immediate?: boolean ): Promise> moveNext( userEvent: string, diff --git a/meteor/server/api/rest/v1/index.ts b/meteor/server/api/rest/v1/index.ts index 0e6bf05903..06855ceb25 100644 --- a/meteor/server/api/rest/v1/index.ts +++ b/meteor/server/api/rest/v1/index.ts @@ -1442,7 +1442,7 @@ sofieAPIRequest<{ playlistId: string }, { segmentId: string; immediate?: boolean async (serverAPI, connection, event, params, body) => { const rundownPlaylistId = protectString(params.playlistId) const segmentId = protectString(body.segmentId) - const immediate = body.immediate ?? false + const immediate = !!body.immediate logger.info(`API PUT: set-next-segment ${rundownPlaylistId} ${segmentId}`) check(rundownPlaylistId, String) diff --git a/meteor/server/api/userActions.ts b/meteor/server/api/userActions.ts index 0634213039..daae445263 100644 --- a/meteor/server/api/userActions.ts +++ b/meteor/server/api/userActions.ts @@ -140,7 +140,8 @@ class ServerUserActionAPI userEvent: string, eventTime: Time, rundownPlaylistId: RundownPlaylistId, - nextSegmentId: SegmentId | null + nextSegmentId: SegmentId | null, + immediate: boolean | null ) { return ServerClientAPI.runUserActionInLogForPlaylistOnWorker( this, @@ -150,11 +151,13 @@ class ServerUserActionAPI () => { check(rundownPlaylistId, String) check(nextSegmentId, Match.OneOf(String, null)) + check(immediate, Match.OneOf(Boolean, null)) }, StudioJobs.SetNextSegment, { playlistId: rundownPlaylistId, nextSegmentId, + immediate: !!immediate, } ) } From d1f523b4c60d3529272dfeaf71186b8416a02cb5 Mon Sep 17 00:00:00 2001 From: ianshade Date: Tue, 3 Oct 2023 12:53:53 +0200 Subject: [PATCH 3/8] refactor(GUI): use `setNextSegment` to "Set segment as Next" just for consistency --- meteor/client/ui/RundownView.tsx | 4 ++-- meteor/client/ui/SegmentTimeline/SegmentContextMenu.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/meteor/client/ui/RundownView.tsx b/meteor/client/ui/RundownView.tsx index 573b26cf63..aeeb85cd96 100644 --- a/meteor/client/ui/RundownView.tsx +++ b/meteor/client/ui/RundownView.tsx @@ -2059,7 +2059,7 @@ export const RundownView = translateWithTracker(( ) } } - onSetNextSegment = (segmentId: SegmentId | null, e: any) => { + onSetNextSegment = (segmentId: SegmentId | null, e: any, immediate = false) => { const { t } = this.props if (this.state.studioMode && (segmentId || segmentId === null) && this.props.playlist) { const playlistId = this.props.playlist._id @@ -2067,7 +2067,7 @@ export const RundownView = translateWithTracker(( t, e, UserAction.SET_NEXT, - (e, ts) => MeteorCall.userAction.setNextSegment(e, ts, playlistId, segmentId), + (e, ts) => MeteorCall.userAction.setNextSegment(e, ts, playlistId, segmentId, immediate), (err) => { if (err) logger.error(err) this.setState({ diff --git a/meteor/client/ui/SegmentTimeline/SegmentContextMenu.tsx b/meteor/client/ui/SegmentTimeline/SegmentContextMenu.tsx index 26614bdc92..3a4898c0df 100644 --- a/meteor/client/ui/SegmentTimeline/SegmentContextMenu.tsx +++ b/meteor/client/ui/SegmentTimeline/SegmentContextMenu.tsx @@ -12,7 +12,7 @@ import { SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' interface IProps { onSetNext: (part: Part | undefined, e: any, offset?: number, take?: boolean) => void - onSetNextSegment: (segmentId: SegmentId | null, e: any) => void + onSetNextSegment: (segmentId: SegmentId | null, e: any, immediate?: boolean) => void playlist?: RundownPlaylist studioMode: boolean contextMenuContext: IContextMenuContext | null @@ -45,7 +45,7 @@ export const SegmentContextMenu = withTranslation()( {part && timecode === null && ( <> this.props.onSetNext(part.instance.part, e)} + onClick={(e) => this.props.onSetNextSegment(part.instance.segmentId, e, true)} disabled={isCurrentPart || !canSetAsNext} > Next') }}> From e3a41f6f6e2d782d053658ed568ca0ee3d097660 Mon Sep 17 00:00:00 2001 From: ianshade Date: Tue, 3 Oct 2023 14:50:54 +0200 Subject: [PATCH 4/8] chore: add more test for setNextSegment --- .../src/playout/__tests__/playout.test.ts | 82 ++++++++++++++++++- 1 file changed, 78 insertions(+), 4 deletions(-) diff --git a/packages/job-worker/src/playout/__tests__/playout.test.ts b/packages/job-worker/src/playout/__tests__/playout.test.ts index 6945cb4a9e..0a2f708145 100644 --- a/packages/job-worker/src/playout/__tests__/playout.test.ts +++ b/packages/job-worker/src/playout/__tests__/playout.test.ts @@ -814,14 +814,22 @@ describe('Playout API', () => { await handleTakeNextPart(context, { playlistId: playlistId0, fromPartInstanceId: null }) { - // doesn't set a segment with no valid parts + // doesn't queue a segment with no valid parts await expect( - handleSetNextSegment(context, { playlistId: playlistId0, nextSegmentId: segments[3]._id }) + handleSetNextSegment(context, { + playlistId: playlistId0, + nextSegmentId: segments[3]._id, + immediate: false, + }) ).rejects.toThrow(/no valid parts/gi) } { - await handleSetNextSegment(context, { playlistId: playlistId0, nextSegmentId: segments[2]._id }) + await handleSetNextSegment(context, { + playlistId: playlistId0, + nextSegmentId: segments[2]._id, + immediate: false, + }) const playlist = await getPlaylist0() expect(playlist.nextSegmentId).toBe(segments[2]._id) } @@ -857,7 +865,11 @@ describe('Playout API', () => { { // set next segment when next part is already outside of the current one const segmentToQueueId = segments[2]._id - await handleSetNextSegment(context, { playlistId: playlistId0, nextSegmentId: segmentToQueueId }) + await handleSetNextSegment(context, { + playlistId: playlistId0, + nextSegmentId: segmentToQueueId, + immediate: false, + }) const playlist = await getPlaylist0() // expect to just set first part of the queued segment as next expect(playlist.nextSegmentId).toBeUndefined() @@ -868,6 +880,68 @@ describe('Playout API', () => { if (firstPartOfQueuedSegment.invalid) throw new Error('Selected Part is invalid') } }) + test('setNextSegment (immediate)', async () => { + const { rundownId: rundownId0, playlistId: playlistId0 } = await setupRundownWithAutoplayPart0( + context, + protectString('rundown0'), + showStyle + ) + expect(rundownId0).toBeTruthy() + expect(playlistId0).toBeTruthy() + + const getRundown0 = async () => { + return (await context.mockCollections.Rundowns.findOne(rundownId0)) as DBRundown + } + const getPlaylist0 = async () => { + const playlist = (await context.mockCollections.RundownPlaylists.findOne(playlistId0)) as DBRundownPlaylist + playlist.activationId = playlist.activationId ?? undefined + return playlist + } + const { parts, segments } = await getAllRundownData(await getRundown0()) + + // Prepare and activate + await handleResetRundownPlaylist(context, { playlistId: playlistId0, activate: 'active' }) + // Take first part + await handleTakeNextPart(context, { playlistId: playlistId0, fromPartInstanceId: null }) + + { + // doesn't set a segment with no valid parts + await expect( + handleSetNextSegment(context, { + playlistId: playlistId0, + nextSegmentId: segments[3]._id, + immediate: true, + }) + ).rejects.toThrow(/no valid parts/gi) + } + + { + // (queue to later check that we're clearing it) + await handleSetNextSegment(context, { + playlistId: playlistId0, + nextSegmentId: segments[2]._id, + immediate: false, + }) + const playlist = await getPlaylist0() + expect(playlist.nextSegmentId).toBe(segments[2]._id) + } + + { + await handleSetNextSegment(context, { + playlistId: playlistId0, + nextSegmentId: segments[1]._id, + immediate: true, + }) + const playlist = await getPlaylist0() + // clears nextSegmentId + expect(playlist.nextSegmentId).toBeUndefined() + const { nextPartInstance } = await getSelectedPartInstances(context, playlist) + // sets first part as next + const firstPartOfQueuedSegment = parts.find((part) => part.segmentId === segments[1]._id) + expect(nextPartInstance?.part._id).toBeDefined() + expect(nextPartInstance?.part._id).toBe(firstPartOfQueuedSegment?._id) + } + }) }) async function setupRundownWithAutoplayPart0( From 7fbcbaf362833eb98a0c0a4d2d4430e3c352c50a Mon Sep 17 00:00:00 2001 From: ianshade Date: Fri, 6 Oct 2023 19:50:48 +0200 Subject: [PATCH 5/8] refactor: separate queueNextSegment and setNextSegment --- meteor/client/lib/__tests__/rundown.test.ts | 2 +- .../lib/__tests__/rundownTiming.test.ts | 24 ++-- meteor/client/ui/RundownView.tsx | 24 +++- .../SegmentContainer/withResolvedSegment.ts | 6 +- .../ui/SegmentList/SegmentListContainer.tsx | 2 +- .../SegmentStoryboardContainer.tsx | 2 +- .../ui/SegmentTimeline/SegmentContextMenu.tsx | 11 +- .../SegmentTimelineContainer.tsx | 2 +- meteor/lib/api/rest.ts | 24 +++- meteor/lib/api/userActions.ts | 14 ++- meteor/lib/userAction.ts | 1 + .../__tests__/externalMessageQueue.test.ts | 4 +- meteor/server/api/rest/v1/index.ts | 58 ++++++++-- meteor/server/api/userActions.ts | 29 ++++- .../corelib/src/dataModel/RundownPlaylist.ts | 8 +- packages/corelib/src/worker/studio.ts | 16 ++- .../__tests__/context-adlibActions.test.ts | 8 +- .../__tests__/externalMessageQueue.test.ts | 8 +- .../src/ingest/__tests__/updateNext.test.ts | 4 +- .../src/playout/__tests__/playout.test.ts | 42 +++---- .../playout/__tests__/selectNextPart.test.ts | 60 +++++----- .../src/playout/activePlaylistActions.ts | 2 +- packages/job-worker/src/playout/debug.ts | 2 +- packages/job-worker/src/playout/lib.ts | 2 +- .../playout/lookahead/__tests__/util.test.ts | 12 +- .../job-worker/src/playout/lookahead/util.ts | 12 +- .../job-worker/src/playout/selectNextPart.ts | 19 ++-- packages/job-worker/src/playout/setNext.ts | 104 ++++++++++++------ .../job-worker/src/playout/setNextJobs.ts | 58 ++++++++-- packages/job-worker/src/playout/snapshot.ts | 4 +- packages/job-worker/src/playout/take.ts | 14 +-- .../src/playout/timings/partPlayback.ts | 4 +- .../job-worker/src/workers/studio/jobs.ts | 8 +- packages/openapi/api/actions.yaml | 2 + .../openapi/api/definitions/playlists.yaml | 50 ++++++++- 35 files changed, 435 insertions(+), 207 deletions(-) diff --git a/meteor/client/lib/__tests__/rundown.test.ts b/meteor/client/lib/__tests__/rundown.test.ts index 7e2ef52688..4a000a1449 100644 --- a/meteor/client/lib/__tests__/rundown.test.ts +++ b/meteor/client/lib/__tests__/rundown.test.ts @@ -345,7 +345,7 @@ describe('client/lib/rundown', () => { partInstanceId: mockCurrentPartInstance._id, rundownId: mockCurrentPartInstance.rundownId, manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, }, }, }) diff --git a/meteor/client/lib/__tests__/rundownTiming.test.ts b/meteor/client/lib/__tests__/rundownTiming.test.ts index 31ba885420..74b59fbf89 100644 --- a/meteor/client/lib/__tests__/rundownTiming.test.ts +++ b/meteor/client/lib/__tests__/rundownTiming.test.ts @@ -1122,13 +1122,13 @@ describe('rundown Timing Calculator', () => { partInstanceId: currentPartInstanceId, rundownId: protectString(rundownId1), manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, } playlist.nextPartInfo = { partInstanceId: nextPartInstanceId, rundownId: protectString(rundownId1), manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, } const rundown = makeMockRundown(rundownId1, playlist) const rundowns = [rundown] @@ -1271,13 +1271,13 @@ describe('rundown Timing Calculator', () => { partInstanceId: currentPartInstanceId, rundownId: protectString(rundownId1), manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, } playlist.nextPartInfo = { partInstanceId: nextPartInstanceId, rundownId: protectString(rundownId1), manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, } const rundown = makeMockRundown(rundownId1, playlist) const rundowns = [rundown] @@ -1426,13 +1426,13 @@ describe('rundown Timing Calculator', () => { partInstanceId: currentPartInstanceId, rundownId: protectString(rundownId1), manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, } playlist.nextPartInfo = { partInstanceId: nextPartInstanceId, rundownId: protectString(rundownId1), manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, } const rundown = makeMockRundown(rundownId1, playlist) const rundowns = [rundown] @@ -1575,13 +1575,13 @@ describe('rundown Timing Calculator', () => { partInstanceId: currentPartInstanceId, rundownId: protectString(rundownId1), manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, } playlist.nextPartInfo = { partInstanceId: nextPartInstanceId, rundownId: protectString(rundownId1), manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, } const rundown = makeMockRundown(rundownId1, playlist) const rundowns = [rundown] @@ -1724,13 +1724,13 @@ describe('rundown Timing Calculator', () => { partInstanceId: currentPartInstanceId, rundownId: protectString(rundownId1), manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, } playlist.nextPartInfo = { partInstanceId: nextPartInstanceId, rundownId: protectString(rundownId1), manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, } const rundown = makeMockRundown(rundownId1, playlist) const rundowns = [rundown] @@ -1879,13 +1879,13 @@ describe('rundown Timing Calculator', () => { partInstanceId: currentPartInstanceId, rundownId: protectString(rundownId1), manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, } playlist.nextPartInfo = { partInstanceId: nextPartInstanceId, rundownId: protectString(rundownId1), manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, } const rundown = makeMockRundown(rundownId1, playlist) const rundowns = [rundown] diff --git a/meteor/client/ui/RundownView.tsx b/meteor/client/ui/RundownView.tsx index aeeb85cd96..6e8323e55a 100644 --- a/meteor/client/ui/RundownView.tsx +++ b/meteor/client/ui/RundownView.tsx @@ -2059,7 +2059,8 @@ export const RundownView = translateWithTracker(( ) } } - onSetNextSegment = (segmentId: SegmentId | null, e: any, immediate = false) => { + + onSetNextSegment = (segmentId: SegmentId, e: any) => { const { t } = this.props if (this.state.studioMode && (segmentId || segmentId === null) && this.props.playlist) { const playlistId = this.props.playlist._id @@ -2067,7 +2068,26 @@ export const RundownView = translateWithTracker(( t, e, UserAction.SET_NEXT, - (e, ts) => MeteorCall.userAction.setNextSegment(e, ts, playlistId, segmentId, immediate), + (e, ts) => MeteorCall.userAction.setNextSegment(e, ts, playlistId, segmentId), + (err) => { + if (err) logger.error(err) + this.setState({ + manualSetAsNext: true, + }) + } + ) + } + } + + onQueueNextSegment = (segmentId: SegmentId | null, e: any) => { + const { t } = this.props + if (this.state.studioMode && (segmentId || segmentId === null) && this.props.playlist) { + const playlistId = this.props.playlist._id + doUserAction( + t, + e, + UserAction.QUEUE_NEXT_SEGMENT, + (e, ts) => MeteorCall.userAction.queueNextSegment(e, ts, playlistId, segmentId), (err) => { if (err) logger.error(err) this.setState({ diff --git a/meteor/client/ui/SegmentContainer/withResolvedSegment.ts b/meteor/client/ui/SegmentContainer/withResolvedSegment.ts index 73783f38c9..d8a4024bf7 100644 --- a/meteor/client/ui/SegmentContainer/withResolvedSegment.ts +++ b/meteor/client/ui/SegmentContainer/withResolvedSegment.ts @@ -361,9 +361,9 @@ export function withResolvedSegment( // Check rundown changes that are important to the segment if ( typeof props.playlist !== typeof nextProps.playlist || - (props.playlist.nextSegmentId !== nextProps.playlist.nextSegmentId && - (props.playlist.nextSegmentId === props.segmentId || - nextProps.playlist.nextSegmentId === props.segmentId)) || + (props.playlist.queuedSegmentId !== nextProps.playlist.queuedSegmentId && + (props.playlist.queuedSegmentId === props.segmentId || + nextProps.playlist.queuedSegmentId === props.segmentId)) || ((props.playlist.currentPartInfo?.partInstanceId !== nextProps.playlist.currentPartInfo?.partInstanceId || props.playlist.nextPartInfo?.partInstanceId !== nextProps.playlist.nextPartInfo?.partInstanceId) && diff --git a/meteor/client/ui/SegmentList/SegmentListContainer.tsx b/meteor/client/ui/SegmentList/SegmentListContainer.tsx index df6795ae6b..7e9509abd8 100644 --- a/meteor/client/ui/SegmentList/SegmentListContainer.tsx +++ b/meteor/client/ui/SegmentList/SegmentListContainer.tsx @@ -230,7 +230,7 @@ export const SegmentListContainer = withResolvedSegment(function Segment segmentNoteCounts={props.segmentNoteCounts} isLiveSegment={isLiveSegment} isNextSegment={isNextSegment} - isQueuedSegment={props.playlist.nextSegmentId === props.segmentui._id} + isQueuedSegment={props.playlist.queuedSegmentId === props.segmentui._id} showCountdownToSegment={props.showCountdownToSegment} fixedSegmentDuration={props.fixedSegmentDuration ?? false} hasAlreadyPlayed={props.hasAlreadyPlayed} diff --git a/meteor/client/ui/SegmentStoryboard/SegmentStoryboardContainer.tsx b/meteor/client/ui/SegmentStoryboard/SegmentStoryboardContainer.tsx index 2583062788..21bf080e8a 100644 --- a/meteor/client/ui/SegmentStoryboard/SegmentStoryboardContainer.tsx +++ b/meteor/client/ui/SegmentStoryboard/SegmentStoryboardContainer.tsx @@ -243,7 +243,7 @@ export const SegmentStoryboardContainer = withResolvedSegment(function S playlist={props.playlist} isLiveSegment={isLiveSegment} isNextSegment={isNextSegment} - isQueuedSegment={props.playlist.nextSegmentId === props.segmentui._id} + isQueuedSegment={props.playlist.queuedSegmentId === props.segmentui._id} hasRemoteItems={props.hasRemoteItems} hasGuestItems={props.hasGuestItems} currentPartWillAutoNext={currentPartWillAutoNext} diff --git a/meteor/client/ui/SegmentTimeline/SegmentContextMenu.tsx b/meteor/client/ui/SegmentTimeline/SegmentContextMenu.tsx index 3a4898c0df..bcf91580cf 100644 --- a/meteor/client/ui/SegmentTimeline/SegmentContextMenu.tsx +++ b/meteor/client/ui/SegmentTimeline/SegmentContextMenu.tsx @@ -12,7 +12,8 @@ import { SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' interface IProps { onSetNext: (part: Part | undefined, e: any, offset?: number, take?: boolean) => void - onSetNextSegment: (segmentId: SegmentId | null, e: any, immediate?: boolean) => void + onSetNextSegment: (segmentId: SegmentId, e: any) => void + onQueueNextSegment: (segmentId: SegmentId | null, e: any) => void playlist?: RundownPlaylist studioMode: boolean contextMenuContext: IContextMenuContext | null @@ -45,20 +46,20 @@ export const SegmentContextMenu = withTranslation()( {part && timecode === null && ( <> this.props.onSetNextSegment(part.instance.segmentId, e, true)} + onClick={(e) => this.props.onSetNextSegment(part.instance.segmentId, e)} disabled={isCurrentPart || !canSetAsNext} > Next') }}> - {part.instance.segmentId !== this.props.playlist.nextSegmentId ? ( + {part.instance.segmentId !== this.props.playlist.queuedSegmentId ? ( this.props.onSetNextSegment(part.instance.segmentId, e)} + onClick={(e) => this.props.onQueueNextSegment(part.instance.segmentId, e)} disabled={!canSetAsNext} > {t('Queue segment')} ) : ( - this.props.onSetNextSegment(null, e)} disabled={!canSetAsNext}> + this.props.onQueueNextSegment(null, e)} disabled={!canSetAsNext}> {t('Clear queued segment')} )} diff --git a/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx b/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx index 4d562c0062..359d09bb58 100644 --- a/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx +++ b/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx @@ -745,7 +745,7 @@ export const SegmentTimelineContainer = withResolvedSegment( followLiveSegments={this.props.followLiveSegments} isLiveSegment={this.state.isLiveSegment} isNextSegment={this.state.isNextSegment} - isQueuedSegment={this.props.playlist.nextSegmentId === this.props.segmentId} + isQueuedSegment={this.props.playlist.queuedSegmentId === this.props.segmentId} hasRemoteItems={this.props.hasRemoteItems} hasGuestItems={this.props.hasGuestItems} autoNextPart={this.state.autoNextPart} diff --git a/meteor/lib/api/rest.ts b/meteor/lib/api/rest.ts index 4f72f2e3ea..19cb9df2ee 100644 --- a/meteor/lib/api/rest.ts +++ b/meteor/lib/api/rest.ts @@ -14,6 +14,7 @@ import { ShowStyleVariantId, StudioId, } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { QueueNextSegmentResult } from '@sofie-automation/corelib/dist/worker/studio' import { Meteor } from 'meteor/meteor' /* ************************************************************************* @@ -171,15 +172,30 @@ export interface RestAPI { * @param event User event string * @param rundownPlaylistId Target Playlist. * @param segmentId Segment to set as next. - * @param immediate Whether given Segment should be the first thing to be taken right after current part, or after the last part of the current segment (aka queued). */ setNextSegment( connection: Meteor.Connection, event: string, rundownPlaylistId: RundownPlaylistId, - segmentId: SegmentId, - immediate: boolean - ): Promise> + segmentId: SegmentId + ): Promise> + /** + * Sets the next Segment to a given SegmentId. + * + * Throws if the target Playlist is not currently active. + * Throws if the specified Segment does not exist. + * Throws if the specified Segment does not contain any playable parts. + * @param connection Connection data including client and header details + * @param event User event string + * @param rundownPlaylistId Target Playlist. + * @param segmentId Segment to set as next. + */ + queueNextSegment( + connection: Meteor.Connection, + event: string, + rundownPlaylistId: RundownPlaylistId, + segmentId: SegmentId + ): Promise> /** * Performs a take in the given Playlist. * diff --git a/meteor/lib/api/userActions.ts b/meteor/lib/api/userActions.ts index fa5cab3de8..9cb16e9456 100644 --- a/meteor/lib/api/userActions.ts +++ b/meteor/lib/api/userActions.ts @@ -7,7 +7,7 @@ import { BucketAdLib } from '@sofie-automation/corelib/dist/dataModel/BucketAdLi import { AdLibActionCommon } from '@sofie-automation/corelib/dist/dataModel/AdlibAction' import { BucketAdLibAction } from '@sofie-automation/corelib/dist/dataModel/BucketAdLibAction' import { getHash, Time } from '../lib' -import { ExecuteActionResult } from '@sofie-automation/corelib/dist/worker/studio' +import { ExecuteActionResult, QueueNextSegmentResult } from '@sofie-automation/corelib/dist/worker/studio' import { AdLibActionId, BucketId, @@ -44,9 +44,14 @@ export interface NewUserActionAPI extends MethodContext { userEvent: string, eventTime: Time, rundownPlaylistId: RundownPlaylistId, - segmentId: SegmentId | null, - immediate?: boolean - ): Promise> + segmentId: SegmentId + ): Promise> + queueNextSegment( + userEvent: string, + eventTime: Time, + rundownPlaylistId: RundownPlaylistId, + segmentId: SegmentId | null + ): Promise> moveNext( userEvent: string, eventTime: Time, @@ -322,6 +327,7 @@ export enum UserActionAPIMethods { 'take' = 'userAction.take', 'setNext' = 'userAction.setNext', 'setNextSegment' = 'userAction.setNextSegment', + 'queueNextSegment' = 'userAction.queueNextSegment', 'moveNext' = 'userAction.moveNext', 'prepareForBroadcast' = 'userAction.prepareForBroadcast', diff --git a/meteor/lib/userAction.ts b/meteor/lib/userAction.ts index 7ecb2af000..c5520521ed 100644 --- a/meteor/lib/userAction.ts +++ b/meteor/lib/userAction.ts @@ -48,4 +48,5 @@ export enum UserAction { RUNDOWN_ORDER_MOVE, RUNDOWN_ORDER_RESET, PERIPHERAL_DEVICE_REFRESH_DEBUG_STATES, + QUEUE_NEXT_SEGMENT, } diff --git a/meteor/server/api/__tests__/externalMessageQueue.test.ts b/meteor/server/api/__tests__/externalMessageQueue.test.ts index 949b4bbf64..8e476cd863 100644 --- a/meteor/server/api/__tests__/externalMessageQueue.test.ts +++ b/meteor/server/api/__tests__/externalMessageQueue.test.ts @@ -26,13 +26,13 @@ describe('Test external message queue static methods', () => { partInstanceId: protectString('part_now'), rundownId: protectString('rundown_1'), manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, }, nextPartInfo: { partInstanceId: protectString('partNext'), rundownId: protectString('rundown_1'), manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, }, previousPartInfo: null, activationId: protectString('active'), diff --git a/meteor/server/api/rest/v1/index.ts b/meteor/server/api/rest/v1/index.ts index 06855ceb25..5338d5ddc3 100644 --- a/meteor/server/api/rest/v1/index.ts +++ b/meteor/server/api/rest/v1/index.ts @@ -25,7 +25,7 @@ import { MeteorCall, MethodContextAPI } from '../../../../lib/api/methods' import { ServerClientAPI } from '../../client' import { ServerRundownAPI } from '../../rundown' import { triggerWriteAccess } from '../../../security/lib/securityVerify' -import { StudioJobs } from '@sofie-automation/corelib/dist/worker/studio' +import { QueueNextSegmentResult, StudioJobs } from '@sofie-automation/corelib/dist/worker/studio' import { CURRENT_SYSTEM_VERSION } from '../../../migration/currentSystemVersion' import { AdLibActionId, @@ -401,9 +401,8 @@ class ServerRestAPI implements RestAPI { connection: Meteor.Connection, event: string, rundownPlaylistId: RundownPlaylistId, - segmentId: SegmentId, - immediate: boolean - ): Promise> { + segmentId: SegmentId + ): Promise> { return ServerClientAPI.runUserActionInLogForPlaylistOnWorker( ServerRestAPI.getMethodContext(connection), event, @@ -417,7 +416,28 @@ class ServerRestAPI implements RestAPI { { playlistId: rundownPlaylistId, nextSegmentId: segmentId, - immediate, + } + ) + } + async queueNextSegment( + connection: Meteor.Connection, + event: string, + rundownPlaylistId: RundownPlaylistId, + segmentId: SegmentId + ): Promise> { + return ServerClientAPI.runUserActionInLogForPlaylistOnWorker( + ServerRestAPI.getMethodContext(connection), + event, + getCurrentTime(), + rundownPlaylistId, + () => { + check(rundownPlaylistId, String) + check(segmentId, String) + }, + StudioJobs.QueueNextSegment, + { + playlistId: rundownPlaylistId, + queuedSegmentId: segmentId, } ) } @@ -1432,8 +1452,8 @@ sofieAPIRequest<{ playlistId: string }, { partId: string }, void>( } ) -sofieAPIRequest<{ playlistId: string }, { segmentId: string; immediate?: boolean }, void>( - 'put', +sofieAPIRequest<{ playlistId: string }, { segmentId: string }, PartId | null>( + 'post', '/playlists/:playlistId/set-next-segment', new Map([ [404, [UserErrorMessage.RundownPlaylistNotFound]], @@ -1442,13 +1462,29 @@ sofieAPIRequest<{ playlistId: string }, { segmentId: string; immediate?: boolean async (serverAPI, connection, event, params, body) => { const rundownPlaylistId = protectString(params.playlistId) const segmentId = protectString(body.segmentId) - const immediate = !!body.immediate - logger.info(`API PUT: set-next-segment ${rundownPlaylistId} ${segmentId}`) + logger.info(`API POST: set-next-segment ${rundownPlaylistId} ${segmentId}`) + + check(rundownPlaylistId, String) + check(segmentId, String) + return await serverAPI.setNextSegment(connection, event, rundownPlaylistId, segmentId) + } +) + +sofieAPIRequest<{ playlistId: string }, { segmentId: string }, QueueNextSegmentResult>( + 'post', + '/playlists/:playlistId/queue-next-segment', + new Map([ + [404, [UserErrorMessage.RundownPlaylistNotFound]], + [412, [UserErrorMessage.PartNotFound]], + ]), + async (serverAPI, connection, event, params, body) => { + const rundownPlaylistId = protectString(params.playlistId) + const segmentId = protectString(body.segmentId) + logger.info(`API POST: set-next-segment ${rundownPlaylistId} ${segmentId}`) check(rundownPlaylistId, String) check(segmentId, String) - check(immediate, Boolean) - return await serverAPI.setNextSegment(connection, event, rundownPlaylistId, segmentId, immediate) + return await serverAPI.queueNextSegment(connection, event, rundownPlaylistId, segmentId) } ) diff --git a/meteor/server/api/userActions.ts b/meteor/server/api/userActions.ts index daae445263..d785517549 100644 --- a/meteor/server/api/userActions.ts +++ b/meteor/server/api/userActions.ts @@ -140,8 +140,7 @@ class ServerUserActionAPI userEvent: string, eventTime: Time, rundownPlaylistId: RundownPlaylistId, - nextSegmentId: SegmentId | null, - immediate: boolean | null + nextSegmentId: SegmentId ) { return ServerClientAPI.runUserActionInLogForPlaylistOnWorker( this, @@ -150,14 +149,34 @@ class ServerUserActionAPI rundownPlaylistId, () => { check(rundownPlaylistId, String) - check(nextSegmentId, Match.OneOf(String, null)) - check(immediate, Match.OneOf(Boolean, null)) + check(nextSegmentId, String) }, StudioJobs.SetNextSegment, { playlistId: rundownPlaylistId, nextSegmentId, - immediate: !!immediate, + } + ) + } + async queueNextSegment( + userEvent: string, + eventTime: Time, + rundownPlaylistId: RundownPlaylistId, + queuedSegmentId: SegmentId | null + ) { + return ServerClientAPI.runUserActionInLogForPlaylistOnWorker( + this, + userEvent, + eventTime, + rundownPlaylistId, + () => { + check(rundownPlaylistId, String) + check(queuedSegmentId, Match.OneOf(String, null)) + }, + StudioJobs.QueueNextSegment, + { + playlistId: rundownPlaylistId, + queuedSegmentId, } ) } diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index 06587b3b64..e01aa38a06 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -88,10 +88,10 @@ export interface DBRundownPlaylist { previousPartInfo: SelectedPartInstance | null /** - * The id of the Next Segment. If set, the Next point will jump to that segment when moving out of currently playing segment. + * 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. * In general this should only be set/cleared by a useraction, or during the take logic. This ensures that it isnt lost when doing manual set-next actions */ - nextSegmentId?: SegmentId + queuedSegmentId?: SegmentId /** Actual time of playback starting */ startedPlayback?: Time @@ -123,6 +123,6 @@ export type SelectedPartInstance = Readonly<{ /** if nextPartId was set manually (ie from a user action) */ manuallySelected: boolean - /** Whether this instance was selected because of RundownPlaylist.nextSegmentId. This will cause it to clear that property as part of the take operation */ - consumesNextSegmentId: boolean + /** Whether this instance was selected because of RundownPlaylist.queuedSegmentId. This will cause it to clear that property as part of the take operation */ + consumesQueuedSegmentId: boolean }> diff --git a/packages/corelib/src/worker/studio.ts b/packages/corelib/src/worker/studio.ts index 802ee5ad9c..8351850081 100644 --- a/packages/corelib/src/worker/studio.ts +++ b/packages/corelib/src/worker/studio.ts @@ -75,9 +75,13 @@ export enum StudioJobs { */ SetNextPart = 'setNextPart', /** - * Set the next Segment to a specified id + * Set the nexted part to first part of the Segment with a specified id */ SetNextSegment = 'setNextSegment', + /** + * Set the queued Segment to a specified id + */ + QueueNextSegment = 'queueNextSegment', /** * Move which Part is nexted by a Part(horizontal) or Segment (vertical) delta */ @@ -207,9 +211,12 @@ export interface SetNextPartProps extends RundownPlayoutPropsBase { nextTimeOffset?: number } export interface SetNextSegmentProps extends RundownPlayoutPropsBase { - nextSegmentId: SegmentId | null - immediate: boolean + nextSegmentId: SegmentId +} +export interface QueueNextSegmentProps extends RundownPlayoutPropsBase { + queuedSegmentId: SegmentId | null } +export type QueueNextSegmentResult = { nextPartId: PartId } | { queuedSegmentId: SegmentId | null } export interface ExecuteActionProps extends RundownPlayoutPropsBase { actionDocId: AdLibActionId | RundownBaselineAdLibActionId | null actionId: string @@ -298,7 +305,8 @@ export type StudioJobFunc = { [StudioJobs.ActivateRundownPlaylist]: (data: ActivateRundownPlaylistProps) => void [StudioJobs.DeactivateRundownPlaylist]: (data: DeactivateRundownPlaylistProps) => void [StudioJobs.SetNextPart]: (data: SetNextPartProps) => void - [StudioJobs.SetNextSegment]: (data: SetNextSegmentProps) => void + [StudioJobs.SetNextSegment]: (data: SetNextSegmentProps) => PartId + [StudioJobs.QueueNextSegment]: (data: QueueNextSegmentProps) => QueueNextSegmentResult [StudioJobs.ExecuteAction]: (data: ExecuteActionProps) => ExecuteActionResult [StudioJobs.TakeNextPart]: (data: TakeNextPartProps) => void [StudioJobs.DisableNextPiece]: (data: DisableNextPieceProps) => void diff --git a/packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts b/packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts index 09a57f5d28..abf1e8a050 100644 --- a/packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts +++ b/packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts @@ -231,14 +231,14 @@ describe('Test blueprint api context', () => { partInstanceId: info.partInstanceId, rundownId: info.rundownId, manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, } } else { return { partInstanceId: info._id, rundownId: info.rundownId, manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, } } } @@ -1437,7 +1437,7 @@ describe('Test blueprint api context', () => { partInstanceId: protectString('abc'), rundownId: protectString('def'), manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, }, }, }) @@ -1516,7 +1516,7 @@ describe('Test blueprint api context', () => { partInstanceId: protectString('abc'), rundownId: protectString('def'), manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, }, }, }) diff --git a/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts b/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts index 87e76797f5..a993c0212a 100644 --- a/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts +++ b/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts @@ -42,13 +42,13 @@ describe('Test external message queue static methods', () => { partInstanceId: protectString('part_now'), rundownId: protectString('rundown_1'), manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, }, nextPartInfo: { partInstanceId: protectString('partNext'), rundownId: protectString('rundown_1'), manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, }, previousPartInfo: null, activationId: protectString('active'), @@ -185,13 +185,13 @@ describe('Test sending messages to mocked endpoints', () => { partInstanceId: protectString('part_now'), rundownId: rundownId, manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, }, nextPartInfo: { partInstanceId: protectString('partNext'), rundownId: rundownId, manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, }, previousPartInfo: null, activationId: protectString('active'), diff --git a/packages/job-worker/src/ingest/__tests__/updateNext.test.ts b/packages/job-worker/src/ingest/__tests__/updateNext.test.ts index 5d629fc18d..300eb75457 100644 --- a/packages/job-worker/src/ingest/__tests__/updateNext.test.ts +++ b/packages/job-worker/src/ingest/__tests__/updateNext.test.ts @@ -328,7 +328,7 @@ describe('ensureNextPartIsValid', () => { partInstanceId: nextPartInstanceId as any, rundownId, manuallySelected: nextPartManual || false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, } : null, currentPartInfo: currentPartInstanceId @@ -336,7 +336,7 @@ describe('ensureNextPartIsValid', () => { partInstanceId: currentPartInstanceId as any, rundownId, manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, } : null, previousPartInfo: null, diff --git a/packages/job-worker/src/playout/__tests__/playout.test.ts b/packages/job-worker/src/playout/__tests__/playout.test.ts index 0a2f708145..5e808eac2c 100644 --- a/packages/job-worker/src/playout/__tests__/playout.test.ts +++ b/packages/job-worker/src/playout/__tests__/playout.test.ts @@ -13,7 +13,7 @@ import { MockJobContext, setupDefaultJobEnvironment } from '../../__mocks__/cont import { PartInstanceId, RundownId, RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { fixSnapshot } from '../../__mocks__/helpers/snapshot' import { sortPartsInSortedSegments, sortSegmentsInRundowns } from '@sofie-automation/corelib/dist/playout/playlist' -import { handleSetNextPart, handleMoveNextPart, handleSetNextSegment } from '../setNextJobs' +import { handleSetNextPart, handleMoveNextPart, handleSetNextSegment, handleQueueNextSegment } from '../setNextJobs' import { handleTakeNextPart } from '../take' import { handleActivateRundownPlaylist, @@ -789,7 +789,7 @@ describe('Playout API', () => { expect(nextPartInstance?.part._id).toBe(parts[0]._id) } }) - test('setNextSegment', async () => { + test('queueNextSegment', async () => { const { rundownId: rundownId0, playlistId: playlistId0 } = await setupRundownWithAutoplayPart0( context, protectString('rundown0'), @@ -816,22 +816,20 @@ describe('Playout API', () => { { // doesn't queue a segment with no valid parts await expect( - handleSetNextSegment(context, { + handleQueueNextSegment(context, { playlistId: playlistId0, - nextSegmentId: segments[3]._id, - immediate: false, + queuedSegmentId: segments[3]._id, }) ).rejects.toThrow(/no valid parts/gi) } { - await handleSetNextSegment(context, { + await handleQueueNextSegment(context, { playlistId: playlistId0, - nextSegmentId: segments[2]._id, - immediate: false, + queuedSegmentId: segments[2]._id, }) const playlist = await getPlaylist0() - expect(playlist.nextSegmentId).toBe(segments[2]._id) + expect(playlist.queuedSegmentId).toBe(segments[2]._id) } { @@ -852,7 +850,7 @@ describe('Playout API', () => { const { currentPartInstance } = await getSelectedPartInstances(context, playlist) // expect first part of queued segment was taken expect(currentPartInstance?.part._id).toBe(parts[5]._id) - expect(playlist.nextSegmentId).toBeUndefined() + expect(playlist.queuedSegmentId).toBeUndefined() // back to last part of the first segment await handleSetNextPart(context, { playlistId: playlistId0, nextPartId: parts[1]._id }) @@ -863,16 +861,15 @@ describe('Playout API', () => { } { - // set next segment when next part is already outside of the current one + // queue next segment when next part is already outside of the current one const segmentToQueueId = segments[2]._id - await handleSetNextSegment(context, { + await handleQueueNextSegment(context, { playlistId: playlistId0, - nextSegmentId: segmentToQueueId, - immediate: false, + queuedSegmentId: segmentToQueueId, }) const playlist = await getPlaylist0() // expect to just set first part of the queued segment as next - expect(playlist.nextSegmentId).toBeUndefined() + expect(playlist.queuedSegmentId).toBeUndefined() const { nextPartInstance } = await getSelectedPartInstances(context, playlist) const firstPartOfQueuedSegment = parts.find((part) => part.segmentId === segmentToQueueId) if (!firstPartOfQueuedSegment) throw new Error('Did not find a part of Queued Segment') @@ -880,7 +877,7 @@ describe('Playout API', () => { if (firstPartOfQueuedSegment.invalid) throw new Error('Selected Part is invalid') } }) - test('setNextSegment (immediate)', async () => { + test('setNextSegment', async () => { const { rundownId: rundownId0, playlistId: playlistId0 } = await setupRundownWithAutoplayPart0( context, protectString('rundown0'), @@ -910,31 +907,28 @@ describe('Playout API', () => { handleSetNextSegment(context, { playlistId: playlistId0, nextSegmentId: segments[3]._id, - immediate: true, }) ).rejects.toThrow(/no valid parts/gi) } { // (queue to later check that we're clearing it) - await handleSetNextSegment(context, { + await handleQueueNextSegment(context, { playlistId: playlistId0, - nextSegmentId: segments[2]._id, - immediate: false, + queuedSegmentId: segments[2]._id, }) const playlist = await getPlaylist0() - expect(playlist.nextSegmentId).toBe(segments[2]._id) + expect(playlist.queuedSegmentId).toBe(segments[2]._id) } { await handleSetNextSegment(context, { playlistId: playlistId0, nextSegmentId: segments[1]._id, - immediate: true, }) const playlist = await getPlaylist0() - // clears nextSegmentId - expect(playlist.nextSegmentId).toBeUndefined() + // clears queuedSegmentId + expect(playlist.queuedSegmentId).toBeUndefined() const { nextPartInstance } = await getSelectedPartInstances(context, playlist) // sets first part as next const firstPartOfQueuedSegment = parts.find((part) => part.segmentId === segments[1]._id) diff --git a/packages/job-worker/src/playout/__tests__/selectNextPart.test.ts b/packages/job-worker/src/playout/__tests__/selectNextPart.test.ts index 143083a6c9..8fe16fb4fa 100644 --- a/packages/job-worker/src/playout/__tests__/selectNextPart.test.ts +++ b/packages/job-worker/src/playout/__tests__/selectNextPart.test.ts @@ -44,7 +44,7 @@ describe('selectNextPart', () => { context = setupDefaultJobEnvironment() defaultPlaylist = { - nextSegmentId: undefined, + queuedSegmentId: undefined, loop: false, } @@ -73,28 +73,28 @@ describe('selectNextPart', () => { { // default const nextPart = selectNextPart(context, defaultPlaylist, null, null, getSegmentsAndParts()) - expect(nextPart).toEqual({ index: 0, part: defaultParts[0], consumesNextSegmentId: false }) + expect(nextPart).toEqual({ index: 0, part: defaultParts[0], consumesQueuedSegmentId: false }) } { // first isnt playable defaultParts[0].playable = false const nextPart = selectNextPart(context, defaultPlaylist, null, null, getSegmentsAndParts()) - expect(nextPart).toEqual({ index: 1, part: defaultParts[1], consumesNextSegmentId: false }) + expect(nextPart).toEqual({ index: 1, part: defaultParts[1], consumesQueuedSegmentId: false }) } { - // nextSegmentId is set - defaultPlaylist.nextSegmentId = segment3 + // queuedSegmentId is set + defaultPlaylist.queuedSegmentId = segment3 const nextPart = selectNextPart(context, defaultPlaylist, null, null, getSegmentsAndParts()) - expect(nextPart).toEqual({ index: 6, part: defaultParts[6], consumesNextSegmentId: true }) + expect(nextPart).toEqual({ index: 6, part: defaultParts[6], consumesQueuedSegmentId: true }) } { - // nextSegmentId is set (and first there isnt playable) - defaultPlaylist.nextSegmentId = segment2 + // queuedSegmentId is set (and first there isnt playable) + defaultPlaylist.queuedSegmentId = segment2 const nextPart = selectNextPart(context, defaultPlaylist, null, null, getSegmentsAndParts()) - expect(nextPart).toEqual({ index: 4, part: defaultParts[4], consumesNextSegmentId: true }) + expect(nextPart).toEqual({ index: 4, part: defaultParts[4], consumesQueuedSegmentId: true }) } }) @@ -102,28 +102,28 @@ describe('selectNextPart', () => { { // default const nextPart = selectNextPart(context, defaultPlaylist, null, null, getSegmentsAndParts(), false) - expect(nextPart).toEqual({ index: 0, part: defaultParts[0], consumesNextSegmentId: false }) + expect(nextPart).toEqual({ index: 0, part: defaultParts[0], consumesQueuedSegmentId: false }) } { // first isnt playable defaultParts[0].playable = false const nextPart = selectNextPart(context, defaultPlaylist, null, null, getSegmentsAndParts(), false) - expect(nextPart).toEqual({ index: 0, part: defaultParts[0], consumesNextSegmentId: false }) + expect(nextPart).toEqual({ index: 0, part: defaultParts[0], consumesQueuedSegmentId: false }) } { - // nextSegmentId is set - defaultPlaylist.nextSegmentId = segment3 + // queuedSegmentId is set + defaultPlaylist.queuedSegmentId = segment3 const nextPart = selectNextPart(context, defaultPlaylist, null, null, getSegmentsAndParts(), false) - expect(nextPart).toEqual({ index: 6, part: defaultParts[6], consumesNextSegmentId: true }) + expect(nextPart).toEqual({ index: 6, part: defaultParts[6], consumesQueuedSegmentId: true }) } { - // nextSegmentId is set (and first there isnt playable) - defaultPlaylist.nextSegmentId = segment2 + // queuedSegmentId is set (and first there isnt playable) + defaultPlaylist.queuedSegmentId = segment2 const nextPart = selectNextPart(context, defaultPlaylist, null, null, getSegmentsAndParts(), false) - expect(nextPart).toEqual({ index: 3, part: defaultParts[3], consumesNextSegmentId: true }) + expect(nextPart).toEqual({ index: 3, part: defaultParts[3], consumesQueuedSegmentId: true }) } }) @@ -132,29 +132,29 @@ describe('selectNextPart', () => { { // default const nextPart = selectNextPart(context, defaultPlaylist, previousPartInstance, null, getSegmentsAndParts()) - expect(nextPart).toEqual({ index: 5, part: defaultParts[5], consumesNextSegmentId: false }) + expect(nextPart).toEqual({ index: 5, part: defaultParts[5], consumesQueuedSegmentId: false }) } { // next isnt playable defaultParts[5].playable = false const nextPart = selectNextPart(context, defaultPlaylist, previousPartInstance, null, getSegmentsAndParts()) - expect(nextPart).toEqual({ index: 6, part: defaultParts[6], consumesNextSegmentId: false }) + expect(nextPart).toEqual({ index: 6, part: defaultParts[6], consumesQueuedSegmentId: false }) } { - // nextSegmentId is set + // queuedSegmentId is set defaultParts[0].playable = false - defaultPlaylist.nextSegmentId = segment1 + defaultPlaylist.queuedSegmentId = segment1 const nextPart = selectNextPart(context, defaultPlaylist, previousPartInstance, null, getSegmentsAndParts()) - expect(nextPart).toEqual({ index: 1, part: defaultParts[1], consumesNextSegmentId: true }) + expect(nextPart).toEqual({ index: 1, part: defaultParts[1], consumesQueuedSegmentId: true }) } { - // nextSegmentId is set (and first there isnt playable) - defaultPlaylist.nextSegmentId = segment2 + // queuedSegmentId is set (and first there isnt playable) + defaultPlaylist.queuedSegmentId = segment2 const nextPart = selectNextPart(context, defaultPlaylist, previousPartInstance, null, getSegmentsAndParts()) - expect(nextPart).toEqual({ index: 4, part: defaultParts[4], consumesNextSegmentId: true }) + expect(nextPart).toEqual({ index: 4, part: defaultParts[4], consumesQueuedSegmentId: true }) } }) @@ -166,14 +166,14 @@ describe('selectNextPart', () => { { // single part is orphaned const nextPart = selectNextPart(context, defaultPlaylist, previousPartInstance, null, getSegmentsAndParts()) - expect(nextPart).toEqual({ index: 4, part: defaultParts[4], consumesNextSegmentId: false }) + expect(nextPart).toEqual({ index: 4, part: defaultParts[4], consumesQueuedSegmentId: false }) } { // whole segment is orphaned/deleted defaultParts = defaultParts.filter((p) => p.segmentId !== previousPartInstance.segmentId) const nextPart = selectNextPart(context, defaultPlaylist, previousPartInstance, null, getSegmentsAndParts()) - expect(nextPart).toEqual({ index: 3, part: defaultParts[3], consumesNextSegmentId: false }) + expect(nextPart).toEqual({ index: 3, part: defaultParts[3], consumesQueuedSegmentId: false }) } { @@ -188,7 +188,7 @@ describe('selectNextPart', () => { defaultPlaylist.loop = true defaultParts = defaultParts.filter((p) => p.segmentId !== segment3) const nextPart = selectNextPart(context, defaultPlaylist, previousPartInstance, null, getSegmentsAndParts()) - expect(nextPart).toEqual({ index: 0, part: defaultParts[0], consumesNextSegmentId: false }) + expect(nextPart).toEqual({ index: 0, part: defaultParts[0], consumesQueuedSegmentId: false }) } }) @@ -204,7 +204,7 @@ describe('selectNextPart', () => { getSegmentsAndParts(), false ) - expect(nextPart).toEqual({ index: 5, part: defaultParts[5], consumesNextSegmentId: false }) + expect(nextPart).toEqual({ index: 5, part: defaultParts[5], consumesQueuedSegmentId: false }) } { @@ -218,7 +218,7 @@ describe('selectNextPart', () => { getSegmentsAndParts(), false ) - expect(nextPart).toEqual({ index: 5, part: defaultParts[5], consumesNextSegmentId: false }) + expect(nextPart).toEqual({ index: 5, part: defaultParts[5], consumesQueuedSegmentId: false }) } }) }) diff --git a/packages/job-worker/src/playout/activePlaylistActions.ts b/packages/job-worker/src/playout/activePlaylistActions.ts index 9a90c39224..21066e0fa6 100644 --- a/packages/job-worker/src/playout/activePlaylistActions.ts +++ b/packages/job-worker/src/playout/activePlaylistActions.ts @@ -189,7 +189,7 @@ export async function deactivateRundownPlaylistInner( p.holdState = RundownHoldState.NONE delete p.activationId - delete p.nextSegmentId + delete p.queuedSegmentId return p }) diff --git a/packages/job-worker/src/playout/debug.ts b/packages/job-worker/src/playout/debug.ts index 94615d72ac..31fe38dd4d 100644 --- a/packages/job-worker/src/playout/debug.ts +++ b/packages/job-worker/src/playout/debug.ts @@ -47,7 +47,7 @@ export async function handleDebugRegenerateNextPartInstance( await setNextPart( context, cache, - { part: part, consumesNextSegmentId: false }, + { part: part, consumesQueuedSegmentId: false }, originalNextPartInfo.manuallySelected ) diff --git a/packages/job-worker/src/playout/lib.ts b/packages/job-worker/src/playout/lib.ts index 8015985145..8f6aef5c1f 100644 --- a/packages/job-worker/src/playout/lib.ts +++ b/packages/job-worker/src/playout/lib.ts @@ -39,7 +39,7 @@ export async function resetRundownPlaylist(context: JobContext, cache: CacheForP delete p.rundownsStartedPlayback delete p.previousPersistentState delete p.trackedAbSessions - delete p.nextSegmentId + delete p.queuedSegmentId return p }) diff --git a/packages/job-worker/src/playout/lookahead/__tests__/util.test.ts b/packages/job-worker/src/playout/lookahead/__tests__/util.test.ts index e49c6a87a4..e63e1b5173 100644 --- a/packages/job-worker/src/playout/lookahead/__tests__/util.test.ts +++ b/packages/job-worker/src/playout/lookahead/__tests__/util.test.ts @@ -157,7 +157,7 @@ describe('getOrderedPartsAfterPlayhead', () => { partInstanceId: firstInstanceId, rundownId: firstPart.rundownId, manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, }, }, }) @@ -190,7 +190,7 @@ describe('getOrderedPartsAfterPlayhead', () => { partInstanceId: firstInstanceId, rundownId: firstPart.rundownId, manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, }, }, }) @@ -223,7 +223,7 @@ describe('getOrderedPartsAfterPlayhead', () => { partInstanceId: lastInstanceId, rundownId: lastPart.rundownId, manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, }, }, }) @@ -281,7 +281,7 @@ describe('getOrderedPartsAfterPlayhead', () => { partInstanceId: nextInstanceId, rundownId: nextPart.rundownId, manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, }, }, }) @@ -307,13 +307,13 @@ describe('getOrderedPartsAfterPlayhead', () => { partInstanceId: nextInstanceId, rundownId: firstPart.rundownId, manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, }, }, }) // Change next segment - await context.mockCollections.RundownPlaylists.update(playlistId, { $set: { nextSegmentId: segmentId2 } }) + await context.mockCollections.RundownPlaylists.update(playlistId, { $set: { queuedSegmentId: segmentId2 } }) const parts = await runJobWithPlayoutCache(context, { playlistId }, null, async (cache) => getOrderedPartsAfterPlayhead(context, cache, 10) ) diff --git a/packages/job-worker/src/playout/lookahead/util.ts b/packages/job-worker/src/playout/lookahead/util.ts index dcf0be371b..7c0c888163 100644 --- a/packages/job-worker/src/playout/lookahead/util.ts +++ b/packages/job-worker/src/playout/lookahead/util.ts @@ -49,11 +49,11 @@ export function getOrderedPartsAfterPlayhead(context: JobContext, cache: CacheFo const { currentPartInstance, nextPartInstance } = getSelectedPartInstancesFromCache(cache) // If the nextPartInstance consumes the - const alreadyConsumedNextSegmentId = + const alreadyConsumedQueuedSegmentId = nextPartInstance && (!currentPartInstance || currentPartInstance.segmentId !== nextPartInstance.segmentId) const strippedPlaylist = { - nextSegmentId: alreadyConsumedNextSegmentId ? undefined : playlist.nextSegmentId, + queuedSegmentId: alreadyConsumedQueuedSegmentId ? undefined : playlist.queuedSegmentId, loop: playlist.loop, } const nextNextPart = selectNextPart( @@ -72,12 +72,12 @@ export function getOrderedPartsAfterPlayhead(context: JobContext, cache: CacheFo const res: DBPart[] = [] - const nextSegmentIndex = playablePartsSlice.findIndex((p) => p.segmentId === playlist.nextSegmentId) + const nextSegmentIndex = playablePartsSlice.findIndex((p) => p.segmentId === playlist.queuedSegmentId) if ( - playlist.nextSegmentId && - !alreadyConsumedNextSegmentId && + playlist.queuedSegmentId && + !alreadyConsumedQueuedSegmentId && nextSegmentIndex !== -1 && - !nextNextPart.consumesNextSegmentId + !nextNextPart.consumesQueuedSegmentId ) { // TODO - this if clause needs some decent testing diff --git a/packages/job-worker/src/playout/selectNextPart.ts b/packages/job-worker/src/playout/selectNextPart.ts index 81b69f37e8..0eecbb562a 100644 --- a/packages/job-worker/src/playout/selectNextPart.ts +++ b/packages/job-worker/src/playout/selectNextPart.ts @@ -20,10 +20,10 @@ export interface SelectNextPartResult { index: number /** - * Whether this Part consumes the `nextSegmentId` property on the rundown. - * If true, when this PartInstance is taken, the `nextSegmentId` property on the Playlist will be cleared + * Whether this Part consumes the `queuedSegmentId` property on the rundown. + * If true, when this PartInstance is taken, the `queuedSegmentId` property on the Playlist will be cleared */ - consumesNextSegmentId: boolean + consumesQueuedSegmentId: boolean } export interface PartsAndSegments { segments: DBSegment[] @@ -36,7 +36,7 @@ export interface PartsAndSegments { export function selectNextPart( context: JobContext, - rundownPlaylist: Pick, + rundownPlaylist: Pick, previousPartInstance: DBPartInstance | null, currentlySelectedPartInstance: DBPartInstance | null, { parts: parts0, segments }: PartsAndSegments, @@ -64,7 +64,7 @@ export function selectNextPart( for (let index = offset; index < (length || parts.length); index++) { const part = parts[index] if ((!ignoreUnplayabale || isPartPlayable(part)) && (!condition || condition(part))) { - return { part, index, consumesNextSegmentId: false } + return { part, index, consumesQueuedSegmentId: false } } } return undefined @@ -123,16 +123,19 @@ export function selectNextPart( // Filter to after and find the first playabale let nextPart = findFirstPlayablePart(searchFromIndex) - if (rundownPlaylist.nextSegmentId) { + if (rundownPlaylist.queuedSegmentId) { // No previous part, or segment has changed if (!previousPartInstance || (nextPart && previousPartInstance.segmentId !== nextPart.part.segmentId)) { // Find first in segment - const newSegmentPart = findFirstPlayablePart(0, (part) => part.segmentId === rundownPlaylist.nextSegmentId) + const newSegmentPart = findFirstPlayablePart( + 0, + (part) => part.segmentId === rundownPlaylist.queuedSegmentId + ) if (newSegmentPart) { // If matched matched, otherwise leave on auto nextPart = { ...newSegmentPart, - consumesNextSegmentId: true, + consumesQueuedSegmentId: true, } } } diff --git a/packages/job-worker/src/playout/setNext.ts b/packages/job-worker/src/playout/setNext.ts index c68a56ca31..e35000c8a6 100644 --- a/packages/job-worker/src/playout/setNext.ts +++ b/packages/job-worker/src/playout/setNext.ts @@ -4,6 +4,7 @@ import { DBPart, isPartPlayable } from '@sofie-automation/corelib/dist/dataModel import { JobContext } from '../jobs' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { + PartId, PartInstanceId, RundownId, RundownPlaylistActivationId, @@ -26,6 +27,7 @@ import { RundownHoldState, SelectedPartInstance } from '@sofie-automation/coreli import { UserError, UserErrorMessage } from '@sofie-automation/corelib/dist/error' import { SelectNextPartResult } from './selectNextPart' import { sortPartsInSortedSegments } from '@sofie-automation/corelib/dist/playout/playlist' +import { QueueNextSegmentResult } from '@sofie-automation/corelib/dist/worker/studio' /** * Set or clear the nexted part, from a given PartInstance, or SelectNextPartResult @@ -53,7 +55,7 @@ export async function setNextPart( // create new instance let newPartInstance: DBPartInstance - let consumesNextSegmentId: boolean + let consumesQueuedSegmentId: boolean if ('playlistActivationId' in rawNextPart) { const inputPartInstance: DBPartInstance = rawNextPart if (inputPartInstance.part.invalid) { @@ -66,7 +68,7 @@ export async function setNextPart( ) } - consumesNextSegmentId = false + consumesQueuedSegmentId = false newPartInstance = await prepareExistingPartInstanceForBeingNexted(context, cache, inputPartInstance) } else { const selectedPart: Omit = rawNextPart @@ -80,7 +82,7 @@ export async function setNextPart( ) } - consumesNextSegmentId = selectedPart.consumesNextSegmentId ?? false + consumesQueuedSegmentId = selectedPart.consumesQueuedSegmentId ?? false if (nextPartInstance && nextPartInstance.part._id === selectedPart.part._id) { // Re-use existing @@ -116,7 +118,7 @@ export async function setNextPart( partInstanceId: newPartInstance._id, rundownId: newPartInstance.rundownId, manuallySelected: !!(setManually || newPartInstance.orphaned), - consumesNextSegmentId, + consumesQueuedSegmentId, }) p.nextTimeOffset = nextTimeOffset || null return p @@ -343,67 +345,99 @@ async function cleanupOrphanedItems(context: JobContext, cache: CacheForPlayout) } /** - * Set or clear the nexted-segment. + * Set or clear the queued segment. * @param context Context for the running job * @param cache The playout cache of the playlist - * @param nextSegment The segment to set as next, or null to clear it - * @param immediate Whether given Segment should be the first thing to be taken right after current part, or after the last part of the current segment (aka queued). + * @param queuedSegment The segment to queue, or null to clear it */ -export async function setNextSegment( +export async function queueNextSegment( context: JobContext, cache: CacheForPlayout, - nextSegment: DBSegment | null, - immediate: boolean -): Promise { - const span = context.startSpan('setNextSegment') - if (nextSegment) { + queuedSegment: DBSegment | null +): Promise { + const span = context.startSpan('queueNextSegment') + if (queuedSegment) { // Just run so that errors will be thrown if something wrong: - const partsInSegment = sortPartsInSortedSegments( - cache.Parts.findAll((p) => p.segmentId === nextSegment._id), - [nextSegment] - ) - const firstPlayablePart = partsInSegment.find((p) => isPartPlayable(p)) - if (!firstPlayablePart) { - throw new Error('Segment contains no valid parts') - } + const firstPlayablePart = findFirstPlayablePartOrThrow(cache, queuedSegment) const { nextPartInstance, currentPartInstance } = getSelectedPartInstancesFromCache(cache) // if there is not currentPartInstance or the nextPartInstance is not in the current segment // behave as if user chose SetNextPart on the first playable part of the segment - if ( - currentPartInstance === undefined || - currentPartInstance.segmentId !== nextPartInstance?.segmentId || - immediate - ) { + if (currentPartInstance === undefined || currentPartInstance.segmentId !== nextPartInstance?.segmentId) { // Clear any existing nextSegment, as this call 'replaces' it cache.Playlist.update((p) => { - delete p.nextSegmentId + delete p.queuedSegmentId return p }) - return setNextPart( + await setNextPart( context, cache, { part: firstPlayablePart, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, }, true ) + + span?.end() + return { nextPartId: firstPlayablePart._id } } cache.Playlist.update((p) => { - p.nextSegmentId = nextSegment._id + p.queuedSegmentId = queuedSegment._id return p }) } else { cache.Playlist.update((p) => { - delete p.nextSegmentId + delete p.queuedSegmentId return p }) } + span?.end() + return { queuedSegmentId: queuedSegment?._id ?? null } +} + +/** + * Set the first playable part of a given segment as next. + * @param context Context for the running job + * @param cache The playout cache of the playlist + * @param nextSegment The segment, whose first part is to be set as next + */ +export async function setNextSegment( + context: JobContext, + cache: CacheForPlayout, + nextSegment: DBSegment +): Promise { + const span = context.startSpan('setNextSegment') + // Just run so that errors will be thrown if something wrong: + const firstPlayablePart = findFirstPlayablePartOrThrow(cache, nextSegment) + + await setNextPart( + context, + cache, + { + part: firstPlayablePart, + consumesQueuedSegmentId: false, + }, + true + ) + if (span) span.end() + return firstPlayablePart._id +} + +function findFirstPlayablePartOrThrow(cache: CacheForPlayout, segment: DBSegment): DBPart { + const partsInSegment = sortPartsInSortedSegments( + cache.Parts.findAll((p) => p.segmentId === segment._id), + [segment] + ) + const firstPlayablePart = partsInSegment.find((p) => isPartPlayable(p)) + if (!firstPlayablePart) { + throw new Error('Segment contains no valid parts') + } + return firstPlayablePart } /** @@ -427,18 +461,18 @@ export async function setNextPartFromPart( throw UserError.create(UserErrorMessage.DuringHold) } - const consumesNextSegmentId = doesPartConsumeNextSegmentId(cache, nextPart) + const consumesQueuedSegmentId = doesPartConsumeQueuedSegmentId(cache, nextPart) - await setNextPart(context, cache, { part: nextPart, consumesNextSegmentId }, setManually, nextTimeOffset) + await setNextPart(context, cache, { part: nextPart, consumesQueuedSegmentId }, setManually, nextTimeOffset) } -function doesPartConsumeNextSegmentId(cache: CacheForPlayout, nextPart: DBPart) { +function doesPartConsumeQueuedSegmentId(cache: CacheForPlayout, nextPart: DBPart) { // If we're setting the next point to somewhere other than the current segment, and in the queued segment, clear the queued segment const playlist = cache.Playlist.doc const { currentPartInstance } = getSelectedPartInstancesFromCache(cache) return !!( currentPartInstance && currentPartInstance.segmentId !== nextPart.segmentId && - playlist.nextSegmentId === nextPart.segmentId + playlist.queuedSegmentId === nextPart.segmentId ) } diff --git a/packages/job-worker/src/playout/setNextJobs.ts b/packages/job-worker/src/playout/setNextJobs.ts index 8fe2a14a82..5fa99166f2 100644 --- a/packages/job-worker/src/playout/setNextJobs.ts +++ b/packages/job-worker/src/playout/setNextJobs.ts @@ -3,10 +3,16 @@ import { isPartPlayable } from '@sofie-automation/corelib/dist/dataModel/Part' import { RundownHoldState } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' import { UserError, UserErrorMessage } from '@sofie-automation/corelib/dist/error' -import { SetNextPartProps, MoveNextPartProps, SetNextSegmentProps } from '@sofie-automation/corelib/dist/worker/studio' +import { + SetNextPartProps, + MoveNextPartProps, + SetNextSegmentProps, + QueueNextSegmentProps, + QueueNextSegmentResult, +} from '@sofie-automation/corelib/dist/worker/studio' import { JobContext } from '../jobs' import { runJobWithPlayoutCache } from './lock' -import { setNextPartFromPart, setNextSegment } from './setNext' +import { setNextPartFromPart, setNextSegment, queueNextSegment } from './setNext' import { moveNextPart } from './moveNextPart' import { updateTimeline } from './timeline/generate' @@ -71,9 +77,9 @@ export async function handleMoveNextPart(context: JobContext, data: MoveNextPart } /** - * Set the next Segment to a specified id + * Set the next part to the first part of a Segment with given id */ -export async function handleSetNextSegment(context: JobContext, data: SetNextSegmentProps): Promise { +export async function handleSetNextSegment(context: JobContext, data: SetNextSegmentProps): Promise { return runJobWithPlayoutCache( context, data, @@ -86,16 +92,50 @@ export async function handleSetNextSegment(context: JobContext, data: SetNextSeg } }, async (cache) => { - let nextSegment: DBSegment | null = null - if (data.nextSegmentId) { - nextSegment = cache.Segments.findOne(data.nextSegmentId) || null - if (!nextSegment) throw new Error(`Segment "${data.nextSegmentId}" not found!`) + const nextSegment = cache.Segments.findOne(data.nextSegmentId) || null + if (!nextSegment) throw new Error(`Segment "${data.nextSegmentId}" not found!`) + + const nextedPartId = await setNextSegment(context, cache, nextSegment) + + // Update any future lookaheads + await updateTimeline(context, cache) + + return nextedPartId + } + ) +} + +/** + * Set the next part to the first part of a given Segment to a specified id + */ +export async function handleQueueNextSegment( + context: JobContext, + data: QueueNextSegmentProps +): Promise { + return runJobWithPlayoutCache( + context, + data, + async (cache) => { + const playlist = cache.Playlist.doc + if (!playlist.activationId) throw UserError.create(UserErrorMessage.InactiveRundown, undefined, 412) + + if (playlist.holdState && playlist.holdState !== RundownHoldState.COMPLETE) { + throw UserError.create(UserErrorMessage.DuringHold, undefined, 412) + } + }, + async (cache) => { + let queuedSegment: DBSegment | null = null + if (data.queuedSegmentId) { + queuedSegment = cache.Segments.findOne(data.queuedSegmentId) || null + if (!queuedSegment) throw new Error(`Segment "${data.queuedSegmentId}" not found!`) } - await setNextSegment(context, cache, nextSegment, data.immediate) + const result = await queueNextSegment(context, cache, queuedSegment) // Update any future lookaheads await updateTimeline(context, cache) + + return result } ) } diff --git a/packages/job-worker/src/playout/snapshot.ts b/packages/job-worker/src/playout/snapshot.ts index eb95e98efb..ae98312ebf 100644 --- a/packages/job-worker/src/playout/snapshot.ts +++ b/packages/job-worker/src/playout/snapshot.ts @@ -499,7 +499,7 @@ function fixupImportedSelectedPartInstanceIds( partInstanceId: oldId, rundownId: partInstanceOldRundownIdMap.get(oldId) || protectString(''), manuallySelected: false, - consumesNextSegmentId: false, + consumesQueuedSegmentId: false, } } @@ -511,7 +511,7 @@ function fixupImportedSelectedPartInstanceIds( partInstanceId: partInstanceIdMap.get(snapshotInfo.partInstanceId) || snapshotInfo.partInstanceId, rundownId: rundownIdMap.get(snapshotInfo.rundownId) || snapshotInfo.rundownId, manuallySelected: snapshotInfo.manuallySelected, - consumesNextSegmentId: snapshotInfo.consumesNextSegmentId, + consumesQueuedSegmentId: snapshotInfo.consumesQueuedSegmentId, } } } diff --git a/packages/job-worker/src/playout/take.ts b/packages/job-worker/src/playout/take.ts index 389e04eb6e..1ab64e0d12 100644 --- a/packages/job-worker/src/playout/take.ts +++ b/packages/job-worker/src/playout/take.ts @@ -201,7 +201,7 @@ export async function performTakeToNextedPart(context: JobContext, cache: CacheF // it is only a first take if the Playlist has no startedPlayback and the taken PartInstance is not untimed const isFirstTake = !cache.Playlist.doc.startedPlayback && !takePartInstance.part.untimed - clearNextSegmentId(cache, takePartInstance, cache.Playlist.doc.nextPartInfo) + clearQueuedSegmentId(cache, takePartInstance, cache.Playlist.doc.nextPartInfo) const nextPart = selectNextPart( context, @@ -241,7 +241,7 @@ export async function performTakeToNextedPart(context: JobContext, cache: CacheF partInstanceId: takePartInstance._id, rundownId: takePartInstance.rundownId, manuallySelected: p.nextPartInfo?.manuallySelected ?? false, - consumesNextSegmentId: p.nextPartInfo?.consumesNextSegmentId ?? false, + consumesQueuedSegmentId: p.nextPartInfo?.consumesQueuedSegmentId ?? false, } p.lastTakeTime = getCurrentTime() @@ -291,19 +291,19 @@ export async function performTakeToNextedPart(context: JobContext, cache: CacheF * @param cache Cache for the active Playlist * @param takenPartInstance PartInstance to check */ -export function clearNextSegmentId( +export function clearQueuedSegmentId( cache: CacheForPlayout, takenPartInstance: DBPartInstance | undefined, takenPartInfo: ReadonlyDeep | null ): void { if ( - takenPartInfo?.consumesNextSegmentId && + takenPartInfo?.consumesQueuedSegmentId && takenPartInstance && - cache.Playlist.doc.nextSegmentId === takenPartInstance.segmentId + cache.Playlist.doc.queuedSegmentId === takenPartInstance.segmentId ) { - // clear the nextSegmentId if the newly taken partInstance says it was selected because of it + // clear the queuedSegmentId if the newly taken partInstance says it was selected because of it cache.Playlist.update((p) => { - delete p.nextSegmentId + delete p.queuedSegmentId return p }) } diff --git a/packages/job-worker/src/playout/timings/partPlayback.ts b/packages/job-worker/src/playout/timings/partPlayback.ts index 66186e2b7a..987431185e 100644 --- a/packages/job-worker/src/playout/timings/partPlayback.ts +++ b/packages/job-worker/src/playout/timings/partPlayback.ts @@ -11,7 +11,7 @@ import { selectNextPart } from '../selectNextPart' import { setNextPart } from '../setNext' import { updateTimeline } from '../timeline/generate' import { getCurrentTime } from '../../lib' -import { afterTake, clearNextSegmentId, resetPreviousSegment, updatePartInstanceOnTake } from '../take' +import { afterTake, clearQueuedSegmentId, resetPreviousSegment, updatePartInstanceOnTake } from '../take' import { queuePartInstanceTimingEvent } from './events' import { INCORRECT_PLAYING_PART_DEBOUNCE, RESET_IGNORE_ERRORS } from '../constants' import { Time } from '@sofie-automation/blueprints-integration' @@ -91,7 +91,7 @@ export async function onPartPlaybackStarted( currentPartInstance ) - clearNextSegmentId(cache, playingPartInstance, playlist.nextPartInfo) + clearQueuedSegmentId(cache, playingPartInstance, playlist.nextPartInfo) resetPreviousSegment(cache) // Update the next partinstance diff --git a/packages/job-worker/src/workers/studio/jobs.ts b/packages/job-worker/src/workers/studio/jobs.ts index 9e0f7f6926..a7913c84ca 100644 --- a/packages/job-worker/src/workers/studio/jobs.ts +++ b/packages/job-worker/src/workers/studio/jobs.ts @@ -8,7 +8,12 @@ import { } from '../../playout/adlibJobs' import { StudioJobs, StudioJobFunc } from '@sofie-automation/corelib/dist/worker/studio' import { handleUpdateTimelineAfterIngest, handleUpdateStudioBaseline } from '../../playout/timelineJobs' -import { handleMoveNextPart, handleSetNextPart, handleSetNextSegment } from '../../playout/setNextJobs' +import { + handleMoveNextPart, + handleSetNextPart, + handleSetNextSegment, + handleQueueNextSegment, +} from '../../playout/setNextJobs' import { handleActivateRundownPlaylist, handleDeactivateRundownPlaylist, @@ -61,6 +66,7 @@ export const studioJobHandlers: StudioJobHandlers = { [StudioJobs.DeactivateRundownPlaylist]: handleDeactivateRundownPlaylist, [StudioJobs.SetNextPart]: handleSetNextPart, [StudioJobs.SetNextSegment]: handleSetNextSegment, + [StudioJobs.QueueNextSegment]: handleQueueNextSegment, [StudioJobs.ExecuteAction]: handleExecuteAdlibAction, [StudioJobs.TakeNextPart]: handleTakeNextPart, [StudioJobs.DisableNextPiece]: handleDisableNextPiece, diff --git a/packages/openapi/api/actions.yaml b/packages/openapi/api/actions.yaml index 788926937e..a3a814fee1 100644 --- a/packages/openapi/api/actions.yaml +++ b/packages/openapi/api/actions.yaml @@ -52,6 +52,8 @@ paths: $ref: 'definitions/playlists.yaml#/resources/setNextPart' /playlists/{playlistId}/set-next-segment: $ref: 'definitions/playlists.yaml#/resources/setNextSegment' + /playlists/{playlistId}/queue-next-segment: + $ref: 'definitions/playlists.yaml#/resources/queueNextSegment' /playlists/{playlistId}/take: $ref: 'definitions/playlists.yaml#/resources/take' /playlists/{playlistId}/sourceLayer/{sourceLayerId}: diff --git a/packages/openapi/api/definitions/playlists.yaml b/packages/openapi/api/definitions/playlists.yaml index e3a846f940..6762bb8166 100644 --- a/packages/openapi/api/definitions/playlists.yaml +++ b/packages/openapi/api/definitions/playlists.yaml @@ -354,7 +354,7 @@ resources: operationId: setNextSegment tags: - playlists - summary: Sets the next Segment to a given SegmentId. + summary: Sets the next part to the first playable Part of the Segment with given segmentId. parameters: - name: playlistId in: path @@ -372,9 +372,51 @@ resources: segmentId: type: string description: Segment to set as next. - immediate: - type: boolean - description: Whether given Segment should be the first thing to be taken right after current part (effectively setting the first part of given segment as Next), as opposed to being taken after the last part of the current segment (queued until after the current segment ends). + required: + - segmentId + responses: + 200: + $ref: '#/components/responses/putSuccess' + 404: + $ref: '#/components/responses/playlistNotFound' + 412: + description: Specified Playlist is not active, the specified Segment does not exist, the specified Segment does not contain any playable parts, or currently in hold. + content: + application/json: + schema: + type: object + properties: + status: + type: number + example: 412 + message: + type: string + example: The selected part does not exist + 500: + $ref: '#/components/responses/internalServerError' + queueNextSegment: + post: + operationId: queueNextSegment + tags: + - playlists + summary: Queue Segment with a given segmentId, so that the Next point will jump to that Segment when reaching the end of the currently playing Segment. + parameters: + - name: playlistId + in: path + description: Target playlist. + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + segmentId: + type: string + description: Segment to queue. required: - segmentId responses: From 4f17b74ae70561b3eed89bd22bc760a4d9dfc5d7 Mon Sep 17 00:00:00 2001 From: ianshade Date: Mon, 9 Oct 2023 11:41:18 +0200 Subject: [PATCH 6/8] fix: missing usage of queueNextSegment --- meteor/client/ui/RundownView.tsx | 1 + meteor/lib/clientUserAction.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/meteor/client/ui/RundownView.tsx b/meteor/client/ui/RundownView.tsx index 6e8323e55a..0b2ccf256b 100644 --- a/meteor/client/ui/RundownView.tsx +++ b/meteor/client/ui/RundownView.tsx @@ -2991,6 +2991,7 @@ export const RundownView = translateWithTracker(( playlist={playlist} onSetNext={this.onSetNext} onSetNextSegment={this.onSetNextSegment} + onQueueNextSegment={this.onQueueNextSegment} studioMode={this.state.studioMode} enablePlayFromAnywhere={!!studio.settings.enablePlayFromAnywhere} /> diff --git a/meteor/lib/clientUserAction.ts b/meteor/lib/clientUserAction.ts index 290238cc2e..95deea8920 100644 --- a/meteor/lib/clientUserAction.ts +++ b/meteor/lib/clientUserAction.ts @@ -48,6 +48,8 @@ function userActionToLabel(userAction: UserAction, t: i18next.TFunction) { return t('Setting Next') case UserAction.SET_NEXT_SEGMENT: return t('Setting Next Segment') + case UserAction.QUEUE_NEXT_SEGMENT: + return t('Queueing next Segment') case UserAction.TAKE_PIECE: return t('Taking Piece') case UserAction.UNSYNC_RUNDOWN: From dee3633c8592dc0cd511bb538af9cbb8977a4621 Mon Sep 17 00:00:00 2001 From: ianshade Date: Mon, 9 Oct 2023 11:55:21 +0200 Subject: [PATCH 7/8] chore: update queue/setNextSegment api docs --- .../openapi/api/definitions/playlists.yaml | 42 +++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/openapi/api/definitions/playlists.yaml b/packages/openapi/api/definitions/playlists.yaml index 6762bb8166..bee5df9a7f 100644 --- a/packages/openapi/api/definitions/playlists.yaml +++ b/packages/openapi/api/definitions/playlists.yaml @@ -372,11 +372,23 @@ resources: segmentId: type: string description: Segment to set as next. + example: 'n1mOVd5_K5tt4sfk6HYfTuwumGQ_' required: - segmentId responses: 200: - $ref: '#/components/responses/putSuccess' + description: Command successfully handled - returns Part ID if the first part of a segment was set as next. + content: + application/json: + schema: + type: object + properties: + status: + type: number + example: 200 + result: + type: string + example: '3Y9at66pZipxE8Kkn850LLV9Cz0_' 404: $ref: '#/components/responses/playlistNotFound' 412: @@ -399,7 +411,7 @@ resources: operationId: queueNextSegment tags: - playlists - summary: Queue Segment with a given segmentId, so that the Next point will jump to that Segment when reaching the end of the currently playing Segment. + summary: Queue Segment with a given segmentId, so that the Next point will jump to that Segment when reaching the end of the currently playing Segment. If the part currently set as next is outside of the current segment, it will set the first part of the given segment as next. parameters: - name: playlistId in: path @@ -417,11 +429,35 @@ resources: segmentId: type: string description: Segment to queue. + example: 'n1mOVd5_K5tt4sfk6HYfTuwumGQ_' required: - segmentId responses: 200: - $ref: '#/components/responses/putSuccess' + description: Command successfully handled - returns ID of the queued Segment, or ID of the Part set as next. + content: + application/json: + schema: + type: object + properties: + status: + type: number + example: 200 + result: + type: object + oneOf: + - properties: + queuedSegmentId: + type: string + nullable: true + description: Segment ID, when a segment was queued, or 'null' when previously queued segment is cleared. + example: 'n1mOVd5_K5tt4sfk6HYfTuwumGQ_' + - properties: + nextPartId: + type: string + description: Part ID, when a part was set as next. + example: '3Y9at66pZipxE8Kkn850LLV9Cz0_' + 404: $ref: '#/components/responses/playlistNotFound' 412: From f79a6a5471b1c9ca3ed17015bee014c53c192d94 Mon Sep 17 00:00:00 2001 From: ianshade Date: Mon, 9 Oct 2023 12:44:30 +0200 Subject: [PATCH 8/8] fix: clear queued segment when setting one as next --- packages/job-worker/src/playout/setNext.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/job-worker/src/playout/setNext.ts b/packages/job-worker/src/playout/setNext.ts index e35000c8a6..338b2374d6 100644 --- a/packages/job-worker/src/playout/setNext.ts +++ b/packages/job-worker/src/playout/setNext.ts @@ -414,6 +414,11 @@ export async function setNextSegment( // Just run so that errors will be thrown if something wrong: const firstPlayablePart = findFirstPlayablePartOrThrow(cache, nextSegment) + cache.Playlist.update((p) => { + delete p.queuedSegmentId + return p + }) + await setNextPart( context, cache,