diff --git a/meteor/server/api/blueprints/api.ts b/meteor/server/api/blueprints/api.ts index af378dd152..ef4b534654 100644 --- a/meteor/server/api/blueprints/api.ts +++ b/meteor/server/api/blueprints/api.ts @@ -215,6 +215,7 @@ async function innerUploadBlueprint( newBlueprint.showStyleConfigSchema = blueprintManifest.showStyleConfigSchema newBlueprint.showStyleConfigPresets = blueprintManifest.configPresets newBlueprint.hasFixUpFunction = !!blueprintManifest.fixUpConfig + newBlueprint.packageStatusMessages = blueprintManifest.packageStatusMessages } else if (blueprintManifest.blueprintType === BlueprintManifestType.STUDIO) { newBlueprint.studioConfigSchema = blueprintManifest.studioConfigSchema newBlueprint.studioConfigPresets = blueprintManifest.configPresets diff --git a/meteor/server/publications/pieceContentStatusUI/__tests__/checkPieceContentStatus.test.ts b/meteor/server/publications/pieceContentStatusUI/__tests__/checkPieceContentStatus.test.ts index 233b115872..1fd4f25426 100644 --- a/meteor/server/publications/pieceContentStatusUI/__tests__/checkPieceContentStatus.test.ts +++ b/meteor/server/publications/pieceContentStatusUI/__tests__/checkPieceContentStatus.test.ts @@ -36,6 +36,7 @@ import { defaultStudio } from '../../../../__mocks__/defaultCollectionObjects' import { MediaObjects } from '../../../collections' import { PieceDependencies } from '../common' import { DEFAULT_MINIMUM_TAKE_SPAN } from '@sofie-automation/shared-lib/dist/core/constants' +import { PieceContentStatusMessageFactory } from '../messageFactory' const mockMediaObjectsCollection = MongoMock.getInnerMockCollection(MediaObjects) @@ -449,7 +450,9 @@ describe('lib/mediaObjects', () => { timelineObjectsString: EmptyPieceTimelineObjectsBlob, }) - const status1 = await checkPieceContentStatusAndDependencies(mockStudio, piece1, sourcelayer1) + const messageFactory = new PieceContentStatusMessageFactory(undefined) + + const status1 = await checkPieceContentStatusAndDependencies(mockStudio, messageFactory, piece1, sourcelayer1) expect(status1[0].status).toEqual(PieceStatusCode.OK) expect(status1[0].messages).toHaveLength(0) expect(status1[1]).toMatchObject( @@ -460,7 +463,7 @@ describe('lib/mediaObjects', () => { }) ) - const status2 = await checkPieceContentStatusAndDependencies(mockStudio, piece2, sourcelayer1) + const status2 = await checkPieceContentStatusAndDependencies(mockStudio, messageFactory, piece2, sourcelayer1) expect(status2[0].status).toEqual(PieceStatusCode.SOURCE_BROKEN) expect(status2[0].messages).toHaveLength(1) expect(status2[0].messages[0]).toMatchObject({ @@ -474,7 +477,7 @@ describe('lib/mediaObjects', () => { }) ) - const status3 = await checkPieceContentStatusAndDependencies(mockStudio, piece3, sourcelayer1) + const status3 = await checkPieceContentStatusAndDependencies(mockStudio, messageFactory, piece3, sourcelayer1) expect(status3[0].status).toEqual(PieceStatusCode.SOURCE_MISSING) expect(status3[0].messages).toHaveLength(1) expect(status3[0].messages[0]).toMatchObject({ diff --git a/meteor/server/publications/pieceContentStatusUI/bucket/bucketContentCache.ts b/meteor/server/publications/pieceContentStatusUI/bucket/bucketContentCache.ts index 0f9cdb13fe..56807bf49a 100644 --- a/meteor/server/publications/pieceContentStatusUI/bucket/bucketContentCache.ts +++ b/meteor/server/publications/pieceContentStatusUI/bucket/bucketContentCache.ts @@ -5,6 +5,7 @@ import { MongoFieldSpecifierOnesStrict } from '@sofie-automation/corelib/dist/mo import { BucketAdLibAction } from '@sofie-automation/corelib/dist/dataModel/BucketAdLibAction' import { BucketAdLib } from '@sofie-automation/corelib/dist/dataModel/BucketAdLibPiece' import { BlueprintId, ShowStyleBaseId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { Blueprint } from '@sofie-automation/corelib/dist/dataModel/Blueprint' export interface SourceLayersDoc { _id: ShowStyleBaseId @@ -53,10 +54,17 @@ export const showStyleBaseFieldSpecifier = literal< sourceLayersWithOverrides: 1, }) +export type BlueprintFields = '_id' | 'packageStatusMessages' +export const blueprintFieldSpecifier = literal>>({ + _id: 1, + packageStatusMessages: 1, +}) + export interface BucketContentCache { BucketAdLibs: ReactiveCacheCollection> BucketAdLibActions: ReactiveCacheCollection> ShowStyleSourceLayers: ReactiveCacheCollection + Blueprints: ReactiveCacheCollection> } export function createReactiveContentCache(): BucketContentCache { @@ -66,6 +74,7 @@ export function createReactiveContentCache(): BucketContentCache { 'bucketAdlibActions' ), ShowStyleSourceLayers: new ReactiveCacheCollection('sourceLayers'), + Blueprints: new ReactiveCacheCollection>('blueprints'), } return cache diff --git a/meteor/server/publications/pieceContentStatusUI/bucket/bucketContentObserver.ts b/meteor/server/publications/pieceContentStatusUI/bucket/bucketContentObserver.ts index e80ab6076b..543c032353 100644 --- a/meteor/server/publications/pieceContentStatusUI/bucket/bucketContentObserver.ts +++ b/meteor/server/publications/pieceContentStatusUI/bucket/bucketContentObserver.ts @@ -1,7 +1,8 @@ import { Meteor } from 'meteor/meteor' -import { BucketId, ShowStyleBaseId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { BlueprintId, BucketId, ShowStyleBaseId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { logger } from '../../../logging' import { + blueprintFieldSpecifier, bucketActionFieldSpecifier, bucketAdlibFieldSpecifier, BucketContentCache, @@ -9,7 +10,7 @@ import { showStyleBaseFieldSpecifier, SourceLayersDoc, } from './bucketContentCache' -import { BucketAdLibActions, BucketAdLibs, ShowStyleBases } from '../../../collections' +import { Blueprints, BucketAdLibActions, BucketAdLibs, ShowStyleBases } from '../../../collections' import { DBShowStyleBase } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' import { equivalentArrays } from '@sofie-automation/shared-lib/dist/lib/lib' import { applyAndValidateOverrides } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' @@ -33,6 +34,9 @@ export class BucketContentObserver implements Meteor.LiveQueryHandle { #showStyleBaseIds: ShowStyleBaseId[] = [] #showStyleBaseIdObserver!: ReactiveMongoObserverGroupHandle + #blueprintIds: BlueprintId[] = [] + #blueprintIdObserver!: ReactiveMongoObserverGroupHandle + #disposed = false private constructor(cache: BucketContentCache) { @@ -59,13 +63,16 @@ export class BucketContentObserver implements Meteor.LiveQueryHandle { added: (doc) => { const newDoc = convertShowStyleBase(doc) cache.ShowStyleSourceLayers.upsert(doc._id, { $set: newDoc as Partial }) + observer.updateBlueprintIds() }, changed: (doc) => { const newDoc = convertShowStyleBase(doc) cache.ShowStyleSourceLayers.upsert(doc._id, { $set: newDoc as Partial }) + observer.updateBlueprintIds() }, removed: (doc) => { cache.ShowStyleSourceLayers.remove(doc._id) + observer.updateBlueprintIds() }, }, { @@ -75,6 +82,27 @@ export class BucketContentObserver implements Meteor.LiveQueryHandle { ] }) + // Run the Blueprint query in a ReactiveMongoObserverGroup, so that it can be restarted whenever + observer.#blueprintIdObserver = await ReactiveMongoObserverGroup(async () => { + // Clear already cached data + cache.Blueprints.remove({}) + + logger.silly(`optimized observer restarting ${observer.#blueprintIds}`) + + return [ + Blueprints.observeChanges( + { + // We can use the `this.#blueprintIds` here, as this is restarted every time that property changes + _id: { $in: observer.#blueprintIds }, + }, + cache.Blueprints.link(), + { + projection: blueprintFieldSpecifier, + } + ), + ] + }) + // Subscribe to the database, and pipe any updates into the ReactiveCacheCollections // This takes ownership of the #showStyleBaseIdObserver, and will stop it if this throws observer.#observers = await waitForAllObserversReady([ @@ -106,6 +134,7 @@ export class BucketContentObserver implements Meteor.LiveQueryHandle { ), observer.#showStyleBaseIdObserver, + observer.#blueprintIdObserver, ]) return observer @@ -132,6 +161,22 @@ export class BucketContentObserver implements Meteor.LiveQueryHandle { REACTIVITY_DEBOUNCE ) + private updateBlueprintIds = _.debounce( + Meteor.bindEnvironment(() => { + if (this.#disposed) return + + const newBlueprintIds = _.uniq(this.#cache.ShowStyleSourceLayers.find({}).map((rd) => rd.blueprintId)) + + if (!equivalentArrays(newBlueprintIds, this.#blueprintIds)) { + logger.silly(`optimized observer changed ids ${JSON.stringify(newBlueprintIds)} ${this.#blueprintIds}`) + this.#blueprintIds = newBlueprintIds + // trigger the rundown group to restart + this.#blueprintIdObserver.restart() + } + }), + REACTIVITY_DEBOUNCE + ) + public get cache(): BucketContentCache { return this.#cache } diff --git a/meteor/server/publications/pieceContentStatusUI/bucket/publication.ts b/meteor/server/publications/pieceContentStatusUI/bucket/publication.ts index 8942d5f75d..7bf8ceb153 100644 --- a/meteor/server/publications/pieceContentStatusUI/bucket/publication.ts +++ b/meteor/server/publications/pieceContentStatusUI/bucket/publication.ts @@ -5,6 +5,7 @@ import { BucketId, ExpectedPackageId, PackageContainerPackageId, + ShowStyleBaseId, StudioId, } from '@sofie-automation/corelib/dist/dataModel/Ids' import { MongoFieldSpecifierOnesStrict } from '@sofie-automation/corelib/dist/mongo' @@ -37,6 +38,7 @@ import { regenerateForBucketActionIds, regenerateForBucketAdLibIds } from './reg import { PieceContentStatusStudio } from '../checkPieceContentStatus' import { check } from 'meteor/check' import { triggerWriteAccessBecauseNoCheckNecessary } from '../../../security/securityVerify' +import { PieceContentStatusMessageFactory } from '../messageFactory' interface UIBucketContentStatusesArgs { readonly studioId: StudioId @@ -48,6 +50,8 @@ interface UIBucketContentStatusesState { studio: PieceContentStatusStudio + showStyleMessageFactories: Map + adlibDependencies: Map actionDependencies: Map } @@ -111,6 +115,16 @@ async function setupUIBucketContentStatusesPublicationObservers( changed: (id) => triggerUpdate(trackActionChange(protectString(id))), removed: (id) => triggerUpdate(trackActionChange(protectString(id))), }), + contentCache.Blueprints.find({}).observeChanges({ + added: () => triggerUpdate({ invalidateAll: true }), + changed: () => triggerUpdate({ invalidateAll: true }), + removed: () => triggerUpdate({ invalidateAll: true }), + }), + contentCache.ShowStyleSourceLayers.find({}).observeChanges({ + added: () => triggerUpdate({ invalidateAll: true }), + changed: () => triggerUpdate({ invalidateAll: true }), + removed: () => triggerUpdate({ invalidateAll: true }), + }), Studios.observeChanges( { _id: bucket.studioId }, @@ -198,14 +212,26 @@ async function manipulateUIBucketContentStatusesPublicationData( let regenerateActionIds: Set let regenerateAdlibIds: Set - if (!state.adlibDependencies || !state.actionDependencies || invalidateAllItems) { + if ( + !state.adlibDependencies || + !state.actionDependencies || + !state.showStyleMessageFactories || + invalidateAllItems + ) { state.adlibDependencies = new Map() state.actionDependencies = new Map() + state.showStyleMessageFactories = new Map() // force every piece to be regenerated collection.remove(null) regenerateAdlibIds = new Set(state.contentCache.BucketAdLibs.find({}).map((p) => p._id)) regenerateActionIds = new Set(state.contentCache.BucketAdLibActions.find({}).map((p) => p._id)) + + // prepare the message factories + for (const showStyle of state.contentCache.ShowStyleSourceLayers.find({})) { + const blueprint = state.contentCache.Blueprints.findOne(showStyle.blueprintId) + state.showStyleMessageFactories.set(showStyle._id, new PieceContentStatusMessageFactory(blueprint)) + } } else { regenerateAdlibIds = new Set(updateProps.invalidateBucketAdlibIds) regenerateActionIds = new Set(updateProps.invalidateBucketActionIds) @@ -227,6 +253,7 @@ async function manipulateUIBucketContentStatusesPublicationData( state.contentCache, state.studio, state.adlibDependencies, + state.showStyleMessageFactories, collection, regenerateAdlibIds ) @@ -234,6 +261,7 @@ async function manipulateUIBucketContentStatusesPublicationData( state.contentCache, state.studio, state.actionDependencies, + state.showStyleMessageFactories, collection, regenerateActionIds ) diff --git a/meteor/server/publications/pieceContentStatusUI/bucket/regenerateForItem.ts b/meteor/server/publications/pieceContentStatusUI/bucket/regenerateForItem.ts index 28f9996888..79285d7c4f 100644 --- a/meteor/server/publications/pieceContentStatusUI/bucket/regenerateForItem.ts +++ b/meteor/server/publications/pieceContentStatusUI/bucket/regenerateForItem.ts @@ -1,4 +1,4 @@ -import { BucketAdLibActionId, BucketAdLibId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { BucketAdLibActionId, BucketAdLibId, ShowStyleBaseId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { ReadonlyDeep } from 'type-fest' import { UIBucketContentStatus } from '@sofie-automation/meteor-lib/dist/api/rundownNotifications' import { literal, protectString } from '../../../lib/tempLib' @@ -11,6 +11,7 @@ import { PieceContentStatusPiece, PieceContentStatusStudio, } from '../checkPieceContentStatus' +import type { PieceContentStatusMessageFactory } from '../messageFactory' /** * Regenerating the status for the provided AdLibActionId @@ -20,6 +21,7 @@ export async function regenerateForBucketAdLibIds( contentCache: ReadonlyDeep, uiStudio: PieceContentStatusStudio, dependenciesState: Map, + messageFactories: Map, collection: CustomPublishCollection, regenerateIds: Set ): Promise { @@ -45,6 +47,7 @@ export async function regenerateForBucketAdLibIds( if (sourceLayer) { const [status, itemDependencies] = await checkPieceContentStatusAndDependencies( uiStudio, + messageFactories.get(actionDoc.showStyleBaseId), actionDoc, sourceLayer ) @@ -79,6 +82,7 @@ export async function regenerateForBucketActionIds( contentCache: ReadonlyDeep, uiStudio: PieceContentStatusStudio, dependenciesState: Map, + messageFactories: Map, collection: CustomPublishCollection, regenerateIds: Set ): Promise { @@ -106,11 +110,16 @@ export async function regenerateForBucketActionIds( const fakedPiece = literal({ _id: protectString(`${actionDoc._id}`), content: 'content' in actionDoc.display ? actionDoc.display.content : {}, + name: + typeof actionDoc.display.label === 'string' + ? actionDoc.display.label + : actionDoc.display.label.key, expectedPackages: actionDoc.expectedPackages, }) const [status, itemDependencies] = await checkPieceContentStatusAndDependencies( uiStudio, + messageFactories.get(actionDoc.showStyleBaseId), fakedPiece, sourceLayer ) diff --git a/meteor/server/publications/pieceContentStatusUI/checkPieceContentStatus.ts b/meteor/server/publications/pieceContentStatusUI/checkPieceContentStatus.ts index dbead8658e..205b05bcd7 100644 --- a/meteor/server/publications/pieceContentStatusUI/checkPieceContentStatus.ts +++ b/meteor/server/publications/pieceContentStatusUI/checkPieceContentStatus.ts @@ -26,12 +26,15 @@ import { StudioPackageContainer, StudioRouteSet, } from '@sofie-automation/corelib/dist/dataModel/Studio' -import { literal, Complete, assertNever } from '@sofie-automation/corelib/dist/lib' +import { literal, Complete, assertNever, omit } from '@sofie-automation/corelib/dist/lib' import { ReadonlyDeep } from 'type-fest' import _ from 'underscore' -import { getSideEffect } from '@sofie-automation/meteor-lib/dist/collections/ExpectedPackages' +import { + getExpectedPackageFileName, + getSideEffect, +} from '@sofie-automation/meteor-lib/dist/collections/ExpectedPackages' import { getActiveRoutes, getRoutedMappings } from '@sofie-automation/meteor-lib/dist/collections/Studios' -import { ensureHasTrailingSlash, generateTranslation, unprotectString } from '../../lib/tempLib' +import { ensureHasTrailingSlash, unprotectString } from '../../lib/tempLib' import { PieceContentStatusObj } from '@sofie-automation/meteor-lib/dist/api/pieceContentStatus' import { MediaObjects, PackageContainerPackageStatuses, PackageInfos } from '../../collections' import { @@ -43,6 +46,10 @@ import { PackageInfoLight, PieceDependencies, } from './common' +import { PieceContentStatusMessageFactory, PieceContentStatusMessageRequiredArgs } from './messageFactory' +import { PackageStatusMessage } from '@sofie-automation/shared-lib/dist/packageStatusMessages' + +const DEFAULT_MESSAGE_FACTORY = new PieceContentStatusMessageFactory(undefined) interface ScanInfoForPackages { [packageId: string]: ScanInfoForPackage @@ -50,6 +57,8 @@ interface ScanInfoForPackages { interface ScanInfoForPackage { /** Display name of the package */ packageName: string + containerLabel: string + scan?: PackageInfo.FFProbeScan['payload'] deepScan?: PackageInfo.FFProbeDeepScan['payload'] timebase?: number // derived from scan @@ -183,7 +192,7 @@ export function getMediaObjectMediaId( return undefined } -export type PieceContentStatusPiece = Pick & { +export type PieceContentStatusPiece = Pick & { pieceInstanceId?: PieceInstanceId } export interface PieceContentStatusStudio @@ -202,6 +211,7 @@ export interface PieceContentStatusStudio export async function checkPieceContentStatusAndDependencies( studio: PieceContentStatusStudio, + messageFactory: PieceContentStatusMessageFactory | undefined, piece: PieceContentStatusPiece, sourceLayer: ISourceLayer ): Promise<[status: PieceContentStatusObj, pieceDependencies: PieceDependencies]> { @@ -251,7 +261,8 @@ export async function checkPieceContentStatusAndDependencies( sourceLayer, studio, getPackageInfos, - getPackageContainerPackageStatus + getPackageContainerPackageStatus, + messageFactory || DEFAULT_MESSAGE_FACTORY ) return [status, pieceDependencies] } else { @@ -267,7 +278,13 @@ export async function checkPieceContentStatusAndDependencies( ) as Promise } - const status = await checkPieceContentMediaObjectStatus(piece, sourceLayer, studio, getMediaObject) + const status = await checkPieceContentMediaObjectStatus( + piece, + sourceLayer, + studio, + getMediaObject, + messageFactory || DEFAULT_MESSAGE_FACTORY + ) return [status, pieceDependencies] } } @@ -292,11 +309,17 @@ export async function checkPieceContentStatusAndDependencies( ] } +interface MediaObjectMessage { + status: PieceStatusCode + message: ITranslatableMessage | null +} + async function checkPieceContentMediaObjectStatus( piece: PieceContentStatusPiece, sourceLayer: ISourceLayer, studio: PieceContentStatusStudio, - getMediaObject: (mediaId: string) => Promise + getMediaObject: (mediaId: string) => Promise, + messageFactory: PieceContentStatusMessageFactory ): Promise { let metadata: MediaObjectLight | null = null const settings: IStudioSettings | undefined = studio?.settings @@ -308,7 +331,7 @@ async function checkPieceContentMediaObjectStatus( let blacks: Array = [] let scenes: Array = [] - const messages: Array = [] + const messages: Array = [] let contentSeemsOK = false const fileName = getMediaObjectMediaId(piece, sourceLayer) switch (sourceLayer.type) { @@ -319,19 +342,31 @@ async function checkPieceContentMediaObjectStatus( if (!fileName) { messages.push({ status: PieceStatusCode.SOURCE_NOT_SET, - message: generateTranslation('{{sourceLayer}} is missing a file path', { + message: messageFactory.getTranslation(PackageStatusMessage.MISSING_FILE_PATH, { sourceLayer: sourceLayer.name, + pieceName: piece.name, + fileName: '', + containerLabels: '', }), }) } else { + const messageRequiredArgs: PieceContentStatusMessageRequiredArgs = { + sourceLayer: sourceLayer.name, + pieceName: piece.name, + fileName: fileName, + containerLabels: '', + } const mediaObject = await getMediaObject(fileName) // If media object not found, then... if (!mediaObject) { messages.push({ status: PieceStatusCode.SOURCE_MISSING, - message: generateTranslation('{{sourceLayer}} is not yet ready on the playout system', { - sourceLayer: sourceLayer.name, - }), + message: messageFactory.getTranslation( + PackageStatusMessage.FILE_NOT_YET_READY_ON_PLAYOUT_SYSTEM, + { + ...messageRequiredArgs, + } + ), }) // All VT content should have at least two streams } else { @@ -340,9 +375,20 @@ async function checkPieceContentMediaObjectStatus( // Do a format check: if (mediaObject.mediainfo) { if (mediaObject.mediainfo.streams) { + const pushMessages = (newMessages: Array) => { + for (const message of newMessages) { + messages.push({ + status: message.status, + message: messageFactory.getTranslation(message.message, { + ...messageRequiredArgs, + ...message.extraArgs, + }), + }) + } + } + const mediainfo = mediaObject.mediainfo - const timebase = checkStreamFormatsAndCounts( - messages, + const { timebase, messages: formatMessages } = checkStreamFormatsAndCounts( mediaObject.mediainfo.streams.map((stream) => // Translate to a package-manager type, for code reuse literal>({ @@ -361,6 +407,7 @@ async function checkPieceContentMediaObjectStatus( sourceLayer, ignoreMediaAudioStatus ) + pushMessages(formatMessages) if (timebase) { mediaObject.mediainfo.timebase = timebase @@ -370,14 +417,14 @@ async function checkPieceContentMediaObjectStatus( if (mediaObject.mediainfo.blacks?.length) { if (!piece.content.ignoreBlackFrames) { - addFrameWarning( - messages, + const blackMessages = addFrameWarning( timebase, sourceDuration, mediaObject.mediainfo.format?.duration, mediaObject.mediainfo.blacks, BlackFrameWarnings ) + pushMessages(blackMessages) } blacks = mediaObject.mediainfo.blacks.map((i): PackageInfo.Anomaly => { @@ -386,14 +433,14 @@ async function checkPieceContentMediaObjectStatus( } if (mediaObject.mediainfo.freezes?.length) { if (!piece.content.ignoreFreezeFrame) { - addFrameWarning( - messages, + const freezeMessages = addFrameWarning( timebase, sourceDuration, mediaObject.mediainfo.format?.duration, mediaObject.mediainfo.freezes, FreezeFrameWarnings ) + pushMessages(freezeMessages) } freezes = mediaObject.mediainfo.freezes.map((i): PackageInfo.Anomaly => { @@ -409,8 +456,8 @@ async function checkPieceContentMediaObjectStatus( } else { messages.push({ status: PieceStatusCode.SOURCE_MISSING, - message: generateTranslation('{{sourceLayer}} is being ingested', { - sourceLayer: sourceLayer.name, + message: messageFactory.getTranslation(PackageStatusMessage.FILE_IS_BEING_INGESTED, { + ...messageRequiredArgs, }), }) } @@ -426,7 +473,12 @@ async function checkPieceContentMediaObjectStatus( if (!mediaObject) { messages.push({ status: PieceStatusCode.SOURCE_MISSING, - message: generateTranslation('{{sourceLayer}} is missing', { sourceLayer: sourceLayer.name }), + message: messageFactory.getTranslation(PackageStatusMessage.FILE_IS_MISSING, { + sourceLayer: sourceLayer.name, + pieceName: piece.name, + fileName: fileName, + containerLabels: '', + }), }) } else { contentSeemsOK = true @@ -459,7 +511,7 @@ async function checkPieceContentMediaObjectStatus( return { status: pieceStatus, - messages: messages.map((msg) => msg.message), + messages: _.compact(messages.map((msg) => msg.message)), progress: 0, freezes, @@ -492,9 +544,15 @@ function getAssetUrlFromContentMetaData( ) } -interface ContentMessage { +interface ContentMessageLight { status: PieceStatusCode - message: ITranslatableMessage + message: PackageStatusMessage + customMessage?: string + extraArgs?: { [key: string]: string | number } +} +interface ContentMessage extends ContentMessageLight { + fileName: string + packageContainers: string[] } async function checkPieceContentExpectedPackageStatus( @@ -505,14 +563,26 @@ async function checkPieceContentExpectedPackageStatus( getPackageContainerPackageStatus: ( packageContainerId: string, expectedPackageId: ExpectedPackageId - ) => Promise + ) => Promise, + messageFactory: PieceContentStatusMessageFactory ): Promise { const settings: IStudioSettings | undefined = studio?.settings - let pieceStatus: PieceStatusCode = PieceStatusCode.UNKNOWN const ignoreMediaAudioStatus = piece.content && piece.content.ignoreAudioFormat const messages: Array = [] + const pushOrMergeMessage = (newMessage: ContentMessage) => { + const existingMessage = messages.find((m) => + _.isEqual(omit(m, 'packageContainers'), omit(newMessage, 'packageContainers')) + ) + if (existingMessage) { + // If we have already added this message, just add the package name to the message + existingMessage.packageContainers.push(...newMessage.packageContainers) + } else { + messages.push(newMessage) + } + } + const packageInfos: ScanInfoForPackages = {} let readyCount = 0 @@ -535,27 +605,27 @@ async function checkPieceContentExpectedPackageStatus( const checkedPackageContainers = new Set() for (const routedDeviceId of routedDeviceIds) { - let packageContainerId: string | undefined - for (const [containerId, packageContainer] of Object.entries>( + let matchedPackageContainer: [string, ReadonlyDeep] | undefined + for (const packageContainer of Object.entries>( studio.packageContainers )) { - if (packageContainer.deviceIds.includes(unprotectString(routedDeviceId))) { + if (packageContainer[1].deviceIds.includes(unprotectString(routedDeviceId))) { // TODO: how to handle if a device has multiple containers? - packageContainerId = containerId + matchedPackageContainer = packageContainer break // just picking the first one found, for now } } - if (!packageContainerId) { + if (!matchedPackageContainer) { continue } - if (checkedPackageContainers.has(packageContainerId)) { + if (checkedPackageContainers.has(matchedPackageContainer[0])) { // we have already checked this package container for this expected package continue } - checkedPackageContainers.add(packageContainerId) + checkedPackageContainers.add(matchedPackageContainer[0]) const expectedPackageIds = [getExpectedPackageId(piece._id, expectedPackage._id)] if (piece.pieceInstanceId) { @@ -563,11 +633,11 @@ async function checkPieceContentExpectedPackageStatus( expectedPackageIds.unshift(getExpectedPackageId(piece.pieceInstanceId, expectedPackage._id)) } - let warningMessage: ContentMessage | null = null + let warningMessage: ContentMessageLight | null = null let matchedExpectedPackageId: ExpectedPackageId | null = null for (const expectedPackageId of expectedPackageIds) { const packageOnPackageContainer = await getPackageContainerPackageStatus( - packageContainerId, + matchedPackageContainer[0], expectedPackageId ) if (!packageOnPackageContainer) continue @@ -598,30 +668,33 @@ async function checkPieceContentExpectedPackageStatus( ) } - warningMessage = getPackageWarningMessage(packageOnPackageContainer, sourceLayer) + warningMessage = getPackageWarningMessage(packageOnPackageContainer.status) - progress = getPackageProgress(packageOnPackageContainer) ?? undefined + progress = getPackageProgress(packageOnPackageContainer.status) ?? undefined // Found a packageOnPackageContainer break } + const fileName = getExpectedPackageFileName(expectedPackage) ?? '' + const containerLabel = matchedPackageContainer[1].container.label + if (!matchedExpectedPackageId || warningMessage) { // If no package matched, we must have a warning - messages.push(warningMessage ?? getPackageSoruceMissingWarning(sourceLayer)) + warningMessage = warningMessage ?? getPackageSourceMissingWarning() + + pushOrMergeMessage({ + ...warningMessage, + fileName: fileName, + packageContainers: [containerLabel], + }) } else { // No warning, must be OK - const packageName = - // @ts-expect-error hack - expectedPackage.content.filePath || - // @ts-expect-error hack - expectedPackage.content.guid || - expectedPackage._id - readyCount++ packageInfos[expectedPackage._id] = { - packageName, + packageName: fileName || expectedPackage._id, + containerLabel, } // Fetch scan-info about the package: const dbPackageInfos = await getPackageInfos(matchedExpectedPackageId) @@ -641,14 +714,25 @@ async function checkPieceContentExpectedPackageStatus( const { scan, deepScan } = packageInfo if (scan && scan.streams) { - const timebase = checkStreamFormatsAndCounts( - messages, + const pushMessages = (newMessages: Array) => { + for (const message of newMessages) { + pushOrMergeMessage({ + ...message, + fileName: packageInfo.packageName, + packageContainers: [packageInfo.containerLabel], + }) + } + } + + const { timebase, messages: formatMessages } = checkStreamFormatsAndCounts( scan.streams, (stream) => (deepScan ? buildFormatString(deepScan.field_order, stream) : null), settings, sourceLayer, ignoreMediaAudioStatus ) + pushMessages(formatMessages) + if (timebase) { packageInfo.timebase = timebase // what todo? @@ -657,24 +741,24 @@ async function checkPieceContentExpectedPackageStatus( const sourceDuration = piece.content.sourceDuration if (!piece.content.ignoreBlackFrames && deepScan?.blacks?.length) { - addFrameWarning( - messages, + const blackMessages = addFrameWarning( timebase, sourceDuration, scan.format?.duration, deepScan.blacks, BlackFrameWarnings ) + pushMessages(blackMessages) } if (!piece.content.ignoreFreezeFrame && deepScan?.freezes?.length) { - addFrameWarning( - messages, + const freezeMessages = addFrameWarning( timebase, sourceDuration, scan.format?.duration, deepScan.freezes, FreezeFrameWarnings ) + pushMessages(freezeMessages) } } } @@ -710,17 +794,30 @@ async function checkPieceContentExpectedPackageStatus( packageName = firstPackage.packageName } + let pieceStatus: PieceStatusCode = PieceStatusCode.UNKNOWN if (messages.length) { pieceStatus = messages.reduce((prev, msg) => Math.max(prev, msg.status), PieceStatusCode.UNKNOWN) - } else { - if (readyCount > 0) { - pieceStatus = PieceStatusCode.OK - } + } else if (readyCount > 0) { + pieceStatus = PieceStatusCode.OK } + const translatedMessages = messages.map((msg) => { + const messageArgs: PieceContentStatusMessageRequiredArgs & { [k: string]: any } = { + sourceLayer: sourceLayer.name, + pieceName: piece.name, + containerLabels: msg.packageContainers.join(', '), + fileName: msg.fileName, + ...msg.extraArgs, + } + + return msg.customMessage + ? { key: msg.customMessage, args: messageArgs } + : messageFactory.getTranslation(msg.message, messageArgs) + }) + return { status: pieceStatus, - messages: messages.map((msg) => msg.message), + messages: _.compact(translatedMessages), progress, freezes, @@ -785,35 +882,32 @@ function getAssetUrlFromExpectedPackages( } function getPackageProgress( - packageOnPackageContainer: Pick | undefined + packageOnPackageContainerStatus: ExpectedPackageStatusAPI.PackageContainerPackageStatus | undefined ): number | null { - return packageOnPackageContainer?.status.progress ?? null + return packageOnPackageContainerStatus?.progress ?? null } -function getPackageSoruceMissingWarning(sourceLayer: ISourceLayer): ContentMessage { +function getPackageSourceMissingWarning(): ContentMessageLight { // Examples of contents in packageOnPackageContainer?.status.statusReason.user: // * Target package: Quantel clip "XXX" not found // * Can't read the Package from PackageContainer "Quantel source 0" (on accessor "${accessorLabel}"), due to: Quantel clip "XXX" not found return { status: PieceStatusCode.SOURCE_MISSING, - message: generateTranslation(`{{sourceLayer}} can't be found on the playout system`, { - sourceLayer: sourceLayer.name, - }), + message: PackageStatusMessage.FILE_CANT_BE_FOUND_ON_PLAYOUT_SYSTEM, } } function getPackageWarningMessage( - packageOnPackageContainer: Pick, - sourceLayer: ISourceLayer -): ContentMessage | null { + packageOnPackageContainerStatus: ExpectedPackageStatusAPI.PackageContainerPackageStatus +): ContentMessageLight | null { if ( - packageOnPackageContainer.status.status === + packageOnPackageContainerStatus.status === ExpectedPackageStatusAPI.PackageContainerPackageStatusStatus.NOT_FOUND ) { - return getPackageSoruceMissingWarning(sourceLayer) + return getPackageSourceMissingWarning() } else if ( - packageOnPackageContainer.status.status === + packageOnPackageContainerStatus.status === ExpectedPackageStatusAPI.PackageContainerPackageStatusStatus.NOT_READY ) { // Examples of contents in packageOnPackageContainer?.status.statusReason.user: @@ -821,73 +915,54 @@ function getPackageWarningMessage( return { status: PieceStatusCode.SOURCE_MISSING, - message: generateTranslation( - '{{reason}} {{sourceLayer}} exists, but is not yet ready on the playout system', - { - reason: ((packageOnPackageContainer?.status.statusReason.user || 'N/A') + '.').replace( - /\.\.$/, - '.' - ), // remove any trailing double "." - sourceLayer: sourceLayer.name, - } - ), + message: PackageStatusMessage.FILE_EXISTS_BUT_IS_NOT_READY_ON_PLAYOUT_SYSTEM, + extraArgs: { + reason: ((packageOnPackageContainerStatus?.statusReason.user || 'N/A') + '.').replace(/\.\.$/, '.'), // remove any trailing double "." + }, } } else if ( // Examples of contents in packageOnPackageContainer?.status.statusReason.user: // * Reserved clip (0 frames) // * Reserved clip (1-9 frames) - packageOnPackageContainer.status.status === + packageOnPackageContainerStatus.status === ExpectedPackageStatusAPI.PackageContainerPackageStatusStatus.PLACEHOLDER ) { return { status: PieceStatusCode.SOURCE_NOT_READY, - message: packageOnPackageContainer?.status.statusReason.user - ? { - // remove any trailing double "." - key: (packageOnPackageContainer?.status.statusReason.user + '.').replace(/\.\.$/, '.'), - } - : generateTranslation( - '{{sourceLayer}} is in a placeholder state for an unknown workflow-defined reason', - { - sourceLayer: sourceLayer.name, - } - ), + message: PackageStatusMessage.FILE_IS_IN_PLACEHOLDER_STATE, + // remove any trailing double "." + customMessage: packageOnPackageContainerStatus?.statusReason.user + ? (packageOnPackageContainerStatus?.statusReason.user + '.').replace(/\.\.$/, '.') + : undefined, } } else if ( - packageOnPackageContainer.status.status === + packageOnPackageContainerStatus.status === ExpectedPackageStatusAPI.PackageContainerPackageStatusStatus.TRANSFERRING_READY ) { return { status: PieceStatusCode.OK, - message: generateTranslation('{{sourceLayer}} is transferring to the playout system', { - sourceLayer: sourceLayer.name, - }), + message: PackageStatusMessage.FILE_IS_TRANSFERRING_TO_PLAYOUT_SYSTEM, } } else if ( - packageOnPackageContainer.status.status === + packageOnPackageContainerStatus.status === ExpectedPackageStatusAPI.PackageContainerPackageStatusStatus.TRANSFERRING_NOT_READY ) { return { status: PieceStatusCode.SOURCE_MISSING, - message: generateTranslation( - '{{sourceLayer}} is transferring to the playout system but cannot be played yet', - { - sourceLayer: sourceLayer.name, - } - ), + message: PackageStatusMessage.FILE_IS_TRANSFERRING_TO_PLAYOUT_SYSTEM_NOT_READY, } } else if ( - packageOnPackageContainer.status.status === ExpectedPackageStatusAPI.PackageContainerPackageStatusStatus.READY + packageOnPackageContainerStatus.status === ExpectedPackageStatusAPI.PackageContainerPackageStatusStatus.READY ) { return null } else { - assertNever(packageOnPackageContainer.status.status) + assertNever(packageOnPackageContainerStatus.status) return { status: PieceStatusCode.SOURCE_UNKNOWN_STATE, - message: generateTranslation('{{sourceLayer}} is in an unknown state: "{{status}}"', { - sourceLayer: sourceLayer.name, - status: packageOnPackageContainer.status.status, - }), + message: PackageStatusMessage.FILE_IS_IN_UNKNOWN_STATE, + extraArgs: { + status: packageOnPackageContainerStatus.status, + }, } } } @@ -897,19 +972,18 @@ export type PieceContentStreamInfo = Pick< 'width' | 'height' | 'time_base' | 'codec_type' | 'codec_time_base' | 'channels' | 'r_frame_rate' | 'field_order' > function checkStreamFormatsAndCounts( - messages: Array, streams: PieceContentStreamInfo[], getScanFormatString: (stream: PieceContentStreamInfo) => string | null, studioSettings: IStudioSettings | undefined, sourceLayer: ISourceLayer, ignoreMediaAudioStatus: boolean | undefined -): number { +): { timebase: number; messages: ContentMessageLight[] } { + const messages: ContentMessageLight[] = [] + if (!ignoreMediaAudioStatus && streams.length < 2 && sourceLayer.type !== SourceLayerType.AUDIO) { messages.push({ status: PieceStatusCode.SOURCE_BROKEN, - message: generateTranslation("{{sourceLayer}} doesn't have both audio & video", { - sourceLayer: sourceLayer.name, - }), + message: PackageStatusMessage.FILE_DOESNT_HAVE_BOTH_VIDEO_AND_AUDIO, }) } const formats = getAcceptedFormats(studioSettings) @@ -935,10 +1009,10 @@ function checkStreamFormatsAndCounts( if (!acceptFormat(deepScanFormat, formats)) { messages.push({ status: PieceStatusCode.SOURCE_BROKEN, - message: generateTranslation('{{sourceLayer}} has the wrong format: {{format}}', { - sourceLayer: sourceLayer.name, + message: PackageStatusMessage.FILE_HAS_WRONG_FORMAT, + extraArgs: { format: deepScanFormat, - }), + }, }) } } @@ -957,52 +1031,58 @@ function checkStreamFormatsAndCounts( ) { messages.push({ status: PieceStatusCode.SOURCE_BROKEN, - message: generateTranslation('{{sourceLayer}} has {{audioStreams}} audio streams', { - sourceLayer: sourceLayer.name, + message: PackageStatusMessage.FILE_HAS_WRONG_AUDIO_STREAMS, + extraArgs: { audioStreams, - }), + }, }) } - return timebase + return { timebase, messages } } function addFrameWarning( - messages: Array, timebase: number, sourceDuration: number | undefined, scannedFormatDuration: number | string | undefined, anomalies: Array, strings: FrameWarningStrings -): void { +): ContentMessageLight[] { + const messages: ContentMessageLight[] = [] + if (anomalies.length === 1) { /** Number of frames */ const frames = Math.ceil((anomalies[0].duration * 1000) / timebase) if (anomalies[0].start === 0) { messages.push({ status: PieceStatusCode.SOURCE_HAS_ISSUES, - message: generateTranslation(strings.clipStartsWithCount, { + message: strings.clipStartsWithCount, + extraArgs: { frames, - }), + seconds: Math.round(anomalies[0].duration), + }, }) } else if ( scannedFormatDuration && anomalies[0].end === Number(scannedFormatDuration) && (sourceDuration === undefined || Math.round(anomalies[0].start) * 1000 < sourceDuration) ) { - const freezeStartsAt = Math.round(anomalies[0].start) messages.push({ status: PieceStatusCode.SOURCE_HAS_ISSUES, - message: generateTranslation(strings.clipEndsWithAfter, { - seconds: freezeStartsAt, - }), + message: strings.clipEndsWithAfter, + extraArgs: { + frames: Math.ceil((anomalies[0].start * 1000) / timebase), + seconds: Math.round(anomalies[0].start), + }, }) } else if (frames > 0) { messages.push({ status: PieceStatusCode.SOURCE_HAS_ISSUES, - message: generateTranslation(strings.countDetectedWithinClip, { + message: strings.countDetectedWithinClip, + extraArgs: { frames, - }), + seconds: Math.round(anomalies[0].duration), + }, }) } } else if (anomalies.length > 0) { @@ -1014,38 +1094,37 @@ function addFrameWarning( if (frames > 0) { messages.push({ status: PieceStatusCode.SOURCE_HAS_ISSUES, - message: generateTranslation(strings.countDetectedInClip, { + message: strings.countDetectedInClip, + extraArgs: { frames, - }), + seconds: Math.round(dur), + }, }) } } -} -interface FrameWarningStrings { - clipStartsWithCount: string - clipEndsWithAfter: string - countDetectedWithinClip: string - countDetectedInClip: string + return messages } -// Mock 't' function for i18next to find the keys -function t(key: string): string { - return key +interface FrameWarningStrings { + clipStartsWithCount: PackageStatusMessage + clipEndsWithAfter: PackageStatusMessage + countDetectedWithinClip: PackageStatusMessage + countDetectedInClip: PackageStatusMessage } const BlackFrameWarnings: FrameWarningStrings = { - clipStartsWithCount: t('Clip starts with {{frames}} black frames'), - clipEndsWithAfter: t('This clip ends with black frames after {{seconds}} seconds'), - countDetectedWithinClip: t('{{frames}} black frames detected within the clip'), - countDetectedInClip: t('{{frames}} black frames detected in the clip'), + clipStartsWithCount: PackageStatusMessage.CLIP_STARTS_WITH_BLACK_FRAMES, + clipEndsWithAfter: PackageStatusMessage.CLIP_ENDS_WITH_BLACK_FRAMES, + countDetectedWithinClip: PackageStatusMessage.CLIP_HAS_SINGLE_BLACK_FRAMES_REGION, + countDetectedInClip: PackageStatusMessage.CLIP_HAS_MULTIPLE_BLACK_FRAMES_REGIONS, } const FreezeFrameWarnings: FrameWarningStrings = { - clipStartsWithCount: t('Clip starts with {{frames}} freeze frames'), - clipEndsWithAfter: t('This clip ends with freeze frames after {{seconds}} seconds'), - countDetectedWithinClip: t('{{frames}} freeze frames detected within the clip'), - countDetectedInClip: t('{{frames}} freeze frames detected in the clip'), + clipStartsWithCount: PackageStatusMessage.CLIP_STARTS_WITH_FREEZE_FRAMES, + clipEndsWithAfter: PackageStatusMessage.CLIP_ENDS_WITH_FREEZE_FRAMES, + countDetectedWithinClip: PackageStatusMessage.CLIP_HAS_SINGLE_FREEZE_FRAMES_REGION, + countDetectedInClip: PackageStatusMessage.CLIP_HAS_MULTIPLE_FREEZE_FRAMES_REGIONS, } function routeExpectedPackage( diff --git a/meteor/server/publications/pieceContentStatusUI/messageFactory.ts b/meteor/server/publications/pieceContentStatusUI/messageFactory.ts new file mode 100644 index 0000000000..04f6d05bbf --- /dev/null +++ b/meteor/server/publications/pieceContentStatusUI/messageFactory.ts @@ -0,0 +1,121 @@ +import { Blueprint } from '@sofie-automation/corelib/dist/dataModel/Blueprint' +import { generateTranslation } from '@sofie-automation/corelib/dist/lib' +import { + ITranslatableMessage, + wrapTranslatableMessageFromBlueprints, +} from '@sofie-automation/corelib/dist/TranslatableMessage' +import { PackageStatusMessage } from '@sofie-automation/shared-lib/dist/packageStatusMessages' + +const DEFAULT_MESSAGES: Record = { + // Media Manager + [PackageStatusMessage.MISSING_FILE_PATH]: generateTranslation('{{sourceLayer}} is missing a file path'), + [PackageStatusMessage.FILE_NOT_YET_READY_ON_PLAYOUT_SYSTEM]: generateTranslation( + '{{sourceLayer}} is not yet ready on the playout system' + ), + [PackageStatusMessage.FILE_IS_BEING_INGESTED]: generateTranslation('{{sourceLayer}} is being ingested'), + [PackageStatusMessage.FILE_IS_MISSING]: generateTranslation('{{sourceLayer}} is missing'), + + // Package manager + [PackageStatusMessage.FILE_CANT_BE_FOUND_ON_PLAYOUT_SYSTEM]: generateTranslation( + `{{sourceLayer}} can't be found on the playout system` + ), + [PackageStatusMessage.FILE_EXISTS_BUT_IS_NOT_READY_ON_PLAYOUT_SYSTEM]: generateTranslation( + '{{reason}} {{sourceLayer}} exists, but is not yet ready on the playout system' + ), + [PackageStatusMessage.FILE_IS_IN_PLACEHOLDER_STATE]: generateTranslation( + '{{sourceLayer}} is in a placeholder state for an unknown workflow-defined reason' + ), + [PackageStatusMessage.FILE_IS_TRANSFERRING_TO_PLAYOUT_SYSTEM]: generateTranslation( + '{{sourceLayer}} is transferring to the playout system' + ), + [PackageStatusMessage.FILE_IS_TRANSFERRING_TO_PLAYOUT_SYSTEM_NOT_READY]: generateTranslation( + '{{sourceLayer}} is transferring to the playout system but cannot be played yet' + ), + [PackageStatusMessage.FILE_IS_IN_UNKNOWN_STATE]: generateTranslation( + '{{sourceLayer}} is in an unknown state: "{{status}}"' + ), + + // Common? + [PackageStatusMessage.FILE_DOESNT_HAVE_BOTH_VIDEO_AND_AUDIO]: generateTranslation( + "{{sourceLayer}} doesn't have both audio & video" + ), + [PackageStatusMessage.FILE_HAS_WRONG_FORMAT]: generateTranslation( + '{{sourceLayer}} has the wrong format: {{format}}' + ), + [PackageStatusMessage.FILE_HAS_WRONG_AUDIO_STREAMS]: generateTranslation( + '{{sourceLayer}} has {{audioStreams}} audio streams' + ), + + [PackageStatusMessage.CLIP_STARTS_WITH_BLACK_FRAMES]: generateTranslation( + 'Clip starts with {{frames}} black frames' + ), + [PackageStatusMessage.CLIP_ENDS_WITH_BLACK_FRAMES]: generateTranslation( + 'This clip ends with black frames after {{seconds}} seconds' + ), + [PackageStatusMessage.CLIP_HAS_SINGLE_BLACK_FRAMES_REGION]: generateTranslation( + '{{frames}} black frames detected within the clip' + ), + [PackageStatusMessage.CLIP_HAS_MULTIPLE_BLACK_FRAMES_REGIONS]: generateTranslation( + '{{frames}} black frames detected in the clip' + ), + + [PackageStatusMessage.CLIP_STARTS_WITH_FREEZE_FRAMES]: generateTranslation( + 'Clip starts with {{frames}} freeze frames' + ), + [PackageStatusMessage.CLIP_ENDS_WITH_FREEZE_FRAMES]: generateTranslation( + 'This clip ends with freeze frames after {{seconds}} seconds' + ), + [PackageStatusMessage.CLIP_HAS_SINGLE_FREEZE_FRAMES_REGION]: generateTranslation( + '{{frames}} freeze frames detected within the clip' + ), + [PackageStatusMessage.CLIP_HAS_MULTIPLE_FREEZE_FRAMES_REGIONS]: generateTranslation( + '{{frames}} freeze frames detected in the clip' + ), +} + +export interface PieceContentStatusMessageRequiredArgs { + sourceLayer: string + pieceName: string + fileName: string + containerLabels: string +} + +export type BlueprintForStatusMessage = Pick + +export class PieceContentStatusMessageFactory { + readonly #blueprint: BlueprintForStatusMessage | undefined + + constructor(blueprint: BlueprintForStatusMessage | undefined) { + this.#blueprint = blueprint + } + + getTranslation( + messageKey: PackageStatusMessage, + args: PieceContentStatusMessageRequiredArgs & { [k: string]: any } + ): ITranslatableMessage | null { + if (this.#blueprint) { + const blueprintMessage = this.#blueprint.packageStatusMessages?.[messageKey] + + if (blueprintMessage === '') { + // If the blueprint gave an empty string, it means it wants to suppress the message + return null + } + + if (blueprintMessage) { + return wrapTranslatableMessageFromBlueprints( + { + key: blueprintMessage, + args, + }, + [this.#blueprint._id] + ) + } + } + + // Otherwise, use the default message + return { + key: DEFAULT_MESSAGES[messageKey]?.key ?? messageKey, + args, + } + } +} diff --git a/meteor/server/publications/pieceContentStatusUI/rundown/publication.ts b/meteor/server/publications/pieceContentStatusUI/rundown/publication.ts index b29e10d999..b20d230459 100644 --- a/meteor/server/publications/pieceContentStatusUI/rundown/publication.ts +++ b/meteor/server/publications/pieceContentStatusUI/rundown/publication.ts @@ -8,6 +8,7 @@ import { PieceId, PieceInstanceId, RundownBaselineAdLibActionId, + RundownId, RundownPlaylistId, SegmentId, } from '@sofie-automation/corelib/dist/dataModel/Ids' @@ -57,6 +58,7 @@ import { PieceContentStatusStudio } from '../checkPieceContentStatus' import { check, Match } from 'meteor/check' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { triggerWriteAccessBecauseNoCheckNecessary } from '../../../security/securityVerify' +import { PieceContentStatusMessageFactory } from '../messageFactory' interface UIPieceContentStatusesArgs { readonly rundownPlaylistId: RundownPlaylistId @@ -67,6 +69,8 @@ interface UIPieceContentStatusesState { studio: PieceContentStatusStudio + rundownMessageFactories: Map + pieceDependencies: Map pieceInstanceDependencies: Map adlibPieceDependencies: Map @@ -183,6 +187,11 @@ async function setupUIPieceContentStatusesPublicationObservers( changed: () => triggerUpdate({ invalidateAll: true }), removed: () => triggerUpdate({ invalidateAll: true }), }), + contentCache.Blueprints.find({}).observeChanges({ + added: () => triggerUpdate({ invalidateAll: true }), + changed: () => triggerUpdate({ invalidateAll: true }), + removed: () => triggerUpdate({ invalidateAll: true }), + }), contentCache.ShowStyleSourceLayers.find({}).observeChanges({ added: () => triggerUpdate({ invalidateAll: true }), changed: () => triggerUpdate({ invalidateAll: true }), @@ -319,6 +328,7 @@ async function manipulateUIPieceContentStatusesPublicationData( !state.adlibActionDependencies || !state.baselineAdlibDependencies || !state.baselineActionDependencies || + !state.rundownMessageFactories || invalidateAllStatuses ) { state.pieceDependencies = new Map() @@ -327,6 +337,7 @@ async function manipulateUIPieceContentStatusesPublicationData( state.adlibActionDependencies = new Map() state.baselineAdlibDependencies = new Map() state.baselineActionDependencies = new Map() + state.rundownMessageFactories = new Map() // force every piece to be regenerated collection.remove(null) @@ -336,6 +347,13 @@ async function manipulateUIPieceContentStatusesPublicationData( regenerateAdlibActionIds = new Set(state.contentCache.AdLibActions.find({}).map((p) => p._id)) regenerateBaselineAdlibPieceIds = new Set(state.contentCache.BaselineAdLibPieces.find({}).map((p) => p._id)) regenerateBaselineAdlibActionIds = new Set(state.contentCache.BaselineAdLibActions.find({}).map((p) => p._id)) + + // prepare the message factories + for (const rundown of state.contentCache.Rundowns.find({})) { + const showStyle = state.contentCache.ShowStyleSourceLayers.findOne(rundown.showStyleBaseId) + const blueprint = showStyle && state.contentCache.Blueprints.findOne(showStyle.blueprintId) + state.rundownMessageFactories.set(rundown._id, new PieceContentStatusMessageFactory(blueprint)) + } } else { regeneratePieceIds = new Set(updateProps.updatedPieceIds) regeneratePieceInstanceIds = new Set(updateProps.updatedPieceInstanceIds) @@ -371,12 +389,15 @@ async function manipulateUIPieceContentStatusesPublicationData( regenerateBaselineAdlibActionIds, state.baselineActionDependencies ) + + // messageFactories don't need to be invalidated, that is only needed when updateProps.invalidateAll which won't reach here } await regenerateForPieceIds( state.contentCache, state.studio, state.pieceDependencies, + state.rundownMessageFactories, collection, regeneratePieceIds ) @@ -384,6 +405,7 @@ async function manipulateUIPieceContentStatusesPublicationData( state.contentCache, state.studio, state.pieceInstanceDependencies, + state.rundownMessageFactories, collection, regeneratePieceInstanceIds ) @@ -391,6 +413,7 @@ async function manipulateUIPieceContentStatusesPublicationData( state.contentCache, state.studio, state.adlibPieceDependencies, + state.rundownMessageFactories, collection, regenerateAdlibPieceIds ) @@ -398,6 +421,7 @@ async function manipulateUIPieceContentStatusesPublicationData( state.contentCache, state.studio, state.adlibActionDependencies, + state.rundownMessageFactories, collection, regenerateAdlibActionIds ) @@ -405,6 +429,7 @@ async function manipulateUIPieceContentStatusesPublicationData( state.contentCache, state.studio, state.baselineAdlibDependencies, + state.rundownMessageFactories, collection, regenerateBaselineAdlibPieceIds ) @@ -412,6 +437,7 @@ async function manipulateUIPieceContentStatusesPublicationData( state.contentCache, state.studio, state.baselineActionDependencies, + state.rundownMessageFactories, collection, regenerateBaselineAdlibActionIds ) diff --git a/meteor/server/publications/pieceContentStatusUI/rundown/reactiveContentCache.ts b/meteor/server/publications/pieceContentStatusUI/rundown/reactiveContentCache.ts index cdcdb4a05b..41adf64fcf 100644 --- a/meteor/server/publications/pieceContentStatusUI/rundown/reactiveContentCache.ts +++ b/meteor/server/publications/pieceContentStatusUI/rundown/reactiveContentCache.ts @@ -13,6 +13,7 @@ import { RundownBaselineAdLibAction } from '@sofie-automation/corelib/dist/dataM import { BlueprintId, ShowStyleBaseId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' +import { Blueprint } from '@sofie-automation/corelib/dist/dataModel/Blueprint' export interface SourceLayersDoc { _id: ShowStyleBaseId @@ -115,6 +116,12 @@ export const showStyleBaseFieldSpecifier = literal< sourceLayersWithOverrides: 1, }) +export type BlueprintFields = '_id' | 'packageStatusMessages' +export const blueprintFieldSpecifier = literal>>({ + _id: 1, + packageStatusMessages: 1, +}) + export interface ContentCache { Rundowns: ReactiveCacheCollection> Segments: ReactiveCacheCollection> @@ -127,6 +134,7 @@ export interface ContentCache { BaselineAdLibPieces: ReactiveCacheCollection> BaselineAdLibActions: ReactiveCacheCollection> ShowStyleSourceLayers: ReactiveCacheCollection + Blueprints: ReactiveCacheCollection> } export function createReactiveContentCache(): ContentCache { @@ -144,6 +152,7 @@ export function createReactiveContentCache(): ContentCache { 'baselineAdlibActions' ), ShowStyleSourceLayers: new ReactiveCacheCollection('sourceLayers'), + Blueprints: new ReactiveCacheCollection>('blueprints'), } return cache diff --git a/meteor/server/publications/pieceContentStatusUI/rundown/regenerateItems.ts b/meteor/server/publications/pieceContentStatusUI/rundown/regenerateItems.ts index 481b64d978..5b4e7ea6b1 100644 --- a/meteor/server/publications/pieceContentStatusUI/rundown/regenerateItems.ts +++ b/meteor/server/publications/pieceContentStatusUI/rundown/regenerateItems.ts @@ -4,6 +4,7 @@ import { PieceId, PieceInstanceId, RundownBaselineAdLibActionId, + RundownId, } from '@sofie-automation/corelib/dist/dataModel/Ids' import { ReadonlyDeep } from 'type-fest' import { UIPieceContentStatus } from '@sofie-automation/meteor-lib/dist/api/rundownNotifications' @@ -16,16 +17,18 @@ import { PieceContentStatusPiece, PieceContentStatusStudio, } from '../checkPieceContentStatus' -import { PieceDependencies } from '../common' +import type { PieceDependencies } from '../common' +import type { PieceContentStatusMessageFactory } from '../messageFactory' async function regenerateGenericPiece( contentCache: ReadonlyDeep, uiStudio: PieceContentStatusStudio, + messageFactory: PieceContentStatusMessageFactory | undefined, pieceDoc: PieceContentStatusPiece, sourceLayerId: string | undefined, doc: Pick, '_id' | 'partId' | 'pieceId' | 'rundownId' | 'name'> ): Promise<{ - dependencies: PieceDependencies + dependencies: Omit doc: UIPieceContentStatus blueprintId: BlueprintId } | null> { @@ -40,7 +43,12 @@ async function regenerateGenericPiece( const sourceLayer = sourceLayerId && sourceLayersForRundown?.sourceLayers?.[sourceLayerId] if (part && segment && sourceLayer) { - const [status, dependencies] = await checkPieceContentStatusAndDependencies(uiStudio, pieceDoc, sourceLayer) + const [status, dependencies] = await checkPieceContentStatusAndDependencies( + uiStudio, + messageFactory, + pieceDoc, + sourceLayer + ) return { dependencies, @@ -74,6 +82,7 @@ export async function regenerateForPieceIds( contentCache: ReadonlyDeep, uiStudio: PieceContentStatusStudio, dependenciesState: Map, + messageFactories: Map, collection: CustomPublishCollection, regeneratePieceIds: Set ): Promise { @@ -89,15 +98,22 @@ export async function regenerateForPieceIds( // Piece has been deleted, queue it for batching deletedPieceIds.add(pieceId) } else { - const res = await regenerateGenericPiece(contentCache, uiStudio, pieceDoc, pieceDoc.sourceLayerId, { - _id: protectString(`piece_${pieceId}`), + const res = await regenerateGenericPiece( + contentCache, + uiStudio, + messageFactories.get(pieceDoc.startRundownId), + pieceDoc, + pieceDoc.sourceLayerId, + { + _id: protectString(`piece_${pieceId}`), - partId: pieceDoc.startPartId, - rundownId: pieceDoc.startRundownId, - pieceId: pieceId, + partId: pieceDoc.startPartId, + rundownId: pieceDoc.startRundownId, + pieceId: pieceId, - name: pieceDoc.name, - }) + name: pieceDoc.name, + } + ) if (res) { dependenciesState.set(pieceId, res.dependencies) @@ -121,6 +137,7 @@ export async function regenerateForPieceInstanceIds( contentCache: ReadonlyDeep, uiStudio: PieceContentStatusStudio, dependenciesState: Map, + messageFactories: Map, collection: CustomPublishCollection, regeneratePieceIds: Set ): Promise { @@ -152,6 +169,7 @@ export async function regenerateForPieceInstanceIds( if (partInstance && segment && sourceLayer) { const [status, dependencies] = await checkPieceContentStatusAndDependencies( uiStudio, + messageFactories.get(pieceDoc.rundownId), { ...pieceDoc.piece, pieceInstanceId: pieceDoc._id, @@ -202,6 +220,7 @@ export async function regenerateForAdLibPieceIds( contentCache: ReadonlyDeep, uiStudio: PieceContentStatusStudio, dependenciesState: Map, + messageFactories: Map, collection: CustomPublishCollection, regenerateAdLibPieceIds: Set ): Promise { @@ -217,15 +236,22 @@ export async function regenerateForAdLibPieceIds( // Piece has been deleted, queue it for batching deletedPieceIds.add(pieceId) } else { - const res = await regenerateGenericPiece(contentCache, uiStudio, pieceDoc, pieceDoc.sourceLayerId, { - _id: protectString(`adlib_${pieceId}`), - - partId: pieceDoc.partId, - rundownId: pieceDoc.rundownId, - pieceId: pieceId, + const res = await regenerateGenericPiece( + contentCache, + uiStudio, + messageFactories.get(pieceDoc.rundownId), + pieceDoc, + pieceDoc.sourceLayerId, + { + _id: protectString(`adlib_${pieceId}`), + + partId: pieceDoc.partId, + rundownId: pieceDoc.rundownId, + pieceId: pieceId, - name: pieceDoc.name, - }) + name: pieceDoc.name, + } + ) if (res) { dependenciesState.set(pieceId, res.dependencies) @@ -249,6 +275,7 @@ export async function regenerateForAdLibActionIds( contentCache: ReadonlyDeep, uiStudio: PieceContentStatusStudio, dependenciesState: Map, + messageFactories: Map, collection: CustomPublishCollection, regenerateActionIds: Set ): Promise { @@ -267,20 +294,29 @@ export async function regenerateForAdLibActionIds( const fakedPiece = literal({ _id: protectString(`${actionDoc._id}`), content: 'content' in actionDoc.display ? actionDoc.display.content : {}, + name: + typeof actionDoc.display.label === 'string' ? actionDoc.display.label : actionDoc.display.label.key, expectedPackages: actionDoc.expectedPackages, }) const sourceLayerId = 'sourceLayerId' in actionDoc.display ? actionDoc.display.sourceLayerId : undefined - const res = await regenerateGenericPiece(contentCache, uiStudio, fakedPiece, sourceLayerId, { - _id: protectString(`action_${pieceId}`), + const res = await regenerateGenericPiece( + contentCache, + uiStudio, + messageFactories.get(actionDoc.rundownId), + fakedPiece, + sourceLayerId, + { + _id: protectString(`action_${pieceId}`), - partId: actionDoc.partId, - rundownId: actionDoc.rundownId, - pieceId: pieceId, + partId: actionDoc.partId, + rundownId: actionDoc.rundownId, + pieceId: pieceId, - name: actionDoc.display.label, - }) + name: actionDoc.display.label, + } + ) if (res) { dependenciesState.set(pieceId, res.dependencies) @@ -304,6 +340,7 @@ export async function regenerateForBaselineAdLibPieceIds( contentCache: ReadonlyDeep, uiStudio: PieceContentStatusStudio, dependenciesState: Map, + messageFactories: Map, collection: CustomPublishCollection, regenerateAdLibPieceIds: Set ): Promise { @@ -330,6 +367,7 @@ export async function regenerateForBaselineAdLibPieceIds( if (sourceLayer) { const [status, dependencies] = await checkPieceContentStatusAndDependencies( uiStudio, + messageFactories.get(pieceDoc.rundownId), pieceDoc, sourceLayer ) @@ -372,6 +410,7 @@ export async function regenerateForBaselineAdLibActionIds( contentCache: ReadonlyDeep, uiStudio: PieceContentStatusStudio, dependenciesState: Map, + messageFactories: Map, collection: CustomPublishCollection, regenerateActionIds: Set ): Promise { @@ -390,6 +429,8 @@ export async function regenerateForBaselineAdLibActionIds( const fakedPiece = literal({ _id: protectString(`${actionDoc._id}`), content: 'content' in actionDoc.display ? actionDoc.display.content : {}, + name: + typeof actionDoc.display.label === 'string' ? actionDoc.display.label : actionDoc.display.label.key, expectedPackages: actionDoc.expectedPackages, }) @@ -406,6 +447,7 @@ export async function regenerateForBaselineAdLibActionIds( if (sourceLayer) { const [status, dependencies] = await checkPieceContentStatusAndDependencies( uiStudio, + messageFactories.get(actionDoc.rundownId), fakedPiece, sourceLayer ) diff --git a/meteor/server/publications/pieceContentStatusUI/rundown/rundownContentObserver.ts b/meteor/server/publications/pieceContentStatusUI/rundown/rundownContentObserver.ts index 6ba425ab3e..1ee145de8c 100644 --- a/meteor/server/publications/pieceContentStatusUI/rundown/rundownContentObserver.ts +++ b/meteor/server/publications/pieceContentStatusUI/rundown/rundownContentObserver.ts @@ -1,9 +1,10 @@ import { Meteor } from 'meteor/meteor' -import { RundownId, ShowStyleBaseId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { BlueprintId, RundownId, ShowStyleBaseId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { logger } from '../../../logging' import { adLibActionFieldSpecifier, adLibPieceFieldSpecifier, + blueprintFieldSpecifier, ContentCache, partFieldSpecifier, partInstanceFieldSpecifier, @@ -18,6 +19,7 @@ import { import { AdLibActions, AdLibPieces, + Blueprints, PartInstances, Parts, PieceInstances, @@ -51,16 +53,22 @@ export class RundownContentObserver { #showStyleBaseIds: ShowStyleBaseId[] = [] #showStyleBaseIdObserver!: ReactiveMongoObserverGroupHandle + #blueprintIds: BlueprintId[] = [] + #blueprintIdObserver!: ReactiveMongoObserverGroupHandle + private constructor(cache: ContentCache) { this.#cache = cache } + #disposed = false + static async create(rundownIds: RundownId[], cache: ContentCache): Promise { logger.silly(`Creating RundownContentObserver for rundowns "${rundownIds.join(',')}"`) const observer = new RundownContentObserver(cache) await observer.initShowStyleBaseIdObserver() + await observer.initBlueprintIdObserver() // This takes ownership of the #showStyleBaseIdObserver, and will stop it if this throws await observer.initContentObservers(rundownIds) @@ -86,13 +94,16 @@ export class RundownContentObserver { added: (doc) => { const newDoc = convertShowStyleBase(doc) this.#cache.ShowStyleSourceLayers.upsert(doc._id, { $set: newDoc as Partial }) + this.updateBlueprintIds() }, changed: (doc) => { const newDoc = convertShowStyleBase(doc) this.#cache.ShowStyleSourceLayers.upsert(doc._id, { $set: newDoc as Partial }) + this.updateBlueprintIds() }, removed: (doc) => { this.#cache.ShowStyleSourceLayers.remove(doc._id) + this.updateBlueprintIds() }, }, { @@ -103,6 +114,29 @@ export class RundownContentObserver { }) } + private async initBlueprintIdObserver() { + // Run the Blueprint query in a ReactiveMongoObserverGroup, so that it can be restarted whenever + this.#blueprintIdObserver = await ReactiveMongoObserverGroup(async () => { + // Clear already cached data + this.#cache.Blueprints.remove({}) + + logger.silly(`optimized observer restarting ${this.#blueprintIds}`) + + return [ + Blueprints.observeChanges( + { + // We can use the `this.#blueprintIds` here, as this is restarted every time that property changes + _id: { $in: this.#blueprintIds }, + }, + this.#cache.Blueprints.link(), + { + projection: blueprintFieldSpecifier, + } + ), + ] + }) + } + private async initContentObservers(rundownIds: RundownId[]) { // Subscribe to the database, and pipe any updates into the ReactiveCacheCollections this.#observers = await waitForAllObserversReady([ @@ -121,6 +155,7 @@ export class RundownContentObserver { } ), this.#showStyleBaseIdObserver, + this.#blueprintIdObserver, Segments.observeChanges( { @@ -228,6 +263,8 @@ export class RundownContentObserver { private updateShowStyleBaseIds = _.debounce( Meteor.bindEnvironment(() => { + if (this.#disposed) return + const newShowStyleBaseIds = _.uniq(this.#cache.Rundowns.find({}).map((rd) => rd.showStyleBaseId)) if (!equivalentArrays(newShowStyleBaseIds, this.#showStyleBaseIds)) { @@ -242,11 +279,29 @@ export class RundownContentObserver { REACTIVITY_DEBOUNCE ) + private updateBlueprintIds = _.debounce( + Meteor.bindEnvironment(() => { + if (this.#disposed) return + + const newBlueprintIds = _.uniq(this.#cache.ShowStyleSourceLayers.find({}).map((rd) => rd.blueprintId)) + + if (!equivalentArrays(newBlueprintIds, this.#blueprintIds)) { + logger.silly(`optimized observer changed ids ${JSON.stringify(newBlueprintIds)} ${this.#blueprintIds}`) + this.#blueprintIds = newBlueprintIds + // trigger the rundown group to restart + this.#blueprintIdObserver.restart() + } + }), + REACTIVITY_DEBOUNCE + ) + public get cache(): ContentCache { return this.#cache } public dispose = (): void => { + this.#disposed = true + this.#observers.forEach((observer) => observer.stop()) } } diff --git a/meteor/yarn.lock b/meteor/yarn.lock index b737b50768..0af7e86b06 100644 --- a/meteor/yarn.lock +++ b/meteor/yarn.lock @@ -1191,7 +1191,7 @@ __metadata: resolution: "@sofie-automation/shared-lib@portal:../packages/shared-lib::locator=automation-core%40workspace%3A." dependencies: "@mos-connection/model": "npm:^4.2.2" - timeline-state-resolver-types: "npm:9.2.0-nightly-release52-20241219-123204-90290cef1.0" + timeline-state-resolver-types: "npm:9.2.0-nightly-release52-20250204-101510-3576f2cd8.0" tslib: "npm:^2.8.1" type-fest: "npm:^4.33.0" languageName: node @@ -10234,12 +10234,12 @@ __metadata: languageName: node linkType: hard -"timeline-state-resolver-types@npm:9.2.0-nightly-release52-20241219-123204-90290cef1.0": - version: 9.2.0-nightly-release52-20241219-123204-90290cef1.0 - resolution: "timeline-state-resolver-types@npm:9.2.0-nightly-release52-20241219-123204-90290cef1.0" +"timeline-state-resolver-types@npm:9.2.0-nightly-release52-20250204-101510-3576f2cd8.0": + version: 9.2.0-nightly-release52-20250204-101510-3576f2cd8.0 + resolution: "timeline-state-resolver-types@npm:9.2.0-nightly-release52-20250204-101510-3576f2cd8.0" dependencies: tslib: "npm:^2.6.3" - checksum: 10/3c0fa5c13b4f2e1dbcb51401cfeb6b51fc143defc629e3b59af4666f451a074f725110c9fe6e2493bd247ae5c1589f3d18379bb5a5003fde429e6fdede4e22b0 + checksum: 10/27a0950b04db1078325ec089e397b3f09336aea4b6926724e67564260042f689f4b002f27dbda0e1b81a5f66b02a54c7a99e234d0bb13aee921a07930e1c981c languageName: node linkType: hard diff --git a/packages/blueprints-integration/src/api/showStyle.ts b/packages/blueprints-integration/src/api/showStyle.ts index c318ab1cf1..b9bd2c9d74 100644 --- a/packages/blueprints-integration/src/api/showStyle.ts +++ b/packages/blueprints-integration/src/api/showStyle.ts @@ -47,6 +47,9 @@ import type { IBlueprintTriggeredActions } from '../triggers' import type { ExpectedPackage } from '../package' import type { ABResolverConfiguration } from '../abPlayback' import type { SofieIngestSegment } from '../ingest-types' +import { PackageStatusMessage } from '@sofie-automation/shared-lib/dist/packageStatusMessages' + +export { PackageStatusMessage } export type TimelinePersistentState = unknown @@ -60,6 +63,9 @@ export interface ShowStyleBlueprintManifest> + /** Alternate package status messages, to override the builtin ones produced by Sofie */ + packageStatusMessages?: Partial> + /** Translations connected to the studio (as stringified JSON) */ translations?: string diff --git a/packages/corelib/src/dataModel/Blueprint.ts b/packages/corelib/src/dataModel/Blueprint.ts index 2ca55d4e2e..acd7442399 100644 --- a/packages/corelib/src/dataModel/Blueprint.ts +++ b/packages/corelib/src/dataModel/Blueprint.ts @@ -8,6 +8,7 @@ import { JSONBlob } from '@sofie-automation/shared-lib/dist/lib/JSONBlob' import { JSONSchema } from '@sofie-automation/shared-lib/dist/lib/JSONSchemaTypes' import { ProtectedString } from '../protectedString' import { BlueprintId, OrganizationId } from './Ids' +import type { PackageStatusMessage } from '@sofie-automation/shared-lib/dist/packageStatusMessages' export type BlueprintHash = ProtectedString<'BlueprintHash'> @@ -52,6 +53,13 @@ export interface Blueprint { /** Whether the blueprint this wraps has a `fixUpConfig` function defined */ hasFixUpFunction: boolean + + /** + * The blueprint provided alternate package status messages, if any were provided + * Any undefined/unset values will use the system default messages. + * Any empty strings will suppress the message from being shown. + */ + packageStatusMessages?: Partial> } /** Describes the last state a Blueprint document was in when applying config changes */ diff --git a/packages/meteor-lib/src/collections/ExpectedPackages.ts b/packages/meteor-lib/src/collections/ExpectedPackages.ts index 1b841c42c0..046799c5c1 100644 --- a/packages/meteor-lib/src/collections/ExpectedPackages.ts +++ b/packages/meteor-lib/src/collections/ExpectedPackages.ts @@ -69,3 +69,18 @@ export function getSideEffect( expectedPackage.sideEffect ) } + +export function getExpectedPackageFileName(expectedPackage: ExpectedPackage.Any): string | undefined { + if (expectedPackage.type === ExpectedPackage.PackageType.MEDIA_FILE) { + return expectedPackage.content.filePath + } else if (expectedPackage.type === ExpectedPackage.PackageType.QUANTEL_CLIP) { + return expectedPackage.content.guid || expectedPackage.content.title + } else if (expectedPackage.type === ExpectedPackage.PackageType.JSON_DATA) { + return undefined // Not supported + } else if (expectedPackage.type === ExpectedPackage.PackageType.HTML_TEMPLATE) { + return expectedPackage.content.path + } else { + assertNever(expectedPackage) + return undefined + } +} diff --git a/packages/playout-gateway/package.json b/packages/playout-gateway/package.json index 8ae547fb26..287c6d717c 100644 --- a/packages/playout-gateway/package.json +++ b/packages/playout-gateway/package.json @@ -60,7 +60,7 @@ "@sofie-automation/shared-lib": "1.52.0-in-development", "debug": "^4.4.0", "influx": "^5.9.7", - "timeline-state-resolver": "9.2.0-nightly-release52-20241219-123204-90290cef1.0", + "timeline-state-resolver": "9.2.0-nightly-release52-20250204-101510-3576f2cd8.0", "tslib": "^2.8.1", "underscore": "^1.13.7", "winston": "^3.17.0" diff --git a/packages/shared-lib/package.json b/packages/shared-lib/package.json index c77d90305f..9fb9c8cb56 100644 --- a/packages/shared-lib/package.json +++ b/packages/shared-lib/package.json @@ -39,7 +39,7 @@ ], "dependencies": { "@mos-connection/model": "^4.2.2", - "timeline-state-resolver-types": "9.2.0-nightly-release52-20241219-123204-90290cef1.0", + "timeline-state-resolver-types": "9.2.0-nightly-release52-20250204-101510-3576f2cd8.0", "tslib": "^2.8.1", "type-fest": "^4.33.0" }, diff --git a/packages/shared-lib/src/packageStatusMessages.ts b/packages/shared-lib/src/packageStatusMessages.ts new file mode 100644 index 0000000000..199fa44ece --- /dev/null +++ b/packages/shared-lib/src/packageStatusMessages.ts @@ -0,0 +1,122 @@ +/** + * The possible statuses of a Package/media object status that can be translated. + * In all cases the following strings will be available in the translation context, some keys add additional context: + * - sourceLayer: The name of the source layer + * - pieceName: The name of the piece the package belongs to + * - fileName: The name of the file + * - containerLabels: The labels of the container(s) that report this status + * + * For statuses not reported by Package Manager, the `containerLabels` will be an empty string. + */ +export enum PackageStatusMessage { + // Media Manager + /** + * The File Path is missing + * Note: Only used for Media Manager flow + * The `fileName` property will always be an empty string + */ + MISSING_FILE_PATH = 'MISSING_FILE_PATH', + /** + * The file is not yet ready on the playout system + * Note: Only used for Media Manager flow + */ + FILE_NOT_YET_READY_ON_PLAYOUT_SYSTEM = 'FILE_NOT_YET_READY_ON_PLAYOUT_SYSTEM', + /** + * The file is being ingested + * Note: Only used for Media Manager flow + */ + FILE_IS_BEING_INGESTED = 'FILE_IS_BEING_INGESTED', + /** + * The file is missing + * Note: Only used for Media Manager flow + */ + FILE_IS_MISSING = 'FILE_IS_MISSING', + + // Package manager + /** + * The file can't be found on the playout system + */ + FILE_CANT_BE_FOUND_ON_PLAYOUT_SYSTEM = 'FILE_CANT_BE_FOUND_ON_PLAYOUT_SYSTEM', + /** + * The file exists, but is not yet ready on the playout system + * This has an extra `reason` property in the translation context, provided by the Package Manager + */ + FILE_EXISTS_BUT_IS_NOT_READY_ON_PLAYOUT_SYSTEM = 'FILE_EXISTS_BUT_IS_NOT_READY_ON_PLAYOUT_SYSTEM', + /** + * The file is in a placeholder state for an unknown workflow-defined reason + * This is typically replaced by a more speific message provided by the Package Manager + */ + FILE_IS_IN_PLACEHOLDER_STATE = 'FILE_IS_IN_PLACEHOLDER_STATE', + /** + * The file is transferring to the playout system + */ + FILE_IS_TRANSFERRING_TO_PLAYOUT_SYSTEM = 'FILE_IS_TRANSFERRING_TO_PLAYOUT_SYSTEM', + /** + * The file is transferring to the playout system but cannot be played yet + */ + FILE_IS_TRANSFERRING_TO_PLAYOUT_SYSTEM_NOT_READY = 'FILE_IS_TRANSFERRING_TO_PLAYOUT_SYSTEM_NOT_READY', + /** + * The file is in an unknown state + * This has an extra `status` property in the translation context, for the unhandled state. + * Seeing this message means the Sofie code is missing handling this status, and is a bug or indicates mismatched versions. + */ + FILE_IS_IN_UNKNOWN_STATE = 'FILE_IS_IN_UNKNOWN_STATE', + + // Common + /** + * The file doesn't have both audio and video streams + */ + FILE_DOESNT_HAVE_BOTH_VIDEO_AND_AUDIO = 'FILE_DOESNT_HAVE_BOTH_VIDEO_AND_AUDIO', + /** + * The file has the wrong format + * This has an extra `format` property in the translation context, a user friendly representation of the scanned format + */ + FILE_HAS_WRONG_FORMAT = 'FILE_HAS_WRONG_FORMAT', + /** + * The file has the wrong number of audio streams + * This has an extra `audioStreams` property in the translation context, the number of audio streams found in the file + */ + FILE_HAS_WRONG_AUDIO_STREAMS = 'FILE_HAS_WRONG_AUDIO_STREAMS', + + /** + * The clip starts with black frames + * This has extra `frames` and `seconds` properties in the translation context, describing the duration of the black region + */ + CLIP_STARTS_WITH_BLACK_FRAMES = 'CLIP_STARTS_WITH_BLACK_FRAMES', + /** + * The clip ends with black frames + * This has extra `frames` and `seconds` properties in the translation context, describing the duration of the black region + */ + CLIP_ENDS_WITH_BLACK_FRAMES = 'CLIP_ENDS_WITH_BLACK_FRAMES', + /** + * The clip has a single region of black frames + * This has extra `frames` and `seconds` properties in the translation context, describing the duration of the black region + */ + CLIP_HAS_SINGLE_BLACK_FRAMES_REGION = 'CLIP_HAS_SINGLE_BLACK_FRAMES_REGION', + /** + * The clip has multiple regions of black frames + * This has extra `frames` and `seconds` properties in the translation context, describing the total duration of all black regions + */ + CLIP_HAS_MULTIPLE_BLACK_FRAMES_REGIONS = 'CLIP_HAS_MULTIPLE_BLACK_FRAMES_REGIONS', + + /** + * The clip starts with freeze frames + * This has extra `frames` and `seconds` properties in the translation context, describing the duration of the freeze region + */ + CLIP_STARTS_WITH_FREEZE_FRAMES = 'CLIP_STARTS_WITH_FREEZE_FRAMES', + /** + * The clip ends with freeze frames + * This has extra `frames` and `seconds` properties in the translation context, describing the duration of the freeze region + */ + CLIP_ENDS_WITH_FREEZE_FRAMES = 'CLIP_ENDS_WITH_FREEZE_FRAMES', + /** + * The clip has a single region of freeze frames + * This has extra `frames` and `seconds` properties in the translation context, describing the duration of the freeze region + */ + CLIP_HAS_SINGLE_FREEZE_FRAMES_REGION = 'CLIP_HAS_SINGLE_FREEZE_FRAMES_REGION', + /** + * The clip has multiple regions of freeze frames + * This has extra `frames` and `seconds` properties in the translation context, describing the total duration of all freeze regions + */ + CLIP_HAS_MULTIPLE_FREEZE_FRAMES_REGIONS = 'CLIP_HAS_MULTIPLE_FREEZE_FRAMES_REGIONS', +} diff --git a/packages/webui/src/client/lib/dev.ts b/packages/webui/src/client/lib/dev.ts index 0bfb3edea3..14c6f72d54 100644 --- a/packages/webui/src/client/lib/dev.ts +++ b/packages/webui/src/client/lib/dev.ts @@ -11,8 +11,11 @@ import { logger } from './logging' const windowAny: any = window Meteor.startup(() => { - windowAny['Collections'] = Object.fromEntries(ClientCollections.entries()) - windowAny['PublicationCollections'] = Object.fromEntries(PublicationCollections.entries()) + // Perform on a delay, to ensure the collections are setup + setTimeout(() => { + windowAny['Collections'] = Object.fromEntries(ClientCollections.entries()) + windowAny['PublicationCollections'] = Object.fromEntries(PublicationCollections.entries()) + }, 1000) }) windowAny['getCurrentTime'] = getCurrentTime diff --git a/packages/webui/src/client/ui/FloatingInspectors/VTFloatingInspector.tsx b/packages/webui/src/client/ui/FloatingInspectors/VTFloatingInspector.tsx index 9260839756..3f61f39e1c 100644 --- a/packages/webui/src/client/ui/FloatingInspectors/VTFloatingInspector.tsx +++ b/packages/webui/src/client/ui/FloatingInspectors/VTFloatingInspector.tsx @@ -39,7 +39,6 @@ function renderNotice( noticeLevel: NoticeLevel, noticeMessages: ReadonlyDeep | null ): JSX.Element { - const messagesStr = noticeMessages ? noticeMessages.map((msg) => translateMessage(msg, t)).join('; ') : '' return ( <>
@@ -49,7 +48,14 @@ function renderNotice( ) : null}
-
{messagesStr}
+
+ {noticeMessages?.map((msg) => ( + <> + {translateMessage(msg, t)} +
+ + ))} +
) } diff --git a/packages/webui/src/client/ui/RundownView/RundownNotifier.tsx b/packages/webui/src/client/ui/RundownView/RundownNotifier.tsx index 56999eca0e..b26568fe72 100644 --- a/packages/webui/src/client/ui/RundownView/RundownNotifier.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownNotifier.tsx @@ -603,10 +603,6 @@ class RundownViewNotifier extends WithManagedTracker { let newNotification: Notification | undefined = undefined if (status !== PieceStatusCode.OK && status !== PieceStatusCode.UNKNOWN) { - const messagesStr = messages.length - ? messages.map((msg) => translateMessage(msg, t)).join('; ') - : t('There is an unspecified problem with the source.') - const issueName = typeof issue.name === 'string' ? issue.name : translateMessage(issue.name, t) let messageName = issue.segmentName || issueName if (issue.segmentName && issueName) { @@ -619,7 +615,15 @@ class RundownViewNotifier extends WithManagedTracker { ( <>
{messageName}
-
{messagesStr}
+
+ {messages.map((msg) => ( + <> + {translateMessage(msg, t)} +
+ + ))} + {messages.length === 0 && t('There is an unspecified problem with the source.')} +
), issue.segmentId ? issue.segmentId : 'line_' + issue.partId, diff --git a/packages/yarn.lock b/packages/yarn.lock index d3710a0c76..2e4d810b81 100644 --- a/packages/yarn.lock +++ b/packages/yarn.lock @@ -6066,7 +6066,7 @@ __metadata: resolution: "@sofie-automation/shared-lib@workspace:shared-lib" dependencies: "@mos-connection/model": "npm:^4.2.2" - timeline-state-resolver-types: "npm:9.2.0-nightly-release52-20241219-123204-90290cef1.0" + timeline-state-resolver-types: "npm:9.2.0-nightly-release52-20250204-101510-3576f2cd8.0" tslib: "npm:^2.8.1" type-fest: "npm:^4.33.0" languageName: unknown @@ -13053,9 +13053,9 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"emberplus-connection@npm:^0.2.1": - version: 0.2.1 - resolution: "emberplus-connection@npm:0.2.1" +"emberplus-connection@npm:^0.2.2": + version: 0.2.2 + resolution: "emberplus-connection@npm:0.2.2" dependencies: asn1: evs-broadcast/node-asn1 debug: "npm:^4.3.4" @@ -13063,7 +13063,7 @@ asn1@evs-broadcast/node-asn1: long: "npm:^3.2.0" smart-buffer: "npm:^3.0.3" tslib: "npm:^2.6.2" - checksum: 10/5bbd8f2dfcd6aee00f12541d370eb229c52e86b26664c68bd7bd79b87f70c6e88420b55017846fac85afba154fafd992d2617b446e483e3ed1fa9f7d15cf4138 + checksum: 10/0b433eae53a979b40334e35af6d4362ab262703b4b76428309230718ec2ad5d181a3aafda3ae03aef1f73d13b3929aef6216886f2863d046e35fd3866045431e languageName: node linkType: hard @@ -22885,7 +22885,7 @@ asn1@evs-broadcast/node-asn1: "@sofie-automation/shared-lib": "npm:1.52.0-in-development" debug: "npm:^4.4.0" influx: "npm:^5.9.7" - timeline-state-resolver: "npm:9.2.0-nightly-release52-20241219-123204-90290cef1.0" + timeline-state-resolver: "npm:9.2.0-nightly-release52-20250204-101510-3576f2cd8.0" tslib: "npm:^2.8.1" underscore: "npm:^1.13.7" winston: "npm:^3.17.0" @@ -27615,18 +27615,18 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"timeline-state-resolver-types@npm:9.2.0-nightly-release52-20241219-123204-90290cef1.0": - version: 9.2.0-nightly-release52-20241219-123204-90290cef1.0 - resolution: "timeline-state-resolver-types@npm:9.2.0-nightly-release52-20241219-123204-90290cef1.0" +"timeline-state-resolver-types@npm:9.2.0-nightly-release52-20250204-101510-3576f2cd8.0": + version: 9.2.0-nightly-release52-20250204-101510-3576f2cd8.0 + resolution: "timeline-state-resolver-types@npm:9.2.0-nightly-release52-20250204-101510-3576f2cd8.0" dependencies: tslib: "npm:^2.6.3" - checksum: 10/3c0fa5c13b4f2e1dbcb51401cfeb6b51fc143defc629e3b59af4666f451a074f725110c9fe6e2493bd247ae5c1589f3d18379bb5a5003fde429e6fdede4e22b0 + checksum: 10/27a0950b04db1078325ec089e397b3f09336aea4b6926724e67564260042f689f4b002f27dbda0e1b81a5f66b02a54c7a99e234d0bb13aee921a07930e1c981c languageName: node linkType: hard -"timeline-state-resolver@npm:9.2.0-nightly-release52-20241219-123204-90290cef1.0": - version: 9.2.0-nightly-release52-20241219-123204-90290cef1.0 - resolution: "timeline-state-resolver@npm:9.2.0-nightly-release52-20241219-123204-90290cef1.0" +"timeline-state-resolver@npm:9.2.0-nightly-release52-20250204-101510-3576f2cd8.0": + version: 9.2.0-nightly-release52-20250204-101510-3576f2cd8.0 + resolution: "timeline-state-resolver@npm:9.2.0-nightly-release52-20250204-101510-3576f2cd8.0" dependencies: "@tv2media/v-connection": "npm:^7.3.4" atem-connection: "npm:3.5.0" @@ -27636,7 +27636,7 @@ asn1@evs-broadcast/node-asn1: casparcg-state: "npm:3.0.3" debug: "npm:^4.3.6" deepmerge: "npm:^4.3.1" - emberplus-connection: "npm:^0.2.1" + emberplus-connection: "npm:^0.2.2" eventemitter3: "npm:^4.0.7" got: "npm:^11.8.6" hpagent: "npm:^1.2.0" @@ -27651,7 +27651,7 @@ asn1@evs-broadcast/node-asn1: sprintf-js: "npm:^1.1.3" superfly-timeline: "npm:^9.0.1" threadedclass: "npm:^1.2.2" - timeline-state-resolver-types: "npm:9.2.0-nightly-release52-20241219-123204-90290cef1.0" + timeline-state-resolver-types: "npm:9.2.0-nightly-release52-20250204-101510-3576f2cd8.0" tslib: "npm:^2.6.3" tv-automation-quantel-gateway-client: "npm:^3.1.7" type-fest: "npm:^3.13.1" @@ -27659,7 +27659,7 @@ asn1@evs-broadcast/node-asn1: utf-8-validate: "npm:^6.0.4" ws: "npm:^8.18.0" xml-js: "npm:^1.6.11" - checksum: 10/f57276763948ca194bd633441d52675a8947b46c66b92dd62c076ef5910a3a9a581cd1a52d5ef1d0a6c731ddb33a2972422c0872489c5bd3730041d43a2b867f + checksum: 10/d08bb6d78f62c2957e511b3a1ba44e5543e870940f43921edb80bf15ed31f1f9d2e9289e9f34955a2f6b05163764fd869f9d2e5849c2df8140d1db7fcc457756 languageName: node linkType: hard