diff --git a/meteor/__mocks__/defaultCollectionObjects.ts b/meteor/__mocks__/defaultCollectionObjects.ts index 2831f96b141..8f70712e481 100644 --- a/meteor/__mocks__/defaultCollectionObjects.ts +++ b/meteor/__mocks__/defaultCollectionObjects.ts @@ -121,6 +121,7 @@ export function defaultStudio(_id: StudioId): DBStudio { previewContainerIds: [], thumbnailContainerIds: [], peripheralDeviceSettings: { + deviceSettings: wrapDefaultObject({}), playoutDevices: wrapDefaultObject({}), ingestDevices: wrapDefaultObject({}), inputDevices: wrapDefaultObject({}), diff --git a/meteor/__mocks__/helpers/database.ts b/meteor/__mocks__/helpers/database.ts index 6abd5a60bff..08c3653e21d 100644 --- a/meteor/__mocks__/helpers/database.ts +++ b/meteor/__mocks__/helpers/database.ts @@ -127,8 +127,7 @@ export async function setupMockPeripheralDevice( _id: protectString('mockDevice' + dbI++), name: 'mockDevice', organizationId: null, - studioId: studio ? studio._id : undefined, - settings: {}, + studioAndConfigId: studio ? { studioId: studio._id, configId: 'test' } : undefined, category: category, type: type, diff --git a/meteor/server/__tests__/cronjobs.test.ts b/meteor/server/__tests__/cronjobs.test.ts index 58cc06c469a..104ed3de454 100644 --- a/meteor/server/__tests__/cronjobs.test.ts +++ b/meteor/server/__tests__/cronjobs.test.ts @@ -488,7 +488,6 @@ describe('cronjobs', () => { statusCode: StatusCode.GOOD, }, token: '', - settings: {}, ...props, }) diff --git a/meteor/server/api/__tests__/peripheralDevice.test.ts b/meteor/server/api/__tests__/peripheralDevice.test.ts index 4a3b69fe5a3..31d652423ff 100644 --- a/meteor/server/api/__tests__/peripheralDevice.test.ts +++ b/meteor/server/api/__tests__/peripheralDevice.test.ts @@ -11,10 +11,7 @@ import { getCurrentTime } from '../../lib/lib' import { waitUntil } from '../../../__mocks__/helpers/jest' import { setupDefaultStudioEnvironment, DefaultEnvironment } from '../../../__mocks__/helpers/database' import { setLogLevel } from '../../logging' -import { - IngestDeviceSettings, - IngestDeviceSecretSettings, -} from '@sofie-automation/corelib/dist/dataModel/PeripheralDeviceSettings/ingestDevice' +import { IngestDeviceSecretSettings } from '@sofie-automation/corelib/dist/dataModel/PeripheralDeviceSettings/ingestDevice' import { MediaWorkFlow } from '@sofie-automation/shared-lib/dist/core/model/MediaWorkFlows' import { MediaWorkFlowStep } from '@sofie-automation/shared-lib/dist/core/model/MediaWorkFlowSteps' import { MediaManagerAPI } from '@sofie-automation/meteor-lib/dist/api/mediaManager' @@ -412,7 +409,7 @@ describe('test peripheralDevice general API methods', () => { expect(QueueStudioJobSpy).toHaveBeenNthCalledWith( 1, StudioJobs.OnPlayoutPlaybackChanged, - device.studioId, + device.studioAndConfigId!.studioId, literal[0]>({ playlistId: rundownPlaylistID, changes: [ @@ -474,7 +471,7 @@ describe('test peripheralDevice general API methods', () => { expect(QueueStudioJobSpy).toHaveBeenNthCalledWith( 1, StudioJobs.OnTimelineTriggerTime, - device.studioId, + device.studioAndConfigId!.studioId, literal({ results: timelineTriggerTimeResult, }) @@ -556,7 +553,7 @@ describe('test peripheralDevice general API methods', () => { expect((deviceWithSecretToken.secretSettings as IngestDeviceSecretSettings).accessToken).toBe( 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' ) - expect((deviceWithSecretToken.settings as IngestDeviceSettings).secretAccessToken).toBe(true) + expect(deviceWithSecretToken.secretSettingsStatus?.accessToken).toBe(true) }) test('uninitialize', async () => { @@ -643,8 +640,10 @@ describe('test peripheralDevice general API methods', () => { organizationId: null, name: 'Mock Media Manager', deviceName: 'Media Manager', - studioId: env.studio._id, - settings: {}, + studioAndConfigId: { + studioId: env.studio._id, + configId: 'test', + }, category: PeripheralDeviceCategory.MEDIA_MANAGER, configManifest: { deviceConfigSchema: JSONBlobStringify({}), @@ -670,7 +669,7 @@ describe('test peripheralDevice general API methods', () => { deviceId: device._id, priority: 1, source: 'MockSource', - studioId: device.studioId!, + studioId: device.studioAndConfigId!.studioId, finished: false, success: false, }) @@ -682,7 +681,7 @@ describe('test peripheralDevice general API methods', () => { deviceId: device._id, priority: 2, status: MediaManagerAPI.WorkStepStatus.IDLE, - studioId: device.studioId!, + studioId: device.studioAndConfigId!.studioId, workFlowId: workFlowId, }) await MediaWorkFlowSteps.insertAsync({ @@ -693,14 +692,14 @@ describe('test peripheralDevice general API methods', () => { deviceId: device._id, priority: 1, status: MediaManagerAPI.WorkStepStatus.IDLE, - studioId: device.studioId!, + studioId: device.studioAndConfigId!.studioId, workFlowId: workFlowId, }) }) test('getMediaWorkFlowRevisions', async () => { const workFlows = ( await MediaWorkFlows.findFetchAsync({ - studioId: device.studioId, + studioId: device.studioAndConfigId!.studioId, }) ).map((wf) => ({ _id: wf._id, @@ -714,7 +713,7 @@ describe('test peripheralDevice general API methods', () => { test('getMediaWorkFlowStepRevisions', async () => { const workFlowSteps = ( await MediaWorkFlowSteps.findFetchAsync({ - studioId: device.studioId, + studioId: device.studioAndConfigId!.studioId, }) ).map((wf) => ({ _id: wf._id, @@ -799,8 +798,10 @@ describe('test peripheralDevice general API methods', () => { organizationId: null, name: 'Mock Media Manager', deviceName: 'Media Manager', - studioId: env.studio._id, - settings: {}, + studioAndConfigId: { + studioId: env.studio._id, + configId: 'test', + }, category: PeripheralDeviceCategory.MEDIA_MANAGER, configManifest: { deviceConfigSchema: JSONBlobStringify({}), @@ -834,7 +835,7 @@ describe('test peripheralDevice general API methods', () => { mediaSize: 10, mediaTime: 0, objId: MOCK_OBJID, - studioId: device.studioId!, + studioId: device.studioAndConfigId!.studioId, thumbSize: 0, thumbTime: 0, tinf: '', @@ -843,7 +844,7 @@ describe('test peripheralDevice general API methods', () => { test('getMediaObjectRevisions', async () => { const mobjects = ( await MediaObjects.findFetchAsync({ - studioId: device.studioId, + studioId: device.studioAndConfigId!.studioId, }) ).map((mo) => ({ _id: mo._id, @@ -864,7 +865,7 @@ describe('test peripheralDevice general API methods', () => { test('update', async () => { const mo = (await MediaObjects.findOneAsync({ collectionId: MOCK_COLLECTION, - studioId: device.studioId!, + studioId: device.studioAndConfigId!.studioId, })) as MediaObject expect(mo).toBeTruthy() @@ -882,14 +883,14 @@ describe('test peripheralDevice general API methods', () => { const updateMo = await MediaObjects.findOneAsync({ collectionId: MOCK_COLLECTION, - studioId: device.studioId!, + studioId: device.studioAndConfigId!.studioId, }) expect(updateMo).toMatchObject(newMo) }) test('remove', async () => { const mo = (await MediaObjects.findOneAsync({ collectionId: MOCK_COLLECTION, - studioId: device.studioId!, + studioId: device.studioAndConfigId!.studioId, })) as MediaObject expect(mo).toBeTruthy() @@ -903,7 +904,7 @@ describe('test peripheralDevice general API methods', () => { const updateMo = await MediaObjects.findOneAsync({ collectionId: MOCK_COLLECTION, - studioId: device.studioId!, + studioId: device.studioAndConfigId!.studioId, }) expect(updateMo).toBeFalsy() }) diff --git a/meteor/server/api/__tests__/userActions/system.test.ts b/meteor/server/api/__tests__/userActions/system.test.ts index 29bf0161c9d..1c6ed9711f3 100644 --- a/meteor/server/api/__tests__/userActions/system.test.ts +++ b/meteor/server/api/__tests__/userActions/system.test.ts @@ -40,7 +40,6 @@ describe('User Actions - Disable Peripheral SubDevice', () => { env.studio, { organizationId, - settings: {}, configManifest: { deviceConfigSchema: JSONBlobStringify({}), // unused subdeviceManifest: { @@ -165,7 +164,6 @@ describe('User Actions - Disable Peripheral SubDevice', () => { env.studio, { organizationId: null, - settings: {}, configManifest: { deviceConfigSchema: JSONBlobStringify({}), // unused subdeviceManifest: { diff --git a/meteor/server/api/deviceTriggers/observer.ts b/meteor/server/api/deviceTriggers/observer.ts index c155bcb6004..0d45085c03c 100644 --- a/meteor/server/api/deviceTriggers/observer.ts +++ b/meteor/server/api/deviceTriggers/observer.ts @@ -102,7 +102,7 @@ export async function receiveInputDeviceTrigger( check(deviceId, String) check(triggerId, String) - const studioId = peripheralDevice.studioId + const studioId = peripheralDevice.studioAndConfigId?.studioId if (!studioId) throw new Meteor.Error(400, `Peripheral Device "${peripheralDevice._id}" not assigned to a studio`) logger.debug( diff --git a/meteor/server/api/integration/expectedPackages.ts b/meteor/server/api/integration/expectedPackages.ts index 8e823b968fb..0127307b4f3 100644 --- a/meteor/server/api/integration/expectedPackages.ts +++ b/meteor/server/api/integration/expectedPackages.ts @@ -32,6 +32,7 @@ import { PackageInfos, } from '../../collections' import { logger } from '../../logging' +import _ from 'underscore' export namespace PackageManagerIntegration { export async function updateExpectedPackageWorkStatuses( @@ -58,7 +59,7 @@ export namespace PackageManagerIntegration { type FromPackage = Omit & { id: ExpectedPackageId } const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, deviceToken, context) - if (!peripheralDevice.studioId) + if (!peripheralDevice.studioAndConfigId) throw new Meteor.Error(400, 'Device "' + peripheralDevice._id + '" has no studio') const bulkChanges: AnyBulkWriteOperation[] = [] @@ -150,11 +151,11 @@ export namespace PackageManagerIntegration { const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, deviceToken, context) await ExpectedPackageWorkStatuses.removeAsync({ - $or: [ + $or: _.compact([ { deviceId: peripheralDevice._id }, // Since we only have one PM in a studio, we can remove everything in the studio: - { studioId: peripheralDevice.studioId }, - ], + peripheralDevice.studioAndConfigId ? { studioId: peripheralDevice.studioAndConfigId.studioId } : null, + ]), }) } @@ -177,10 +178,10 @@ export namespace PackageManagerIntegration { )[] ): Promise { const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, deviceToken, context) - if (!peripheralDevice.studioId) + if (!peripheralDevice.studioAndConfigId) throw new Meteor.Error(400, 'Device "' + peripheralDevice._id + '" has no studio') - const studioId = peripheralDevice.studioId + const studioId = peripheralDevice.studioAndConfigId.studioId const removedIds: PackageContainerPackageId[] = [] const ps: Promise[] = [] @@ -189,7 +190,7 @@ export namespace PackageManagerIntegration { check(change.packageId, String) const id = getPackageContainerPackageId( - peripheralDevice.studioId, + peripheralDevice.studioAndConfigId.studioId, change.containerId, protectString(change.packageId) ) @@ -245,11 +246,11 @@ export namespace PackageManagerIntegration { const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, deviceToken, context) await PackageContainerPackageStatuses.removeAsync({ - $or: [ + $or: _.compact([ { deviceId: peripheralDevice._id }, // Since we only have one PM in a studio, we can remove everything in the studio: - { studioId: peripheralDevice.studioId }, - ], + peripheralDevice.studioAndConfigId ? { studioId: peripheralDevice.studioAndConfigId.studioId } : null, + ]), }) } @@ -270,17 +271,17 @@ export namespace PackageManagerIntegration { )[] ): Promise { const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, deviceToken, context) - if (!peripheralDevice.studioId) + if (!peripheralDevice.studioAndConfigId) throw new Meteor.Error(400, 'Device "' + peripheralDevice._id + '" has no studio') - const studioId = peripheralDevice.studioId + const studioId = peripheralDevice.studioAndConfigId.studioId const removedIds: PackageContainerId[] = [] const ps: Promise[] = [] for (const change of changes) { check(change.containerId, String) - const id = getPackageContainerId(peripheralDevice.studioId, change.containerId) + const id = getPackageContainerId(peripheralDevice.studioAndConfigId.studioId, change.containerId) if (change.type === 'delete') { removedIds.push(id) @@ -332,11 +333,11 @@ export namespace PackageManagerIntegration { const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, deviceToken, context) await PackageContainerStatuses.removeAsync({ - $or: [ + $or: _.compact([ { deviceId: peripheralDevice._id }, // Since we only have one PM in a studio, we can remove everything in the studio: - { studioId: peripheralDevice.studioId }, - ], + peripheralDevice.studioAndConfigId ? { studioId: peripheralDevice.studioAndConfigId.studioId } : null, + ]), }) } @@ -352,7 +353,7 @@ export namespace PackageManagerIntegration { const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, deviceToken, context) check(packageIds, [String]) check(type, String) - if (!peripheralDevice.studioId) + if (!peripheralDevice.studioAndConfigId) throw new Meteor.Error(400, 'Device "' + peripheralDevice._id + '" has no studio') const ids = packageIds.map((packageId) => getPackageInfoId(packageId, type)) @@ -386,7 +387,7 @@ export namespace PackageManagerIntegration { const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, deviceToken, context) check(packageId, String) check(type, String) - if (!peripheralDevice.studioId) + if (!peripheralDevice.studioAndConfigId) throw new Meteor.Error(400, 'Device "' + peripheralDevice._id + '" has no studio') const id = getPackageInfoId(packageId, type) @@ -398,7 +399,7 @@ export namespace PackageManagerIntegration { expectedContentVersionHash: expectedContentVersionHash, actualContentVersionHash: actualContentVersionHash, - studioId: peripheralDevice.studioId, + studioId: peripheralDevice.studioAndConfigId.studioId, deviceId: peripheralDevice._id, @@ -425,7 +426,7 @@ export namespace PackageManagerIntegration { const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, deviceToken, context) check(packageId, String) check(type, String) - if (!peripheralDevice.studioId) + if (!peripheralDevice.studioAndConfigId) throw new Meteor.Error(400, 'Device "' + peripheralDevice._id + '" has no studio') const id = getPackageInfoId(packageId, type) diff --git a/meteor/server/api/integration/mediaWorkFlows.ts b/meteor/server/api/integration/mediaWorkFlows.ts index 36fb3e2461d..c2167e63659 100644 --- a/meteor/server/api/integration/mediaWorkFlows.ts +++ b/meteor/server/api/integration/mediaWorkFlows.ts @@ -22,10 +22,10 @@ export namespace MediaManagerIntegration { logger.debug('getMediaWorkFlowStepRevisions') const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, deviceToken, context) - if (peripheralDevice.studioId) { + if (peripheralDevice.studioAndConfigId) { const rawSteps = (await MediaWorkFlowSteps.findFetchAsync( { - studioId: peripheralDevice.studioId, + studioId: peripheralDevice.studioAndConfigId.studioId, }, { fields: { @@ -54,10 +54,10 @@ export namespace MediaManagerIntegration { logger.debug('getMediaWorkFlowRevisions') const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, deviceToken, context) - if (peripheralDevice.studioId) { + if (peripheralDevice.studioAndConfigId) { const rawWorkflows = (await MediaWorkFlows.findFetchAsync( { - studioId: peripheralDevice.studioId, + studioId: peripheralDevice.studioAndConfigId.studioId, }, { fields: { @@ -91,7 +91,7 @@ export namespace MediaManagerIntegration { 400, `Device "${peripheralDevice._id}".type is "${peripheralDevice.type}", should be MEDIA_MANAGER ` ) - if (!peripheralDevice.studioId) + if (!peripheralDevice.studioAndConfigId) throw new Meteor.Error(400, 'Device "' + peripheralDevice._id + '" has no studio') check(workFlowId, String) @@ -100,7 +100,7 @@ export namespace MediaManagerIntegration { if (obj) { check(obj._id, String) obj.deviceId = peripheralDevice._id - obj.studioId = peripheralDevice.studioId + obj.studioId = peripheralDevice.studioAndConfigId.studioId await MediaWorkFlows.upsertAsync(workFlowId, obj) @@ -131,7 +131,7 @@ export namespace MediaManagerIntegration { 400, `Device "${peripheralDevice._id}".type is "${peripheralDevice.type}", should be MEDIA_MANAGER ` ) - if (!peripheralDevice.studioId) + if (!peripheralDevice.studioAndConfigId) throw new Meteor.Error(400, 'Device "' + peripheralDevice._id + '" has no studio') check(stepId, String) @@ -147,7 +147,7 @@ export namespace MediaManagerIntegration { obj.workFlowId = workflow._id obj.deviceId = peripheralDevice._id - obj.studioId = peripheralDevice.studioId + obj.studioId = peripheralDevice.studioAndConfigId.studioId await MediaWorkFlowSteps.upsertAsync(stepId, obj) } else { diff --git a/meteor/server/api/packageManager.ts b/meteor/server/api/packageManager.ts index c446852f257..dcad87fe041 100644 --- a/meteor/server/api/packageManager.ts +++ b/meteor/server/api/packageManager.ts @@ -16,7 +16,7 @@ export async function abortExpectation(deviceId: PeripheralDeviceId, workId: str export async function restartAllExpectationsInStudio(studioId: StudioId): Promise { const packageManagerDevices = await PeripheralDevices.findFetchAsync({ - studioId: studioId, + 'studioAndConfigId.studioId': studioId, category: PeripheralDeviceCategory.PACKAGE_MANAGER, type: PeripheralDeviceType.PACKAGE_MANAGER, subType: PERIPHERAL_SUBTYPE_PROCESS, diff --git a/meteor/server/api/peripheralDevice.ts b/meteor/server/api/peripheralDevice.ts index b6b29b218fd..e20cdae255d 100644 --- a/meteor/server/api/peripheralDevice.ts +++ b/meteor/server/api/peripheralDevice.ts @@ -68,6 +68,7 @@ import { executePeripheralDeviceFunction } from './peripheralDevice/executeFunct import KoaRouter from '@koa/router' import bodyParser from 'koa-bodyparser' import { assertConnectionHasOneOfPermissions } from '../security/auth' +import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' const apmNamespace = 'peripheralDevice' export namespace ServerPeripheralDeviceAPI { @@ -146,7 +147,6 @@ export namespace ServerPeripheralDeviceAPI { status: { statusCode: StatusCode.UNKNOWN, }, - settings: {}, connected: true, connectionId: options.connectionId, lastSeen: getCurrentTime(), @@ -161,7 +161,6 @@ export namespace ServerPeripheralDeviceAPI { deviceName: options.name, parentDeviceId: options.parentDeviceId, versions: options.versions, - // settings: {}, configManifest: options.configManifest ? { @@ -267,7 +266,7 @@ export namespace ServerPeripheralDeviceAPI { const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, token, context) - if (!peripheralDevice.studioId) + if (!peripheralDevice.studioAndConfigId) throw new Meteor.Error(401, `peripheralDevice "${deviceId}" not attached to a studio`) // check(r.time, Number) @@ -278,9 +277,13 @@ export namespace ServerPeripheralDeviceAPI { }) if (results.length > 0) { - const job = await QueueStudioJob(StudioJobs.OnTimelineTriggerTime, peripheralDevice.studioId, { - results, - }) + const job = await QueueStudioJob( + StudioJobs.OnTimelineTriggerTime, + peripheralDevice.studioAndConfigId.studioId, + { + results, + } + ) await job.complete } @@ -298,16 +301,20 @@ export namespace ServerPeripheralDeviceAPI { // Note that this function can / might be called several times from playout-gateway for the same part const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, token, context) - if (!peripheralDevice.studioId) + if (!peripheralDevice.studioAndConfigId) throw new Error(`PeripheralDevice "${peripheralDevice._id}" sent piecePlaybackStarted, but has no studioId`) if (changedResults.changes.length) { check(changedResults.rundownPlaylistId, String) - const job = await QueueStudioJob(StudioJobs.OnPlayoutPlaybackChanged, peripheralDevice.studioId, { - playlistId: changedResults.rundownPlaylistId, - changes: changedResults.changes, - }) + const job = await QueueStudioJob( + StudioJobs.OnPlayoutPlaybackChanged, + peripheralDevice.studioAndConfigId.studioId, + { + playlistId: changedResults.rundownPlaylistId, + changes: changedResults.changes, + } + ) await job.complete } @@ -370,10 +377,10 @@ export namespace ServerPeripheralDeviceAPI { throw new Meteor.Error(405, `PeripheralDevice "${deviceId}" cannot have subdevice disabled`) if (!peripheralDevice.configManifest) throw new Meteor.Error(405, `PeripheralDevice "${deviceId}" does not provide a configuration manifest`) - if (!peripheralDevice.studioId) + if (!peripheralDevice.studioAndConfigId) throw new Meteor.Error(405, `PeripheralDevice "${deviceId}" does not belong to a Studio`) - const studio = await Studios.findOneAsync(peripheralDevice.studioId) + const studio = await Studios.findOneAsync(peripheralDevice.studioAndConfigId.studioId) if (!studio) throw new Meteor.Error(405, `PeripheralDevice "${deviceId}" does not belong to a Studio`) const playoutDevices = applyAndValidateOverrides(studio.peripheralDeviceSettings.playoutDevices).obj @@ -421,13 +428,13 @@ export namespace ServerPeripheralDeviceAPI { (o) => o.path === propPath ) if (existingIndex !== -1) { - await Studios.updateAsync(peripheralDevice.studioId, { + await Studios.updateAsync(peripheralDevice.studioAndConfigId.studioId, { $set: { [`${overridesPath}.${existingIndex}`]: newOverrideOp, }, }) } else { - await Studios.updateAsync(peripheralDevice.studioId, { + await Studios.updateAsync(peripheralDevice.studioAndConfigId.studioId, { $push: { [overridesPath]: newOverrideOp, }, @@ -441,12 +448,29 @@ export namespace ServerPeripheralDeviceAPI { if ( // Debug states are only valid for Playout devices and must be enabled with the `debugState` option peripheralDevice.type !== PeripheralDeviceType.PLAYOUT || - !peripheralDevice.settings || - !(peripheralDevice.settings as any)['debugState'] + !peripheralDevice.studioAndConfigId // Must be attached to a studio ) { return {} } + // Fetch the relevant studio + const studioForDevice = (await Studios.findOneAsync(peripheralDevice.studioAndConfigId.studioId, { + fields: { + peripheralDeviceSettings: 1, + }, + })) as Pick | undefined + if (!studioForDevice) return {} + + const studioDeviceSettings = applyAndValidateOverrides( + studioForDevice.peripheralDeviceSettings.deviceSettings + ).obj + + const settingsForDevice = studioDeviceSettings[peripheralDevice.studioAndConfigId.configId] + if (!settingsForDevice) return {} + + // Make sure debugState is enabled + if (!(settingsForDevice.options as Record | undefined)?.['debugState']) return {} + try { return await executePeripheralDeviceFunction(peripheralDevice._id, 'getDebugStates') } catch (e) { @@ -510,7 +534,7 @@ export namespace ServerPeripheralDeviceAPI { $set: { accessTokenUrl: '', 'secretSettings.accessToken': accessToken, - 'settings.secretAccessToken': true, + 'secretSettingsStatus.accessToken': true, }, }) } @@ -610,7 +634,7 @@ peripheralDeviceRouter.post('/:deviceId/uploadCredentials', bodyParser(), async await PeripheralDevices.updateAsync(peripheralDevice._id, { $set: { 'secretSettings.credentials': body, - 'settings.secretCredentials': true, + 'secretSettingsStatus.credentials': true, }, }) @@ -633,11 +657,11 @@ peripheralDeviceRouter.get('/:deviceId/oauthResponse', async (ctx) => { const peripheralDevice = await PeripheralDevices.findOneAsync(deviceId) if (!peripheralDevice) throw new Meteor.Error(404, `Peripheral device "${deviceId}" not found`) - if (!peripheralDevice.studioId) + if (!peripheralDevice.studioAndConfigId) throw new Meteor.Error(400, `Peripheral device "${deviceId}" is not attached to a studio`) - if (!(await checkStudioExists(peripheralDevice.studioId))) - throw new Meteor.Error(404, `Studio "${peripheralDevice.studioId}" not found`) + if (!(await checkStudioExists(peripheralDevice.studioAndConfigId.studioId))) + throw new Meteor.Error(404, `Studio "${peripheralDevice.studioAndConfigId.studioId}" not found`) let accessToken = ctx.query['code'] || undefined const scopes = ctx.query['scope'] || undefined @@ -681,7 +705,7 @@ peripheralDeviceRouter.post('/:deviceId/resetAuth', async (ctx) => { $unset: { // User credentials 'secretSettings.accessToken': true, - 'settings.secretAccessToken': true, + 'secretSettingsStatus.accessToken': true, accessTokenUrl: true, }, }) @@ -711,10 +735,10 @@ peripheralDeviceRouter.post('/:deviceId/resetAppCredentials', async (ctx) => { $unset: { // App credentials 'secretSettings.credentials': true, - 'settings.secretCredentials': true, + 'secretSettingsStatus.credentials': true, // User credentials 'secretSettings.accessToken': true, - 'settings.secretAccessToken': true, + 'secretSettingsStatus.accessToken': true, accessTokenUrl: true, }, }) @@ -832,7 +856,9 @@ class ServerPeripheralDeviceAPIClass extends MethodContextAPI implements NewPeri async getPeripheralDevice(deviceId: PeripheralDeviceId, deviceToken: string) { const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, deviceToken, this) - const studio = peripheralDevice.studioId && (await Studios.findOneAsync(peripheralDevice.studioId)) + const studio = + peripheralDevice.studioAndConfigId?.studioId && + (await Studios.findOneAsync(peripheralDevice.studioAndConfigId.studioId)) return convertPeripheralDeviceForGateway(peripheralDevice, studio) } diff --git a/meteor/server/api/rest/v1/studios.ts b/meteor/server/api/rest/v1/studios.ts index aaabedc6933..33d31e6df5e 100644 --- a/meteor/server/api/rest/v1/studios.ts +++ b/meteor/server/api/rest/v1/studios.ts @@ -12,15 +12,19 @@ import { APIStudioFrom, studioFrom, validateAPIBlueprintConfigForStudio } from ' import { runUpgradeForStudio, validateConfigForStudio } from '../../../migration/upgrades' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { ServerClientAPI } from '../../client' -import { assertNever } from '../../../lib/tempLib' +import { assertNever, literal } from '../../../lib/tempLib' import { getCurrentTime } from '../../../lib/lib' import { StudioJobs } from '@sofie-automation/corelib/dist/worker/studio' -import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' +import { DBStudio, StudioDeviceSettings } from '@sofie-automation/corelib/dist/dataModel/Studio' import { PeripheralDevice } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' import { ServerPlayoutAPI } from '../../playout/playout' import { checkValidation } from '.' import { assertConnectionHasOneOfPermissions } from '../../../security/auth' import { UserPermissions } from '@sofie-automation/meteor-lib/dist/userPermissions' +import { + applyAndValidateOverrides, + ObjectOverrideSetOp, +} from '@sofie-automation/corelib/dist/settings/objectWithOverrides' const PERMISSIONS_FOR_PLAYOUT_USERACTION: Array = ['studio'] @@ -167,7 +171,11 @@ class StudiosServerAPI implements StudiosRestAPI { } } - await PeripheralDevices.updateAsync({ studioId }, { $unset: { studioId: 1 } }, { multi: true }) + await PeripheralDevices.updateAsync( + { 'studioAndConfigId.studioId': studioId }, + { $unset: { studioAndConfigId: 1 } }, + { multi: true } + ) const rundownPlaylists = (await RundownPlaylists.findFetchAsync( { studioId }, @@ -231,7 +239,7 @@ class StudiosServerAPI implements StudiosRestAPI { studioId: StudioId ): Promise>> { const peripheralDevices = (await PeripheralDevices.findFetchAsync( - { studioId }, + { 'studioAndConfigId.studioId': studioId }, { projection: { _id: 1 } } )) as Array> @@ -242,7 +250,8 @@ class StudiosServerAPI implements StudiosRestAPI { _connection: Meteor.Connection, _event: string, studioId: StudioId, - deviceId: PeripheralDeviceId + deviceId: PeripheralDeviceId, + configId: string | undefined ): Promise> { const studio = await Studios.findOneAsync(studioId) if (!studio) @@ -258,7 +267,7 @@ class StudiosServerAPI implements StudiosRestAPI { 404 ) - if (device.studioId !== undefined && device.studioId !== studio._id) { + if (device.studioAndConfigId !== undefined && device.studioAndConfigId.studioId !== studio._id) { return ClientAPI.responseError( UserError.from( new Error(`Device already attached to studio`), @@ -267,9 +276,33 @@ class StudiosServerAPI implements StudiosRestAPI { 412 ) } + + // If no configId is provided, use the id of the device + configId = configId || unprotectString(device._id) + + // Ensure that the requested config blob exists + const availableDeviceSettings = applyAndValidateOverrides(studio.peripheralDeviceSettings.deviceSettings).obj + if (!availableDeviceSettings[configId]) { + await Studios.updateAsync(studioId, { + $push: { + 'peripheralDeviceSettings.deviceSettings.overrides': literal({ + op: 'set', + path: configId, + value: literal({ + name: device.name, + options: {}, + }), + }), + }, + }) + } + await PeripheralDevices.updateAsync(deviceId, { $set: { - studioId, + studioAndConfigId: { + studioId, + configId, + }, }, }) @@ -288,11 +321,17 @@ class StudiosServerAPI implements StudiosRestAPI { UserError.from(new Error(`Studio does not exist`), UserErrorMessage.StudioNotFound), 404 ) - await PeripheralDevices.updateAsync(deviceId, { - $unset: { - studioId: 1, + await PeripheralDevices.updateAsync( + { + _id: deviceId, + 'studioAndConfigId.studioId': studioId, }, - }) + { + $unset: { + studioAndConfigId: 1, + }, + } + ) return ClientAPI.responseSuccess(undefined, 200) } @@ -452,7 +491,7 @@ export function registerRoutes(registerRoute: APIRegisterHook): } ) - registerRoute<{ studioId: string }, { deviceId: string }, void>( + registerRoute<{ studioId: string }, { deviceId: string; configId: string | undefined }, void>( 'put', '/studios/:studioId/devices', new Map([ @@ -463,9 +502,10 @@ export function registerRoutes(registerRoute: APIRegisterHook): async (serverAPI, connection, events, params, body) => { const studioId = protectString(params.studioId) const deviceId = protectString(body.deviceId) - logger.info(`API PUT: Attach device ${deviceId} to studio ${studioId}`) + const configId = body.configId + logger.info(`API PUT: Attach device ${deviceId} to studio ${studioId} (${configId})`) - return await serverAPI.attachDeviceToStudio(connection, events, studioId, deviceId) + return await serverAPI.attachDeviceToStudio(connection, events, studioId, deviceId, configId) } ) diff --git a/meteor/server/api/rest/v1/typeConversion.ts b/meteor/server/api/rest/v1/typeConversion.ts index 75a38a37b21..ff013539ba9 100644 --- a/meteor/server/api/rest/v1/typeConversion.ts +++ b/meteor/server/api/rest/v1/typeConversion.ts @@ -324,6 +324,7 @@ export async function studioFrom(apiStudio: APIStudio, existingId?: StudioId): P previewContainerIds: [], thumbnailContainerIds: [], peripheralDeviceSettings: { + deviceSettings: wrapDefaultObject({}), playoutDevices: wrapDefaultObject({}), ingestDevices: wrapDefaultObject({}), inputDevices: wrapDefaultObject({}), diff --git a/meteor/server/api/snapshot.ts b/meteor/server/api/snapshot.ts index f17871e12d3..b534e246d03 100644 --- a/meteor/server/api/snapshot.ts +++ b/meteor/server/api/snapshot.ts @@ -188,7 +188,7 @@ async function createSystemSnapshot( queryRundownLayouts = { showStyleBaseId: { $in: showStyleBaseIds } } queryTriggeredActions = { showStyleBaseIds: { $in: [null, ...showStyleBaseIds] } } - if (studioId) queryDevices = { studioId: studioId } + if (studioId) queryDevices = { 'studioAndConfigId.studioId': studioId } else if (organizationId) queryDevices = { organizationId: organizationId } const [showStyleVariants, rundownLayouts, devices, triggeredActions] = await Promise.all([ @@ -663,7 +663,7 @@ async function restoreFromSystemSnapshot(snapshot: SystemSnapshot): Promise { - if (peripheralDevice.studioId) { - return peripheralDevice.studioId + if (peripheralDevice.studioAndConfigId?.studioId) { + return peripheralDevice.studioAndConfigId.studioId } if (peripheralDevice.parentDeviceId) { // Also check the parent device: const parentDevice = (await PeripheralDevices.findOneAsync(peripheralDevice.parentDeviceId, { fields: { _id: 1, - studioId: 1, + studioAndConfigId: 1, }, - })) as Pick | undefined + })) as Pick | undefined if (parentDevice) { - return parentDevice.studioId + return parentDevice.studioAndConfigId?.studioId } } return undefined diff --git a/meteor/server/collections/collection.ts b/meteor/server/collections/collection.ts index 700ceda53d5..8ae8aae4a84 100644 --- a/meteor/server/collections/collection.ts +++ b/meteor/server/collections/collection.ts @@ -149,7 +149,7 @@ export interface AsyncOnlyMongoCollection>), modifier: MongoModifier, options?: UpdateOptions ): Promise diff --git a/meteor/server/collections/index.ts b/meteor/server/collections/index.ts index 1e326b43662..d5d040dd950 100644 --- a/meteor/server/collections/index.ts +++ b/meteor/server/collections/index.ts @@ -109,27 +109,22 @@ export const PeripheralDevices = createAsyncOnlyMongoCollection> /** * Detaches a device from a studio. diff --git a/meteor/server/migration/0_1_0.ts b/meteor/server/migration/0_1_0.ts index 461ce95d6f5..81248aff750 100644 --- a/meteor/server/migration/0_1_0.ts +++ b/meteor/server/migration/0_1_0.ts @@ -454,6 +454,7 @@ export const addSteps = addMigrationSteps('0.1.0', [ thumbnailContainerIds: [], previewContainerIds: [], peripheralDeviceSettings: { + deviceSettings: wrapDefaultObject({}), playoutDevices: wrapDefaultObject({}), ingestDevices: wrapDefaultObject({}), inputDevices: wrapDefaultObject({}), diff --git a/meteor/server/migration/1_50_0.ts b/meteor/server/migration/1_50_0.ts index 9d85f90c983..2d7fd6e4147 100644 --- a/meteor/server/migration/1_50_0.ts +++ b/meteor/server/migration/1_50_0.ts @@ -171,7 +171,7 @@ export const addSteps = addMigrationSteps('1.50.0', [ }) const badObject = objects.find( (device) => - !!Object.values((device.settings as any)?.['devices'] ?? {}).find( + !!Object.values((device as any).settings?.['devices'] ?? {}).find( (subdev: any) => !subdev?.type || !subdev?.options ) ) @@ -187,7 +187,7 @@ export const addSteps = addMigrationSteps('1.50.0', [ 'settings.device': { $exists: true }, }) for (const obj of objects) { - const newDevices: any = clone((obj.settings as any)?.['devices'] || {}) + const newDevices: any = clone((obj as any).settings?.['devices'] || {}) for (const [id, subdev] of Object.entries(newDevices)) { if (!subdev) continue @@ -436,6 +436,7 @@ export const addSteps = addMigrationSteps('1.50.0', [ await Studios.updateAsync(studio._id, { $set: { peripheralDeviceSettings: { + deviceSettings: wrapDefaultObject({}), playoutDevices: wrapDefaultObject({}), ingestDevices: wrapDefaultObject({}), inputDevices: wrapDefaultObject({}), @@ -496,11 +497,13 @@ export const addSteps = addMigrationSteps('1.50.0', [ 'settings.devices': { $exists: true }, }) for (const device of objects) { - if (!device.studioId) continue + // @ts-expect-error removed in 1.52 + const studioId: StudioId = device.studioId + if (!studioId) continue const newOverrides: SomeObjectOverrideOp[] = [] - for (const [id, subDevice] of Object.entries((device.settings as any)?.['devices'] || {})) { + for (const [id, subDevice] of Object.entries((device as any).settings?.['devices'] || {})) { newOverrides.push( literal({ op: 'set', @@ -513,7 +516,7 @@ export const addSteps = addMigrationSteps('1.50.0', [ ) } - await Studios.updateAsync(device.studioId, { + await Studios.updateAsync(studioId, { $set: { [`peripheralDeviceSettings.playoutDevices.overrides`]: newOverrides, }, @@ -550,11 +553,13 @@ export const addSteps = addMigrationSteps('1.50.0', [ 'settings.devices': { $exists: true }, }) for (const device of objects) { - if (!device.studioId) continue + // @ts-expect-error removed in 1.52 + const studioId: StudioId = device.studioId + if (!studioId) continue const newOverrides: SomeObjectOverrideOp[] = [] - for (const [id, subDevice] of Object.entries((device.settings as any)?.['devices'] || {})) { + for (const [id, subDevice] of Object.entries((device as any).settings?.['devices'] || {})) { newOverrides.push( literal({ op: 'set', @@ -567,7 +572,7 @@ export const addSteps = addMigrationSteps('1.50.0', [ ) } - await Studios.updateAsync(device.studioId, { + await Studios.updateAsync(studioId, { $set: { [`peripheralDeviceSettings.ingestDevices.overrides`]: newOverrides, }, @@ -604,11 +609,13 @@ export const addSteps = addMigrationSteps('1.50.0', [ 'settings.devices': { $exists: true }, }) for (const device of objects) { - if (!device.studioId) continue + // @ts-expect-error removed in 1.52 + const studioId: StudioId = device.studioId + if (!studioId) continue const newOverrides: SomeObjectOverrideOp[] = [] - for (const [id, subDevice] of Object.entries((device.settings as any)?.['devices'] || {})) { + for (const [id, subDevice] of Object.entries((device as any).settings?.['devices'] || {})) { newOverrides.push( literal({ op: 'set', @@ -621,7 +628,7 @@ export const addSteps = addMigrationSteps('1.50.0', [ ) } - await Studios.updateAsync(device.studioId, { + await Studios.updateAsync(studioId, { $set: { [`peripheralDeviceSettings.inputDevices.overrides`]: newOverrides, }, diff --git a/meteor/server/migration/X_X_X.ts b/meteor/server/migration/X_X_X.ts index 0b7409ea774..41033e84eea 100644 --- a/meteor/server/migration/X_X_X.ts +++ b/meteor/server/migration/X_X_X.ts @@ -1,12 +1,19 @@ import { addMigrationSteps } from './databaseMigration' import { CURRENT_SYSTEM_VERSION } from './currentSystemVersion' -import { Studios } from '../collections' -import { convertObjectIntoOverrides } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' +import { PeripheralDevices, Studios } from '../collections' +import { + convertObjectIntoOverrides, + ObjectOverrideSetOp, + wrapDefaultObject, +} from '@sofie-automation/corelib/dist/settings/objectWithOverrides' import { StudioRouteSet, StudioRouteSetExclusivityGroup, StudioPackageContainer, + StudioDeviceSettings, } from '@sofie-automation/corelib/dist/dataModel/Studio' +import { logger } from '../logging' +import { literal, unprotectString } from '../lib/tempLib' /* * ************************************************************************************** @@ -224,4 +231,142 @@ export const addSteps = addMigrationSteps(CURRENT_SYSTEM_VERSION, [ } }, }, + + { + id: `studios create peripheralDeviceSettings.deviceSettings`, + canBeRunAutomatically: true, + validate: async () => { + const studios = await Studios.findFetchAsync({ + 'peripheralDeviceSettings.deviceSettings.defaults': { $exists: false }, + }) + if (studios.length > 0) { + return 'studio is missing peripheralDeviceSettings.deviceSettings' + } + return false + }, + migrate: async () => { + const studios = await Studios.findFetchAsync({ + 'peripheralDeviceSettings.deviceSettings.defaults': { $exists: false }, + }) + for (const studio of studios) { + await Studios.updateAsync(studio._id, { + $set: { + 'peripheralDeviceSettings.deviceSettings': { + // Ensure the object is setup, preserving anything already configured + ...wrapDefaultObject({}), + ...studio.peripheralDeviceSettings.deviceSettings, + }, + }, + }) + } + }, + }, + { + id: `PeripheralDevice populate secretSettingsStatus`, + canBeRunAutomatically: true, + dependOnResultFrom: `studios create peripheralDeviceSettings.deviceSettings`, + validate: async () => { + const devices = await PeripheralDevices.findFetchAsync({ + secretSettings: { $exists: true }, + settings: { $exists: true }, + secretSettingsStatus: { $exists: false }, + }) + if (devices.length > 0) { + return 'settings must be moved to the studio' + } + return false + }, + migrate: async () => { + const devices = await PeripheralDevices.findFetchAsync({ + secretSettings: { $exists: true }, + settings: { $exists: true }, + secretSettingsStatus: { $exists: false }, + }) + for (const device of devices) { + // @ts-expect-error settings is typed as Record + const oldSettings = device.settings as Record | undefined + await PeripheralDevices.updateAsync(device._id, { + $set: { + secretSettingsStatus: { + credentials: oldSettings?.secretCredentials, + accessToken: oldSettings?.secretAccessToken, + }, + }, + $unset: { + 'settings.secretCredentials': 1, + 'settings.secretAccessToken': 1, + }, + }) + } + }, + }, + { + id: `move PeripheralDevice settings to studio`, + canBeRunAutomatically: true, + dependOnResultFrom: `PeripheralDevice populate secretSettingsStatus`, + validate: async () => { + const devices = await PeripheralDevices.findFetchAsync({ + studioId: { $exists: true }, + settings: { $exists: true }, + }) + if (devices.length > 0) { + return 'settings must be moved to the studio' + } + return false + }, + migrate: async () => { + const devices = await PeripheralDevices.findFetchAsync({ + studioId: { $exists: true }, + settings: { $exists: true }, + }) + for (const device of devices) { + // @ts-expect-error settings is typed as Record + const oldSettings = device.settings + // @ts-expect-error studioId is typed as StudioId + const oldStudioId: StudioId = device.studioId + // Will never happen, but make types match query + if (!oldSettings || !oldStudioId) { + logger.warn(`Skipping migration of device ${device._id} as it is missing settings or studioId`) + continue + } + // If the studio is not found, then something is a little broken so skip + const existingStudio = await Studios.findOneAsync(oldStudioId) + if (!existingStudio) { + logger.warn(`Skipping migration of device ${device._id} as the studio ${oldStudioId} is missing`) + continue + } + // Use the device id as the settings id + const newConfigId = unprotectString(device._id) + // Compile the new list of overrides + const newOverrides = [ + ...existingStudio.peripheralDeviceSettings.deviceSettings.overrides, + literal({ + op: 'set', + path: newConfigId, + value: literal({ + name: device.name, + options: oldSettings, + }), + }), + ] + await Studios.updateAsync(existingStudio._id, { + $set: { + 'peripheralDeviceSettings.deviceSettings.overrides': newOverrides, + }, + }) + await PeripheralDevices.updateAsync(device._id, { + $set: { + studioAndConfigId: { + studioId: oldStudioId, + configId: newConfigId, + }, + }, + $unset: { + settings: 1, + studioId: 1, + }, + }) + } + }, + }, ]) diff --git a/meteor/server/migration/__tests__/migrations.test.ts b/meteor/server/migration/__tests__/migrations.test.ts index 788fdaf33aa..a230bc80775 100644 --- a/meteor/server/migration/__tests__/migrations.test.ts +++ b/meteor/server/migration/__tests__/migrations.test.ts @@ -138,6 +138,7 @@ describe('Migrations', () => { previewContainerIds: [], thumbnailContainerIds: [], peripheralDeviceSettings: { + deviceSettings: wrapDefaultObject({}), playoutDevices: wrapDefaultObject({}), ingestDevices: wrapDefaultObject({}), inputDevices: wrapDefaultObject({}), @@ -179,6 +180,7 @@ describe('Migrations', () => { previewContainerIds: [], thumbnailContainerIds: [], peripheralDeviceSettings: { + deviceSettings: wrapDefaultObject({}), playoutDevices: wrapDefaultObject({}), ingestDevices: wrapDefaultObject({}), inputDevices: wrapDefaultObject({}), @@ -220,6 +222,7 @@ describe('Migrations', () => { previewContainerIds: [], thumbnailContainerIds: [], peripheralDeviceSettings: { + deviceSettings: wrapDefaultObject({}), playoutDevices: wrapDefaultObject({}), ingestDevices: wrapDefaultObject({}), inputDevices: wrapDefaultObject({}), diff --git a/meteor/server/publications/deviceTriggersPreview.ts b/meteor/server/publications/deviceTriggersPreview.ts index c8352ba51fa..d4cbbaeb7f8 100644 --- a/meteor/server/publications/deviceTriggersPreview.ts +++ b/meteor/server/publications/deviceTriggersPreview.ts @@ -39,7 +39,7 @@ export async function insertInputDeviceTriggerIntoPreview( if (!pDevice) throw new Meteor.Error(404, `Could not find peripheralDevice "${deviceId}"`) - const studioId = unprotectString(pDevice.studioId) + const studioId = unprotectString(pDevice.studioAndConfigId?.studioId) if (!studioId) throw new Meteor.Error(501, `Device "${pDevice._id}" is not assigned to any studio`) const lastTriggersStudio = prepareTriggerBufferForStudio(studioId) diff --git a/meteor/server/publications/mountedTriggers.ts b/meteor/server/publications/mountedTriggers.ts index 13c520221b8..63a68b9de1d 100644 --- a/meteor/server/publications/mountedTriggers.ts +++ b/meteor/server/publications/mountedTriggers.ts @@ -25,7 +25,7 @@ meteorCustomPublish( const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, token, this) - const studioId = peripheralDevice.studioId + const studioId = peripheralDevice.studioAndConfigId?.studioId if (!studioId) throw new Meteor.Error(400, `Peripheral Device "${deviceId}" not attached to a studio`) cursorCustomPublish( @@ -48,7 +48,7 @@ meteorCustomPublish( const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, token, this) - const studioId = peripheralDevice.studioId + const studioId = peripheralDevice.studioAndConfigId?.studioId if (!studioId) throw new Meteor.Error(400, `Peripheral Device "${deviceId}" not attached to a studio`) cursorCustomPublish( diff --git a/meteor/server/publications/packageManager/expectedPackages/publication.ts b/meteor/server/publications/packageManager/expectedPackages/publication.ts index 66ee316ae7f..c200a25d733 100644 --- a/meteor/server/publications/packageManager/expectedPackages/publication.ts +++ b/meteor/server/publications/packageManager/expectedPackages/publication.ts @@ -207,7 +207,7 @@ meteorCustomPublish( const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, token, this) - const studioId = peripheralDevice.studioId + const studioId = peripheralDevice.studioAndConfigId?.studioId if (!studioId) { logger.warn(`Pub.packageManagerExpectedPackages: device "${peripheralDevice._id}" has no studioId`) return this.ready() diff --git a/meteor/server/publications/packageManager/packageContainers.ts b/meteor/server/publications/packageManager/packageContainers.ts index 133569a882d..0c41c5cf295 100644 --- a/meteor/server/publications/packageManager/packageContainers.ts +++ b/meteor/server/publications/packageManager/packageContainers.ts @@ -97,7 +97,7 @@ meteorCustomPublish( const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, token, this) - const studioId = peripheralDevice.studioId + const studioId = peripheralDevice.studioAndConfigId?.studioId if (!studioId) { logger.warn(`Pub.packageManagerPackageContainers: device "${peripheralDevice._id}" has no studioId`) return this.ready() diff --git a/meteor/server/publications/packageManager/playoutContext.ts b/meteor/server/publications/packageManager/playoutContext.ts index 70b55955ca1..5959ae236e0 100644 --- a/meteor/server/publications/packageManager/playoutContext.ts +++ b/meteor/server/publications/packageManager/playoutContext.ts @@ -115,7 +115,7 @@ meteorCustomPublish( const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, token, this) - const studioId = peripheralDevice.studioId + const studioId = peripheralDevice.studioAndConfigId?.studioId if (!studioId) { logger.warn(`Pub.packageManagerPlayoutContext: device "${peripheralDevice._id}" has no studioId`) return this.ready() diff --git a/meteor/server/publications/peripheralDevice.ts b/meteor/server/publications/peripheralDevice.ts index a6add93fdc4..59bedc9eff4 100644 --- a/meteor/server/publications/peripheralDevice.ts +++ b/meteor/server/publications/peripheralDevice.ts @@ -49,7 +49,7 @@ meteorPublish(CorelibPubSub.peripheralDevicesAndSubDevices, async function (stud triggerWriteAccessBecauseNoCheckNecessary() const selector: MongoQuery = { - studioId, + 'studioAndConfigId.studioId': studioId, } // TODO - this is not correctly reactive when changing the `studioId` property of a parent device diff --git a/meteor/server/publications/peripheralDeviceForDevice.ts b/meteor/server/publications/peripheralDeviceForDevice.ts index cb45ec57ee0..3c0e3a7b1e0 100644 --- a/meteor/server/publications/peripheralDeviceForDevice.ts +++ b/meteor/server/publications/peripheralDeviceForDevice.ts @@ -43,14 +43,13 @@ const studioFieldsSpecifier = literal> >({ _id: 1, category: 1, - studioId: 1, - settings: 1, + studioAndConfigId: 1, secretSettings: 1, }) @@ -62,7 +61,17 @@ export function convertPeripheralDeviceForGateway( const ingestDevices: PeripheralDeviceForDevice['ingestDevices'] = {} const inputDevices: PeripheralDeviceForDevice['inputDevices'] = {} + let deviceSettings: PeripheralDeviceForDevice['deviceSettings'] = {} + if (studio) { + if (peripheralDevice.studioAndConfigId?.configId) { + const allDeviceSettingsInStudio = applyAndValidateOverrides( + studio.peripheralDeviceSettings.deviceSettings + ).obj + deviceSettings = + allDeviceSettingsInStudio[peripheralDevice.studioAndConfigId.configId]?.options ?? deviceSettings + } + switch (peripheralDevice.category) { case PeripheralDeviceCategory.INGEST: { const resolvedDevices = applyAndValidateOverrides(studio.peripheralDeviceSettings.ingestDevices).obj @@ -110,9 +119,9 @@ export function convertPeripheralDeviceForGateway( return literal>({ _id: peripheralDevice._id, - studioId: peripheralDevice.studioId, + studioId: peripheralDevice.studioAndConfigId?.studioId, - deviceSettings: peripheralDevice.settings, + deviceSettings: deviceSettings, secretSettings: peripheralDevice.secretSettings, playoutDevices, @@ -127,13 +136,13 @@ async function setupPeripheralDevicePublicationObservers( ): Promise { const studioObserver = await ReactiveMongoObserverGroup(async () => { const peripheralDeviceCompact = (await PeripheralDevices.findOneAsync(args.deviceId, { - fields: { studioId: 1 }, - })) as Pick | undefined + fields: { studioAndConfigId: 1 }, + })) as Pick | undefined - if (peripheralDeviceCompact?.studioId) { + if (peripheralDeviceCompact?.studioAndConfigId?.studioId) { return [ Studios.observeChanges( - peripheralDeviceCompact.studioId, + peripheralDeviceCompact.studioAndConfigId.studioId, { added: () => triggerUpdate({ invalidatePublication: true }), changed: () => triggerUpdate({ invalidatePublication: true }), @@ -160,7 +169,7 @@ async function setupPeripheralDevicePublicationObservers( triggerUpdate({ invalidatePublication: true }) }, changed: (_id, fields) => { - if ('studioId' in fields) studioObserver.restart() + if ('studioAndConfigId' in fields) studioObserver.restart() triggerUpdate({ invalidatePublication: true }) }, @@ -191,9 +200,10 @@ async function manipulatePeripheralDevicePublicationData( })) as Pick | undefined if (!peripheralDevice) return [] + const studioId = peripheralDevice.studioAndConfigId?.studioId const studio = - peripheralDevice.studioId && - ((await Studios.findOneAsync(peripheralDevice.studioId, { projection: studioFieldsSpecifier })) as + studioId && + ((await Studios.findOneAsync(studioId, { projection: studioFieldsSpecifier })) as | Pick | undefined) @@ -208,7 +218,7 @@ meteorCustomPublish( const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, token, this) - const studioId = peripheralDevice.studioId + const studioId = peripheralDevice.studioAndConfigId?.studioId if (!studioId) return await setUpOptimizedObserverArray< diff --git a/meteor/server/publications/rundown.ts b/meteor/server/publications/rundown.ts index e4be7f6dac7..6e21b949568 100644 --- a/meteor/server/publications/rundown.ts +++ b/meteor/server/publications/rundown.ts @@ -57,11 +57,12 @@ meteorPublish( const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, token, this) // No studio, then no rundowns - if (!peripheralDevice.studioId) return null + const studioId = peripheralDevice.studioAndConfigId?.studioId + if (!studioId) return null return Rundowns.findWithCursor( { - studioId: peripheralDevice.studioId, + studioId: studioId, }, { fields: { @@ -448,7 +449,7 @@ meteorPublish( const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, token, this) - const studioId = peripheralDevice.studioId + const studioId = peripheralDevice.studioAndConfigId?.studioId if (!studioId) return null return ExpectedPlayoutItems.findWithCursor({ studioId }) diff --git a/meteor/server/publications/studio.ts b/meteor/server/publications/studio.ts index 633f2bd3936..955d0ba8da7 100644 --- a/meteor/server/publications/studio.ts +++ b/meteor/server/publications/studio.ts @@ -121,7 +121,7 @@ meteorCustomPublish( const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, token, this) - const studioId = peripheralDevice.studioId + const studioId = peripheralDevice.studioAndConfigId?.studioId if (!studioId) return await createObserverForMappingsPublication(pub, studioId) diff --git a/meteor/server/publications/timeline.ts b/meteor/server/publications/timeline.ts index c32c42b938b..cbe91128f3e 100644 --- a/meteor/server/publications/timeline.ts +++ b/meteor/server/publications/timeline.ts @@ -58,7 +58,7 @@ meteorCustomPublish( const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, token, this) - const studioId = peripheralDevice.studioId + const studioId = peripheralDevice.studioAndConfigId?.studioId if (!studioId) return await createObserverForTimelinePublication(pub, studioId) @@ -71,7 +71,7 @@ meteorPublish( const peripheralDevice = await checkAccessAndGetPeripheralDevice(deviceId, token, this) - const studioId = peripheralDevice.studioId + const studioId = peripheralDevice.studioAndConfigId?.studioId if (!studioId) return null const modifier: FindOptions = { diff --git a/meteor/server/systemStatus/systemStatus.ts b/meteor/server/systemStatus/systemStatus.ts index 34ae34ce499..566e6336414 100644 --- a/meteor/server/systemStatus/systemStatus.ts +++ b/meteor/server/systemStatus/systemStatus.ts @@ -248,7 +248,7 @@ export async function getSystemStatus(_cred: RequestCredentials | null, studioId if (studioId) { // Check status for a certain studio: - devices = await PeripheralDevices.findFetchAsync({ studioId: studioId }) + devices = await PeripheralDevices.findFetchAsync({ 'studioAndConfigId.studioId': studioId }) } else { // Check status for all studios: diff --git a/packages/blueprints-integration/src/api/studio.ts b/packages/blueprints-integration/src/api/studio.ts index fd0c49c5d26..098bbc2283f 100644 --- a/packages/blueprints-integration/src/api/studio.ts +++ b/packages/blueprints-integration/src/api/studio.ts @@ -148,6 +148,8 @@ export interface BlueprintResultApplyStudioConfig { /** Playout Mappings */ mappings: BlueprintMappings + /** Parent device settings */ + parentDevices: Record /** Playout-gateway subdevices */ playoutDevices: Record /** Ingest-gateway subdevices, the types here depend on the gateway you use */ @@ -161,6 +163,14 @@ export interface BlueprintResultApplyStudioConfig { /** Package Containers */ packageContainers?: Record } +export interface BlueprintParentDeviceSettings { + /** + * User friendly name for the device + */ + name: string + + options: Record +} export interface IStudioConfigPreset { name: string diff --git a/packages/corelib/src/dataModel/PeripheralDevice.ts b/packages/corelib/src/dataModel/PeripheralDevice.ts index bcba365e0c6..40c7ca2bb01 100644 --- a/packages/corelib/src/dataModel/PeripheralDevice.ts +++ b/packages/corelib/src/dataModel/PeripheralDevice.ts @@ -1,6 +1,10 @@ import { Time } from '@sofie-automation/blueprints-integration' import { DeviceConfigManifest } from '../deviceConfig' import { OrganizationId, PeripheralDeviceId, StudioId } from './Ids' +import type { + IngestDeviceSecretSettings, + IngestDeviceSecretSettingsStatus, +} from '@sofie-automation/shared-lib/dist/core/model/peripheralDevice' import { PeripheralDeviceStatusObject, @@ -18,12 +22,6 @@ export { PERIPHERAL_SUBTYPE_PROCESS, } -import { - GenericPeripheralDeviceSettings, - IngestDeviceSecretSettings, - IngestDeviceSettings, -} from '@sofie-automation/shared-lib/dist/core/model/peripheralDevice' - export interface PeripheralDevice { _id: PeripheralDeviceId @@ -33,8 +31,11 @@ export interface PeripheralDevice { /** Name of the device (set by the device) */ deviceName: string - /** The studio this device is assigned to. Will be undefined for sub-devices */ - studioId?: StudioId + /** The studio and config this device is assigned to. Will be undefined for sub-devices */ + studioAndConfigId?: { + studioId: StudioId + configId: string + } category: PeripheralDeviceCategory type: PeripheralDeviceType @@ -46,8 +47,6 @@ export interface PeripheralDevice { created: number status: PeripheralDeviceStatusObject - settings: IngestDeviceSettings | GenericPeripheralDeviceSettings - /** If set, this device is owned by that organization */ organizationId: OrganizationId | null @@ -70,6 +69,7 @@ export interface PeripheralDevice { token: string secretSettings?: IngestDeviceSecretSettings | { [key: string]: any } + secretSettingsStatus?: IngestDeviceSecretSettingsStatus /** If the device is of category ingest, the name of the NRCS being used */ nrcsName?: string diff --git a/packages/corelib/src/dataModel/PeripheralDeviceSettings/ingestDevice.ts b/packages/corelib/src/dataModel/PeripheralDeviceSettings/ingestDevice.ts index a8b0b15dbed..0dd830b45a7 100644 --- a/packages/corelib/src/dataModel/PeripheralDeviceSettings/ingestDevice.ts +++ b/packages/corelib/src/dataModel/PeripheralDeviceSettings/ingestDevice.ts @@ -1,6 +1,6 @@ export * from '@sofie-automation/shared-lib/dist/peripheralDevice/ingest' export { - IngestDeviceSettings, + IngestDeviceSecretSettingsStatus, IngestDeviceSecretSettings, } from '@sofie-automation/shared-lib/dist/core/model/peripheralDevice' diff --git a/packages/corelib/src/dataModel/Studio.ts b/packages/corelib/src/dataModel/Studio.ts index ba6d7233d20..f4a70a90737 100644 --- a/packages/corelib/src/dataModel/Studio.ts +++ b/packages/corelib/src/dataModel/Studio.ts @@ -165,6 +165,9 @@ export interface DBStudio { } export interface StudioPeripheralDeviceSettings { + /** Settings for gateway parent-devices */ + deviceSettings: ObjectWithOverrides> + /** Playout gateway sub-devices */ playoutDevices: ObjectWithOverrides> @@ -204,3 +207,12 @@ export interface StudioPlayoutDevice { options: TSR.DeviceOptionsAny } + +export interface StudioDeviceSettings { + /** + * User friendly name for the device + */ + name: string + + options: unknown +} diff --git a/packages/corelib/src/overrideOpHelper.ts b/packages/corelib/src/overrideOpHelper.ts index fd04bc3814b..a0043224ca6 100644 --- a/packages/corelib/src/overrideOpHelper.ts +++ b/packages/corelib/src/overrideOpHelper.ts @@ -44,13 +44,25 @@ export function getAllCurrentAndDeletedItemsFromOverrides( // Sort and wrap in the return type const sortedItems = getAllCurrentItemsFromOverrides(rawObject, comparitor) - const removedOutputLayers: WrappedOverridableItemDeleted[] = [] + const computedItemIds = new Set(sortedItems.map((l) => l.id)) + const removedItems = getAllRemovedItemsFromOverrides(rawObject, comparitor, computedItemIds) + + return [...sortedItems, ...removedItems] +} + +export function getAllRemovedItemsFromOverrides( + rawObject: ReadonlyDeep>>, + comparitor: + | ((a: [id: string, obj: T | ReadonlyDeep], b: [id: string, obj: T | ReadonlyDeep]) => number) + | null, + validItemIds: Set // TODO - should this be optional? +): WrappedOverridableItemDeleted[] { + const removedItems: WrappedOverridableItemDeleted[] = [] // Find the items which have been deleted with an override - const computedOutputLayerIds = new Set(sortedItems.map((l) => l.id)) for (const [id, output] of Object.entries>(rawObject.defaults)) { - if (!computedOutputLayerIds.has(id) && output) { - removedOutputLayers.push( + if (!validItemIds.has(id) && output) { + removedItems.push( literal>({ type: 'deleted', id: id, @@ -62,9 +74,9 @@ export function getAllCurrentAndDeletedItemsFromOverrides( } } - if (comparitor) removedOutputLayers.sort((a, b) => comparitor([a.id, a.defaults], [b.id, b.defaults])) + if (comparitor) removedItems.sort((a, b) => comparitor([a.id, a.defaults], [b.id, b.defaults])) - return [...sortedItems, ...removedOutputLayers] + return removedItems } /** diff --git a/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts b/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts index adef4895e20..9ccd8fcf1ab 100644 --- a/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts +++ b/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts @@ -117,6 +117,7 @@ export function defaultStudio(_id: StudioId): DBStudio { previewContainerIds: [], thumbnailContainerIds: [], peripheralDeviceSettings: { + deviceSettings: wrapDefaultObject({}), playoutDevices: wrapDefaultObject({}), ingestDevices: wrapDefaultObject({}), inputDevices: wrapDefaultObject({}), diff --git a/packages/job-worker/src/__mocks__/presetCollections.ts b/packages/job-worker/src/__mocks__/presetCollections.ts index 7c1cf9e9918..9fd120815f6 100644 --- a/packages/job-worker/src/__mocks__/presetCollections.ts +++ b/packages/job-worker/src/__mocks__/presetCollections.ts @@ -420,8 +420,10 @@ export async function setupMockPeripheralDevice( name: 'mockDevice', deviceName: 'Mock Gateway', organizationId: null, - studioId: context.studioId, - settings: {}, + studioAndConfigId: { + studioId: context.studioId, + configId: 'test', + }, nrcsName: category === PeripheralDeviceCategory.INGEST ? 'JEST-NRCS' : undefined, category: category, diff --git a/packages/job-worker/src/events/handle.ts b/packages/job-worker/src/events/handle.ts index d9efe5387f4..e195e1641e1 100644 --- a/packages/job-worker/src/events/handle.ts +++ b/packages/job-worker/src/events/handle.ts @@ -256,7 +256,7 @@ export async function handleNotifyCurrentlyPlayingPart( } const parentDevice = await context.directCollections.PeripheralDevices.findOne({ _id: device.parentDeviceId, - studioId: context.studioId, + 'studioAndConfigId.studioId': context.studioId, parentDeviceId: { $exists: false }, }) if (!parentDevice) { diff --git a/packages/job-worker/src/peripheralDevice.ts b/packages/job-worker/src/peripheralDevice.ts index 7ac13b0747e..0b8b1c36391 100644 --- a/packages/job-worker/src/peripheralDevice.ts +++ b/packages/job-worker/src/peripheralDevice.ts @@ -199,7 +199,7 @@ export async function listPlayoutDevices( ): Promise { const parentDevicesMap = normalizeArrayToMap( playoutModel.peripheralDevices.filter( - (doc) => doc.studioId === context.studioId && doc.type === PeripheralDeviceType.PLAYOUT + (doc) => doc.studioAndConfigId?.studioId === context.studioId && doc.type === PeripheralDeviceType.PLAYOUT ), '_id' ) diff --git a/packages/job-worker/src/playout/model/implementation/LoadPlayoutModel.ts b/packages/job-worker/src/playout/model/implementation/LoadPlayoutModel.ts index 210524ff2d7..0099896c701 100644 --- a/packages/job-worker/src/playout/model/implementation/LoadPlayoutModel.ts +++ b/packages/job-worker/src/playout/model/implementation/LoadPlayoutModel.ts @@ -46,7 +46,7 @@ export async function loadPlayoutModelPreInit( } const [PeripheralDevices, Playlist, Rundowns] = await Promise.all([ - context.directCollections.PeripheralDevices.findFetch({ studioId: tmpPlaylist.studioId }), + context.directCollections.PeripheralDevices.findFetch({ 'studioAndConfigId.studioId': tmpPlaylist.studioId }), reloadPlaylist ? context.directCollections.RundownPlaylists.findOne(tmpPlaylist._id) : clone(tmpPlaylist), context.directCollections.Rundowns.findFetch({ playlistId: tmpPlaylist._id }), ]) @@ -121,7 +121,7 @@ async function loadInitData( existingRundowns: ReadonlyDeep | undefined ): Promise<[ReadonlyDeep, DBRundownPlaylist, ReadonlyDeep]> { const [peripheralDevices, reloadedPlaylist, rundowns] = await Promise.all([ - context.directCollections.PeripheralDevices.findFetch({ studioId: tmpPlaylist.studioId }), + context.directCollections.PeripheralDevices.findFetch({ 'studioAndConfigId.studioId': tmpPlaylist.studioId }), reloadPlaylist ? await context.directCollections.RundownPlaylists.findOne(tmpPlaylist._id) : clone(tmpPlaylist), diff --git a/packages/job-worker/src/playout/upgrade.ts b/packages/job-worker/src/playout/upgrade.ts index 66fba34cfa1..935c9873b98 100644 --- a/packages/job-worker/src/playout/upgrade.ts +++ b/packages/job-worker/src/playout/upgrade.ts @@ -1,12 +1,14 @@ import { BlueprintMapping, BlueprintMappings, + BlueprintParentDeviceSettings, JSONBlobParse, StudioRouteBehavior, TSR, } from '@sofie-automation/blueprints-integration' import { MappingsExt, + StudioDeviceSettings, StudioIngestDevice, StudioInputDevice, StudioPackageContainer, @@ -49,6 +51,15 @@ export async function handleBlueprintUpgradeForStudio(context: JobContext, _data compileCoreConfigValues(context.studio.settings) ) + const parentDevices = Object.fromEntries( + Object.entries(result.parentDevices ?? {}).map((dev) => [ + dev[0], + literal>({ + name: dev[1].name ?? '', + options: dev[1], + }), + ]) + ) const playoutDevices = Object.fromEntries( Object.entries(result.playoutDevices ?? {}).map((dev) => [ dev[0], @@ -112,6 +123,7 @@ export async function handleBlueprintUpgradeForStudio(context: JobContext, _data await context.directCollections.Studios.update(context.studioId, { $set: { 'mappingsWithOverrides.defaults': translateMappings(result.mappings), + 'peripheralDeviceSettings.deviceSettings.defaults': parentDevices, 'peripheralDeviceSettings.playoutDevices.defaults': playoutDevices, 'peripheralDeviceSettings.ingestDevices.defaults': ingestDevices, 'peripheralDeviceSettings.inputDevices.defaults': inputDevices, diff --git a/packages/job-worker/src/studio/model/StudioPlayoutModelImpl.ts b/packages/job-worker/src/studio/model/StudioPlayoutModelImpl.ts index 8abd587defe..f00630f5a11 100644 --- a/packages/job-worker/src/studio/model/StudioPlayoutModelImpl.ts +++ b/packages/job-worker/src/studio/model/StudioPlayoutModelImpl.ts @@ -177,7 +177,7 @@ export async function loadStudioPlayoutModel( const studioId = context.studioId const collections = await Promise.all([ - context.directCollections.PeripheralDevices.findFetch({ studioId }), + context.directCollections.PeripheralDevices.findFetch({ 'studioAndConfigId.studioId': studioId }), context.directCollections.RundownPlaylists.findFetch({ studioId }), context.directCollections.Timelines.findOne(studioId), ]) diff --git a/packages/meteor-lib/src/api/studios.ts b/packages/meteor-lib/src/api/studios.ts index ee232fe4360..4aee06ed448 100644 --- a/packages/meteor-lib/src/api/studios.ts +++ b/packages/meteor-lib/src/api/studios.ts @@ -1,4 +1,4 @@ -import { StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PeripheralDeviceId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { IStudioSettings, MappingsExt, @@ -9,11 +9,19 @@ import { export interface NewStudiosAPI { insertStudio(): Promise removeStudio(studioId: StudioId): Promise + + assignConfigToPeripheralDevice( + studioId: StudioId, + configId: string, + deviceId: PeripheralDeviceId | null + ): Promise } export enum StudiosAPIMethods { 'insertStudio' = 'studio.insertStudio', 'removeStudio' = 'studio.removeStudio', + + 'assignConfigToPeripheralDevice' = 'studio.assignConfigToPeripheralDevice', } /** diff --git a/packages/openapi/api/definitions/studios.yaml b/packages/openapi/api/definitions/studios.yaml index 2c27bbd1cbd..ae1e4851d8c 100644 --- a/packages/openapi/api/definitions/studios.yaml +++ b/packages/openapi/api/definitions/studios.yaml @@ -317,6 +317,9 @@ resources: properties: deviceId: type: string + configId: + type: string + description: Id of the studio owned configuration to assign to the device. If not specified, one will be created. required: - deviceId responses: diff --git a/packages/shared-lib/src/core/model/peripheralDevice.ts b/packages/shared-lib/src/core/model/peripheralDevice.ts index cbd40c5f000..e6441956f0b 100644 --- a/packages/shared-lib/src/core/model/peripheralDevice.ts +++ b/packages/shared-lib/src/core/model/peripheralDevice.ts @@ -1,12 +1,10 @@ import { TSR } from '../../tsr' import { PeripheralDeviceId, StudioId } from './Ids' -export type GenericPeripheralDeviceSettings = Record - -export interface IngestDeviceSettings { +export interface IngestDeviceSecretSettingsStatus { /** OAuth: Set to true when secret value exists */ - secretCredentials: boolean - secretAccessToken: boolean + credentials?: boolean + accessToken?: boolean } export interface IngestDeviceSecretSettings { /** OAuth: */ diff --git a/packages/webui/src/__mocks__/defaultCollectionObjects.ts b/packages/webui/src/__mocks__/defaultCollectionObjects.ts index 89c7749232f..a21796d77f6 100644 --- a/packages/webui/src/__mocks__/defaultCollectionObjects.ts +++ b/packages/webui/src/__mocks__/defaultCollectionObjects.ts @@ -116,6 +116,7 @@ export function defaultStudio(_id: StudioId): DBStudio { previewContainerIds: [], thumbnailContainerIds: [], peripheralDeviceSettings: { + deviceSettings: wrapDefaultObject({}), playoutDevices: wrapDefaultObject({}), ingestDevices: wrapDefaultObject({}), inputDevices: wrapDefaultObject({}), diff --git a/packages/webui/src/client/lib/reactiveData/reactiveData.ts b/packages/webui/src/client/lib/reactiveData/reactiveData.ts index cb95386f6a3..f1fce992774 100644 --- a/packages/webui/src/client/lib/reactiveData/reactiveData.ts +++ b/packages/webui/src/client/lib/reactiveData/reactiveData.ts @@ -131,7 +131,7 @@ export namespace reactiveData { const allDevices: PeripheralDevice[] = [] const peripheralDevices = PeripheralDevices.find( { - studioId: studioId, + 'studioAndConfigId.studioId': studioId, ignore: { $ne: true, }, diff --git a/packages/webui/src/client/ui/RundownView.tsx b/packages/webui/src/client/ui/RundownView.tsx index adc47efde19..fb1917cdbae 100644 --- a/packages/webui/src/client/ui/RundownView.tsx +++ b/packages/webui/src/client/ui/RundownView.tsx @@ -1443,7 +1443,7 @@ const RundownViewContent = translateWithTracker i._id), @@ -2776,7 +2776,7 @@ const RundownViewContent = translateWithTracker PeripheralDevices.find({ - studioId: props.studioId, + 'studioAndConfigId.studioId': props.studioId, }).fetch(), [], [] diff --git a/packages/webui/src/client/ui/Settings/Studio/Devices/ParentDevices.tsx b/packages/webui/src/client/ui/Settings/Studio/Devices/ParentDevices.tsx new file mode 100644 index 00000000000..8d7f4941388 --- /dev/null +++ b/packages/webui/src/client/ui/Settings/Studio/Devices/ParentDevices.tsx @@ -0,0 +1,519 @@ +import React, { useCallback, useMemo } from 'react' +import { PeripheralDeviceId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { useTranslation } from 'react-i18next' +import { + getAllCurrentAndDeletedItemsFromOverrides, + OverrideOpHelper, + useOverrideOpHelper, + WrappedOverridableItem, + WrappedOverridableItemDeleted, + WrappedOverridableItemNormal, +} from '../../util/OverrideOpHelper' +import { faCheck, faPencilAlt, faPlus, faSync, faTrash } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { JSONBlob, JSONBlobParse, JSONSchema } from '@sofie-automation/blueprints-integration' +import { DropdownInputControl, DropdownInputOption } from '../../../../lib/Components/DropdownInput' +import { useToggleExpandHelper } from '../../../util/useToggleExpandHelper' +import { doModalDialog } from '../../../../lib/ModalDialog' +import classNames from 'classnames' +import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' +import { SchemaFormWithOverrides } from '../../../../lib/forms/SchemaFormWithOverrides' +import { LabelActual, LabelAndOverrides } from '../../../../lib/Components/LabelAndOverrides' +import { getRandomString, literal } from '@sofie-automation/corelib/dist/lib' +import { StudioDeviceSettings } from '@sofie-automation/corelib/dist/dataModel/Studio' +import { + SomeObjectOverrideOp, + wrapDefaultObject, + ObjectOverrideSetOp, +} from '@sofie-automation/corelib/dist/settings/objectWithOverrides' +import Tooltip from 'rc-tooltip' +import { PeripheralDevices, Studios } from '../../../../collections' +import { getHelpMode } from '../../../../lib/localStorage' +import { useTracker } from '../../../../lib/ReactMeteorData/ReactMeteorData' +import { TextInputControl } from '../../../../lib/Components/TextInput' +import { MomentFromNow } from '../../../../lib/Moment' +import { MeteorCall } from '../../../../lib/meteorApi' +import { ReadonlyDeep } from 'type-fest' +import { PeripheralDevice } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' + +interface StudioParentDevicesProps { + studioId: StudioId +} +export function StudioParentDevices({ studioId }: Readonly): JSX.Element { + const { t } = useTranslation() + + const studio = useTracker(() => Studios.findOne(studioId), [studioId]) + + const saveOverrides = useCallback( + (newOps: SomeObjectOverrideOp[]) => { + if (studio?._id) { + Studios.update(studio._id, { + $set: { + 'peripheralDeviceSettings.deviceSettings.overrides': newOps, + }, + }) + } + }, + [studio?._id] + ) + + const deviceSettings = useMemo( + () => + studio?.peripheralDeviceSettings?.deviceSettings ?? wrapDefaultObject>({}), + [studio?.peripheralDeviceSettings?.deviceSettings] + ) + + const overrideHelper = useOverrideOpHelper(saveOverrides, deviceSettings) + + const wrappedDeviceSettings = useMemo( + () => + getAllCurrentAndDeletedItemsFromOverrides(deviceSettings, (a, b) => + a[0].localeCompare(b[0]) + ), + [deviceSettings] + ) + + const addNewItem = useCallback( + (id?: string) => { + const newId = id ?? getRandomString() + const newDevice = literal({ + // peripheralDeviceId: undefined, + name: 'New Device', + options: {}, + }) + + const addOp = literal({ + op: 'set', + path: newId, + value: newDevice, + }) + + Studios.update(studioId, { + $push: { + 'peripheralDeviceSettings.deviceSettings.overrides': addOp, + }, + }) + }, + [studioId] + ) + const addNewItemClick = useCallback(() => addNewItem(), [studioId]) + + const hasCurrentDevice = wrappedDeviceSettings.find((d) => d.type === 'normal') + + return ( +
+

+ + {t('Parent Devices')} + +

+ + + +
+ +
+
+ ) +} + +interface PeripheralDeviceTranslated { + _id: PeripheralDeviceId + name: string + lastSeen: number + deviceConfigSchema: JSONBlob +} + +interface ParentDevicesTableProps { + studioId: StudioId + devices: WrappedOverridableItem[] + overrideHelper: OverrideOpHelper + createItemWithId: (id: string) => void +} +function GenericParentDevicesTable({ + studioId, + devices, + overrideHelper, + createItemWithId, +}: Readonly): JSX.Element { + const { t } = useTranslation() + const { toggleExpanded, isExpanded } = useToggleExpandHelper() + + const allParentDevices = useTracker(() => PeripheralDevices.find({ parentDeviceId: undefined }).fetch(), [], []) + + const studioParentDevices = useTracker( + () => PeripheralDevices.find({ parentDeviceId: undefined, 'studioAndConfigId.studioId': studioId }).fetch(), + [studioId], + [] + ) + const allKnownConfigIds = new Set(devices.map((d) => d.id)) + + const peripheralDevicesByConfigIdMap = useMemo(() => { + const devicesMap = new Map() + + for (const device of allParentDevices) { + if (!device.studioAndConfigId) continue + if (device.studioAndConfigId.studioId !== studioId) continue + + devicesMap.set( + device.studioAndConfigId.configId, + literal({ + _id: device._id, + name: device.name || unprotectString(device._id), + lastSeen: device.lastSeen, + deviceConfigSchema: device.configManifest.deviceConfigSchema, + }) + ) + } + + return devicesMap + }, [studioId, allParentDevices]) + + const confirmRemove = useCallback( + (parentdeviceId: string) => { + doModalDialog({ + title: t('Remove this device?'), + no: t('Cancel'), + yes: t('Remove'), + onAccept: () => { + overrideHelper().deleteItem(parentdeviceId).commit() + }, + message: ( + +

+ {t('Are you sure you want to remove {{type}} "{{deviceId}}"?', { + type: 'device', + deviceId: parentdeviceId, + })} +

+

{t('Please note: This action is irreversible!')}

+
+ ), + }) + }, + [t, overrideHelper] + ) + + const peripheralDeviceOptions = useMemo(() => { + const options: DropdownInputOption[] = [ + { + value: undefined, + name: 'Unassigned', + i: 0, + }, + ] + + for (const device of allParentDevices) { + options.push({ + value: device._id, + name: device.name || unprotectString(device._id), + i: options.length, + }) + } + + return options + }, [allParentDevices]) + + const undeleteItemWithId = useCallback( + (itemId: string) => overrideHelper().resetItem(itemId).commit(), + [overrideHelper] + ) + + return ( + + + + + + + + + + + {devices.map((item) => { + if (item.type === 'deleted') { + return + } else { + const peripheralDevice = peripheralDevicesByConfigIdMap.get(item.id) + + return ( + + + {isExpanded(item.id) && ( + + )} + + ) + } + })} + {studioParentDevices.map((device) => { + if (!device.studioAndConfigId) return null + if (allKnownConfigIds.has(device.studioAndConfigId.configId)) return null + + return ( + + ) + })} + +
{t('Name')}{t('Gateway')}{t('Last Seen')} 
+ ) +} + +interface SummaryRowProps { + item: WrappedOverridableItemNormal + peripheralDevice: PeripheralDeviceTranslated | undefined + isEdited: boolean + editItemWithId: (itemId: string) => void + removeItemWithId: (itemId: string) => void +} +function SummaryRow({ + item, + peripheralDevice, + isEdited, + editItemWithId, + removeItemWithId, +}: Readonly): JSX.Element { + const editItem = useCallback(() => editItemWithId(item.id), [editItemWithId, item.id]) + const removeItem = useCallback(() => removeItemWithId(item.id), [removeItemWithId, item.id]) + + return ( + + {item.computed.name} + + {peripheralDevice?.name || '-'} + + + {peripheralDevice ? : '-'} + + + + + + + + ) +} + +interface DeletedSummaryRowProps { + item: WrappedOverridableItemDeleted + undeleteItemWithId: (itemId: string) => void +} +function DeletedSummaryRow({ item, undeleteItemWithId }: Readonly): JSX.Element { + const undeleteItem = useCallback(() => undeleteItemWithId(item.id), [undeleteItemWithId, item.id]) + + return ( + + {item.defaults.name} + + - + + - + + + + + + ) +} + +interface OrphanedSummaryRowProps { + configId: string + device: ReadonlyDeep + createItemWithId: (itemId: string) => void +} +function OrphanedSummaryRow({ configId, device, createItemWithId }: Readonly): JSX.Element { + const createItem = useCallback(() => createItemWithId(configId), [createItemWithId, configId]) + + return ( + + - + + {device.name || unprotectString(device._id)} + + {} + + + + + + ) +} + +interface ParentDeviceEditRowProps { + studioId: StudioId + peripheralDevice: PeripheralDeviceTranslated | undefined + peripheralDeviceOptions: DropdownInputOption[] + editItemWithId: (parentdeviceId: string, forceState?: boolean) => void + item: WrappedOverridableItemNormal + overrideHelper: OverrideOpHelper +} +function ParentDeviceEditRow({ + studioId, + peripheralDevice, + peripheralDeviceOptions, + editItemWithId, + item, + overrideHelper, +}: Readonly) { + const { t } = useTranslation() + + const finishEditItem = useCallback(() => editItemWithId(item.id, false), [editItemWithId, item.id]) + + return ( + + +
+ + {(value, handleUpdate) => ( + + )} + + + + + {!peripheralDevice &&

{t('A device must be assigned to the config to edit the settings')}

} + + {peripheralDevice && ( + + )} +
+
+ +
+ + + ) +} + +interface AssignPeripheralDeviceConfigIdProps { + studioId: StudioId + configId: string + value: PeripheralDeviceId | undefined + peripheralDeviceOptions: DropdownInputOption[] +} + +function AssignPeripheralDeviceConfigId({ + studioId, + configId, + value, + peripheralDeviceOptions, +}: AssignPeripheralDeviceConfigIdProps) { + const handleUpdate = useCallback( + (peripheralDeviceId: PeripheralDeviceId | undefined) => { + MeteorCall.studio.assignConfigToPeripheralDevice(studioId, configId, peripheralDeviceId ?? null).catch((e) => { + console.error('assignConfigToPeripheralDevice failed', e) + }) + }, + [configId] + ) + + return ( + + ) +} + +interface ParentDeviceEditFormProps { + peripheralDevice: PeripheralDeviceTranslated + item: WrappedOverridableItemNormal + overrideHelper: OverrideOpHelper +} +function ParentDeviceEditForm({ peripheralDevice, item, overrideHelper }: Readonly) { + const { t } = useTranslation() + + const parsedSchema = useMemo((): JSONSchema | undefined => { + if (peripheralDevice?.deviceConfigSchema) { + return JSONBlobParse(peripheralDevice.deviceConfigSchema) + } + + return undefined + }, [peripheralDevice]) + + const translationNamespaces = useMemo(() => ['peripheralDevice_' + peripheralDevice._id], [peripheralDevice._id]) + + return ( + <> + {parsedSchema ? ( + + ) : ( +

{t('Device is missing configuration schema')}

+ )} + + ) +} diff --git a/packages/webui/src/client/ui/Settings/Studio/Devices/SelectDevices.tsx b/packages/webui/src/client/ui/Settings/Studio/Devices/SelectDevices.tsx deleted file mode 100644 index 9245171e51a..00000000000 --- a/packages/webui/src/client/ui/Settings/Studio/Devices/SelectDevices.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { useCallback, useState } from 'react' -import Tooltip from 'rc-tooltip' -import { doModalDialog } from '../../../../lib/ModalDialog' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faExclamationTriangle, faTrash, faPlus } from '@fortawesome/free-solid-svg-icons' -import { PeripheralDevice, PeripheralDeviceType } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' -import { Link } from 'react-router-dom' -import { MomentFromNow } from '../../../../lib/Moment' -import { useTranslation } from 'react-i18next' -import { getHelpMode } from '../../../../lib/localStorage' -import { unprotectString } from '../../../../lib/tempLib' -import { PeripheralDevices } from '../../../../collections' -import { PeripheralDeviceId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { useTracker } from '../../../../lib/ReactMeteorData/ReactMeteorData' - -interface StudioSelectDevicesProps { - studioId: StudioId - studioDevices: PeripheralDevice[] -} -export function StudioSelectDevices({ studioId, studioDevices }: Readonly): JSX.Element { - const { t } = useTranslation() - - const availableDevices = useTracker( - () => - PeripheralDevices.find( - { - studioId: { - $not: { - $eq: studioId, - }, - }, - parentDeviceId: { - $exists: false, - }, - }, - { - sort: { - lastConnected: -1, - }, - } - ).fetch(), - [studioId], - [] - ) - - const [showAvailableDevices, setShowAvailableDevices] = useState(false) - const toggleAvailableDevices = useCallback(() => setShowAvailableDevices((show) => !show), []) - - const isPlayoutConnected = !!studioDevices.find((device) => device.type === PeripheralDeviceType.PLAYOUT) - - const confirmRemove = useCallback((deviceId: PeripheralDeviceId, deviceName: string | undefined) => { - doModalDialog({ - title: t('Remove this device?'), - yes: t('Remove'), - no: t('Cancel'), - onAccept: () => { - PeripheralDevices.update(deviceId, { - $unset: { - studioId: 1, - }, - }) - }, - message: ( -

- {t('Are you sure you want to remove device "{{deviceId}}"?', { - deviceId: deviceName || deviceId, - })} -

- ), - }) - }, []) - - const addDevice = useCallback((deviceId: PeripheralDeviceId) => { - PeripheralDevices.update(deviceId, { - $set: { - studioId: studioId, - }, - }) - }, []) - - return ( -
-

- - {t('Peripheral Devices')} - -

-   - {!studioDevices.length ? ( -
- {t('No devices connected')} -
- ) : null} - {!isPlayoutConnected ? ( -
- {t('Playout gateway not connected')} -
- ) : null} - - - {studioDevices.map((device) => ( - - ))} - -
-
- - {showAvailableDevices && ( -
-
- {availableDevices.map((device) => ( - - ))} -
-
- )} -
-
- ) -} - -interface StudioDeviceEntryProps { - device: PeripheralDevice - confirmRemove: (deviceId: PeripheralDeviceId, deviceName: string | undefined) => void -} -function StudioDeviceEntry({ device, confirmRemove }: Readonly) { - const doConfirmRemove = useCallback( - () => confirmRemove(device._id, device.name), - [confirmRemove, device._id, device.name] - ) - return ( - - - {device.name} - - {unprotectString(device._id)} - - - - - - - - ) -} - -interface AvailableDeviceEntryProps { - device: PeripheralDevice - addDevice: (deviceId: PeripheralDeviceId) => void -} -function AvailableDeviceEntry({ device, addDevice }: Readonly) { - const doAddDevice = useCallback(() => { - addDevice(device._id) - }, [addDevice, device._id]) - - return ( -
- {device.name} ({unprotectString(device._id)}) -
- ) -} diff --git a/packages/webui/src/client/ui/Settings/Studio/Devices/index.tsx b/packages/webui/src/client/ui/Settings/Studio/Devices/index.tsx index b88c4fa2e67..119bde6e36f 100644 --- a/packages/webui/src/client/ui/Settings/Studio/Devices/index.tsx +++ b/packages/webui/src/client/ui/Settings/Studio/Devices/index.tsx @@ -1,10 +1,10 @@ import { PeripheralDevices } from '../../../../collections' import { StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { useTracker } from '../../../../lib/ReactMeteorData/ReactMeteorData' -import { StudioSelectDevices } from './SelectDevices' import { StudioPlayoutSubDevices } from './PlayoutSubDevices' import { StudioInputSubDevices } from './InputSubDevices' import { StudioIngestSubDevices } from './IngestSubDevices' +import { StudioParentDevices } from './ParentDevices' interface IStudioDevicesProps { studioId: StudioId @@ -14,7 +14,7 @@ export function StudioDevices({ studioId }: Readonly): JSX. const studioDevices = useTracker( () => PeripheralDevices.find({ - studioId: studioId, + 'studioAndConfigId.studioId': studioId, }).fetch(), [studioId], [] @@ -22,7 +22,7 @@ export function StudioDevices({ studioId }: Readonly): JSX. return ( <> - + diff --git a/packages/webui/src/client/ui/Settings/StudioSettings.tsx b/packages/webui/src/client/ui/Settings/StudioSettings.tsx index 1adb7c17d9d..19d10f98e98 100644 --- a/packages/webui/src/client/ui/Settings/StudioSettings.tsx +++ b/packages/webui/src/client/ui/Settings/StudioSettings.tsx @@ -50,7 +50,7 @@ export default function StudioSettings(): JSX.Element { () => PeripheralDevices.findOne( { - studioId: { + 'studioAndConfigId.studioId': { $eq: studioId, }, parentDeviceId: { diff --git a/packages/webui/src/client/ui/Settings/components/ConfigManifestOAuthFlow.tsx b/packages/webui/src/client/ui/Settings/components/ConfigManifestOAuthFlow.tsx index d1b286f1ce5..543da251685 100644 --- a/packages/webui/src/client/ui/Settings/components/ConfigManifestOAuthFlow.tsx +++ b/packages/webui/src/client/ui/Settings/components/ConfigManifestOAuthFlow.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { withTranslation } from 'react-i18next' import { PeripheralDevice } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' import { Translated } from '../../../lib/ReactMeteorData/react-meteor-data' -import { IngestDeviceSettings } from '@sofie-automation/corelib/dist/dataModel/PeripheralDeviceSettings/ingestDevice' +import { IngestDeviceSecretSettingsStatus } from '@sofie-automation/corelib/dist/dataModel/PeripheralDeviceSettings/ingestDevice' import { NotificationCenter, Notification, NoticeLevel } from '../../../lib/notifications/notifications' import { fetchFrom } from '../../../lib/lib' @@ -126,12 +126,12 @@ export const ConfigManifestOAuthFlowComponent = withTranslation()( render(): JSX.Element { const { t } = this.props - const settings = (this.props.device.settings || {}) as IngestDeviceSettings + const secretStatus = (this.props.device.secretSettingsStatus || {}) as IngestDeviceSecretSettingsStatus const device = this.props.device return (
- {settings.secretAccessToken ? ( + {secretStatus.accessToken ? ( // If this is set, we have completed the authentication procedure. // A reset button is provided to begin the flow again if authorization is revoked by the user.
@@ -145,7 +145,7 @@ export const ConfigManifestOAuthFlowComponent = withTranslation()(
) : (
- {!settings.secretCredentials ? ( + {!secretStatus.credentials ? (
@@ -101,66 +91,6 @@ function useSystemStatus(): StatusResponse | undefined { return sytemStatus } -function usePlayoutDebugStates( - devices: PeripheralDevice[], - userPermissions: UserPermissions -): Map { - const { t } = useTranslation() - - const [playoutDebugStates, setPlayoutDebugStates] = useState>(new Map()) - - const playoutDeviceIds = useMemo(() => { - const deviceIds: PeripheralDeviceId[] = [] - - for (const device of devices) { - if (device.type === PeripheralDeviceType.PLAYOUT && device.settings && (device.settings as any)['debugState']) { - deviceIds.push(device._id) - } - } - - deviceIds.sort() - return deviceIds - }, [devices]) - - useEffect(() => { - if (!userPermissions.developer) { - setPlayoutDebugStates(new Map()) - return - } - - let destroyed = false - - const refreshDebugStates = () => { - for (const deviceId of playoutDeviceIds) { - MeteorCall.systemStatus - .getDebugStates(deviceId) - .then((res) => { - if (destroyed) return - - setPlayoutDebugStates((oldState) => { - // Create a new map based on the old one - const newStates = new Map(oldState.entries()) - for (const [key, state] of Object.entries(res)) { - newStates.set(protectString(key), state) - } - return newStates - }) - }) - .catch((err) => console.log(`Error fetching device states: ${stringifyError(err)}`)) - } - } - - const interval = setInterval(refreshDebugStates, 1000) - - return () => { - clearInterval(interval) - destroyed = true - } - }, [t, JSON.stringify(playoutDeviceIds), userPermissions.developer]) - - return playoutDebugStates -} - function convertDevicesIntoHeirarchy(devices: PeripheralDevice[]): DeviceInHierarchy[] { const devicesMap = new Map() const devicesToAdd: DeviceInHierarchy[] = [] @@ -194,8 +124,18 @@ function convertDevicesIntoHeirarchy(devices: PeripheralDevice[]): DeviceInHiera return devicesHeirarchy } +interface ParentDeviceItemWithChildrenProps { + device: DeviceInHierarchy +} + +function ParentDeviceItemWithChildren({ device }: ParentDeviceItemWithChildrenProps) { + const playoutDebugStates = useDebugStatesForPlayoutDevice(device.device) + + return +} + interface DeviceItemWithChildrenProps { - playoutDebugStates: Map + playoutDebugStates: ReadonlyMap parentDevice: DeviceInHierarchy | null device: DeviceInHierarchy }