diff --git a/meteor/server/api/rest/v1/playlists.ts b/meteor/server/api/rest/v1/playlists.ts index 2616f2e6f9..8cb73b082c 100644 --- a/meteor/server/api/rest/v1/playlists.ts +++ b/meteor/server/api/rest/v1/playlists.ts @@ -29,7 +29,7 @@ import { } from '../../../collections' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { ServerClientAPI } from '../../client' -import { QueueNextSegmentResult, StudioJobs } from '@sofie-automation/corelib/dist/worker/studio' +import { QueueNextSegmentResult, StudioJobs, TakeNextPartResult } from '@sofie-automation/corelib/dist/worker/studio' import { getCurrentTime } from '../../../lib/lib' import { TriggerReloadDataResponse } from '@sofie-automation/meteor-lib/dist/api/userActions' import { ServerRundownAPI } from '../../rundown' @@ -457,7 +457,7 @@ class PlaylistsServerAPI implements PlaylistsRestAPI { event: string, rundownPlaylistId: RundownPlaylistId, fromPartInstanceId: PartInstanceId | undefined - ): Promise> { + ): Promise> { triggerWriteAccess() const rundownPlaylist = await RundownPlaylists.findOneAsync(rundownPlaylistId) if (!rundownPlaylist) throw new Error(`Rundown playlist ${rundownPlaylistId} does not exist`) @@ -801,7 +801,7 @@ export function registerRoutes(registerRoute: APIRegisterHook) } ) - registerRoute<{ playlistId: string }, { fromPartInstanceId?: string }, void>( + registerRoute<{ playlistId: string }, { fromPartInstanceId?: string }, TakeNextPartResult>( 'post', '/playlists/:playlistId/take', new Map([ diff --git a/meteor/server/lib/rest/v1/playlists.ts b/meteor/server/lib/rest/v1/playlists.ts index 74a60a2976..780226aa4d 100644 --- a/meteor/server/lib/rest/v1/playlists.ts +++ b/meteor/server/lib/rest/v1/playlists.ts @@ -10,7 +10,7 @@ import { RundownPlaylistId, SegmentId, } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { QueueNextSegmentResult } from '@sofie-automation/corelib/dist/worker/studio' +import { QueueNextSegmentResult, TakeNextPartResult } from '@sofie-automation/corelib/dist/worker/studio' import { Meteor } from 'meteor/meteor' /* ************************************************************************* @@ -228,7 +228,7 @@ export interface PlaylistsRestAPI { event: string, rundownPlaylistId: RundownPlaylistId, fromPartInstanceId: PartInstanceId | undefined - ): Promise> + ): Promise> /** * Clears the specified SourceLayers. * diff --git a/packages/corelib/src/worker/studio.ts b/packages/corelib/src/worker/studio.ts index 6eb045fc5e..70e78241b0 100644 --- a/packages/corelib/src/worker/studio.ts +++ b/packages/corelib/src/worker/studio.ts @@ -380,6 +380,10 @@ export interface CleanupOrphanedExpectedPackageReferencesProps { rundownId: RundownId } +export interface TakeNextPartResult { + nextTakeTime: number +} + /** * Set of valid functions, of form: * `id: (data) => return` @@ -404,7 +408,7 @@ export type StudioJobFunc = { [StudioJobs.QueueNextSegment]: (data: QueueNextSegmentProps) => QueueNextSegmentResult [StudioJobs.ExecuteAction]: (data: ExecuteActionProps) => ExecuteActionResult [StudioJobs.ExecuteBucketAdLibOrAction]: (data: ExecuteBucketAdLibOrActionProps) => ExecuteActionResult - [StudioJobs.TakeNextPart]: (data: TakeNextPartProps) => void + [StudioJobs.TakeNextPart]: (data: TakeNextPartProps) => TakeNextPartResult [StudioJobs.DisableNextPiece]: (data: DisableNextPieceProps) => void [StudioJobs.RemovePlaylist]: (data: RemovePlaylistProps) => void [StudioJobs.RegeneratePlaylist]: (data: RegeneratePlaylistProps) => void diff --git a/packages/job-worker/src/playout/take.ts b/packages/job-worker/src/playout/take.ts index c9a2dd32b9..dd103379fb 100644 --- a/packages/job-worker/src/playout/take.ts +++ b/packages/job-worker/src/playout/take.ts @@ -40,10 +40,12 @@ import { PlayoutRundownModel } from './model/PlayoutRundownModel.js' import { convertNoteToNotification } from '../notifications/util.js' import { PersistentPlayoutStateStore } from '../blueprints/context/services/PersistantStateStore.js' +import { TakeNextPartResult } from '@sofie-automation/corelib/dist/worker/studio' + /** * Take the currently Next:ed Part (start playing it) */ -export async function handleTakeNextPart(context: JobContext, data: TakeNextPartProps): Promise { +export async function handleTakeNextPart(context: JobContext, data: TakeNextPartProps): Promise { const now = getCurrentTime() return runJobWithPlayoutModel( @@ -77,17 +79,29 @@ export async function handleTakeNextPart(context: JobContext, data: TakeNextPart } } if (lastTakeTime && now - lastTakeTime < context.studio.settings.minimumTakeSpan) { + const nextTakeTime = lastTakeTime + context.studio.settings.minimumTakeSpan logger.debug( `Time since last take is shorter than ${context.studio.settings.minimumTakeSpan} for ${ playlist.currentPartInfo?.partInstanceId }: ${now - lastTakeTime}` ) - throw UserError.create(UserErrorMessage.TakeRateLimit, { - duration: context.studio.settings.minimumTakeSpan, - }) + throw UserError.create( + UserErrorMessage.TakeRateLimit, + { + duration: context.studio.settings.minimumTakeSpan, + nextAllowedTakeTime: nextTakeTime, + }, + 429 + ) } - return performTakeToNextedPart(context, playoutModel, now, undefined) + const nextTakeTime = now + context.studio.settings.minimumTakeSpan + + await performTakeToNextedPart(context, playoutModel, now, undefined) + + return { + nextTakeTime, + } } ) } @@ -159,7 +173,14 @@ export async function performTakeToNextedPart( logger.debug( `Take is blocked until ${currentPartInstance.partInstance.blockTakeUntil}. Which is in: ${remainingTime}` ) - throw UserError.create(UserErrorMessage.TakeBlockedDuration, { duration: remainingTime }) + throw UserError.create( + UserErrorMessage.TakeBlockedDuration, + { + duration: remainingTime, + nextAllowedTakeTime: currentPartInstance.partInstance.blockTakeUntil, + }, + 425 + ) } // If there was a transition from the previous Part, then ensure that has finished before another take is permitted @@ -171,11 +192,17 @@ export async function performTakeToNextedPart( start && now < start + currentPartInstance.partInstance.part.inTransition.blockTakeDuration ) { - throw UserError.create(UserErrorMessage.TakeDuringTransition) + throw UserError.create( + UserErrorMessage.TakeDuringTransition, + { + nextAllowedTakeTime: start + currentPartInstance.partInstance.part.inTransition.blockTakeDuration, + }, + 425 + ) } if (currentPartInstance.isTooCloseToAutonext(true)) { - throw UserError.create(UserErrorMessage.TakeCloseToAutonext) + throw UserError.create(UserErrorMessage.TakeCloseToAutonext, undefined, 425) } } diff --git a/packages/meteor-lib/src/api/__tests__/client.test.ts b/packages/meteor-lib/src/api/__tests__/client.test.ts index 969ed62ec5..0a5f35574c 100644 --- a/packages/meteor-lib/src/api/__tests__/client.test.ts +++ b/packages/meteor-lib/src/api/__tests__/client.test.ts @@ -50,6 +50,24 @@ describe('ClientAPI', () => { }) } }) + it('Extracts nextAllowedTakeTime from error args', () => { + const error = ClientAPI.responseError( + UserError.create( + UserErrorMessage.TakeRateLimit, + { + duration: 1000, + nextAllowedTakeTime: 1234567890, + }, + 429 + ) + ) + expect(error.nextAllowedTakeTime).toBe(1234567890) + expect(error.errorCode).toBe(429) + }) + it('Does not include nextAllowedTakeTime when not in args', () => { + const error = ClientAPI.responseError(UserError.create(UserErrorMessage.InactiveRundown)) + expect(error.nextAllowedTakeTime).toBeUndefined() + }) describe('isClientResponseSuccess', () => { it('Correctly recognizes a responseSuccess object', () => { const response = ClientAPI.responseSuccess(undefined) diff --git a/packages/meteor-lib/src/api/client.ts b/packages/meteor-lib/src/api/client.ts index 3f301e9b02..69bf618797 100644 --- a/packages/meteor-lib/src/api/client.ts +++ b/packages/meteor-lib/src/api/client.ts @@ -50,6 +50,8 @@ export namespace ClientAPI { errorCode: number /** On error, provide a human-readable error message */ error: SerializedUserError + /** For blocked TAKE operations, the next allowed take time (Unix timestamp ms) */ + nextAllowedTakeTime?: number } /** @@ -59,7 +61,12 @@ export namespace ClientAPI { * @returns A `ClientResponseError` object containing the error and the resolved error code. */ export function responseError(userError: UserError): ClientResponseError { - return { error: UserError.serialize(userError), errorCode: userError.errorCode } + const nextAllowedTakeTime = userError.userMessage.args?.nextAllowedTakeTime as number | undefined + return { + error: UserError.serialize(userError), + errorCode: userError.errorCode, + ...(nextAllowedTakeTime !== undefined && { nextAllowedTakeTime }), + } } export interface ClientResponseSuccess { /** On success, return success code (by default, use 200) */ diff --git a/packages/meteor-lib/src/api/userActions.ts b/packages/meteor-lib/src/api/userActions.ts index fd1a07347e..227ce3f7d3 100644 --- a/packages/meteor-lib/src/api/userActions.ts +++ b/packages/meteor-lib/src/api/userActions.ts @@ -6,7 +6,11 @@ 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 { Time } from '@sofie-automation/blueprints-integration' -import { ExecuteActionResult, QueueNextSegmentResult } from '@sofie-automation/corelib/dist/worker/studio' +import { + ExecuteActionResult, + QueueNextSegmentResult, + TakeNextPartResult, +} from '@sofie-automation/corelib/dist/worker/studio' import { AdLibActionId, BucketAdLibActionId, @@ -34,7 +38,7 @@ export interface NewUserActionAPI { eventTime: Time, rundownPlaylistId: RundownPlaylistId, fromPartInstanceId: PartInstanceId | null - ): Promise> + ): Promise> setNext( userEvent: string, eventTime: Time, diff --git a/packages/openapi/api/definitions/playlists.yaml b/packages/openapi/api/definitions/playlists.yaml index 943778f641..024736df25 100644 --- a/packages/openapi/api/definitions/playlists.yaml +++ b/packages/openapi/api/definitions/playlists.yaml @@ -563,7 +563,22 @@ resources: description: May be specified to ensure that multiple take requests from the same Part do not result in multiple takes. responses: 200: - $ref: '#/components/responses/putSuccess' + description: Take was successful - returns the next allowed take time. + content: + application/json: + schema: + type: object + properties: + status: + type: number + example: 200 + result: + type: object + properties: + nextTakeTime: + type: number + description: Unix timestamp (ms) of when the next take will be allowed. + example: 1707024000000 404: $ref: '#/components/responses/playlistNotFound' 412: @@ -579,6 +594,40 @@ resources: message: type: string example: No Next point found, please set a part as Next before doing a TAKE. + 425: + description: Take is blocked due to a transition or adlib action. + content: + application/json: + schema: + type: object + properties: + status: + type: number + example: 425 + message: + type: string + example: Cannot take during a transition + nextAllowedTakeTime: + type: number + description: Unix timestamp (ms) of when the next take will be allowed. + example: 1707024000000 + 429: + description: Take rate limit exceeded - takes are happening too quickly. + content: + application/json: + schema: + type: object + properties: + status: + type: number + example: 429 + message: + type: string + example: Ignoring TAKES that are too quick after eachother (1000 ms) + nextAllowedTakeTime: + type: number + description: Unix timestamp (ms) of when the next take will be allowed. + example: 1707024000000 500: $ref: '#/components/responses/internalServerError'