diff --git a/packages/corelib/src/overrideOpHelper.ts b/packages/corelib/src/overrideOpHelper.ts index 5962a8126a..fd04bc3814 100644 --- a/packages/corelib/src/overrideOpHelper.ts +++ b/packages/corelib/src/overrideOpHelper.ts @@ -162,12 +162,15 @@ export interface OverrideOpHelperBatcher extends OverrideOpHelperForItemContents export type OverrideOpHelper = () => OverrideOpHelperBatcher export class OverrideOpHelperImpl implements OverrideOpHelperBatcher { - readonly #saveOverrides: SaveOverridesFunction + readonly #saveOverrides: SaveOverridesFunction | null readonly #object: ObjectWithOverrides - constructor(saveOverrides: SaveOverridesFunction, object: ObjectWithOverrides) { + constructor( + saveOverrides: SaveOverridesFunction | null, + object: ObjectWithOverrides | ReadonlyDeep> + ) { this.#saveOverrides = saveOverrides - this.#object = { ...object } + this.#object = { defaults: object.defaults, overrides: [...object.overrides] } } clearItemOverrides = (itemId: string, subPath: string): this => { @@ -314,6 +317,12 @@ export class OverrideOpHelperImpl implements OverrideOpHelperBatcher { } commit = (): void => { + if (!this.#saveOverrides) throw new Error('Cannot commit changes without a save function') + this.#saveOverrides(this.#object.overrides) } + + getPendingOps = (): SomeObjectOverrideOp[] => { + return this.#object.overrides + } } diff --git a/packages/job-worker/src/__mocks__/context.ts b/packages/job-worker/src/__mocks__/context.ts index 9e2751de4e..15082d9d67 100644 --- a/packages/job-worker/src/__mocks__/context.ts +++ b/packages/job-worker/src/__mocks__/context.ts @@ -226,6 +226,18 @@ export class MockJobContext implements JobContext { // throw new Error('Method not implemented.') } + setRouteSetActive(_routeSetId: string, _isActive: boolean | 'toggle'): boolean { + throw new Error('Method not implemented.') + } + + async saveRouteSetChanges(): Promise { + // throw new Error('Method not implemented.') + } + + discardRouteSetChanges(): void { + // throw new Error('Method not implemented.') + } + /** * Mock methods */ diff --git a/packages/job-worker/src/jobs/index.ts b/packages/job-worker/src/jobs/index.ts index c2e71c4ec9..f8ab90e9f4 100644 --- a/packages/job-worker/src/jobs/index.ts +++ b/packages/job-worker/src/jobs/index.ts @@ -64,6 +64,26 @@ export interface JobContext extends StudioCacheContext { /** Hack: fast-track the timeline out to the playout-gateway. */ hackPublishTimelineToFastTrack(newTimeline: TimelineComplete): void + + /** + * Set whether a routeset for this studio is active. + * Any routeset `exclusivityGroup` will be respected. + * The changes will be immediately visible in subsequent calls to the `studio` getter + * @param routeSetId The routeSetId to change + * @param isActive Whether the routeSet should be active, or toggle + * @returns Whether the change could affect playout + */ + setRouteSetActive(routeSetId: string, isActive: boolean | 'toggle'): boolean + + /** + * Save any changes to the routesets for this studio to the database + */ + saveRouteSetChanges(): Promise + + /** + * Discard any unsaved changes to the routesets for this studio + */ + discardRouteSetChanges(): void } /** diff --git a/packages/job-worker/src/playout/abPlayback/__tests__/routeSetDisabling.spec.ts b/packages/job-worker/src/playout/abPlayback/__tests__/routeSetDisabling.spec.ts index ca88fa1bff..88228e04ec 100644 --- a/packages/job-worker/src/playout/abPlayback/__tests__/routeSetDisabling.spec.ts +++ b/packages/job-worker/src/playout/abPlayback/__tests__/routeSetDisabling.spec.ts @@ -26,6 +26,35 @@ describe('route set disabling ab players', () => { expect(result).toEqual(DEFAULT_PLAYERS) }) + test('mismatch of playerId types', () => { + const routesets: Record = { + route1: { + name: '', + active: false, + behavior: StudioRouteBehavior.TOGGLE, + routes: [], + abPlayers: [ + { + poolName: POOL_NAME, + playerId: '1', // because ui field is always a string + }, + ], + }, + } + + const players: ABPlayerDefinition[] = [ + { + playerId: 1, // number because blueprint defined it as such + }, + { playerId: 2 }, + ] + + const result = runDisablePlayersFiltering(routesets, players) + + const expectedPlayers = players.filter((p) => p.playerId !== 1) + expect(result).toEqual(expectedPlayers) + }) + describe('single routeset per player', () => { const ROUTESETS_SEPARATE: Record = { pl1: { diff --git a/packages/job-worker/src/playout/abPlayback/routeSetDisabling.ts b/packages/job-worker/src/playout/abPlayback/routeSetDisabling.ts index 64f9ccd0d8..d6be0a4ab6 100644 --- a/packages/job-worker/src/playout/abPlayback/routeSetDisabling.ts +++ b/packages/job-worker/src/playout/abPlayback/routeSetDisabling.ts @@ -1,11 +1,12 @@ -import type { ABPlayerDefinition, AbPlayerId } from '@sofie-automation/blueprints-integration' +import type { ABPlayerDefinition } from '@sofie-automation/blueprints-integration' import type { StudioRouteSet } from '@sofie-automation/corelib/dist/dataModel/Studio' import { logger } from '../../logging' /** * Map> + * Note: this explicitly uses a string for the playerId, to avoid issues with types for values from the ui */ -type MembersOfRouteSets = Map> +type MembersOfRouteSets = Map> export function findPlayersInRouteSets(routeSets: Record): MembersOfRouteSets { const routeSetEnabledPlayers: MembersOfRouteSets = new Map() @@ -18,8 +19,8 @@ export function findPlayersInRouteSets(routeSets: Record } // Make sure player is marked as enabled - const currentState = poolEntry.get(abPlayer.playerId) - poolEntry.set(abPlayer.playerId, currentState || routeSet.active) + const currentState = poolEntry.get(String(abPlayer.playerId)) + poolEntry.set(String(abPlayer.playerId), currentState || routeSet.active) } } return routeSetEnabledPlayers @@ -35,7 +36,7 @@ export function abPoolFilterDisabled( // Filter out any disabled players: return players.filter((player) => { - const playerState = poolRouteSetEnabledPlayers.get(player.playerId) + const playerState = poolRouteSetEnabledPlayers.get(String(player.playerId)) if (playerState === false) { logger.silly(`AB Pool ${poolName} playerId : ${player.playerId} are disabled`) return false diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts index 9c20a98636..f602d63fbe 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts @@ -481,7 +481,7 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou } switchRouteSet(routeSetId: string, isActive: boolean | 'toggle'): boolean { - return this.#baselineHelper.updateRouteSetActive(routeSetId, isActive) + return this.context.setRouteSetActive(routeSetId, isActive) } cycleSelectedPartInstances(): void { @@ -645,6 +645,7 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou ...writePartInstancesAndPieceInstances(this.context, this.allPartInstances), writeAdlibTestingSegments(this.context, this.rundownsImpl), this.#baselineHelper.saveAllToDatabase(), + this.context.saveRouteSetChanges(), ]) this.#playlistHasChanged = false diff --git a/packages/job-worker/src/studio/model/StudioBaselineHelper.ts b/packages/job-worker/src/studio/model/StudioBaselineHelper.ts index 35256befb4..5b41352248 100644 --- a/packages/job-worker/src/studio/model/StudioBaselineHelper.ts +++ b/packages/job-worker/src/studio/model/StudioBaselineHelper.ts @@ -6,33 +6,19 @@ import { } from '@sofie-automation/corelib/dist/dataModel/ExpectedPackages' import { ExpectedPlayoutItemStudio } from '@sofie-automation/corelib/dist/dataModel/ExpectedPlayoutItem' import { saveIntoDb } from '../../db/changes' -import { StudioRouteBehavior, StudioRouteSet } from '@sofie-automation/corelib/dist/dataModel/Studio' -import { logger } from '../../logging' -import { - WrappedOverridableItemNormal, - getAllCurrentItemsFromOverrides, - OverrideOpHelperImpl, -} from '@sofie-automation/corelib/dist/overrideOpHelper' -import { ObjectWithOverrides, SomeObjectOverrideOp } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' export class StudioBaselineHelper { readonly #context: JobContext - #overridesRouteSetBuffer: ObjectWithOverrides> #pendingExpectedPackages: ExpectedPackageDBFromStudioBaselineObjects[] | undefined #pendingExpectedPlayoutItems: ExpectedPlayoutItemStudio[] | undefined - #routeSetChanged: boolean constructor(context: JobContext) { this.#context = context - this.#overridesRouteSetBuffer = { ...context.studio.routeSetsWithOverrides } as ObjectWithOverrides< - Record - > - this.#routeSetChanged = false } hasChanges(): boolean { - return !!this.#pendingExpectedPackages || !!this.#pendingExpectedPlayoutItems || this.#routeSetChanged + return !!this.#pendingExpectedPackages || !!this.#pendingExpectedPlayoutItems } setExpectedPackages(packages: ExpectedPackageDBFromStudioBaselineObjects[]): void { @@ -63,74 +49,9 @@ export class StudioBaselineHelper { this.#pendingExpectedPackages ) : undefined, - this.#routeSetChanged - ? this.#context.directCollections.Studios.update( - { - _id: this.#context.studioId, - }, - { - $set: { 'routeSetsWithOverrides.overrides': this.#overridesRouteSetBuffer.overrides }, - } - ) - : undefined, ]) this.#pendingExpectedPlayoutItems = undefined this.#pendingExpectedPackages = undefined - this.#routeSetChanged = false - this.#overridesRouteSetBuffer = { ...this.#context.studio.routeSetsWithOverrides } as ObjectWithOverrides< - Record - > - } - - updateRouteSetActive(routeSetId: string, isActive: boolean | 'toggle'): boolean { - const studio = this.#context.studio - - const routeSets: WrappedOverridableItemNormal[] = getAllCurrentItemsFromOverrides( - this.#overridesRouteSetBuffer, - null - ) - - const routeSet = routeSets.find((routeSet) => routeSet.id === routeSetId) - - if (routeSet === undefined) throw new Error(`RouteSet "${routeSetId}" not found!`) - - if (isActive === 'toggle') isActive = !routeSet.computed.active - - if (routeSet.computed?.behavior === StudioRouteBehavior.ACTIVATE_ONLY && isActive === false) - throw new Error(`RouteSet "${routeSet.id}" is ACTIVATE_ONLY`) - - const saveOverrides = (newOps: SomeObjectOverrideOp[]) => { - this.#overridesRouteSetBuffer.overrides = newOps - this.#routeSetChanged = true - } - const overrideHelper = new OverrideOpHelperImpl(saveOverrides, this.#overridesRouteSetBuffer) - - // Track whether changing this routeset could affect how the timeline is generated, so that it can be following this update - let mayAffectTimeline = couldRoutesetAffectTimelineGeneration(routeSet) - - logger.debug(`switchRouteSet "${studio._id}" "${routeSet.id}"=${isActive}`) - overrideHelper.setItemValue(routeSet.id, `active`, isActive) - - // Deactivate other routeSets in the same exclusivity group: - if (routeSet.computed.exclusivityGroup && isActive === true) { - for (const [, otherRouteSet] of Object.entries>(routeSets)) { - if (otherRouteSet.id === routeSet.id) continue - if (otherRouteSet.computed?.exclusivityGroup === routeSet.computed.exclusivityGroup) { - logger.debug(`switchRouteSet Other ID "${studio._id}" "${otherRouteSet.id}"=false`) - overrideHelper.setItemValue(otherRouteSet.id, `active`, false) - - mayAffectTimeline = mayAffectTimeline || couldRoutesetAffectTimelineGeneration(otherRouteSet) - } - } - } - - overrideHelper.commit() - - return mayAffectTimeline } } - -function couldRoutesetAffectTimelineGeneration(routeSet: WrappedOverridableItemNormal): boolean { - return routeSet.computed.abPlayers.length > 0 -} diff --git a/packages/job-worker/src/studio/model/StudioPlayoutModelImpl.ts b/packages/job-worker/src/studio/model/StudioPlayoutModelImpl.ts index 53486ee37c..8abd587def 100644 --- a/packages/job-worker/src/studio/model/StudioPlayoutModelImpl.ts +++ b/packages/job-worker/src/studio/model/StudioPlayoutModelImpl.ts @@ -102,7 +102,7 @@ export class StudioPlayoutModelImpl implements StudioPlayoutModel { } switchRouteSet(routeSetId: string, isActive: boolean | 'toggle'): boolean { - return this.#baselineHelper.updateRouteSetActive(routeSetId, isActive) + return this.context.setRouteSetActive(routeSetId, isActive) } /** @@ -125,7 +125,11 @@ export class StudioPlayoutModelImpl implements StudioPlayoutModel { } this.#timelineHasChanged = false - await this.#baselineHelper.saveAllToDatabase() + await Promise.all([ + this.#baselineHelper.saveAllToDatabase(), + this.context.saveRouteSetChanges(), + // + ]) if (span) span.end() } diff --git a/packages/job-worker/src/workers/caches.ts b/packages/job-worker/src/workers/caches.ts index f816a8f407..f252a9a0de 100644 --- a/packages/job-worker/src/workers/caches.ts +++ b/packages/job-worker/src/workers/caches.ts @@ -16,7 +16,7 @@ import { clone, deepFreeze } from '@sofie-automation/corelib/dist/lib' import { logger } from '../logging' import deepmerge = require('deepmerge') import { ProcessedShowStyleBase, ProcessedShowStyleVariant, StudioCacheContext } from '../jobs' -import { StudioCacheContextImpl } from './context' +import { StudioCacheContextImpl } from './context/StudioCacheContextImpl' /** * A Wrapper to maintain a cache and provide a context using the cache when appropriate diff --git a/packages/job-worker/src/workers/context/JobContextImpl.ts b/packages/job-worker/src/workers/context/JobContextImpl.ts new file mode 100644 index 0000000000..7be35b55f2 --- /dev/null +++ b/packages/job-worker/src/workers/context/JobContextImpl.ts @@ -0,0 +1,164 @@ +import { IDirectCollections } from '../../db' +import { JobContext } from '../../jobs' +import { WorkerDataCache } from '../caches' +import { RundownId, RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { getIngestQueueName, IngestJobFunc } from '@sofie-automation/corelib/dist/worker/ingest' +import { ApmSpan, ApmTransaction } from '../../profiler' +import { getRandomString } from '@sofie-automation/corelib/dist/lib' +import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' +import { getStudioQueueName, StudioJobFunc } from '@sofie-automation/corelib/dist/worker/studio' +import { LockBase, PlaylistLock, RundownLock } from '../../jobs/lock' +import { logger } from '../../logging' +import { BaseModel } from '../../modelBase' +import { LocksManager } from '../locks' +import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' +import { EventsJobFunc, getEventsQueueName } from '@sofie-automation/corelib/dist/worker/events' +import { FastTrackTimelineFunc } from '../../main' +import { TimelineComplete } from '@sofie-automation/corelib/dist/dataModel/Timeline' +import type { QueueJobFunc } from './util' +import { StudioCacheContextImpl } from './StudioCacheContextImpl' +import { PlaylistLockImpl, RundownLockImpl } from './Locks' +import { StudioRouteSetUpdater } from './StudioRouteSetUpdater' +import type { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' +import type { ReadonlyDeep } from 'type-fest' + +export class JobContextImpl extends StudioCacheContextImpl implements JobContext { + private readonly locks: Array = [] + private readonly caches: Array = [] + + private readonly studioRouteSetUpdater: StudioRouteSetUpdater + + constructor( + directCollections: Readonly, + cacheData: WorkerDataCache, + private readonly locksManager: LocksManager, + private readonly transaction: ApmTransaction | undefined, + private readonly queueJob: QueueJobFunc, + private readonly fastTrackTimeline: FastTrackTimelineFunc | null + ) { + super(directCollections, cacheData) + + this.studioRouteSetUpdater = new StudioRouteSetUpdater(directCollections, cacheData) + } + + get studio(): ReadonlyDeep { + return this.studioRouteSetUpdater.studioWithChanges ?? super.studio + } + + trackCache(cache: BaseModel): void { + this.caches.push(cache) + } + + async lockPlaylist(playlistId: RundownPlaylistId): Promise { + const span = this.startSpan('lockPlaylist') + if (span) span.setLabel('playlistId', unprotectString(playlistId)) + + const lockId = getRandomString() + logger.silly(`PlaylistLock: Locking "${playlistId}"`) + + const resourceId = `playlist:${playlistId}` + await this.locksManager.aquire(lockId, resourceId) + + const doRelease = async () => { + const span = this.startSpan('unlockPlaylist') + if (span) span.setLabel('playlistId', unprotectString(playlistId)) + + await this.locksManager.release(lockId, resourceId) + + if (span) span.end() + } + const lock = new PlaylistLockImpl(playlistId, doRelease) + this.locks.push(lock) + + logger.silly(`PlaylistLock: Locked "${playlistId}"`) + + if (span) span.end() + + return lock + } + + async lockRundown(rundownId: RundownId): Promise { + const span = this.startSpan('lockRundown') + if (span) span.setLabel('rundownId', unprotectString(rundownId)) + + const lockId = getRandomString() + logger.silly(`RundownLock: Locking "${rundownId}"`) + + const resourceId = `rundown:${rundownId}` + await this.locksManager.aquire(lockId, resourceId) + + const doRelease = async () => { + const span = this.startSpan('unlockRundown') + if (span) span.setLabel('rundownId', unprotectString(rundownId)) + + await this.locksManager.release(lockId, resourceId) + + if (span) span.end() + } + const lock = new RundownLockImpl(rundownId, doRelease) + this.locks.push(lock) + + logger.silly(`RundownLock: Locked "${rundownId}"`) + + if (span) span.end() + + return lock + } + + /** Ensure resources are cleaned up after the job completes */ + async cleanupResources(): Promise { + // Ensure all locks are freed + for (const lock of this.locks) { + if (lock.isLocked) { + logger.warn(`Lock never freed: ${lock}`) + await lock.release().catch((e) => { + logger.error(`Lock free failed: ${stringifyError(e)}`) + }) + } + } + + // Ensure all caches were saved/aborted + for (const cache of this.caches) { + try { + cache.assertNoChanges() + } catch (e) { + logger.warn(`${cache.displayName} has unsaved changes: ${stringifyError(e)}`) + } + } + } + + startSpan(spanName: string): ApmSpan | null { + if (this.transaction) return this.transaction.startSpan(spanName) + return null + } + + async queueIngestJob(name: T, data: Parameters[0]): Promise { + await this.queueJob(getIngestQueueName(this.studioId), name, data) + } + async queueStudioJob(name: T, data: Parameters[0]): Promise { + await this.queueJob(getStudioQueueName(this.studioId), name, data) + } + async queueEventJob(name: T, data: Parameters[0]): Promise { + await this.queueJob(getEventsQueueName(this.studioId), name, data) + } + + hackPublishTimelineToFastTrack(newTimeline: TimelineComplete): void { + if (this.fastTrackTimeline) { + this.fastTrackTimeline(newTimeline).catch((e) => { + logger.error(`Failed to publish timeline to fast track: ${stringifyError(e)}`) + }) + } + } + + setRouteSetActive(routeSetId: string, isActive: boolean | 'toggle'): boolean { + return this.studioRouteSetUpdater.setRouteSetActive(routeSetId, isActive) + } + + async saveRouteSetChanges(): Promise { + return this.studioRouteSetUpdater.saveRouteSetChanges() + } + + discardRouteSetChanges(): void { + return this.studioRouteSetUpdater.discardRouteSetChanges() + } +} diff --git a/packages/job-worker/src/workers/context/Locks.ts b/packages/job-worker/src/workers/context/Locks.ts new file mode 100644 index 0000000000..55cc72f36d --- /dev/null +++ b/packages/job-worker/src/workers/context/Locks.ts @@ -0,0 +1,67 @@ +import type { RundownId, RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PlaylistLock, RundownLock } from '../../jobs/lock' +import { logger } from '../../logging' + +export class PlaylistLockImpl extends PlaylistLock { + #isLocked = true + + public constructor(playlistId: RundownPlaylistId, private readonly doRelease: () => Promise) { + super(playlistId) + } + + get isLocked(): boolean { + return this.#isLocked + } + + async release(): Promise { + if (!this.#isLocked) { + logger.warn(`PlaylistLock: Already released "${this.playlistId}"`) + } else { + logger.silly(`PlaylistLock: Releasing "${this.playlistId}"`) + + this.#isLocked = false + + await this.doRelease() + + logger.silly(`PlaylistLock: Released "${this.playlistId}"`) + + if (this.deferedFunctions.length > 0) { + for (const fcn of this.deferedFunctions) { + await fcn() + } + } + } + } +} + +export class RundownLockImpl extends RundownLock { + #isLocked = true + + public constructor(rundownId: RundownId, private readonly doRelease: () => Promise) { + super(rundownId) + } + + get isLocked(): boolean { + return this.#isLocked + } + + async release(): Promise { + if (!this.#isLocked) { + logger.warn(`RundownLock: Already released "${this.rundownId}"`) + } else { + logger.silly(`RundownLock: Releasing "${this.rundownId}"`) + + this.#isLocked = false + + await this.doRelease() + + logger.silly(`RundownLock: Released "${this.rundownId}"`) + + if (this.deferedFunctions.length > 0) { + for (const fcn of this.deferedFunctions) { + await fcn() + } + } + } + } +} diff --git a/packages/job-worker/src/workers/context.ts b/packages/job-worker/src/workers/context/StudioCacheContextImpl.ts similarity index 57% rename from packages/job-worker/src/workers/context.ts rename to packages/job-worker/src/workers/context/StudioCacheContextImpl.ts index 383b7f41a9..dff38b6e88 100644 --- a/packages/job-worker/src/workers/context.ts +++ b/packages/job-worker/src/workers/context/StudioCacheContextImpl.ts @@ -1,48 +1,28 @@ -import { IDirectCollections } from '../db' +import { IDirectCollections } from '../../db' import { ProcessedShowStyleBase, ProcessedShowStyleVariant, - JobContext, ProcessedShowStyleCompound, StudioCacheContext, -} from '../jobs' +} from '../../jobs' import { ReadonlyDeep } from 'type-fest' -import { WorkerDataCache } from './caches' +import { WorkerDataCache } from '../caches' import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' -import { - RundownId, - RundownPlaylistId, - ShowStyleBaseId, - ShowStyleVariantId, - StudioId, -} from '@sofie-automation/corelib/dist/dataModel/Ids' -import { getIngestQueueName, IngestJobFunc } from '@sofie-automation/corelib/dist/worker/ingest' -import { parseBlueprintDocument, WrappedShowStyleBlueprint, WrappedStudioBlueprint } from '../blueprints/cache' +import { ShowStyleBaseId, ShowStyleVariantId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { parseBlueprintDocument, WrappedShowStyleBlueprint, WrappedStudioBlueprint } from '../../blueprints/cache' import { ReadonlyObjectDeep } from 'type-fest/source/readonly-deep' -import { ApmSpan, ApmTransaction } from '../profiler' import { DBShowStyleBase } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' -import { clone, deepFreeze, getRandomString } from '@sofie-automation/corelib/dist/lib' -import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' -import { createShowStyleCompound } from '../showStyles' +import { clone, deepFreeze } from '@sofie-automation/corelib/dist/lib' +import { createShowStyleCompound } from '../../showStyles' import { BlueprintManifestType } from '@sofie-automation/blueprints-integration' import { preprocessShowStyleConfig, preprocessStudioConfig, ProcessedShowStyleConfig, ProcessedStudioConfig, -} from '../blueprints/config' -import { getStudioQueueName, StudioJobFunc } from '@sofie-automation/corelib/dist/worker/studio' -import { LockBase, PlaylistLock, RundownLock } from '../jobs/lock' -import { logger } from '../logging' -import { BaseModel } from '../modelBase' -import { LocksManager } from './locks' -import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' -import { EventsJobFunc, getEventsQueueName } from '@sofie-automation/corelib/dist/worker/events' -import { FastTrackTimelineFunc } from '../main' -import { TimelineComplete } from '@sofie-automation/corelib/dist/dataModel/Timeline' -import { processShowStyleBase, processShowStyleVariant } from '../jobs/showStyle' - -export type QueueJobFunc = (queueName: string, jobName: string, jobData: unknown) => Promise +} from '../../blueprints/config' + +import { processShowStyleBase, processShowStyleVariant } from '../../jobs/showStyle' export class StudioCacheContextImpl implements StudioCacheContext { constructor( @@ -276,127 +256,6 @@ export class StudioCacheContextImpl implements StudioCacheContext { } } -export class JobContextImpl extends StudioCacheContextImpl implements JobContext { - private readonly locks: Array = [] - private readonly caches: Array = [] - - constructor( - directCollections: Readonly, - cacheData: WorkerDataCache, - private readonly locksManager: LocksManager, - private readonly transaction: ApmTransaction | undefined, - private readonly queueJob: QueueJobFunc, - private readonly fastTrackTimeline: FastTrackTimelineFunc | null - ) { - super(directCollections, cacheData) - } - - trackCache(cache: BaseModel): void { - this.caches.push(cache) - } - - async lockPlaylist(playlistId: RundownPlaylistId): Promise { - const span = this.startSpan('lockPlaylist') - if (span) span.setLabel('playlistId', unprotectString(playlistId)) - - const lockId = getRandomString() - logger.silly(`PlaylistLock: Locking "${playlistId}"`) - - const resourceId = `playlist:${playlistId}` - await this.locksManager.aquire(lockId, resourceId) - - const doRelease = async () => { - const span = this.startSpan('unlockPlaylist') - if (span) span.setLabel('playlistId', unprotectString(playlistId)) - - await this.locksManager.release(lockId, resourceId) - - if (span) span.end() - } - const lock = new PlaylistLockImpl(playlistId, doRelease) - this.locks.push(lock) - - logger.silly(`PlaylistLock: Locked "${playlistId}"`) - - if (span) span.end() - - return lock - } - - async lockRundown(rundownId: RundownId): Promise { - const span = this.startSpan('lockRundown') - if (span) span.setLabel('rundownId', unprotectString(rundownId)) - - const lockId = getRandomString() - logger.silly(`RundownLock: Locking "${rundownId}"`) - - const resourceId = `rundown:${rundownId}` - await this.locksManager.aquire(lockId, resourceId) - - const doRelease = async () => { - const span = this.startSpan('unlockRundown') - if (span) span.setLabel('rundownId', unprotectString(rundownId)) - - await this.locksManager.release(lockId, resourceId) - - if (span) span.end() - } - const lock = new RundownLockImpl(rundownId, doRelease) - this.locks.push(lock) - - logger.silly(`RundownLock: Locked "${rundownId}"`) - - if (span) span.end() - - return lock - } - - /** Ensure resources are cleaned up after the job completes */ - async cleanupResources(): Promise { - // Ensure all locks are freed - for (const lock of this.locks) { - if (lock.isLocked) { - logger.warn(`Lock never freed: ${lock}`) - await lock.release().catch((e) => { - logger.error(`Lock free failed: ${stringifyError(e)}`) - }) - } - } - - // Ensure all caches were saved/aborted - for (const cache of this.caches) { - try { - cache.assertNoChanges() - } catch (e) { - logger.warn(`${cache.displayName} has unsaved changes: ${stringifyError(e)}`) - } - } - } - - startSpan(spanName: string): ApmSpan | null { - if (this.transaction) return this.transaction.startSpan(spanName) - return null - } - - async queueIngestJob(name: T, data: Parameters[0]): Promise { - await this.queueJob(getIngestQueueName(this.studioId), name, data) - } - async queueStudioJob(name: T, data: Parameters[0]): Promise { - await this.queueJob(getStudioQueueName(this.studioId), name, data) - } - async queueEventJob(name: T, data: Parameters[0]): Promise { - await this.queueJob(getEventsQueueName(this.studioId), name, data) - } - - hackPublishTimelineToFastTrack(newTimeline: TimelineComplete): void { - if (this.fastTrackTimeline) { - this.fastTrackTimeline(newTimeline).catch((e) => { - logger.error(`Failed to publish timeline to fast track: ${stringifyError(e)}`) - }) - } - } -} - async function loadShowStyleBlueprint( collections: IDirectCollections, showStyleBase: Pick, '_id' | 'blueprintId'> @@ -424,67 +283,3 @@ async function loadShowStyleBlueprint( blueprint: blueprintManifest, }) } - -class PlaylistLockImpl extends PlaylistLock { - #isLocked = true - - public constructor(playlistId: RundownPlaylistId, private readonly doRelease: () => Promise) { - super(playlistId) - } - - get isLocked(): boolean { - return this.#isLocked - } - - async release(): Promise { - if (!this.#isLocked) { - logger.warn(`PlaylistLock: Already released "${this.playlistId}"`) - } else { - logger.silly(`PlaylistLock: Releasing "${this.playlistId}"`) - - this.#isLocked = false - - await this.doRelease() - - logger.silly(`PlaylistLock: Released "${this.playlistId}"`) - - if (this.deferedFunctions.length > 0) { - for (const fcn of this.deferedFunctions) { - await fcn() - } - } - } - } -} - -class RundownLockImpl extends RundownLock { - #isLocked = true - - public constructor(rundownId: RundownId, private readonly doRelease: () => Promise) { - super(rundownId) - } - - get isLocked(): boolean { - return this.#isLocked - } - - async release(): Promise { - if (!this.#isLocked) { - logger.warn(`RundownLock: Already released "${this.rundownId}"`) - } else { - logger.silly(`RundownLock: Releasing "${this.rundownId}"`) - - this.#isLocked = false - - await this.doRelease() - - logger.silly(`RundownLock: Released "${this.rundownId}"`) - - if (this.deferedFunctions.length > 0) { - for (const fcn of this.deferedFunctions) { - await fcn() - } - } - } - } -} diff --git a/packages/job-worker/src/workers/context/StudioRouteSetUpdater.ts b/packages/job-worker/src/workers/context/StudioRouteSetUpdater.ts new file mode 100644 index 0000000000..cea5c9e53b --- /dev/null +++ b/packages/job-worker/src/workers/context/StudioRouteSetUpdater.ts @@ -0,0 +1,109 @@ +import { StudioRouteBehavior, StudioRouteSet } from '@sofie-automation/blueprints-integration' +import type { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' +import { deepFreeze } from '@sofie-automation/corelib/dist/lib' +import { + getAllCurrentItemsFromOverrides, + OverrideOpHelperImpl, + WrappedOverridableItemNormal, +} from '@sofie-automation/corelib/dist/overrideOpHelper' +import { logger } from '../../logging' +import type { ReadonlyDeep } from 'type-fest' +import type { WorkerDataCache } from '../caches' +import type { IDirectCollections } from '../../db' + +export class StudioRouteSetUpdater { + readonly #directCollections: Readonly + readonly #cacheData: Pick + + constructor(directCollections: Readonly, cacheData: Pick) { + this.#directCollections = directCollections + this.#cacheData = cacheData + } + + // Future: this could store a Map, if the context exposed a simplified view of DBStudio + #studioWithRouteSetChanges: ReadonlyDeep | undefined = undefined + + get studioWithChanges(): ReadonlyDeep | undefined { + return this.#studioWithRouteSetChanges + } + + setRouteSetActive(routeSetId: string, isActive: boolean | 'toggle'): boolean { + const currentStudio = this.#studioWithRouteSetChanges ?? this.#cacheData.studio + const currentRouteSets = getAllCurrentItemsFromOverrides(currentStudio.routeSetsWithOverrides, null) + + const routeSet = currentRouteSets.find((routeSet) => routeSet.id === routeSetId) + if (!routeSet) throw new Error(`RouteSet "${routeSetId}" not found!`) + + if (isActive === 'toggle') { + isActive = !routeSet.computed.active + } + + if (routeSet.computed.behavior === StudioRouteBehavior.ACTIVATE_ONLY && !isActive) + throw new Error(`RouteSet "${routeSet.id}" is ACTIVATE_ONLY`) + + const overrideHelper = new OverrideOpHelperImpl(null, currentStudio.routeSetsWithOverrides) + + // Update the pending changes + logger.debug(`switchRouteSet "${this.#cacheData.studio._id}" "${routeSet.id}"=${isActive}`) + overrideHelper.setItemValue(routeSetId, 'active', isActive) + + let mayAffectTimeline = couldRoutesetAffectTimelineGeneration(routeSet) + + // Deactivate other routeSets in the same exclusivity group: + if (routeSet.computed.exclusivityGroup && isActive) { + for (const otherRouteSet of Object.values>(currentRouteSets)) { + if (otherRouteSet.id === routeSet.id) continue + if (otherRouteSet.computed?.exclusivityGroup === routeSet.computed.exclusivityGroup) { + logger.debug(`switchRouteSet Other ID "${this.#cacheData.studio._id}" "${otherRouteSet.id}"=false`) + overrideHelper.setItemValue(otherRouteSet.id, 'active', false) + + mayAffectTimeline = mayAffectTimeline || couldRoutesetAffectTimelineGeneration(otherRouteSet) + } + } + } + + const updatedOverrideOps = overrideHelper.getPendingOps() + + // Update the cached studio + this.#studioWithRouteSetChanges = Object.freeze({ + ...currentStudio, + routeSetsWithOverrides: Object.freeze({ + ...currentStudio.routeSetsWithOverrides, + overrides: deepFreeze(updatedOverrideOps), + }), + }) + + return mayAffectTimeline + } + + async saveRouteSetChanges(): Promise { + if (!this.#studioWithRouteSetChanges) return + + // Save the changes to the database + // This is technically a little bit of a race condition, if someone uses the config pages but no more so than the rest of the system + await this.#directCollections.Studios.update( + { + _id: this.#cacheData.studio._id, + }, + { + $set: { + 'routeSetsWithOverrides.overrides': + this.#studioWithRouteSetChanges.routeSetsWithOverrides.overrides, + }, + } + ) + + // Pretend that the studio as reported by the database has changed, this will be fixed after this job by the ChangeStream firing + this.#cacheData.studio = this.#studioWithRouteSetChanges + this.#studioWithRouteSetChanges = undefined + } + + discardRouteSetChanges(): void { + // Discard any pending changes + this.#studioWithRouteSetChanges = undefined + } +} + +function couldRoutesetAffectTimelineGeneration(routeSet: WrappedOverridableItemNormal): boolean { + return routeSet.computed.abPlayers.length > 0 +} diff --git a/packages/job-worker/src/workers/context/__tests__/StudioRouteSetUpdater.spec.ts b/packages/job-worker/src/workers/context/__tests__/StudioRouteSetUpdater.spec.ts new file mode 100644 index 0000000000..77692f4072 --- /dev/null +++ b/packages/job-worker/src/workers/context/__tests__/StudioRouteSetUpdater.spec.ts @@ -0,0 +1,403 @@ +import { StudioRouteBehavior, StudioRouteSet } from '@sofie-automation/blueprints-integration' +import { setupDefaultJobEnvironment } from '../../../__mocks__/context' +import { StudioRouteSetUpdater } from '../StudioRouteSetUpdater' +import type { WorkerDataCache } from '../../caches' +import { wrapDefaultObject } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' + +function setupTest(routeSets: Record) { + const context = setupDefaultJobEnvironment() + const mockCache: Pick = { + studio: { + ...context.studio, + routeSetsWithOverrides: wrapDefaultObject(routeSets), + }, + } + const mockCollection = context.mockCollections.Studios + const routeSetHelper = new StudioRouteSetUpdater(context.directCollections, mockCache) + + return { context, mockCache, mockCollection, routeSetHelper } +} + +const SINGLE_ROUTESET: Record = { + one: { + name: 'test', + active: false, + behavior: StudioRouteBehavior.TOGGLE, + routes: [], + abPlayers: [], + }, +} +const SINGLE_ROUTESET_WITH_AB: Record = { + one: { + name: 'test', + active: false, + behavior: StudioRouteBehavior.TOGGLE, + routes: [], + abPlayers: [{ playerId: 'test', poolName: 'test' }], + }, +} +const EXCLUSIVE_ROUTESETS: Record = { + one: { + name: 'test', + active: false, + behavior: StudioRouteBehavior.TOGGLE, + exclusivityGroup: 'main', + routes: [], + abPlayers: [{ playerId: 'test', poolName: 'test' }], + }, + two: { + name: 'test', + active: true, + behavior: StudioRouteBehavior.TOGGLE, + exclusivityGroup: 'main', + routes: [], + abPlayers: [], + }, + activate: { + name: 'test', + active: false, + behavior: StudioRouteBehavior.ACTIVATE_ONLY, + exclusivityGroup: 'main', + routes: [], + abPlayers: [], + }, +} + +describe('StudioRouteSetUpdater', () => { + it('no changes should not save', async () => { + const { mockCollection, routeSetHelper } = setupTest(SINGLE_ROUTESET) + + expect(mockCollection.operations).toHaveLength(0) + await routeSetHelper.saveRouteSetChanges() + expect(mockCollection.operations).toHaveLength(0) + }) + + it('no changes when setting missing routeset', async () => { + const { mockCollection, routeSetHelper } = setupTest(SINGLE_ROUTESET) + + expect(() => routeSetHelper.setRouteSetActive('missing', true)).toThrow(/not found/) + + expect(mockCollection.operations).toHaveLength(0) + await routeSetHelper.saveRouteSetChanges() + expect(mockCollection.operations).toHaveLength(0) + }) + + it('change when setting routeset - true', async () => { + const { mockCollection, routeSetHelper } = setupTest(SINGLE_ROUTESET) + + routeSetHelper.setRouteSetActive('one', true) + + expect(mockCollection.operations).toHaveLength(0) + await routeSetHelper.saveRouteSetChanges() + expect(mockCollection.operations).toEqual([ + { + type: 'update', + args: [ + { _id: 'mockStudio0' }, + { + $set: { + 'routeSetsWithOverrides.overrides': [ + { + op: 'set', + path: 'one.active', + value: true, + }, + ], + }, + }, + ], + }, + ]) + }) + it('change when setting routeset - false', async () => { + const { mockCollection, routeSetHelper } = setupTest(SINGLE_ROUTESET) + + routeSetHelper.setRouteSetActive('one', false) + + expect(mockCollection.operations).toHaveLength(0) + await routeSetHelper.saveRouteSetChanges() + expect(mockCollection.operations).toEqual([ + { + type: 'update', + args: [ + { _id: 'mockStudio0' }, + { + $set: { + 'routeSetsWithOverrides.overrides': [ + { + op: 'set', + path: 'one.active', + value: false, + }, + ], + }, + }, + ], + }, + ]) + }) + it('change when setting routeset - toggle', async () => { + const { mockCollection, routeSetHelper } = setupTest(SINGLE_ROUTESET) + + routeSetHelper.setRouteSetActive('one', 'toggle') + + expect(mockCollection.operations).toHaveLength(0) + await routeSetHelper.saveRouteSetChanges() + expect(mockCollection.operations).toEqual([ + { + type: 'update', + args: [ + { _id: 'mockStudio0' }, + { + $set: { + 'routeSetsWithOverrides.overrides': [ + { + op: 'set', + path: 'one.active', + value: true, + }, + ], + }, + }, + ], + }, + ]) + }) + it('change when setting routeset - toggle twice', async () => { + const { mockCollection, routeSetHelper } = setupTest(SINGLE_ROUTESET) + + routeSetHelper.setRouteSetActive('one', 'toggle') + routeSetHelper.setRouteSetActive('one', 'toggle') + + expect(mockCollection.operations).toHaveLength(0) + await routeSetHelper.saveRouteSetChanges() + expect(mockCollection.operations).toEqual([ + { + type: 'update', + args: [ + { _id: 'mockStudio0' }, + { + $set: { + 'routeSetsWithOverrides.overrides': [ + { + op: 'set', + path: 'one.active', + value: false, + }, + ], + }, + }, + ], + }, + ]) + }) + + it('discard changes should not save', async () => { + const { mockCollection, routeSetHelper } = setupTest(SINGLE_ROUTESET) + + routeSetHelper.setRouteSetActive('one', true) + + expect(routeSetHelper.studioWithChanges).toBeTruthy() + + routeSetHelper.discardRouteSetChanges() + + expect(routeSetHelper.studioWithChanges).toBeFalsy() + + expect(mockCollection.operations).toHaveLength(0) + await routeSetHelper.saveRouteSetChanges() + expect(mockCollection.operations).toHaveLength(0) + }) + + it('save should update mockCache', async () => { + const { mockCache, mockCollection, routeSetHelper } = setupTest(SINGLE_ROUTESET) + + const studioBefore = mockCache.studio + expect(routeSetHelper.studioWithChanges).toBeFalsy() + + routeSetHelper.setRouteSetActive('one', true) + expect(routeSetHelper.studioWithChanges).toBeTruthy() + + expect(mockCollection.operations).toHaveLength(0) + await routeSetHelper.saveRouteSetChanges() + expect(mockCollection.operations).toHaveLength(1) + + // Object should have changed + expect(mockCache.studio).not.toBe(studioBefore) + // Object should not be equal + expect(mockCache.studio).not.toEqual(studioBefore) + expect(routeSetHelper.studioWithChanges).toBeFalsy() + }) + + it('no changes should not update mockCache', async () => { + const { mockCache, mockCollection, routeSetHelper } = setupTest(SINGLE_ROUTESET) + + const studioBefore = mockCache.studio + expect(routeSetHelper.studioWithChanges).toBeFalsy() + + expect(mockCollection.operations).toHaveLength(0) + await routeSetHelper.saveRouteSetChanges() + expect(mockCollection.operations).toHaveLength(0) + + expect(mockCache.studio).toBe(studioBefore) + expect(routeSetHelper.studioWithChanges).toBeFalsy() + }) + + it('discard changes should not update mockCache', async () => { + const { mockCache, mockCollection, routeSetHelper } = setupTest(SINGLE_ROUTESET) + + const studioBefore = mockCache.studio + expect(routeSetHelper.studioWithChanges).toBeFalsy() + + routeSetHelper.setRouteSetActive('one', true) + expect(routeSetHelper.studioWithChanges).toBeTruthy() + routeSetHelper.discardRouteSetChanges() + expect(routeSetHelper.studioWithChanges).toBeFalsy() + + expect(mockCollection.operations).toHaveLength(0) + await routeSetHelper.saveRouteSetChanges() + expect(mockCollection.operations).toHaveLength(0) + + expect(mockCache.studio).toBe(studioBefore) + expect(routeSetHelper.studioWithChanges).toBeFalsy() + }) + + it('ACTIVATE_ONLY routeset can be activated', async () => { + const { mockCollection, routeSetHelper } = setupTest(EXCLUSIVE_ROUTESETS) + + routeSetHelper.setRouteSetActive('activate', true) + + expect(mockCollection.operations).toHaveLength(0) + await routeSetHelper.saveRouteSetChanges() + expect(mockCollection.operations).toHaveLength(1) + }) + + it('ACTIVATE_ONLY routeset canot be deactivated', async () => { + const { mockCollection, routeSetHelper } = setupTest(EXCLUSIVE_ROUTESETS) + + expect(() => routeSetHelper.setRouteSetActive('activate', false)).toThrow(/ACTIVATE_ONLY/) + + expect(mockCollection.operations).toHaveLength(0) + await routeSetHelper.saveRouteSetChanges() + expect(mockCollection.operations).toHaveLength(0) + }) + + describe('exclusive groups', () => { + it('deactivate member of exclusive group', async () => { + const { mockCollection, routeSetHelper } = setupTest(EXCLUSIVE_ROUTESETS) + + routeSetHelper.setRouteSetActive('one', false) + + expect(mockCollection.operations).toHaveLength(0) + await routeSetHelper.saveRouteSetChanges() + expect(mockCollection.operations).toEqual([ + { + type: 'update', + args: [ + { _id: 'mockStudio0' }, + { + $set: { + 'routeSetsWithOverrides.overrides': [ + { + op: 'set', + path: 'one.active', + value: false, + }, + ], + }, + }, + ], + }, + ]) + }) + + it('activate member of exclusive group', async () => { + const { mockCollection, routeSetHelper } = setupTest(EXCLUSIVE_ROUTESETS) + + routeSetHelper.setRouteSetActive('one', true) + + expect(mockCollection.operations).toHaveLength(0) + await routeSetHelper.saveRouteSetChanges() + expect(mockCollection.operations).toEqual([ + { + type: 'update', + args: [ + { _id: 'mockStudio0' }, + { + $set: { + 'routeSetsWithOverrides.overrides': [ + { + op: 'set', + path: 'one.active', + value: true, + }, + { + op: 'set', + path: 'two.active', + value: false, + }, + { + op: 'set', + path: 'activate.active', + value: false, + }, + ], + }, + }, + ], + }, + ]) + }) + }) + + describe('Return value', () => { + it('update player with ab', async () => { + const { mockCollection, routeSetHelper } = setupTest(SINGLE_ROUTESET_WITH_AB) + + expect(routeSetHelper.setRouteSetActive('one', false)).toBe(true) + + expect(mockCollection.operations).toHaveLength(0) + await routeSetHelper.saveRouteSetChanges() + expect(mockCollection.operations).toHaveLength(1) + }) + + it('update player without ab', async () => { + const { mockCollection, routeSetHelper } = setupTest(SINGLE_ROUTESET) + + expect(routeSetHelper.setRouteSetActive('one', false)).toBe(false) + + expect(mockCollection.operations).toHaveLength(0) + await routeSetHelper.saveRouteSetChanges() + expect(mockCollection.operations).toHaveLength(1) + }) + + it('update exclusive group - disabling player without ab', async () => { + const { mockCollection, routeSetHelper } = setupTest(EXCLUSIVE_ROUTESETS) + + expect(routeSetHelper.setRouteSetActive('two', false)).toBe(false) + + expect(mockCollection.operations).toHaveLength(0) + await routeSetHelper.saveRouteSetChanges() + expect(mockCollection.operations).toHaveLength(1) + }) + + it('update exclusive group - disabling player with ab', async () => { + const { mockCollection, routeSetHelper } = setupTest(EXCLUSIVE_ROUTESETS) + + expect(routeSetHelper.setRouteSetActive('one', false)).toBe(true) + + expect(mockCollection.operations).toHaveLength(0) + await routeSetHelper.saveRouteSetChanges() + expect(mockCollection.operations).toHaveLength(1) + }) + + it('update exclusive group - enabling player without ab', async () => { + const { mockCollection, routeSetHelper } = setupTest(EXCLUSIVE_ROUTESETS) + + expect(routeSetHelper.setRouteSetActive('two', true)).toBe(true) + + expect(mockCollection.operations).toHaveLength(0) + await routeSetHelper.saveRouteSetChanges() + expect(mockCollection.operations).toHaveLength(1) + }) + }) +}) diff --git a/packages/job-worker/src/workers/context/util.ts b/packages/job-worker/src/workers/context/util.ts new file mode 100644 index 0000000000..38ac084220 --- /dev/null +++ b/packages/job-worker/src/workers/context/util.ts @@ -0,0 +1 @@ +export type QueueJobFunc = (queueName: string, jobName: string, jobData: unknown) => Promise diff --git a/packages/job-worker/src/workers/events/child.ts b/packages/job-worker/src/workers/events/child.ts index 55745e5331..76d95c4c31 100644 --- a/packages/job-worker/src/workers/events/child.ts +++ b/packages/job-worker/src/workers/events/child.ts @@ -11,7 +11,8 @@ import { WorkerDataCache, WorkerDataCacheWrapperImpl, } from '../caches' -import { JobContextImpl, QueueJobFunc } from '../context' +import { JobContextImpl } from '../context/JobContextImpl' +import { QueueJobFunc } from '../context/util' import { AnyLockEvent, LocksManager } from '../locks' import { FastTrackTimelineFunc, LogLineWithSourceFunc } from '../../main' import { interceptLogging, logger } from '../../logging' diff --git a/packages/job-worker/src/workers/ingest/child.ts b/packages/job-worker/src/workers/ingest/child.ts index d00f8a4ae8..86af4b8634 100644 --- a/packages/job-worker/src/workers/ingest/child.ts +++ b/packages/job-worker/src/workers/ingest/child.ts @@ -5,7 +5,8 @@ import { createMongoConnection, getMongoCollections, IDirectCollections } from ' import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' import { setupApmAgent, startTransaction } from '../../profiler' import { InvalidateWorkerDataCache, invalidateWorkerDataCache, loadWorkerDataCache, WorkerDataCache } from '../caches' -import { JobContextImpl, QueueJobFunc } from '../context' +import { JobContextImpl } from '../context/JobContextImpl' +import { QueueJobFunc } from '../context/util' import { AnyLockEvent, LocksManager } from '../locks' import { FastTrackTimelineFunc, LogLineWithSourceFunc } from '../../main' import { interceptLogging, logger } from '../../logging' diff --git a/packages/job-worker/src/workers/studio/child.ts b/packages/job-worker/src/workers/studio/child.ts index d582e03e80..40903527c6 100644 --- a/packages/job-worker/src/workers/studio/child.ts +++ b/packages/job-worker/src/workers/studio/child.ts @@ -5,7 +5,8 @@ import { createMongoConnection, getMongoCollections, IDirectCollections } from ' import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' import { setupApmAgent, startTransaction } from '../../profiler' import { InvalidateWorkerDataCache, invalidateWorkerDataCache, loadWorkerDataCache, WorkerDataCache } from '../caches' -import { QueueJobFunc, JobContextImpl } from '../context' +import { JobContextImpl } from '../context/JobContextImpl' +import { QueueJobFunc } from '../context/util' import { AnyLockEvent, LocksManager } from '../locks' import { FastTrackTimelineFunc, LogLineWithSourceFunc } from '../../main' import { interceptLogging, logger } from '../../logging' diff --git a/packages/webui/src/client/ui/Settings/Studio/Routings/RouteSetAbPlayers.tsx b/packages/webui/src/client/ui/Settings/Studio/Routings/RouteSetAbPlayers.tsx index e2c1c5025d..f6dc204d50 100644 --- a/packages/webui/src/client/ui/Settings/Studio/Routings/RouteSetAbPlayers.tsx +++ b/packages/webui/src/client/ui/Settings/Studio/Routings/RouteSetAbPlayers.tsx @@ -115,9 +115,9 @@ function AbPlayerRow({
@@ -131,9 +131,9 @@ function AbPlayerRow({ )}