diff --git a/meteor/__mocks__/defaultCollectionObjects.ts b/meteor/__mocks__/defaultCollectionObjects.ts index 62420caaac..ae3cc5aa1f 100644 --- a/meteor/__mocks__/defaultCollectionObjects.ts +++ b/meteor/__mocks__/defaultCollectionObjects.ts @@ -110,6 +110,8 @@ export function defaultStudio(_id: StudioId): DBStudio { mediaPreviewsUrl: '', minimumTakeSpan: DEFAULT_MINIMUM_TAKE_SPAN, fallbackPartDuration: DEFAULT_FALLBACK_PART_DURATION, + allowHold: false, + allowPieceDirectPlay: false, }), _rundownVersionHash: '', routeSetsWithOverrides: wrapDefaultObject({}), diff --git a/meteor/server/api/rest/v1/typeConversion.ts b/meteor/server/api/rest/v1/typeConversion.ts index 7b44254687..afd3f7ccd3 100644 --- a/meteor/server/api/rest/v1/typeConversion.ts +++ b/meteor/server/api/rest/v1/typeConversion.ts @@ -10,7 +10,7 @@ import { PeripheralDevice, PeripheralDeviceType } from '@sofie-automation/coreli import { Blueprint } from '@sofie-automation/corelib/dist/dataModel/Blueprint' import { BucketId, ShowStyleBaseId, ShowStyleVariantId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { DBStudio, IStudioSettings } from '@sofie-automation/corelib/dist/dataModel/Studio' -import { assertNever, getRandomId, literal } from '@sofie-automation/corelib/dist/lib' +import { assertNever, Complete, getRandomId, literal } from '@sofie-automation/corelib/dist/lib' import { protectString, unprotectString } from '@sofie-automation/corelib/dist/protectedString' import { applyAndValidateOverrides, @@ -296,7 +296,7 @@ export async function studioFrom(apiStudio: APIStudio, existingId?: StudioId): P } } -export function APIStudioFrom(studio: DBStudio): APIStudio { +export function APIStudioFrom(studio: DBStudio): Complete { const studioSettings = APIStudioSettingsFrom(applyAndValidateOverrides(studio.settingsWithOverrides).obj) return { @@ -309,7 +309,7 @@ export function APIStudioFrom(studio: DBStudio): APIStudio { } } -export function studioSettingsFrom(apiStudioSettings: APIStudioSettings): IStudioSettings { +export function studioSettingsFrom(apiStudioSettings: APIStudioSettings): Complete { return { frameRate: apiStudioSettings.frameRate, mediaPreviewsUrl: apiStudioSettings.mediaPreviewsUrl, @@ -325,10 +325,13 @@ export function studioSettingsFrom(apiStudioSettings: APIStudioSettings): IStudi enableQuickLoop: apiStudioSettings.enableQuickLoop, forceQuickLoopAutoNext: forceQuickLoopAutoNextFrom(apiStudioSettings.forceQuickLoopAutoNext), fallbackPartDuration: apiStudioSettings.fallbackPartDuration ?? DEFAULT_FALLBACK_PART_DURATION, + allowAdlibTestingSegment: apiStudioSettings.allowAdlibTestingSegment, + allowHold: apiStudioSettings.allowHold ?? true, // Backwards compatible + allowPieceDirectPlay: apiStudioSettings.allowPieceDirectPlay ?? true, // Backwards compatible } } -export function APIStudioSettingsFrom(settings: IStudioSettings): APIStudioSettings { +export function APIStudioSettingsFrom(settings: IStudioSettings): Complete { return { frameRate: settings.frameRate, mediaPreviewsUrl: settings.mediaPreviewsUrl, @@ -344,6 +347,9 @@ export function APIStudioSettingsFrom(settings: IStudioSettings): APIStudioSetti enableQuickLoop: settings.enableQuickLoop, forceQuickLoopAutoNext: APIForceQuickLoopAutoNextFrom(settings.forceQuickLoopAutoNext), fallbackPartDuration: settings.fallbackPartDuration, + allowAdlibTestingSegment: settings.allowAdlibTestingSegment, + allowHold: settings.allowHold, + allowPieceDirectPlay: settings.allowPieceDirectPlay, } } diff --git a/meteor/server/api/studio/api.ts b/meteor/server/api/studio/api.ts index fa5d7c1c37..bbcfcbb93a 100644 --- a/meteor/server/api/studio/api.ts +++ b/meteor/server/api/studio/api.ts @@ -49,6 +49,8 @@ export async function insertStudioInner(organizationId: OrganizationId | null, n frameRate: 25, mediaPreviewsUrl: '', minimumTakeSpan: DEFAULT_MINIMUM_TAKE_SPAN, + allowHold: false, + allowPieceDirectPlay: false, }), _rundownVersionHash: '', routeSetsWithOverrides: wrapDefaultObject({}), diff --git a/meteor/server/lib/rest/v1/studios.ts b/meteor/server/lib/rest/v1/studios.ts index 211ea2c34e..2d043e97be 100644 --- a/meteor/server/lib/rest/v1/studios.ts +++ b/meteor/server/lib/rest/v1/studios.ts @@ -186,4 +186,7 @@ export interface APIStudioSettings { forceQuickLoopAutoNext?: 'disabled' | 'enabled_when_valid_duration' | 'enabled_forcing_min_duration' minimumTakeSpan?: number fallbackPartDuration?: number + allowAdlibTestingSegment?: boolean + allowHold?: boolean + allowPieceDirectPlay?: boolean } diff --git a/meteor/server/migration/0_1_0.ts b/meteor/server/migration/0_1_0.ts index f4a6abf7ad..bd6ed719ab 100644 --- a/meteor/server/migration/0_1_0.ts +++ b/meteor/server/migration/0_1_0.ts @@ -33,6 +33,8 @@ export const addSteps = addMigrationSteps('0.1.0', [ frameRate: 25, mediaPreviewsUrl: '', minimumTakeSpan: DEFAULT_MINIMUM_TAKE_SPAN, + allowHold: false, + allowPieceDirectPlay: false, }), mappingsWithOverrides: wrapDefaultObject({}), blueprintConfigWithOverrides: wrapDefaultObject({}), diff --git a/meteor/server/migration/X_X_X.ts b/meteor/server/migration/X_X_X.ts index 62abd9a8f8..13451de2b9 100644 --- a/meteor/server/migration/X_X_X.ts +++ b/meteor/server/migration/X_X_X.ts @@ -310,6 +310,43 @@ export const addSteps = addMigrationSteps(CURRENT_SYSTEM_VERSION, [ } }, }, + + { + id: `add studio settings allowHold & allowPieceDirectPlay`, + canBeRunAutomatically: true, + validate: async () => { + const studios = await Studios.findFetchAsync({ + $or: [ + { 'settings.allowHold': { $exists: false } }, + { 'settings.allowPieceDirectPlay': { $exists: false } }, + ], + }) + + if (studios.length > 0) { + return 'studios must have settings.allowHold and settings.allowPieceDirectPlay defined' + } + + return false + }, + migrate: async () => { + const studios = await Studios.findFetchAsync({ + $or: [ + { 'settings.allowHold': { $exists: false } }, + { 'settings.allowPieceDirectPlay': { $exists: false } }, + ], + }) + + for (const studio of studios) { + // Populate the settings to be backwards compatible + await Studios.updateAsync(studio._id, { + $set: { + 'settings.allowHold': true, + 'settings.allowPieceDirectPlay': true, + }, + }) + } + }, + }, ]) interface PartialOldICoreSystem { diff --git a/meteor/server/migration/__tests__/migrations.test.ts b/meteor/server/migration/__tests__/migrations.test.ts index 62967260fe..8094b84390 100644 --- a/meteor/server/migration/__tests__/migrations.test.ts +++ b/meteor/server/migration/__tests__/migrations.test.ts @@ -125,6 +125,8 @@ describe('Migrations', () => { mediaPreviewsUrl: '', frameRate: 25, minimumTakeSpan: DEFAULT_MINIMUM_TAKE_SPAN, + allowHold: true, + allowPieceDirectPlay: true, }), mappingsWithOverrides: wrapDefaultObject({}), blueprintConfigWithOverrides: wrapDefaultObject({}), @@ -163,6 +165,8 @@ describe('Migrations', () => { mediaPreviewsUrl: '', frameRate: 25, minimumTakeSpan: DEFAULT_MINIMUM_TAKE_SPAN, + allowHold: true, + allowPieceDirectPlay: true, }), mappingsWithOverrides: wrapDefaultObject({}), blueprintConfigWithOverrides: wrapDefaultObject({}), @@ -201,6 +205,8 @@ describe('Migrations', () => { mediaPreviewsUrl: '', frameRate: 25, minimumTakeSpan: DEFAULT_MINIMUM_TAKE_SPAN, + allowHold: true, + allowPieceDirectPlay: true, }), mappingsWithOverrides: wrapDefaultObject({}), blueprintConfigWithOverrides: wrapDefaultObject({}), diff --git a/meteor/server/publications/pieceContentStatusUI/__tests__/checkPieceContentStatus.test.ts b/meteor/server/publications/pieceContentStatusUI/__tests__/checkPieceContentStatus.test.ts index 4e3604807d..5d7e3ab300 100644 --- a/meteor/server/publications/pieceContentStatusUI/__tests__/checkPieceContentStatus.test.ts +++ b/meteor/server/publications/pieceContentStatusUI/__tests__/checkPieceContentStatus.test.ts @@ -166,9 +166,7 @@ describe('lib/mediaObjects', () => { test('getAcceptedFormats', () => { const acceptedFormats = getAcceptedFormats({ supportedMediaFormats: '1920x1080i5000, 1280x720, i5000, i5000tff', - mediaPreviewsUrl: '', frameRate: 25, - minimumTakeSpan: DEFAULT_MINIMUM_TAKE_SPAN, }) expect(acceptedFormats).toEqual([ ['1920', '1080', 'i', '5000', undefined], @@ -251,6 +249,8 @@ describe('lib/mediaObjects', () => { supportedAudioStreams: '4', frameRate: 25, minimumTakeSpan: DEFAULT_MINIMUM_TAKE_SPAN, + allowHold: false, + allowPieceDirectPlay: false, } const mockDefaultStudio = defaultStudio(protectString('studio0')) diff --git a/meteor/server/publications/pieceContentStatusUI/checkPieceContentStatus.ts b/meteor/server/publications/pieceContentStatusUI/checkPieceContentStatus.ts index 159830fbc5..dbead8658e 100644 --- a/meteor/server/publications/pieceContentStatusUI/checkPieceContentStatus.ts +++ b/meteor/server/publications/pieceContentStatusUI/checkPieceContentStatus.ts @@ -147,7 +147,9 @@ export function acceptFormat(format: string, formats: Array>): boo * [undefined, undefined, i, 5000, tff] * ] */ -export function getAcceptedFormats(settings: IStudioSettings | undefined): Array> { +export function getAcceptedFormats( + settings: Pick | undefined +): Array> { const formatsConfigField = settings ? settings.supportedMediaFormats : '' const formatsString: string = (formatsConfigField && formatsConfigField !== '' ? formatsConfigField : '1920x1080i5000') + '' diff --git a/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts b/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts index 752b8f9eb6..19633fe017 100644 --- a/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts +++ b/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts @@ -107,6 +107,8 @@ export function defaultStudio(_id: StudioId): DBStudio { mediaPreviewsUrl: '', minimumTakeSpan: DEFAULT_MINIMUM_TAKE_SPAN, allowAdlibTestingSegment: true, + allowHold: true, + allowPieceDirectPlay: true, }), routeSetsWithOverrides: wrapDefaultObject({}), routeSetExclusivityGroupsWithOverrides: wrapDefaultObject({}), diff --git a/packages/job-worker/src/blueprints/__tests__/config.test.ts b/packages/job-worker/src/blueprints/__tests__/config.test.ts index 5e142a6bec..1794afc6f5 100644 --- a/packages/job-worker/src/blueprints/__tests__/config.test.ts +++ b/packages/job-worker/src/blueprints/__tests__/config.test.ts @@ -15,6 +15,8 @@ describe('Test blueprint config', () => { mediaPreviewsUrl: '', frameRate: 25, minimumTakeSpan: DEFAULT_MINIMUM_TAKE_SPAN, + allowHold: true, + allowPieceDirectPlay: true, }), blueprintConfigWithOverrides: wrapDefaultObject({ sdfsdf: 'one', another: 5 }), }) @@ -38,6 +40,8 @@ describe('Test blueprint config', () => { mediaPreviewsUrl: '', frameRate: 25, minimumTakeSpan: DEFAULT_MINIMUM_TAKE_SPAN, + allowHold: true, + allowPieceDirectPlay: true, }), blueprintConfigWithOverrides: wrapDefaultObject({ sdfsdf: 'one', another: 5 }), }) diff --git a/packages/job-worker/src/playout/adlibJobs.ts b/packages/job-worker/src/playout/adlibJobs.ts index 31eeb8382c..4c64fa05dd 100644 --- a/packages/job-worker/src/playout/adlibJobs.ts +++ b/packages/job-worker/src/playout/adlibJobs.ts @@ -35,6 +35,12 @@ import { PlayoutPieceInstanceModel } from './model/PlayoutPieceInstanceModel' * Play an existing Piece in the Rundown as an AdLib */ export async function handleTakePieceAsAdlibNow(context: JobContext, data: TakePieceAsAdlibNowProps): Promise { + if (!context.studio.settings.allowPieceDirectPlay) { + // Piece direct play isn't allowed, making this a noop + logger.debug(`Piece direct play isn't allowed, skipping`) + return + } + return runJobWithPlayoutModel( context, data, diff --git a/packages/job-worker/src/playout/holdJobs.ts b/packages/job-worker/src/playout/holdJobs.ts index ab00c41738..3c88aca6a6 100644 --- a/packages/job-worker/src/playout/holdJobs.ts +++ b/packages/job-worker/src/playout/holdJobs.ts @@ -5,11 +5,18 @@ import { ActivateHoldProps, DeactivateHoldProps } from '@sofie-automation/coreli import { JobContext } from '../jobs' import { runJobWithPlayoutModel } from './lock' import { updateTimeline } from './timeline/generate' +import { logger } from '../logging' /** * Activate Hold */ export async function handleActivateHold(context: JobContext, data: ActivateHoldProps): Promise { + if (!context.studio.settings.allowHold) { + // Hold isn't allowed, making this a noop + logger.debug(`Hold isn't allowed, skipping`) + return + } + return runJobWithPlayoutModel( context, data, @@ -59,6 +66,8 @@ export async function handleActivateHold(context: JobContext, data: ActivateHold * Deactivate Hold */ export async function handleDeactivateHold(context: JobContext, data: DeactivateHoldProps): Promise { + // This should be possible even when hold is not allowed, as it is a way to get out of a stuck state + return runJobWithPlayoutModel( context, data, diff --git a/packages/job-worker/src/playout/upgrade.ts b/packages/job-worker/src/playout/upgrade.ts index 4e2cf3dece..54b1c123de 100644 --- a/packages/job-worker/src/playout/upgrade.ts +++ b/packages/job-worker/src/playout/upgrade.ts @@ -115,6 +115,8 @@ export async function handleBlueprintUpgradeForStudio(context: JobContext, _data frameRate: 25, mediaPreviewsUrl: '', minimumTakeSpan: DEFAULT_MINIMUM_TAKE_SPAN, + allowHold: true, + allowPieceDirectPlay: true, } await context.directCollections.Studios.update(context.studioId, { diff --git a/packages/openapi/api/definitions/studios.yaml b/packages/openapi/api/definitions/studios.yaml index 8b70c2e7e5..314e7c4bfc 100644 --- a/packages/openapi/api/definitions/studios.yaml +++ b/packages/openapi/api/definitions/studios.yaml @@ -476,6 +476,15 @@ components: fallbackPartDuration: type: number description: The duration to apply on too short Parts Within QuickLoop when forceQuickLoopAutoNext is set to `enabled_forcing_min_duration` + allowAdlibTestingSegment: + type: boolean + description: Whether to allow adlib testing mode, before a Part is playing in a Playlist + allowHold: + type: boolean + description: Whether to allow hold operations for Rundowns in this Studio + allowPieceDirectPlay: + type: boolean + description: Whether to allow direct playing of a piece in the rundown required: - frameRate diff --git a/packages/shared-lib/src/core/model/StudioSettings.ts b/packages/shared-lib/src/core/model/StudioSettings.ts index 45f88be0a4..08f38a597d 100644 --- a/packages/shared-lib/src/core/model/StudioSettings.ts +++ b/packages/shared-lib/src/core/model/StudioSettings.ts @@ -63,4 +63,17 @@ export interface IStudioSettings { * Default: 3000 */ fallbackPartDuration?: number + + /** + * Whether to allow hold operations for Rundowns in this Studio + * When disabled, any action-triggers that would normally trigger a hold operation will be silently ignored + * This should only block entering hold, to ensure Sofie doesn't get stuck if it somehow gets into hold + */ + allowHold: boolean + + /** + * Whether to allow direct playing of a piece in the rundown + * This behaviour is usally triggered by double-clicking on a piece in the GUI + */ + allowPieceDirectPlay: boolean } diff --git a/packages/webui/src/__mocks__/defaultCollectionObjects.ts b/packages/webui/src/__mocks__/defaultCollectionObjects.ts index 609f774ae7..bef95149b4 100644 --- a/packages/webui/src/__mocks__/defaultCollectionObjects.ts +++ b/packages/webui/src/__mocks__/defaultCollectionObjects.ts @@ -105,6 +105,8 @@ export function defaultStudio(_id: StudioId): DBStudio { frameRate: 25, mediaPreviewsUrl: '', minimumTakeSpan: DEFAULT_MINIMUM_TAKE_SPAN, + allowHold: true, + allowPieceDirectPlay: true, }), _rundownVersionHash: '', routeSetsWithOverrides: wrapDefaultObject({}), diff --git a/packages/webui/src/client/ui/RundownView.tsx b/packages/webui/src/client/ui/RundownView.tsx index 021c09e9f6..21048d356b 100644 --- a/packages/webui/src/client/ui/RundownView.tsx +++ b/packages/webui/src/client/ui/RundownView.tsx @@ -1050,7 +1050,7 @@ const RundownHeader = withTranslation()( {this.props.playlist.activationId ? ( this.take(e)}>{t('Take')} ) : null} - {this.props.playlist.activationId ? ( + {this.props.studio.settings.allowHold && this.props.playlist.activationId ? ( this.hold(e)}>{t('Hold')} ) : null} {this.props.playlist.activationId && canClearQuickLoop ? ( @@ -2252,7 +2252,8 @@ const RundownViewContent = translateWithTracker )} + + + {(value, handleUpdate) => } + + + + {(value, handleUpdate) => } + ) }