diff --git a/meteor/server/api/ingest/actions.ts b/meteor/server/api/ingest/actions.ts index 6a6cf852bf..29fc700312 100644 --- a/meteor/server/api/ingest/actions.ts +++ b/meteor/server/api/ingest/actions.ts @@ -7,6 +7,7 @@ import { PeripheralDeviceType } from '@sofie-automation/corelib/dist/dataModel/P import { IngestJobs } from '@sofie-automation/corelib/dist/worker/ingest' import { assertNever } from '@sofie-automation/corelib/dist/lib' import { VerifiedRundownForUserAction } from '../../security/check' +import { logger } from '../../logging' /* This file contains actions that can be performed on an ingest-device @@ -27,6 +28,26 @@ export namespace IngestActions { return TriggerReloadDataResponse.COMPLETED } + case 'restApi': { + const resyncUrl = rundown.source.resyncUrl + fetch(resyncUrl, { method: 'POST' }) + .then(() => { + logger.info(`Reload rundown: resync request sent to "${resyncUrl}"`) + }) + .catch((error) => { + if (error.cause.code === 'ECONNREFUSED' || error.cause.code === 'ENOTFOUND') { + logger.error( + `Reload rundown: could not establish connection with "${resyncUrl}" (${error.cause.code})` + ) + return + } + logger.error( + `Reload rundown: error occured while sending resync request to "${resyncUrl}", message: ${error.message}, cause: ${JSON.stringify(error.cause)}` + ) + }) + + return TriggerReloadDataResponse.WORKING + } case 'testing': { await runIngestOperation(rundown.studioId, IngestJobs.CreateAdlibTestingRundownForShowStyleVariant, { showStyleVariantId: rundown.showStyleVariantId, diff --git a/meteor/server/api/rest/v1/index.ts b/meteor/server/api/rest/v1/index.ts index 854316580b..751908243c 100644 --- a/meteor/server/api/rest/v1/index.ts +++ b/meteor/server/api/rest/v1/index.ts @@ -19,6 +19,7 @@ import { registerRoutes as registerStudiosRoutes } from './studios' import { registerRoutes as registerSystemRoutes } from './system' import { registerRoutes as registerBucketsRoutes } from './buckets' import { registerRoutes as registerSnapshotRoutes } from './snapshots' +import { registerRoutes as registerIngestRoutes } from './ingest' import { APIFactory, ServerAPIContext } from './types' function restAPIUserEvent( @@ -201,3 +202,4 @@ registerStudiosRoutes(sofieAPIRequest) registerSystemRoutes(sofieAPIRequest) registerBucketsRoutes(sofieAPIRequest) registerSnapshotRoutes(sofieAPIRequest) +registerIngestRoutes(sofieAPIRequest) diff --git a/meteor/server/api/rest/v1/ingest.ts b/meteor/server/api/rest/v1/ingest.ts new file mode 100644 index 0000000000..ee27c1edc5 --- /dev/null +++ b/meteor/server/api/rest/v1/ingest.ts @@ -0,0 +1,1728 @@ +import { IngestPart, IngestRundown, IngestSegment } from '@sofie-automation/blueprints-integration' +import { + BlueprintId, + PartId, + RundownId, + RundownPlaylistId, + SegmentId, + StudioId, +} from '@sofie-automation/corelib/dist/dataModel/Ids' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { getRundownNrcsName, Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { protectString, unprotectString } from '@sofie-automation/corelib/dist/protectedString' +import { IngestJobs } from '@sofie-automation/corelib/dist/worker/ingest' +import { Meteor } from 'meteor/meteor' +import { ClientAPI } from '@sofie-automation/meteor-lib/dist/api/client' +import { + RestApiIngestRundown, + IngestRestAPI, + PartResponse, + PlaylistResponse, + RundownResponse, + SegmentResponse, +} from '../../../lib/rest/v1/ingest' +import { check } from '../../../lib/check' +import { Parts, RundownPlaylists, Rundowns, Segments, Studios } from '../../../collections' +import { logger } from '../../../logging' +import { runIngestOperation } from '../../ingest/lib' +import { validateAPIPartPayload, validateAPIRundownPayload, validateAPISegmentPayload } from './typeConversion' +import { APIFactory, APIRegisterHook, ServerAPIContext } from './types' + +class IngestServerAPI implements IngestRestAPI { + private async validateAPIPayloadsForRundown( + blueprintId: BlueprintId | undefined, + rundown: IngestRundown, + indexes?: { + rundown?: number + } + ) { + const validationResult = await validateAPIRundownPayload(blueprintId, rundown.payload) + const errorMessage = this.formatPayloadValidationErrors('Rundown', validationResult, indexes) + + if (errorMessage) { + logger.error(`${errorMessage} with errors: ${validationResult}`) + throw new Meteor.Error(409, errorMessage, JSON.stringify(validationResult)) + } + + return Promise.all( + rundown.segments.map(async (segment, index) => { + return this.validateAPIPayloadsForSegment(blueprintId, segment, { + ...indexes, + segment: index, + }) + }) + ) + } + + private async validateAPIPayloadsForSegment( + blueprintId: BlueprintId | undefined, + segment: IngestRundown['segments'][number], + indexes?: { + rundown?: number + segment?: number + } + ) { + const validationResult = await validateAPISegmentPayload(blueprintId, segment.payload) + const errorMessage = this.formatPayloadValidationErrors('Segment', validationResult, indexes) + + if (errorMessage) { + logger.error(`${errorMessage} with errors: ${validationResult}`) + throw new Meteor.Error(409, errorMessage, JSON.stringify(validationResult)) + } + + return Promise.all( + segment.parts.map(async (part, index) => { + return this.validateAPIPayloadsForPart(blueprintId, part, { ...indexes, part: index }) + }) + ) + } + + private async validateAPIPayloadsForPart( + blueprintId: BlueprintId | undefined, + part: IngestRundown['segments'][number]['parts'][number], + indexes?: { + rundown?: number + segment?: number + part?: number + } + ) { + const validationResult = await validateAPIPartPayload(blueprintId, part.payload) + const errorMessage = this.formatPayloadValidationErrors('Part', validationResult, indexes) + + if (errorMessage) { + logger.error(`${errorMessage} with errors: ${validationResult}`) + throw new Meteor.Error(409, errorMessage, JSON.stringify(validationResult)) + } + } + + private formatPayloadValidationErrors( + type: 'Rundown' | 'Segment' | 'Part', + validationResult: string[] | undefined, + indexes?: { + rundown?: number + segment?: number + part?: number + } + ) { + if (!validationResult || validationResult.length === 0) { + return + } + + const messageParts = [] + if (indexes?.rundown !== undefined) messageParts.push(`rundowns[${indexes.rundown}]`) + if (indexes?.segment !== undefined) messageParts.push(`segments[${indexes.segment}]`) + if (indexes?.part !== undefined) messageParts.push(`parts[${indexes.part}]`) + let message = `${type} payload validation failed` + if (messageParts.length > 0) message += ` for ${messageParts.join('.')}` + return message + } + + private validateRundown(ingestRundown: RestApiIngestRundown) { + check(ingestRundown, Object) + check(ingestRundown.externalId, String) + check(ingestRundown.name, String) + check(ingestRundown.type, String) + check(ingestRundown.segments, Array) + check(ingestRundown.resyncUrl, String) + + check(ingestRundown.timing, Object) + check(ingestRundown.timing?.type, String) + + if (ingestRundown.timing?.type === 'forward-time') { + check(ingestRundown.timing.expectedStart, Number) + } else if (ingestRundown.timing?.type === 'back-time') { + check(ingestRundown.timing?.expectedEnd, Number) + } + + ingestRundown.segments.forEach((ingestSegment) => this.validateSegment(ingestSegment)) + } + + private validateSegment(ingestSegment: IngestSegment) { + check(ingestSegment, Object) + check(ingestSegment.externalId, String) + check(ingestSegment.name, String) + check(ingestSegment.rank, Number) + check(ingestSegment.parts, Array) + + if (ingestSegment.isHidden !== undefined) check(ingestSegment.isHidden, Boolean) + if (ingestSegment.timing !== undefined) { + check(ingestSegment.timing.expectedStart, Number) + check(ingestSegment.timing.expectedEnd, Number) + } + + ingestSegment.parts.forEach((ingestPart) => this.validatePart(ingestPart)) + } + + private validatePart(ingestPart: IngestPart) { + check(ingestPart, Object) + check(ingestPart.externalId, String) + check(ingestPart.name, String) + check(ingestPart.rank, Number) + + if (ingestPart.float !== undefined) check(ingestPart.float, Boolean) + if (ingestPart.autoNext !== undefined) check(ingestPart.autoNext, Boolean) + } + + private adaptPlaylist(rawPlaylist: DBRundownPlaylist): PlaylistResponse { + return { + id: unprotectString(rawPlaylist._id), + externalId: rawPlaylist.externalId, + rundownIds: rawPlaylist.rundownIdsInOrder.map((id) => unprotectString(id)), + studioId: unprotectString(rawPlaylist.studioId), + } + } + + private adaptRundown(rawRundown: Rundown): RundownResponse { + return { + id: unprotectString(rawRundown._id), + externalId: rawRundown.externalId, + playlistId: unprotectString(rawRundown.playlistId), + playlistExternalId: rawRundown.playlistExternalId, + studioId: unprotectString(rawRundown.studioId), + name: rawRundown.name, + } + } + + private adaptSegment(rawSegment: DBSegment): SegmentResponse { + return { + id: unprotectString(rawSegment._id), + externalId: rawSegment.externalId, + name: rawSegment.name, + rank: rawSegment._rank, + rundownId: unprotectString(rawSegment.rundownId), + isHidden: rawSegment.isHidden, + } + } + + private adaptPart(rawPart: DBPart): PartResponse { + return { + id: unprotectString(rawPart._id), + externalId: rawPart.externalId, + name: rawPart.title, + rank: rawPart._rank, + rundownId: unprotectString(rawPart.rundownId), + autoNext: rawPart.autoNext, + expectedDuration: rawPart.expectedDuration, + segmentId: unprotectString(rawPart.segmentId), + } + } + + private async findPlaylist(studioId: StudioId, playlistId: string) { + const playlist = await RundownPlaylists.findOneAsync({ + $or: [ + { _id: protectString(playlistId), studioId }, + { externalId: playlistId, studioId }, + ], + }) + if (!playlist) { + throw new Meteor.Error(404, `Playlist ID '${playlistId}' was not found`) + } + return playlist + } + + private async findRundown(studioId: StudioId, playlistId: RundownPlaylistId, rundownId: string) { + const rundown = await Rundowns.findOneAsync({ + $or: [ + { + _id: protectString(rundownId), + playlistId, + studioId, + }, + { + externalId: rundownId, + playlistId, + studioId, + }, + ], + }) + if (!rundown) { + throw new Meteor.Error(404, `Rundown ID '${rundownId}' was not found`) + } + return rundown + } + + private async findRundowns(studioId: StudioId, playlistId: RundownPlaylistId) { + const rundowns = await Rundowns.findFetchAsync({ + $or: [ + { + playlistId, + studioId, + }, + ], + }) + + return rundowns + } + + private async softFindSegment(rundownId: RundownId, segmentId: string) { + const segment = await Segments.findOneAsync({ + $or: [ + { + _id: protectString(segmentId), + rundownId: rundownId, + }, + { + externalId: segmentId, + rundownId: rundownId, + }, + ], + }) + return segment + } + + private async findSegment(rundownId: RundownId, segmentId: string) { + const segment = await this.softFindSegment(rundownId, segmentId) + if (!segment) { + throw new Meteor.Error(404, `Segment ID '${segmentId}' was not found`) + } + return segment + } + + private async findSegments(rundownId: RundownId) { + const segments = await Segments.findFetchAsync({ + $or: [ + { + rundownId: rundownId, + }, + ], + }) + return segments + } + + private async softFindPart(segmentId: SegmentId, partId: string) { + const part = await Parts.findOneAsync({ + $or: [ + { _id: protectString(partId), segmentId }, + { + externalId: partId, + segmentId, + }, + ], + }) + return part + } + + private async findPart(segmentId: SegmentId, partId: string) { + const part = await this.softFindPart(segmentId, partId) + if (!part) { + throw new Meteor.Error(404, `Part ID '${partId}' was not found`) + } + return part + } + + private async findParts(segmentId: SegmentId) { + const parts = await Parts.findFetchAsync({ + $or: [{ segmentId }], + }) + return parts + } + + private async findStudio(studioId: StudioId) { + const studio = await Studios.findOneAsync({ _id: studioId }) + if (!studio) { + throw new Meteor.Error(500, `Studio '${studioId}' does not exist`) + } + + return studio + } + + private checkRundownSource(rundown: Rundown | undefined) { + if (rundown && rundown.source.type !== 'restApi') { + throw new Meteor.Error( + 403, + `Cannot replace existing rundown from source '${getRundownNrcsName( + rundown + )}' with new data from 'restApi' source` + ) + } + } + + // Playlists + + async getPlaylists( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId + ): Promise>> { + check(studioId, String) + + const studio = await this.findStudio(studioId) + const rawPlaylists = await RundownPlaylists.findFetchAsync({ studioId: studio._id }) + const playlists = rawPlaylists.map((rawPlaylist) => this.adaptPlaylist(rawPlaylist)) + + return ClientAPI.responseSuccess(playlists) + } + + async getPlaylist( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string + ): Promise> { + check(studioId, String) + check(playlistId, String) + + const studio = await this.findStudio(studioId) + const rawPlaylist = await this.findPlaylist(studio._id, playlistId) + const playlist = this.adaptPlaylist(rawPlaylist) + + return ClientAPI.responseSuccess(playlist) + } + + async deletePlaylists( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId + ): Promise> { + check(studioId, String) + + const rundowns = await Rundowns.findFetchAsync({}) + const studio = await this.findStudio(studioId) + + await Promise.all( + rundowns.map(async (rundown) => + runIngestOperation(studio._id, IngestJobs.RemoveRundown, { + rundownExternalId: rundown.externalId, + }) + ) + ) + + return ClientAPI.responseSuccess(undefined) + } + + async deletePlaylist( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string + ): Promise> { + check(studioId, String) + check(playlistId, String) + + const studio = await this.findStudio(studioId) + await this.findPlaylist(studio._id, playlistId) + + const rundowns = await Rundowns.findFetchAsync({ + $or: [{ playlistId: protectString(playlistId) }, { playlistExternalId: playlistId }], + }) + + await Promise.all( + rundowns.map(async (rundown) => + runIngestOperation(studio._id, IngestJobs.RemoveRundown, { + rundownExternalId: rundown.externalId, + }) + ) + ) + + return ClientAPI.responseSuccess(undefined) + } + + // Rundowns + + async getRundowns( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string + ): Promise>> { + check(studioId, String) + check(playlistId, String) + + const studio = await this.findStudio(studioId) + const playlist = await this.findPlaylist(studio._id, playlistId) + const rawRundowns = await this.findRundowns(studio._id, playlist._id) + const rundowns = rawRundowns.map((rawRundown) => this.adaptRundown(rawRundown)) + + return ClientAPI.responseSuccess(rundowns) + } + + async getRundown( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string + ): Promise> { + check(studioId, String) + check(playlistId, String) + check(rundownId, String) + + const studio = await this.findStudio(studioId) + const playlist = await this.findPlaylist(studio._id, playlistId) + const rawRundown = await this.findRundown(studio._id, playlist._id, rundownId) + const rundown = this.adaptRundown(rawRundown) + + return ClientAPI.responseSuccess(rundown) + } + + async postRundown( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + ingestRundown: RestApiIngestRundown + ): Promise> { + check(studioId, String) + check(playlistId, String) + check(ingestRundown, Object) + + const studio = await this.findStudio(studioId) + + this.validateRundown(ingestRundown) + await this.validateAPIPayloadsForRundown(studio.blueprintId, ingestRundown) + + const existingRundown = await Rundowns.findOneAsync({ + $or: [ + { + _id: protectString(ingestRundown.externalId), + playlistId: protectString(playlistId), + studioId: studio._id, + }, + { + externalId: ingestRundown.externalId, + playlistExternalId: playlistId, + studioId: studio._id, + }, + ], + }) + if (existingRundown) { + throw new Meteor.Error(400, `Rundown '${ingestRundown.externalId}' already exists`) + } + + await runIngestOperation(studio._id, IngestJobs.UpdateRundown, { + rundownExternalId: ingestRundown.externalId, + ingestRundown: { ...ingestRundown, playlistExternalId: playlistId }, + isCreateAction: true, + rundownSource: { + type: 'restApi', + resyncUrl: ingestRundown.resyncUrl, + }, + }) + + return ClientAPI.responseSuccess(undefined) + } + + async putRundowns( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + ingestRundowns: RestApiIngestRundown[] + ): Promise> { + check(studioId, String) + check(playlistId, String) + check(ingestRundowns, Array) + + const studio = await this.findStudio(studioId) + + await Promise.all( + ingestRundowns.map(async (ingestRundown, index) => { + this.validateRundown(ingestRundown) + return this.validateAPIPayloadsForRundown(studio.blueprintId, ingestRundown, { rundown: index }) + }) + ) + + const playlist = await this.findPlaylist(studio._id, playlistId) + + await Promise.all( + ingestRundowns.map(async (ingestRundown) => { + const rundownExternalId = ingestRundown.externalId + const existingRundown = await this.findRundown(studio._id, playlist._id, rundownExternalId) + if (!existingRundown) { + return + } + + this.checkRundownSource(existingRundown) + + return runIngestOperation(studio._id, IngestJobs.UpdateRundown, { + rundownExternalId: ingestRundown.externalId, + ingestRundown: { ...ingestRundown, playlistExternalId: playlist.externalId }, + isCreateAction: true, + rundownSource: { + type: 'restApi', + resyncUrl: ingestRundown.resyncUrl, + }, + }) + }) + ) + + return ClientAPI.responseSuccess(undefined) + } + + async putRundown( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string, + ingestRundown: RestApiIngestRundown + ): Promise> { + check(studioId, String) + check(playlistId, String) + check(rundownId, String) + check(ingestRundown, Object) + + const studio = await this.findStudio(studioId) + + this.validateRundown(ingestRundown) + await this.validateAPIPayloadsForRundown(studio.blueprintId, ingestRundown) + + const playlist = await this.findPlaylist(studio._id, playlistId) + const existingRundown = await this.findRundown(studio._id, playlist._id, rundownId) + if (!existingRundown) { + throw new Meteor.Error(400, `Rundown '${rundownId}' does not exist`) + } + this.checkRundownSource(existingRundown) + + await runIngestOperation(studio._id, IngestJobs.UpdateRundown, { + rundownExternalId: existingRundown.externalId, + ingestRundown: { ...ingestRundown, playlistExternalId: playlist.externalId }, + isCreateAction: true, + rundownSource: { + type: 'restApi', + resyncUrl: ingestRundown.resyncUrl, + }, + }) + + return ClientAPI.responseSuccess(undefined) + } + + async deleteRundowns( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string + ): Promise> { + check(studioId, String) + check(playlistId, String) + + const studio = await this.findStudio(studioId) + const playlist = await this.findPlaylist(studio._id, playlistId) + const rundowns = await this.findRundowns(studio._id, playlist._id) + + await Promise.all( + rundowns.map(async (rundown) => { + this.checkRundownSource(rundown) + return runIngestOperation(studio._id, IngestJobs.RemoveRundown, { + rundownExternalId: rundown.externalId, + }) + }) + ) + + return ClientAPI.responseSuccess(undefined) + } + + async deleteRundown( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string + ): Promise> { + check(studioId, String) + check(playlistId, String) + check(rundownId, String) + + const studio = await this.findStudio(studioId) + const playlist = await this.findPlaylist(studio._id, playlistId) + const rundown = await this.findRundown(studio._id, playlist._id, rundownId) + this.checkRundownSource(rundown) + + await runIngestOperation(studio._id, IngestJobs.RemoveRundown, { + rundownExternalId: rundown.externalId, + }) + + return ClientAPI.responseSuccess(undefined) + } + + // Segments + + async getSegments( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string + ): Promise>> { + check(studioId, String) + check(playlistId, String) + check(rundownId, String) + + const studio = await this.findStudio(studioId) + const playlist = await this.findPlaylist(studio._id, playlistId) + const rundown = await this.findRundown(studio._id, playlist._id, rundownId) + this.checkRundownSource(rundown) + const rawSegments = await this.findSegments(rundown._id) + const segments = rawSegments.map((rawSegment) => this.adaptSegment(rawSegment)) + + return ClientAPI.responseSuccess(segments) + } + + async getSegment( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string, + segmentId: string + ): Promise> { + check(studioId, String) + check(playlistId, String) + check(rundownId, String) + check(segmentId, String) + + const studio = await this.findStudio(studioId) + const playlist = await this.findPlaylist(studio._id, playlistId) + const rundown = await this.findRundown(studio._id, playlist._id, rundownId) + this.checkRundownSource(rundown) + const rawSegment = await this.findSegment(rundown._id, segmentId) + const segment = this.adaptSegment(rawSegment) + + return ClientAPI.responseSuccess(segment) + } + + async postSegment( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string, + ingestSegment: IngestSegment + ): Promise> { + check(studioId, String) + check(playlistId, String) + check(rundownId, String) + check(ingestSegment, Object) + + const studio = await this.findStudio(studioId) + + this.validateSegment(ingestSegment) + await this.validateAPIPayloadsForSegment(studio.blueprintId, ingestSegment) + + const playlist = await this.findPlaylist(studio._id, playlistId) + const rundown = await this.findRundown(studio._id, playlist._id, rundownId) + this.checkRundownSource(rundown) + const existingSegment = await this.softFindSegment(rundown._id, ingestSegment.externalId) + if (existingSegment) { + throw new Meteor.Error(400, `Segment '${ingestSegment.externalId}' already exists`) + } + + await runIngestOperation(studio._id, IngestJobs.UpdateSegment, { + rundownExternalId: rundown.externalId, + isCreateAction: true, + ingestSegment, + }) + + return ClientAPI.responseSuccess(undefined) + } + + async putSegments( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string, + ingestSegments: IngestSegment[] + ): Promise> { + check(studioId, String) + check(playlistId, String) + check(rundownId, String) + check(ingestSegments, Array) + + const studio = await this.findStudio(studioId) + + await Promise.all( + ingestSegments.map(async (ingestSegment, index) => { + this.validateSegment(ingestSegment) + return await this.validateAPIPayloadsForSegment(studio.blueprintId, ingestSegment, { + segment: index, + }) + }) + ) + + const playlist = await this.findPlaylist(studio._id, playlistId) + const rundown = await this.findRundown(studio._id, playlist._id, rundownId) + this.checkRundownSource(rundown) + + await Promise.all( + ingestSegments.map(async (ingestSegment) => { + const segment = await this.findSegment(rundown._id, ingestSegment.externalId) + if (!segment) { + return + } + + const parts = await this.findParts(segment._id) + return Promise.all( + parts.map(async (part) => + runIngestOperation(studio._id, IngestJobs.RemovePart, { + partExternalId: part.externalId, + rundownExternalId: rundown.externalId, + segmentExternalId: segment.externalId, + }) + ) + ) + }) + ) + + await Promise.all( + ingestSegments.map(async (ingestSegment) => { + const existingSegment = await this.softFindSegment(rundown._id, ingestSegment.externalId) + if (!existingSegment) { + return null + } + + return runIngestOperation(studio._id, IngestJobs.UpdateSegment, { + rundownExternalId: rundown.externalId, + isCreateAction: true, + ingestSegment, + }) + }) + ) + + return ClientAPI.responseSuccess(undefined) + } + + async putSegment( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string, + segmentId: string, + ingestSegment: IngestSegment + ): Promise> { + check(studioId, String) + check(playlistId, String) + check(rundownId, String) + check(segmentId, String) + check(ingestSegment, Object) + + const studio = await this.findStudio(studioId) + + this.validateSegment(ingestSegment) + await this.validateAPIPayloadsForSegment(studio.blueprintId, ingestSegment) + + const playlist = await this.findPlaylist(studio._id, playlistId) + const rundown = await this.findRundown(studio._id, playlist._id, rundownId) + this.checkRundownSource(rundown) + const segment = await this.softFindSegment(rundown._id, segmentId) + if (!segment) { + throw new Meteor.Error(400, `Segment '${segmentId}' does not exist`) + } + const parts = await this.findParts(segment._id) + + await Promise.all( + parts.map(async (part) => + runIngestOperation(studio._id, IngestJobs.RemovePart, { + partExternalId: part.externalId, + rundownExternalId: rundown.externalId, + segmentExternalId: segment.externalId, + }) + ) + ) + + await runIngestOperation(studio._id, IngestJobs.UpdateSegment, { + rundownExternalId: rundown.externalId, + isCreateAction: true, + ingestSegment, + }) + + return ClientAPI.responseSuccess(undefined) + } + + async deleteSegments( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string + ): Promise> { + check(studioId, String) + check(playlistId, String) + check(rundownId, String) + + const studio = await this.findStudio(studioId) + const playlist = await this.findPlaylist(studio._id, playlistId) + const rundown = await this.findRundown(studio._id, playlist._id, rundownId) + const segments = await this.findSegments(rundown._id) + + await Promise.all( + segments.map(async (segment) => + // This also removes linked Parts + runIngestOperation(studio._id, IngestJobs.RemoveSegment, { + rundownExternalId: rundown.externalId, + segmentExternalId: segment.externalId, + }) + ) + ) + + return ClientAPI.responseSuccess(undefined) + } + + async deleteSegment( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string, + segmentId: string + ): Promise> { + check(studioId, String) + check(playlistId, String) + check(rundownId, String) + check(segmentId, String) + + const studio = await this.findStudio(studioId) + const playlist = await this.findPlaylist(studio._id, playlistId) + const rundown = await this.findRundown(studio._id, playlist._id, rundownId) + this.checkRundownSource(rundown) + const segment = await this.findSegment(rundown._id, segmentId) + + // This also removes linked Parts + await runIngestOperation(studio._id, IngestJobs.RemoveSegment, { + segmentExternalId: segment.externalId, + rundownExternalId: rundown.externalId, + }) + + return ClientAPI.responseSuccess(undefined) + } + + // Parts + + async getParts( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string, + segmentId: string + ): Promise>> { + check(studioId, String) + check(playlistId, String) + check(rundownId, String) + check(segmentId, String) + + const studio = await this.findStudio(studioId) + const playlist = await this.findPlaylist(studio._id, playlistId) + const rundown = await this.findRundown(studio._id, playlist._id, rundownId) + this.checkRundownSource(rundown) + const segment = await this.findSegment(rundown._id, segmentId) + const rawParts = await this.findParts(segment._id) + const parts = rawParts.map((rawPart) => this.adaptPart(rawPart)) + + return ClientAPI.responseSuccess(parts) + } + + async getPart( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string, + segmentId: string, + partId: string + ): Promise> { + check(studioId, String) + check(playlistId, String) + check(rundownId, String) + check(segmentId, String) + check(partId, String) + + const studio = await this.findStudio(studioId) + const playlist = await this.findPlaylist(studio._id, playlistId) + const rundown = await this.findRundown(studio._id, playlist._id, rundownId) + this.checkRundownSource(rundown) + const segment = await this.findSegment(rundown._id, segmentId) + const rawPart = await this.findPart(segment._id, partId) + const part = this.adaptPart(rawPart) + + return ClientAPI.responseSuccess(part) + } + + async postPart( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string, + segmentId: string, + ingestPart: IngestPart + ): Promise> { + check(studioId, String) + check(playlistId, String) + check(rundownId, String) + check(segmentId, String) + check(ingestPart, Object) + + const studio = await this.findStudio(studioId) + + this.validatePart(ingestPart) + await this.validateAPIPayloadsForPart(studio.blueprintId, ingestPart) + + const playlist = await this.findPlaylist(studio._id, playlistId) + const rundown = await this.findRundown(studio._id, playlist._id, rundownId) + this.checkRundownSource(rundown) + const segment = await this.findSegment(rundown._id, segmentId) + const existingPart = await this.softFindPart(segment._id, ingestPart.externalId) + if (existingPart) { + throw new Meteor.Error(400, `Part '${ingestPart.externalId}' already exists`) + } + + await runIngestOperation(studio._id, IngestJobs.UpdatePart, { + rundownExternalId: rundown.externalId, + segmentExternalId: segment.externalId, + isCreateAction: true, + ingestPart, + }) + + return ClientAPI.responseSuccess(undefined) + } + + async putParts( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string, + segmentId: string, + ingestParts: IngestPart[] + ): Promise> { + check(studioId, String) + check(playlistId, String) + check(rundownId, String) + check(segmentId, String) + check(ingestParts, Array) + + const studio = await this.findStudio(studioId) + + await Promise.all( + ingestParts.map(async (ingestPart, index) => { + this.validatePart(ingestPart) + return this.validateAPIPayloadsForPart(studio.blueprintId, ingestPart, { part: index }) + }) + ) + + const playlist = await this.findPlaylist(studio._id, playlistId) + const rundown = await this.findRundown(studio._id, playlist._id, rundownId) + this.checkRundownSource(rundown) + const segment = await this.findSegment(rundown._id, segmentId) + + await Promise.all( + ingestParts.map(async (ingestPart) => { + const existingPart = await this.findPart(segment._id, ingestPart.externalId) + if (!existingPart) { + return + } + + return runIngestOperation(studio._id, IngestJobs.UpdatePart, { + segmentExternalId: segment.externalId, + rundownExternalId: rundown.externalId, + isCreateAction: true, + ingestPart, + }) + }) + ) + + return ClientAPI.responseSuccess(undefined) + } + + async putPart( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string, + segmentId: string, + partId: string, + ingestPart: IngestPart + ): Promise> { + check(studioId, String) + check(playlistId, String) + check(rundownId, String) + check(segmentId, String) + check(partId, String) + check(ingestPart, Object) + + const studio = await this.findStudio(studioId) + + this.validatePart(ingestPart) + await this.validateAPIPayloadsForPart(studio.blueprintId, ingestPart) + + const playlist = await this.findPlaylist(studio._id, playlistId) + const rundown = await this.findRundown(studio._id, playlist._id, rundownId) + this.checkRundownSource(rundown) + const segment = await this.findSegment(rundown._id, segmentId) + const existingPart = await this.findPart(segment._id, partId) + if (!existingPart) { + throw new Meteor.Error(400, `Part '${partId}' does not exists`) + } + + await runIngestOperation(studio._id, IngestJobs.UpdatePart, { + rundownExternalId: rundown.externalId, + segmentExternalId: segment.externalId, + isCreateAction: true, + ingestPart, + }) + + return ClientAPI.responseSuccess(undefined) + } + + async deleteParts( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string, + segmentId: string + ): Promise> { + check(studioId, String) + check(playlistId, String) + check(rundownId, String) + check(segmentId, String) + + const studio = await this.findStudio(studioId) + const playlist = await this.findPlaylist(studio._id, playlistId) + const rundown = await this.findRundown(studio._id, playlist._id, rundownId) + this.checkRundownSource(rundown) + const segment = await this.findSegment(rundown._id, segmentId) + const parts = await this.findParts(segment._id) + + await Promise.all( + parts.map(async (part) => + runIngestOperation(studio._id, IngestJobs.RemovePart, { + rundownExternalId: rundown.externalId, + segmentExternalId: segment.externalId, + partExternalId: part.externalId, + }) + ) + ) + + return ClientAPI.responseSuccess(undefined) + } + + async deletePart( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string, + segmentId: string, + partId: string + ): Promise> { + check(studioId, String) + check(playlistId, String) + check(rundownId, String) + check(segmentId, String) + check(partId, String) + + const studio = await this.findStudio(studioId) + const playlist = await this.findPlaylist(studio._id, playlistId) + const rundown = await this.findRundown(studio._id, playlist._id, rundownId) + this.checkRundownSource(rundown) + const segment = await this.findSegment(rundown._id, segmentId) + const part = await this.findPart(segment._id, partId) + + await runIngestOperation(studio._id, IngestJobs.RemovePart, { + rundownExternalId: rundown.externalId, + segmentExternalId: segment.externalId, + partExternalId: part.externalId, + }) + + return ClientAPI.responseSuccess(undefined) + } +} + +class IngestAPIFactory implements APIFactory { + createServerAPI(_context: ServerAPIContext): IngestRestAPI { + return new IngestServerAPI() + } +} + +export function registerRoutes(registerRoute: APIRegisterHook): void { + const ingestAPIFactory = new IngestAPIFactory() + + // Playlists + + // Get all playlists + registerRoute<{ studioId: string }, never, Array>( + 'get', + '/ingest/:studioId/playlists', + new Map(), + ingestAPIFactory, + async (serverAPI, connection, event, params, _) => { + logger.info(`INGEST API GET: Playlists`) + + const studioId = protectString(params.studioId) + check(studioId, String) + + return await serverAPI.getPlaylists(connection, event, studioId) + } + ) + + // Get playlist + registerRoute<{ studioId: string; playlistId: string }, never, PlaylistResponse>( + 'get', + '/ingest/:studioId/playlists/:playlistId', + new Map(), + ingestAPIFactory, + async (serverAPI, connection, event, params, _) => { + logger.info(`INGEST API GET: Playlist`) + + const studioId = protectString(params.studioId) + check(studioId, String) + const playlistId = params.playlistId + check(playlistId, String) + + return await serverAPI.getPlaylist(connection, event, studioId, playlistId) + } + ) + + // Delete all playlists + registerRoute<{ studioId: string }, never, void>( + 'delete', + '/ingest/:studioId/playlists', + new Map(), + ingestAPIFactory, + async (serverAPI, connection, event, params, _) => { + logger.info(`INGEST API DELETE: Playlists`) + + const studioId = protectString(params.studioId) + check(studioId, String) + + return await serverAPI.deletePlaylists(connection, event, studioId) + } + ) + + // Delete playlist + registerRoute<{ studioId: string; playlistId: string }, never, void>( + 'delete', + '/ingest/:studioId/playlists/:playlistId', + new Map(), + ingestAPIFactory, + async (serverAPI, connection, event, params, _) => { + logger.info(`INGEST API DELETE: Playlist`) + + const studioId = protectString(params.studioId) + check(studioId, String) + const playlistId = params.playlistId + check(playlistId, String) + + return await serverAPI.deletePlaylist(connection, event, studioId, playlistId) + } + ) + + // Rundowns + + // Get all rundowns + registerRoute<{ studioId: string; playlistId: string }, never, RundownResponse[]>( + 'get', + '/ingest/:studioId/playlists/:playlistId/rundowns', + new Map(), + ingestAPIFactory, + async (serverAPI, connection, event, params, _) => { + logger.info(`INGEST API GET: Rundowns`) + + const studioId = protectString(params.studioId) + check(studioId, String) + const playlistId = params.playlistId + check(playlistId, String) + + return await serverAPI.getRundowns(connection, event, studioId, playlistId) + } + ) + + // Get rundown + registerRoute<{ studioId: string; playlistId: string; rundownId: string }, never, RundownResponse>( + 'get', + '/ingest/:studioId/playlists/:playlistId/rundowns/:rundownId', + new Map(), + ingestAPIFactory, + async (serverAPI, connection, event, params, _) => { + logger.info(`INGEST API GET: Rundown`) + + const studioId = protectString(params.studioId) + check(studioId, String) + const playlistId = params.playlistId + check(playlistId, String) + const rundownId = params.rundownId + check(rundownId, String) + + return await serverAPI.getRundown(connection, event, studioId, playlistId, rundownId) + } + ) + + // Create rundown + registerRoute<{ studioId: string; playlistId: string; rundownId: string }, never, void>( + 'post', + '/ingest/:studioId/playlists/:playlistId/rundowns', + new Map(), + ingestAPIFactory, + async (serverAPI, connection, event, params, body) => { + logger.info(`INGEST API POST: Rundowns`) + + const studioId = protectString(params.studioId) + check(studioId, String) + const playlistId = params.playlistId + check(playlistId, String) + + const ingestRundown = body as RestApiIngestRundown + if (!ingestRundown) throw new Meteor.Error(400, 'Upload rundown: Missing request body') + if (typeof ingestRundown !== 'object') throw new Meteor.Error(400, 'Upload rundown: Invalid request body') + + return await serverAPI.postRundown(connection, event, studioId, playlistId, ingestRundown) + } + ) + + // Update rundowns + registerRoute<{ studioId: string; playlistId: string }, never, void>( + 'put', + '/ingest/:studioId/playlists/:playlistId/rundowns', + new Map(), + ingestAPIFactory, + async (serverAPI, connection, event, params, body) => { + logger.info(`INGEST API PUT: Rundowns`) + + const studioId = protectString(params.studioId) + check(studioId, String) + const playlistId = params.playlistId + check(playlistId, String) + + const ingestRundowns = body as RestApiIngestRundown[] + if (!ingestRundowns) throw new Meteor.Error(400, 'Upload rundown: Missing request body') + if (typeof ingestRundowns !== 'object') throw new Meteor.Error(400, 'Upload rundown: Invalid request body') + + return await serverAPI.putRundowns(connection, event, studioId, playlistId, ingestRundowns) + } + ) + + // Update rundown + registerRoute<{ studioId: string; playlistId: string; rundownId: string }, never, void>( + 'put', + '/ingest/:studioId/playlists/:playlistId/rundowns/:rundownId', + new Map(), + ingestAPIFactory, + async (serverAPI, connection, event, params, body) => { + logger.info(`INGEST API PUT: Rundown`) + + const studioId = protectString(params.studioId) + check(studioId, String) + const playlistId = params.playlistId + check(playlistId, String) + const rundownId = params.rundownId + check(rundownId, String) + + const ingestRundown = body as RestApiIngestRundown + if (!ingestRundown) throw new Meteor.Error(400, 'Upload rundown: Missing request body') + if (typeof ingestRundown !== 'object') throw new Meteor.Error(400, 'Upload rundown: Invalid request body') + + return await serverAPI.putRundown(connection, event, studioId, playlistId, rundownId, ingestRundown) + } + ) + + // Delete rundowns + registerRoute<{ studioId: string; playlistId: string }, never, void>( + 'delete', + '/ingest/:studioId/playlists/:playlistId/rundowns', + new Map(), + ingestAPIFactory, + async (serverAPI, connection, event, params, _) => { + logger.info(`INGEST API DELETE: Rundowns`) + + const studioId = protectString(params.studioId) + check(studioId, String) + const playlistId = params.playlistId + check(playlistId, String) + + return await serverAPI.deleteRundowns(connection, event, studioId, playlistId) + } + ) + + // Delete rundown + registerRoute<{ studioId: string; playlistId: string; rundownId: string }, never, void>( + 'delete', + '/ingest/:studioId/playlists/:playlistId/rundowns/:rundownId', + new Map(), + ingestAPIFactory, + async (serverAPI, connection, event, params, _) => { + logger.info(`INGEST API DELETE: Rundown`) + + const studioId = protectString(params.studioId) + check(studioId, String) + const playlistId = params.playlistId + check(playlistId, String) + const rundownId = params.rundownId + check(rundownId, String) + + return await serverAPI.deleteRundown(connection, event, studioId, playlistId, rundownId) + } + ) + + // Segments + + // Get all segments + registerRoute<{ studioId: string; playlistId: string; rundownId: string }, never, SegmentResponse[]>( + 'get', + '/ingest/:studioId/playlists/:playlistId/rundowns/:rundownId/segments', + new Map(), + ingestAPIFactory, + async (serverAPI, connection, event, params, _) => { + logger.info(`INGEST API GET: Segments`) + + const studioId = protectString(params.studioId) + check(studioId, String) + const playlistId = params.playlistId + check(playlistId, String) + const rundownId = params.rundownId + check(rundownId, String) + + return await serverAPI.getSegments(connection, event, studioId, playlistId, rundownId) + } + ) + + // Get segment + registerRoute< + { studioId: string; playlistId: string; rundownId: string; segmentId: string }, + never, + SegmentResponse + >( + 'get', + '/ingest/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId', + new Map(), + ingestAPIFactory, + async (serverAPI, connection, event, params, _) => { + logger.info(`INGEST API GET: Segment`) + + const studioId = protectString(params.studioId) + check(studioId, String) + const playlistId = params.playlistId + check(playlistId, String) + const rundownId = params.rundownId + check(rundownId, String) + const segmentId = params.segmentId + check(segmentId, String) + + return await serverAPI.getSegment(connection, event, studioId, playlistId, rundownId, segmentId) + } + ) + + // Create segment + registerRoute<{ studioId: string; playlistId: string; rundownId: string }, never, void>( + 'post', + '/ingest/:studioId/playlists/:playlistId/rundowns/:rundownId/segments', + new Map(), + ingestAPIFactory, + async (serverAPI, connection, event, params, body) => { + logger.info(`INGEST API POST: Segments`) + + const studioId = protectString(params.studioId) + check(studioId, String) + const playlistId = params.playlistId + check(playlistId, String) + const rundownId = params.rundownId + check(rundownId, String) + + const ingestSegment = body as IngestSegment + if (!ingestSegment) throw new Meteor.Error(400, 'Upload rundown: Missing request body') + + return await serverAPI.postSegment(connection, event, studioId, playlistId, rundownId, ingestSegment) + } + ) + + // Update segments + registerRoute<{ studioId: string; playlistId: string; rundownId: string }, never, void>( + 'put', + '/ingest/:studioId/playlists/:playlistId/rundowns/:rundownId/segments', + new Map(), + ingestAPIFactory, + async (serverAPI, connection, event, params, body) => { + logger.info(`INGEST API PUT: Segments`) + + const studioId = protectString(params.studioId) + check(studioId, String) + const playlistId = params.playlistId + check(playlistId, String) + const rundownId = params.rundownId + check(rundownId, String) + + const ingestSegments = body as IngestSegment[] + if (!ingestSegments) throw new Meteor.Error(400, 'Upload rundown: Missing request body') + if (!Array.isArray(ingestSegments)) throw new Meteor.Error(400, 'Upload rundown: Invalid request body') + + return await serverAPI.putSegments(connection, event, studioId, playlistId, rundownId, ingestSegments) + } + ) + + // Update segment + registerRoute<{ studioId: string; playlistId: string; rundownId: string; segmentId: string }, never, void>( + 'put', + '/ingest/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId', + new Map(), + ingestAPIFactory, + async (serverAPI, connection, event, params, body) => { + logger.info(`INGEST API PUT: Segment`) + + const studioId = protectString(params.studioId) + check(studioId, String) + const playlistId = params.playlistId + check(playlistId, String) + const rundownId = params.rundownId + check(rundownId, String) + const segmentId = params.segmentId + check(segmentId, String) + + const ingestSegment = body as IngestSegment + if (!ingestSegment) throw new Meteor.Error(400, 'Upload rundown: Missing request body') + + return await serverAPI.putSegment( + connection, + event, + studioId, + playlistId, + rundownId, + segmentId, + ingestSegment + ) + } + ) + + // Delete segments + registerRoute<{ studioId: string; playlistId: string; rundownId: string }, never, void>( + 'delete', + '/ingest/:studioId/playlists/:playlistId/rundowns/:rundownId/segments', + new Map(), + ingestAPIFactory, + async (serverAPI, connection, event, params, _) => { + logger.info(`INGEST API DELETE: Segments`) + + const studioId = protectString(params.studioId) + check(studioId, String) + const playlistId = params.playlistId + check(playlistId, String) + const rundownId = params.rundownId + check(rundownId, String) + + return await serverAPI.deleteSegments(connection, event, studioId, playlistId, rundownId) + } + ) + + // Delete segment + registerRoute<{ studioId: string; playlistId: string; rundownId: string; segmentId: string }, never, void>( + 'delete', + '/ingest/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId', + new Map(), + ingestAPIFactory, + async (serverAPI, connection, event, params, _) => { + logger.info(`INGEST API DELETE: Segment`) + + const studioId = protectString(params.studioId) + check(studioId, String) + const playlistId = params.playlistId + check(playlistId, String) + const rundownId = params.rundownId + check(rundownId, String) + const segmentId = params.segmentId + check(segmentId, String) + + return await serverAPI.deleteSegment(connection, event, studioId, playlistId, rundownId, segmentId) + } + ) + + // Parts + + // Get all parts + registerRoute< + { studioId: string; playlistId: string; rundownId: string; segmentId: string }, + never, + PartResponse[] + >( + 'get', + '/ingest/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts', + new Map(), + ingestAPIFactory, + async (serverAPI, connection, event, params, _) => { + logger.info(`INGEST API GET: Parts`) + + const studioId = protectString(params.studioId) + check(studioId, String) + const playlistId = params.playlistId + check(playlistId, String) + const rundownId = params.rundownId + check(rundownId, String) + const segmentId = params.segmentId + check(segmentId, String) + + return await serverAPI.getParts(connection, event, studioId, playlistId, rundownId, segmentId) + } + ) + + // Get part + registerRoute< + { studioId: string; playlistId: string; rundownId: string; segmentId: string; partId: string }, + never, + PartResponse + >( + 'get', + '/ingest/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts/:partId', + new Map(), + ingestAPIFactory, + async (serverAPI, connection, event, params, _) => { + logger.info(`INGEST API GET: Part`) + + const studioId = protectString(params.studioId) + check(studioId, String) + const playlistId = params.playlistId + check(playlistId, String) + const rundownId = params.rundownId + check(rundownId, String) + const segmentId = params.segmentId + check(segmentId, String) + const partId = params.partId + check(partId, String) + + return await serverAPI.getPart(connection, event, studioId, playlistId, rundownId, segmentId, partId) + } + ) + + // Create part + registerRoute<{ studioId: string; playlistId: string; rundownId: string; segmentId: string }, never, void>( + 'post', + '/ingest/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts', + new Map(), + ingestAPIFactory, + async (serverAPI, connection, event, params, body) => { + logger.info(`INGEST API POST: Parts`) + + const studioId = protectString(params.studioId) + check(studioId, String) + const playlistId = params.playlistId + check(playlistId, String) + const rundownId = params.rundownId + check(rundownId, String) + const segmentId = params.segmentId + check(segmentId, String) + + const ingestPart = body as IngestPart + if (!ingestPart) throw new Meteor.Error(400, 'Upload rundown: Missing request body') + + return await serverAPI.postPart(connection, event, studioId, playlistId, rundownId, segmentId, ingestPart) + } + ) + + // Update parts + registerRoute<{ studioId: string; playlistId: string; rundownId: string; segmentId: string }, never, void>( + 'put', + '/ingest/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts', + new Map(), + ingestAPIFactory, + async (serverAPI, connection, event, params, body) => { + logger.info(`INGEST API PUT: Parts`) + + const studioId = protectString(params.studioId) + check(studioId, String) + const playlistId = params.playlistId + check(playlistId, String) + const rundownId = params.rundownId + check(rundownId, String) + const segmentId = params.segmentId + check(segmentId, String) + + const ingestParts = body as IngestPart[] + if (!ingestParts) throw new Meteor.Error(400, 'Upload rundown: Missing request body') + if (!Array.isArray(ingestParts)) throw new Meteor.Error(400, 'Upload rundown: Invalid request body') + + return await serverAPI.putParts(connection, event, studioId, playlistId, rundownId, segmentId, ingestParts) + } + ) + + // Update part + registerRoute< + { studioId: string; playlistId: string; rundownId: string; segmentId: string; partId: string }, + never, + void + >( + 'put', + '/ingest/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts/:partId', + new Map(), + ingestAPIFactory, + async (serverAPI, connection, event, params, body) => { + logger.info(`INGEST API PUT: Part`) + + const studioId = protectString(params.studioId) + check(studioId, String) + const playlistId = params.playlistId + check(playlistId, String) + const rundownId = params.rundownId + check(rundownId, String) + const segmentId = params.segmentId + check(segmentId, String) + const partId = params.partId + check(partId, String) + + const ingestPart = body as IngestPart + if (!ingestPart) throw new Meteor.Error(400, 'Upload rundown: Missing request body') + + return await serverAPI.putPart( + connection, + event, + studioId, + playlistId, + rundownId, + segmentId, + partId, + ingestPart + ) + } + ) + + // Delete parts + registerRoute<{ studioId: string; playlistId: string; rundownId: string; segmentId: string }, never, void>( + 'delete', + '/ingest/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts', + new Map(), + ingestAPIFactory, + async (serverAPI, connection, event, params, _) => { + logger.info(`INGEST API DELETE: Parts`) + + const studioId = protectString(params.studioId) + check(studioId, String) + const playlistId = params.playlistId + check(playlistId, String) + const rundownId = params.rundownId + check(rundownId, String) + const segmentId = params.segmentId + check(segmentId, String) + + return await serverAPI.deleteParts(connection, event, studioId, playlistId, rundownId, segmentId) + } + ) + + // Delete part + registerRoute< + { studioId: string; playlistId: string; rundownId: string; segmentId: string; partId: string }, + never, + void + >( + 'delete', + '/ingest/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts/:partId', + new Map(), + ingestAPIFactory, + async (serverAPI, connection, event, params, _) => { + logger.info(`INGEST API DELETE: Part`) + + const studioId = protectString(params.studioId) + check(studioId, String) + const playlistId = params.playlistId + check(playlistId, String) + const rundownId = params.rundownId + check(rundownId, String) + const segmentId = params.segmentId + check(segmentId, String) + const partId = params.partId + check(partId, String) + + return await serverAPI.deletePart(connection, event, studioId, playlistId, rundownId, segmentId, partId) + } + ) +} diff --git a/meteor/server/api/rest/v1/playlists.ts b/meteor/server/api/rest/v1/playlists.ts index db955dba05..ff4cbf6a45 100644 --- a/meteor/server/api/rest/v1/playlists.ts +++ b/meteor/server/api/rest/v1/playlists.ts @@ -23,9 +23,11 @@ import { BucketAdLibActions, BucketAdLibs, Buckets, + Parts, RundownBaselineAdLibActions, RundownBaselineAdLibPieces, RundownPlaylists, + Segments, } from '../../../collections' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { ServerClientAPI } from '../../client' @@ -38,6 +40,48 @@ import { triggerWriteAccess } from '../../../security/securityVerify' class PlaylistsServerAPI implements PlaylistsRestAPI { constructor(private context: ServerAPIContext) {} + private async findPlaylist(playlistId: RundownPlaylistId) { + const playlist = await RundownPlaylists.findOneAsync({ + $or: [{ _id: playlistId }, { externalId: playlistId }], + }) + if (!playlist) { + throw new Meteor.Error(404, `Playlist ID '${playlistId}' was not found`) + } + return playlist + } + + private async findSegment(segmentId: SegmentId) { + const segment = await Segments.findOneAsync({ + $or: [ + { + _id: segmentId, + }, + { + externalId: segmentId, + }, + ], + }) + if (!segment) { + throw new Meteor.Error(404, `Segment ID '${segmentId}' was not found`) + } + return segment + } + + private async findPart(partId: PartId) { + const part = await Parts.findOneAsync({ + $or: [ + { _id: partId }, + { + externalId: partId, + }, + ], + }) + if (!part) { + throw new Meteor.Error(404, `Part ID '${partId}' was not found`) + } + return part + } + async getAllRundownPlaylists( _connection: Meteor.Connection, _event: string @@ -56,41 +100,47 @@ class PlaylistsServerAPI implements PlaylistsRestAPI { rundownPlaylistId: RundownPlaylistId, rehearsal: boolean ): Promise> { + const playlist = await this.findPlaylist(rundownPlaylistId) + return ServerClientAPI.runUserActionInLogForPlaylistOnWorker( this.context.getMethodContext(connection), event, getCurrentTime(), - rundownPlaylistId, + playlist._id, () => { - check(rundownPlaylistId, String) + check(playlist._id, String) check(rehearsal, Boolean) }, StudioJobs.ActivateRundownPlaylist, { - playlistId: rundownPlaylistId, + playlistId: playlist._id, rehearsal, } ) } + async deactivate( connection: Meteor.Connection, event: string, rundownPlaylistId: RundownPlaylistId ): Promise> { + const playlist = await this.findPlaylist(rundownPlaylistId) + return ServerClientAPI.runUserActionInLogForPlaylistOnWorker( this.context.getMethodContext(connection), event, getCurrentTime(), - rundownPlaylistId, + playlist._id, () => { - check(rundownPlaylistId, String) + check(playlist._id, String) }, StudioJobs.DeactivateRundownPlaylist, { - playlistId: rundownPlaylistId, + playlistId: playlist._id, } ) } + async executeAdLib( connection: Meteor.Connection, event: string, @@ -122,9 +172,12 @@ class PlaylistsServerAPI implements PlaylistsRestAPI { if (regularAdLibDoc) { // This is an AdLib Piece const pieceType = baselineAdLibDoc ? 'baseline' : segmentAdLibDoc ? 'normal' : 'bucket' - const rundownPlaylist = await RundownPlaylists.findOneAsync(rundownPlaylistId, { - projection: { currentPartInfo: 1 }, - }) + const rundownPlaylist = await RundownPlaylists.findOneAsync( + { $or: [{ _id: rundownPlaylistId }, { externalId: rundownPlaylistId }] }, + { + projection: { currentPartInfo: 1 }, + } + ) if (!rundownPlaylist) return ClientAPI.responseError( UserError.from( @@ -143,14 +196,14 @@ class PlaylistsServerAPI implements PlaylistsRestAPI { this.context.getMethodContext(connection), event, getCurrentTime(), - rundownPlaylistId, + rundownPlaylist._id, () => { - check(rundownPlaylistId, String) + check(rundownPlaylist._id, String) check(adLibId, Match.OneOf(String, null)) }, StudioJobs.AdlibPieceStart, { - playlistId: rundownPlaylistId, + playlistId: rundownPlaylist._id, adLibPieceId: regularAdLibDoc._id, partInstanceId: rundownPlaylist.currentPartInfo.partInstanceId, pieceType, @@ -160,9 +213,12 @@ class PlaylistsServerAPI implements PlaylistsRestAPI { return ClientAPI.responseSuccess({}) } else if (adLibActionDoc) { // This is an AdLib Action - const rundownPlaylist = await RundownPlaylists.findOneAsync(rundownPlaylistId, { - projection: { currentPartInfo: 1, activationId: 1 }, - }) + const rundownPlaylist = await RundownPlaylists.findOneAsync( + { $or: [{ _id: rundownPlaylistId }, { externalId: rundownPlaylistId }] }, + { + projection: { currentPartInfo: 1, activationId: 1 }, + } + ) if (!rundownPlaylist) return ClientAPI.responseError( @@ -193,14 +249,14 @@ class PlaylistsServerAPI implements PlaylistsRestAPI { this.context.getMethodContext(connection), event, getCurrentTime(), - rundownPlaylistId, + rundownPlaylist._id, () => { - check(rundownPlaylistId, String) + check(rundownPlaylist._id, String) check(adLibId, Match.OneOf(String, null)) }, StudioJobs.ExecuteAction, { - playlistId: rundownPlaylistId, + playlistId: rundownPlaylist._id, actionDocId: adLibActionDoc._id, actionId: adLibActionDoc.actionId, userData: adLibActionDoc.userData, @@ -215,6 +271,7 @@ class PlaylistsServerAPI implements PlaylistsRestAPI { ) } } + async executeBucketAdLib( connection: Meteor.Connection, event: string, @@ -223,6 +280,8 @@ class PlaylistsServerAPI implements PlaylistsRestAPI { externalId: string, triggerMode?: string | null ): Promise> { + const playlist = await this.findPlaylist(rundownPlaylistId) + const bucketPromise = Buckets.findOneAsync(bucketId, { projection: { _id: 1 } }) const bucketAdlibPromise = BucketAdLibs.findOneAsync({ bucketId, externalId }, { projection: { _id: 1 } }) const bucketAdlibActionPromise = BucketAdLibActions.findOneAsync( @@ -256,21 +315,22 @@ class PlaylistsServerAPI implements PlaylistsRestAPI { this.context.getMethodContext(connection), event, getCurrentTime(), - rundownPlaylistId, + playlist._id, () => { - check(rundownPlaylistId, String) + check(playlist._id, String) check(bucketId, String) check(externalId, String) }, StudioJobs.ExecuteBucketAdLibOrAction, { - playlistId: rundownPlaylistId, + playlistId: playlist._id, bucketId, externalId, triggerMode: triggerMode ?? undefined, } ) } + async moveNextPart( connection: Meteor.Connection, event: string, @@ -278,42 +338,47 @@ class PlaylistsServerAPI implements PlaylistsRestAPI { delta: number, ignoreQuickLoop?: boolean ): Promise> { + const playlist = await this.findPlaylist(rundownPlaylistId) + return ServerClientAPI.runUserActionInLogForPlaylistOnWorker( this.context.getMethodContext(connection), event, getCurrentTime(), - rundownPlaylistId, + playlist._id, () => { - check(rundownPlaylistId, String) + check(playlist._id, String) check(delta, Number) }, StudioJobs.MoveNextPart, { - playlistId: rundownPlaylistId, + playlistId: playlist._id, partDelta: delta, segmentDelta: 0, ignoreQuickLoop, } ) } + async moveNextSegment( connection: Meteor.Connection, event: string, rundownPlaylistId: RundownPlaylistId, delta: number ): Promise> { + const playlist = await this.findPlaylist(rundownPlaylistId) + return ServerClientAPI.runUserActionInLogForPlaylistOnWorker( this.context.getMethodContext(connection), event, getCurrentTime(), - rundownPlaylistId, + playlist._id, () => { - check(rundownPlaylistId, String) + check(playlist._id, String) check(delta, Number) }, StudioJobs.MoveNextPart, { - playlistId: rundownPlaylistId, + playlistId: playlist._id, partDelta: 0, segmentDelta: delta, } @@ -325,16 +390,18 @@ class PlaylistsServerAPI implements PlaylistsRestAPI { event: string, rundownPlaylistId: RundownPlaylistId ): Promise> { + const playlist = await this.findPlaylist(rundownPlaylistId) + return ServerClientAPI.runUserActionInLogForPlaylist( this.context.getMethodContext(connection), event, getCurrentTime(), - rundownPlaylistId, + playlist._id, () => { - check(rundownPlaylistId, String) + check(playlist._id, String) }, 'reloadPlaylist', - { rundownPlaylistId }, + { rundownPlaylistId: playlist._id }, async (access) => { const reloadResponse = await ServerRundownAPI.resyncRundownPlaylist(access) const success = !reloadResponse.rundownsResponses.reduce((missing, rundownsResponse) => { @@ -342,7 +409,7 @@ class PlaylistsServerAPI implements PlaylistsRestAPI { }, false) if (!success) throw UserError.from( - new Error(`Failed to reload playlist ${rundownPlaylistId}`), + new Error(`Failed to reload playlist ${playlist._id}`), UserErrorMessage.InternalError ) } @@ -354,17 +421,19 @@ class PlaylistsServerAPI implements PlaylistsRestAPI { event: string, rundownPlaylistId: RundownPlaylistId ): Promise> { + const playlist = await this.findPlaylist(rundownPlaylistId) + return ServerClientAPI.runUserActionInLogForPlaylistOnWorker( this.context.getMethodContext(connection), event, getCurrentTime(), - rundownPlaylistId, + playlist._id, () => { - check(rundownPlaylistId, String) + check(playlist._id, String) }, StudioJobs.ResetRundownPlaylist, { - playlistId: rundownPlaylistId, + playlistId: playlist._id, } ) } @@ -374,19 +443,22 @@ class PlaylistsServerAPI implements PlaylistsRestAPI { rundownPlaylistId: RundownPlaylistId, segmentId: SegmentId ): Promise> { + const playlist = await this.findPlaylist(rundownPlaylistId) + const segment = await this.findSegment(segmentId) + return ServerClientAPI.runUserActionInLogForPlaylistOnWorker( this.context.getMethodContext(connection), event, getCurrentTime(), - rundownPlaylistId, + playlist._id, () => { - check(rundownPlaylistId, String) - check(segmentId, String) + check(playlist._id, String) + check(segment._id, String) }, StudioJobs.SetNextSegment, { - playlistId: rundownPlaylistId, - nextSegmentId: segmentId, + playlistId: playlist._id, + nextSegmentId: segment._id, } ) } @@ -396,19 +468,22 @@ class PlaylistsServerAPI implements PlaylistsRestAPI { rundownPlaylistId: RundownPlaylistId, partId: PartId ): Promise> { + const playlist = await this.findPlaylist(rundownPlaylistId) + const part = await this.findPart(partId) + return ServerClientAPI.runUserActionInLogForPlaylistOnWorker( this.context.getMethodContext(connection), event, getCurrentTime(), - rundownPlaylistId, + playlist._id, () => { - check(rundownPlaylistId, String) - check(partId, String) + check(playlist._id, String) + check(part._id, String) }, StudioJobs.SetNextPart, { - playlistId: rundownPlaylistId, - nextPartId: partId, + playlistId: playlist._id, + nextPartId: part._id, } ) } @@ -419,19 +494,22 @@ class PlaylistsServerAPI implements PlaylistsRestAPI { rundownPlaylistId: RundownPlaylistId, segmentId: SegmentId ): Promise> { + const playlist = await this.findPlaylist(rundownPlaylistId) + const segment = await this.findSegment(segmentId) + return ServerClientAPI.runUserActionInLogForPlaylistOnWorker( this.context.getMethodContext(connection), event, getCurrentTime(), - rundownPlaylistId, + playlist._id, () => { - check(rundownPlaylistId, String) - check(segmentId, String) + check(playlist._id, String) + check(segment._id, String) }, StudioJobs.QueueNextSegment, { - playlistId: rundownPlaylistId, - queuedSegmentId: segmentId, + playlistId: playlist._id, + queuedSegmentId: segment._id, } ) } @@ -443,21 +521,20 @@ class PlaylistsServerAPI implements PlaylistsRestAPI { fromPartInstanceId: PartInstanceId | undefined ): Promise> { triggerWriteAccess() - const rundownPlaylist = await RundownPlaylists.findOneAsync(rundownPlaylistId) - if (!rundownPlaylist) throw new Error(`Rundown playlist ${rundownPlaylistId} does not exist`) + const playlist = await this.findPlaylist(rundownPlaylistId) return ServerClientAPI.runUserActionInLogForPlaylistOnWorker( this.context.getMethodContext(connection), event, getCurrentTime(), - rundownPlaylistId, + playlist._id, () => { - check(rundownPlaylistId, String) + check(playlist._id, String) }, StudioJobs.TakeNextPart, { - playlistId: rundownPlaylistId, - fromPartInstanceId: fromPartInstanceId ?? rundownPlaylist.currentPartInfo?.partInstanceId ?? null, + playlistId: playlist._id, + fromPartInstanceId: fromPartInstanceId ?? playlist.currentPartInfo?.partInstanceId ?? null, } ) } @@ -468,8 +545,8 @@ class PlaylistsServerAPI implements PlaylistsRestAPI { rundownPlaylistId: RundownPlaylistId, sourceLayerIds: string[] ): Promise> { - const rundownPlaylist = await RundownPlaylists.findOneAsync(rundownPlaylistId) - if (!rundownPlaylist) + const playlist = await this.findPlaylist(rundownPlaylistId) + if (!playlist) return ClientAPI.responseError( UserError.from( Error(`Rundown playlist ${rundownPlaylistId} does not exist`), @@ -477,10 +554,10 @@ class PlaylistsServerAPI implements PlaylistsRestAPI { ), 412 ) - if (!rundownPlaylist.currentPartInfo?.partInstanceId || !rundownPlaylist.activationId) + if (!playlist.currentPartInfo?.partInstanceId || !playlist.activationId) return ClientAPI.responseError( UserError.from( - new Error(`Rundown playlist ${rundownPlaylistId} is not currently active`), + new Error(`Rundown playlist ${playlist._id} is not currently active`), UserErrorMessage.InactiveRundown ), 412 @@ -490,15 +567,15 @@ class PlaylistsServerAPI implements PlaylistsRestAPI { this.context.getMethodContext(connection), event, getCurrentTime(), - rundownPlaylistId, + playlist._id, () => { - check(rundownPlaylistId, String) + check(playlist._id, String) check(sourceLayerIds, [String]) }, StudioJobs.StopPiecesOnSourceLayers, { - playlistId: rundownPlaylistId, - partInstanceId: rundownPlaylist.currentPartInfo.partInstanceId, + playlistId: playlist._id, + partInstanceId: playlist.currentPartInfo.partInstanceId, sourceLayerIds: sourceLayerIds, } ) @@ -510,18 +587,20 @@ class PlaylistsServerAPI implements PlaylistsRestAPI { rundownPlaylistId: RundownPlaylistId, sourceLayerId: string ): Promise> { + const playlist = await this.findPlaylist(rundownPlaylistId) + return ServerClientAPI.runUserActionInLogForPlaylistOnWorker( this.context.getMethodContext(connection), event, getCurrentTime(), - rundownPlaylistId, + playlist._id, () => { - check(rundownPlaylistId, String) + check(playlist._id, String) check(sourceLayerId, String) }, StudioJobs.StartStickyPieceOnSourceLayer, { - playlistId: rundownPlaylistId, + playlistId: playlist._id, sourceLayerId, } ) diff --git a/meteor/server/api/rest/v1/typeConversion.ts b/meteor/server/api/rest/v1/typeConversion.ts index db5f7e4030..2a3b008c58 100644 --- a/meteor/server/api/rest/v1/typeConversion.ts +++ b/meteor/server/api/rest/v1/typeConversion.ts @@ -727,3 +727,54 @@ export function playlistSnapshotOptionsFrom(options: APIPlaylistSnapshotOptions) withTimeline: !!options.withTimeline, } } + +export async function validateAPIRundownPayload( + blueprintId: BlueprintId | undefined, + rundownPayload: unknown +): Promise { + const blueprint = await getBlueprint(blueprintId, BlueprintManifestType.STUDIO) + const blueprintManifest = evalBlueprint(blueprint) as StudioBlueprintManifest + + if (typeof blueprintManifest.validateRundownPayloadFromAPI !== 'function') { + logger.info(`Blueprint ${blueprintManifest.blueprintId} does not support rundown payload validation`) + return [] + } + + const blueprintContext = new CommonContext('validateAPIRundownPayload', `blueprint:${blueprint._id}`) + + return blueprintManifest.validateRundownPayloadFromAPI(blueprintContext, rundownPayload) +} + +export async function validateAPISegmentPayload( + blueprintId: BlueprintId | undefined, + segmentPayload: unknown +): Promise { + const blueprint = await getBlueprint(blueprintId, BlueprintManifestType.STUDIO) + const blueprintManifest = evalBlueprint(blueprint) as StudioBlueprintManifest + + if (typeof blueprintManifest.validateSegmentPayloadFromAPI !== 'function') { + logger.info(`Blueprint ${blueprintManifest.blueprintId} does not support segment payload validation`) + return [] + } + + const blueprintContext = new CommonContext('validateAPISegmentPayload', `blueprint:${blueprint._id}`) + + return blueprintManifest.validateSegmentPayloadFromAPI(blueprintContext, segmentPayload) +} + +export async function validateAPIPartPayload( + blueprintId: BlueprintId | undefined, + partPayload: unknown +): Promise { + const blueprint = await getBlueprint(blueprintId, BlueprintManifestType.STUDIO) + const blueprintManifest = evalBlueprint(blueprint) as StudioBlueprintManifest + + if (typeof blueprintManifest.validatePartPayloadFromAPI !== 'function') { + logger.info(`Blueprint ${blueprintManifest.blueprintId} does not support part payload validation`) + return [] + } + + const blueprintContext = new CommonContext('validateAPIPartPayload', `blueprint:${blueprint._id}`) + + return blueprintManifest.validatePartPayloadFromAPI(blueprintContext, partPayload) +} diff --git a/meteor/server/lib/rest/v1/ingest.ts b/meteor/server/lib/rest/v1/ingest.ts new file mode 100644 index 0000000000..2469541a94 --- /dev/null +++ b/meteor/server/lib/rest/v1/ingest.ts @@ -0,0 +1,273 @@ +import { IngestPart, IngestSegment } from '@sofie-automation/blueprints-integration' +import { StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { Meteor } from 'meteor/meteor' +import { ClientAPI } from '@sofie-automation/meteor-lib/dist/api/client' +import { IngestRundown } from '@sofie-automation/blueprints-integration' + +/* ************************************************************************* +This file contains types and interfaces that are used by the REST API. +When making changes to these types, you should be aware of any breaking changes +and update packages/openapi accordingly if needed. +************************************************************************* */ + +export interface IngestRestAPI { + // Playlists + + getPlaylists( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId + ): Promise>> + + getPlaylist( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string // Internal or external ID + ): Promise> + + deletePlaylists( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId + ): Promise> + + deletePlaylist( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string // Internal or external ID + ): Promise> + + // Rundowns + + getRundowns( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string + ): Promise>> + + getRundown( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string + ): Promise> + + postRundown( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + ingestRundown: RestApiIngestRundown + ): Promise> + + putRundowns( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + ingestRundowns: RestApiIngestRundown[] + ): Promise> + + putRundown( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string, + ingestRundown: RestApiIngestRundown + ): Promise> + + deleteRundowns( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string + ): Promise> + + deleteRundown( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string + ): Promise> + + // Segments + + getSegments( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string + ): Promise>> + + getSegment( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string, + segmentId: string + ): Promise> + + postSegment( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string, + ingestSegment: IngestSegment + ): Promise> + + putSegments( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string, + ingestSegments: IngestSegment[] + ): Promise> + + putSegment( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string, + segmentId: string, + ingestSegment: IngestSegment + ): Promise> + + deleteSegments( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string + ): Promise> + + deleteSegment( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string, + segmentId: string + ): Promise> + + // Parts + + getParts( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string, + segmentId: string + ): Promise>> + + getPart( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string, + segmentId: string, + partId: string + ): Promise> + + postPart( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string, + segmentId: string, + ingestPart: IngestPart + ): Promise> + + putParts( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string, + segmentId: string, + ingestParts: IngestPart[] + ): Promise> + + putPart( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string, + segmentId: string, + partId: string, + ingestPart: IngestPart + ): Promise> + + deleteParts( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string, + segmentId: string + ): Promise> + + deletePart( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string, + segmentId: string, + partId: string + ): Promise> +} + +export type RestApiIngestRundown = Omit & { + resyncUrl: string +} + +export type PlaylistResponse = { + id: string + externalId: string + rundownIds: string[] + studioId: string +} + +export type RundownResponse = { + id: string + externalId: string + studioId: string + playlistId: string + playlistExternalId?: string + name: string +} + +export type SegmentResponse = { + id: string + externalId: string + rundownId: string + name: string + rank: number + isHidden?: boolean +} + +export type PartResponse = { + id: string + externalId: string + rundownId: string + segmentId: string + name: string + expectedDuration?: number + autoNext?: boolean + rank: number +} diff --git a/packages/blueprints-integration/src/api/studio.ts b/packages/blueprints-integration/src/api/studio.ts index d1e6f42a8e..e902446754 100644 --- a/packages/blueprints-integration/src/api/studio.ts +++ b/packages/blueprints-integration/src/api/studio.ts @@ -103,6 +103,15 @@ export interface StudioBlueprintManifest Array + /** Validate the rundown payload passed to this blueprint according to the API schema, returning a list of error messages. */ + validateRundownPayloadFromAPI?: (context: ICommonContext, payload: unknown) => Array + + /** Validate the segment payload passed to this blueprint according to the API schema, returning a list of error messages. */ + validateSegmentPayloadFromAPI?: (context: ICommonContext, payload: unknown) => Array + + /** Validate the part payload passed to this blueprint according to the API schema, returning a list of error messages. */ + validatePartPayloadFromAPI?: (context: ICommonContext, payload: unknown) => Array + /** * Optional method to transform from an API blueprint config to the database blueprint config if these are required to be different. * If this method is not defined the config object will be used directly diff --git a/packages/corelib/src/dataModel/Rundown.ts b/packages/corelib/src/dataModel/Rundown.ts index 61b1159eb9..005f54d9c3 100644 --- a/packages/corelib/src/dataModel/Rundown.ts +++ b/packages/corelib/src/dataModel/Rundown.ts @@ -93,7 +93,12 @@ export interface Rundown { } /** A description of where a Rundown originated from */ -export type RundownSource = RundownSourceNrcs | RundownSourceSnapshot | RundownSourceHttp | RundownSourceTesting +export type RundownSource = + | RundownSourceNrcs + | RundownSourceSnapshot + | RundownSourceHttp + | RundownSourceTesting + | RundownSourceRestApi /** A description of the external NRCS source of a Rundown */ export interface RundownSourceNrcs { @@ -119,6 +124,11 @@ export interface RundownSourceTesting { /** The ShowStyleVariant the Rundown is created for */ showStyleVariantId: ShowStyleVariantId } +/** A description of the source of a Rundown which was through the new HTTP ingest API */ +export interface RundownSourceRestApi { + type: 'restApi' + resyncUrl: string +} export function getRundownNrcsName(rundown: ReadonlyDeep> | undefined): string { if (rundown?.source?.type === 'nrcs' && rundown.source.nrcsName) { diff --git a/packages/job-worker/src/blueprints/ingest/MutableIngestPartImpl.ts b/packages/job-worker/src/blueprints/ingest/MutableIngestPartImpl.ts index 068eefddd6..7948a2c4ad 100644 --- a/packages/job-worker/src/blueprints/ingest/MutableIngestPartImpl.ts +++ b/packages/job-worker/src/blueprints/ingest/MutableIngestPartImpl.ts @@ -20,6 +20,14 @@ export class MutableIngestPartImpl implements MutableIng return this.#ingestPart.name } + get float(): boolean { + return this.#ingestPart.float ?? false + } + + get autoNext(): boolean { + return this.#ingestPart.autoNext ?? false + } + get payload(): ReadonlyDeep | undefined { return this.#ingestPart.payload as ReadonlyDeep } diff --git a/packages/job-worker/src/blueprints/ingest/MutableIngestRundownImpl.ts b/packages/job-worker/src/blueprints/ingest/MutableIngestRundownImpl.ts index 4a7b286d43..5c217d4a8a 100644 --- a/packages/job-worker/src/blueprints/ingest/MutableIngestRundownImpl.ts +++ b/packages/job-worker/src/blueprints/ingest/MutableIngestRundownImpl.ts @@ -314,6 +314,8 @@ export class MutableIngestRundownImpl | undefined { return this.#ingestSegment.payload as ReadonlyDeep } @@ -225,6 +233,8 @@ export class MutableIngestSegmentImpl = { externalId: part.externalId, rank, + float: part.float, + autoNext: part.autoNext, name: part.name, payload: part.payload, userEditStates: part.userEditStates, diff --git a/packages/job-worker/src/blueprints/ingest/__tests__/MutableIngestRundownImpl.spec.ts b/packages/job-worker/src/blueprints/ingest/__tests__/MutableIngestRundownImpl.spec.ts index f24ff4b9cb..542ce44981 100644 --- a/packages/job-worker/src/blueprints/ingest/__tests__/MutableIngestRundownImpl.spec.ts +++ b/packages/job-worker/src/blueprints/ingest/__tests__/MutableIngestRundownImpl.spec.ts @@ -28,6 +28,11 @@ describe('MutableIngestRundownImpl', () => { externalId: 'seg0', name: 'name', rank: 0, + isHidden: false, + timing: { + expectedStart: 0, + expectedEnd: 0, + }, payload: { val: 'first-val', second: 5, @@ -38,6 +43,8 @@ describe('MutableIngestRundownImpl', () => { externalId: 'part0', name: 'my first part', rank: 0, + float: false, + autoNext: false, payload: { val: 'some-val', }, @@ -49,6 +56,11 @@ describe('MutableIngestRundownImpl', () => { externalId: 'seg1', name: 'name 2', rank: 1, + isHidden: false, + timing: { + expectedStart: 0, + expectedEnd: 0, + }, payload: { val: 'next-val', }, @@ -58,6 +70,8 @@ describe('MutableIngestRundownImpl', () => { externalId: 'part1', name: 'my second part', rank: 0, + float: false, + autoNext: false, payload: { val: 'some-val', }, @@ -69,6 +83,11 @@ describe('MutableIngestRundownImpl', () => { externalId: 'seg2', name: 'name 3', rank: 2, + isHidden: false, + timing: { + expectedStart: 0, + expectedEnd: 0, + }, payload: { val: 'last-val', }, @@ -78,6 +97,8 @@ describe('MutableIngestRundownImpl', () => { externalId: 'part2', name: 'my third part', rank: 0, + float: false, + autoNext: false, payload: { val: 'some-val', }, @@ -445,6 +466,11 @@ describe('MutableIngestRundownImpl', () => { const newSegment: Omit = { externalId: 'seg1', name: 'new name', + isHidden: false, + timing: { + expectedStart: 0, + expectedEnd: 0, + }, payload: { val: 'new-val', }, @@ -454,6 +480,8 @@ describe('MutableIngestRundownImpl', () => { externalId: 'part1', name: 'new part name', rank: 0, + float: false, + autoNext: false, payload: { val: 'new-part-val', }, @@ -498,6 +526,11 @@ describe('MutableIngestRundownImpl', () => { const newSegment: Omit = { externalId: 'segX', name: 'new name', + isHidden: false, + timing: { + expectedStart: 0, + expectedEnd: 0, + }, payload: { val: 'new-val', }, @@ -507,6 +540,8 @@ describe('MutableIngestRundownImpl', () => { externalId: 'partX', name: 'new part name', rank: 0, + float: false, + autoNext: false, payload: { val: 'new-part-val', }, @@ -543,6 +578,11 @@ describe('MutableIngestRundownImpl', () => { const newSegment: Omit = { externalId: 'segX', name: 'new name', + isHidden: false, + timing: { + expectedStart: 0, + expectedEnd: 0, + }, payload: { val: 'new-val', }, @@ -551,6 +591,8 @@ describe('MutableIngestRundownImpl', () => { externalId: 'partX', name: 'new part name', rank: 0, + float: false, + autoNext: false, payload: { val: 'new-part-val', }, diff --git a/packages/job-worker/src/blueprints/ingest/__tests__/MutableIngestSegmentImpl.spec.ts b/packages/job-worker/src/blueprints/ingest/__tests__/MutableIngestSegmentImpl.spec.ts index 5ab56ecb46..a35aa9c669 100644 --- a/packages/job-worker/src/blueprints/ingest/__tests__/MutableIngestSegmentImpl.spec.ts +++ b/packages/job-worker/src/blueprints/ingest/__tests__/MutableIngestSegmentImpl.spec.ts @@ -25,6 +25,8 @@ describe('MutableIngestSegmentImpl', () => { externalId: 'part0', name: 'my first part', rank: 0, + float: false, + autoNext: false, payload: { val: 'some-val', }, @@ -34,6 +36,8 @@ describe('MutableIngestSegmentImpl', () => { externalId: 'part1', name: 'another part', rank: 1, + float: false, + autoNext: false, payload: { val: 'second-val', }, @@ -43,6 +47,8 @@ describe('MutableIngestSegmentImpl', () => { externalId: 'part2', name: 'third part', rank: 2, + float: false, + autoNext: false, payload: { val: 'third-val', }, @@ -52,6 +58,8 @@ describe('MutableIngestSegmentImpl', () => { externalId: 'part3', name: 'last part', rank: 3, + float: false, + autoNext: false, payload: { val: 'last-val', }, @@ -329,6 +337,8 @@ describe('MutableIngestSegmentImpl', () => { const newPart: Omit = { externalId: 'part1', name: 'new name', + float: false, + autoNext: false, payload: { val: 'new-val', }, @@ -369,6 +379,8 @@ describe('MutableIngestSegmentImpl', () => { const newPart: Omit = { externalId: 'partX', name: 'new name', + float: false, + autoNext: false, payload: { val: 'new-val', }, @@ -408,6 +420,8 @@ describe('MutableIngestSegmentImpl', () => { const newPart: Omit = { externalId: 'partX', name: 'new name', + float: false, + autoNext: false, payload: { val: 'new-val', }, diff --git a/packages/job-worker/src/ingest/runOperation.ts b/packages/job-worker/src/ingest/runOperation.ts index 86716a7bab..4f90eb559e 100644 --- a/packages/job-worker/src/ingest/runOperation.ts +++ b/packages/job-worker/src/ingest/runOperation.ts @@ -275,6 +275,8 @@ async function updateSofieIngestRundown( name: nrcsIngestRundown.name, type: nrcsIngestRundown.type, segments: [], + timing: nrcsIngestRundown.timing, + playlistExternalId: nrcsIngestRundown.playlistExternalId, payload: undefined, userEditStates: {}, rundownSource: nrcsIngestRundown.rundownSource, diff --git a/packages/openapi/api/actions.yaml b/packages/openapi/api/actions.yaml index f2b61596ad..e0d9f826be 100644 --- a/packages/openapi/api/actions.yaml +++ b/packages/openapi/api/actions.yaml @@ -109,3 +109,20 @@ paths: # snapshot operations /snapshots: $ref: 'definitions/snapshots.yaml#/resources/snapshots' + # ingest operations + /ingest/{studioId}/playlists: + $ref: 'definitions/ingest.yaml#/resources/playlists' + /ingest/{studioId}/playlists/{playlistId}: + $ref: 'definitions/ingest.yaml#/resources/playlist' + /ingest/{studioId}/playlists/{playlistId}/rundowns: + $ref: 'definitions/ingest.yaml#/resources/rundowns' + /ingest/{studioId}/playlists/{playlistId}/rundowns/{rundownId}: + $ref: 'definitions/ingest.yaml#/resources/rundown' + /ingest/{studioId}/playlists/{playlistId}/rundowns/{rundownId}/segments: + $ref: 'definitions/ingest.yaml#/resources/segments' + /ingest/{studioId}/playlists/{playlistId}/rundowns/{rundownId}/segments/{segmentId}: + $ref: 'definitions/ingest.yaml#/resources/segment' + /ingest/{studioId}/playlists/{playlistId}/rundowns/{rundownId}/segments/{segmentId}/parts: + $ref: 'definitions/ingest.yaml#/resources/parts' + /ingest/{studioId}/playlists/{playlistId}/rundowns/{rundownId}/segments/{segmentId}/parts/{partId}: + $ref: 'definitions/ingest.yaml#/resources/part' diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml new file mode 100644 index 0000000000..d59ab57913 --- /dev/null +++ b/packages/openapi/api/definitions/ingest.yaml @@ -0,0 +1,1415 @@ +title: ingest +description: Ingest methods +resources: + playlists: + get: + operationId: getPlaylists + summary: Gets all Playlists. + tags: + - ingest + parameters: + - name: studioId + in: path + description: ID of the studio that is performing ingest operation. + required: true + schema: + type: string + responses: + 200: + description: Command successfully handled - returns an array of Playlists. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/playlistResponse' + delete: + operationId: deletePlaylists + tags: + - ingest + parameters: + - name: studioId + in: path + description: ID of the studio that is performing ingest operation. + required: true + schema: + type: string + summary: Deletes all Playlists. Resources under the Playlists (e.g. Rundowns) will also be removed. + responses: + 202: + description: Request for deleting accepted. + playlist: + get: + operationId: getPlaylist + summary: Gets the specified Playlist. + tags: + - ingest + parameters: + - name: studioId + in: path + description: ID of the studio that is performing ingest operation. + required: true + schema: + type: string + - name: playlistId + in: path + description: Internal or external ID of the Playlist to return. + required: true + schema: + type: string + responses: + 200: + description: Playlist is returned. + content: + application/json: + schema: + $ref: '#/components/schemas/playlistResponse' + 404: + description: Invalid playlistId + $ref: '#/components/responses/playlistNotFound' + delete: + operationId: deletePlaylist + summary: Deletes a specified Playlist. Resources under the Playlist (e.g. Rundowns) will also be removed. + tags: + - ingest + parameters: + - name: studioId + in: path + description: ID of the studio that is performing ingest operation. + required: true + schema: + type: string + - name: playlistId + in: path + description: Internal or external ID of the Playlist to delete. + required: true + schema: + type: string + responses: + 202: + description: Request for deleting accepted. + 404: + $ref: '#/components/responses/playlistNotFound' + rundowns: + get: + operationId: getRundowns + summary: Gets all Rundowns belonging to a specified Playlist. + tags: + - ingest + parameters: + - name: studioId + in: path + description: ID of the studio that is performing ingest operation. + required: true + schema: + type: string + - name: playlistId + in: path + description: Internal or external ID of the Playlist the Rundowns belong to. + required: true + schema: + type: string + responses: + 200: + description: Command successfully handled - returns an array of Rundowns. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/rundownResponse' + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + post: + operationId: postRundown + summary: Creates a Rundown in a specified Playlist. + tags: + - ingest + parameters: + - name: studioId + in: path + description: ID of the studio that is performing ingest operation. + required: true + schema: + type: string + - name: playlistId + in: path + description: Internal or external ID of the Playlist the new Rundown belongs to. + required: true + schema: + type: string + requestBody: + description: Rundown data to ingest. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/rundown' + responses: + 202: + description: Request has been accepted. + 400: + description: Bad request. + put: + operationId: putRundowns + summary: Updates Rundowns belonging to a specified Playlist. + tags: + - ingest + parameters: + - name: studioId + in: path + description: ID of the studio that is performing ingest operation. + required: true + schema: + type: string + - name: playlistId + in: path + description: Internal or external ID of the Playlist the Rundowns to update belong to. + required: true + schema: + type: string + requestBody: + description: Contains the Rundown data. + required: true + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/rundown' + responses: + 202: + description: Request has been accepted. + 400: + description: Bad request. + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/playlistNotFound' + delete: + operationId: deleteRundowns + tags: + - ingest + summary: Deletes all Rundowns belonging to specified Playlist. Resources under the Rundowns (e.g. Segments) will also be removed. + parameters: + - name: studioId + in: path + description: ID of the studio that is performing ingest operation. + required: true + schema: + type: string + - name: playlistId + in: path + description: Internal or external ID of the Playlist the Rundowns to delete belong to. + required: true + schema: + type: string + responses: + 202: + description: Request accepted. + 404: + $ref: '#/components/responses/idNotFound' + rundown: + get: + operationId: getRundown + summary: Gets the specified Rundown. + tags: + - ingest + parameters: + - name: studioId + in: path + description: ID of the studio that is performing ingest operation. + required: true + schema: + type: string + - name: playlistId + in: path + description: Internal or external ID of the Playlist the Rundown belongs to. + required: true + schema: + type: string + - name: rundownId + in: path + description: Internal or external ID of the Rundown to return. + required: true + schema: + type: string + responses: + 200: + description: Rundown is returned. + content: + application/json: + schema: + $ref: '#/components/schemas/rundownResponse' + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + put: + operationId: putRundown + summary: Updates an existing specified Rundown. + tags: + - ingest + parameters: + - name: studioId + in: path + description: ID of the studio that is performing ingest operation. + required: true + schema: + type: string + - name: playlistId + in: path + description: Internal or external ID of the Playlist the Rundown to update belongs to. + required: true + schema: + type: string + - name: rundownId + in: path + description: Internal or external ID of the Rundown to update. + required: true + schema: + type: string + requestBody: + description: Contains the Rundown data. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/rundown' + responses: + 202: + description: Request has been accepted. + 400: + description: Bad request. + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + delete: + operationId: deleteRundown + summary: Deletes a specified Rundown. Resources under the Rundown (e.g. Segments) will also be removed. + tags: + - ingest + parameters: + - name: studioId + in: path + description: ID of the studio that is performing ingest operation. + required: true + schema: + type: string + - name: playlistId + in: path + description: Internal or external ID of the Playlist the Rundown belongs to. + required: true + schema: + type: string + - name: rundownId + in: path + description: Internal or external ID of the Rundown to delete. + required: true + schema: + type: string + responses: + 202: + description: Request for deleting accepted. + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + segments: + get: + operationId: getSegments + tags: + - ingest + summary: Gets all Segments belonging to a specified Rundown. + parameters: + - name: studioId + in: path + description: ID of the studio that is performing ingest operation. + required: true + schema: + type: string + - name: playlistId + in: path + description: Internal or external ID of the Playlist the Segments belong to. + required: true + schema: + type: string + - name: rundownId + in: path + description: Internal or external ID of the Rundown the Segments belong to. + required: true + schema: + type: string + responses: + 200: + description: Command successfully handled - returns an array of Segments. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/segmentResponse' + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + post: + operationId: postSegment + tags: + - ingest + summary: Creates a Segment in a specified Rundown. + parameters: + - name: studioId + in: path + description: ID of the studio that is performing ingest operation. + required: true + schema: + type: string + - name: playlistId + in: path + description: Internal or external ID of the Playlist the new Segment belongs to. + required: true + schema: + type: string + - name: rundownId + in: path + description: Internal or external ID of the Rundown the new Segment belongs to. + required: true + schema: + type: string + requestBody: + description: Contains the Segment data. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/segment' + responses: + 202: + description: Request accepted. + 400: + description: Bad request. + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + put: + operationId: putSegments + tags: + - ingest + summary: Updates Segments belonging to a specified Rundown. + parameters: + - name: studioId + in: path + description: ID of the studio that is performing ingest operation. + required: true + schema: + type: string + - name: playlistId + in: path + description: Internal or external ID of the Playlist the Segments to update belongs to. + required: true + schema: + type: string + - name: rundownId + in: path + description: Internal or external ID of the Rundown the Segments to update belong to. + required: true + schema: + type: string + requestBody: + description: Contains the Segment data. + required: true + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/segment' + responses: + 202: + description: Request accepted. + 400: + description: Bad request. + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + delete: + operationId: deleteSegments + tags: + - ingest + summary: Deletes all Segments belonging to specified Rundown. Resources under the Segments (e.g. Parts) will also be removed. + parameters: + - name: studioId + in: path + description: ID of the studio that is performing ingest operation. + required: true + schema: + type: string + - name: playlistId + in: path + description: Internal or external ID of the Playlist the Segments belong to. + required: true + schema: + type: string + - name: rundownId + in: path + description: Internal or external ID of the Rundown the Segments to delete belong to. + required: true + schema: + type: string + responses: + 202: + description: Request accepted. + 404: + $ref: '#/components/responses/idNotFound' + segment: + get: + operationId: getSegment + tags: + - ingest + summary: Gets the specified Segment. + parameters: + - name: studioId + in: path + description: ID of the studio that is performing ingest operation. + required: true + schema: + type: string + - name: playlistId + in: path + description: Internal or external ID of the Playlist the Segment belongs to. + required: true + schema: + type: string + - name: rundownId + in: path + description: Internal or external ID of the Rundown the Segment belongs to. + required: true + schema: + type: string + - name: segmentId + in: path + description: Internal or external ID of the Segment to return. + required: true + schema: + type: string + responses: + 200: + description: Segment is returned. + content: + application/json: + schema: + $ref: '#/components/schemas/segmentResponse' + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + # - $ref: '#/components/responses/segmentNotFound' + put: + operationId: putSegment + tags: + - ingest + summary: Updates an existing specified Segment. + parameters: + - name: studioId + in: path + description: ID of the studio that is performing ingest operation. + required: true + schema: + type: string + - name: playlistId + in: path + description: Internal or external ID of the Playlist the Segment to update belongs to. + required: true + schema: + type: string + - name: rundownId + in: path + description: Internal or external ID of the Rundown the Segment to update belongs to. + required: true + schema: + type: string + - name: segmentId + in: path + description: Internal or external ID of the Segment to update. + required: true + schema: + type: string + requestBody: + description: Contains the Segment data. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/segment' + responses: + 202: + description: Request accepted. + 400: + description: Bad request. + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + delete: + operationId: deleteSegment + tags: + - ingest + summary: Deletes a specified Segment. Resources under the Segment (e.g. Parts) will also be removed. + parameters: + - name: studioId + in: path + description: ID of the studio that is performing ingest operation. + required: true + schema: + type: string + - name: playlistId + in: path + description: Internal or external ID of the Playlist the Segment belongs to. + required: true + schema: + type: string + - name: rundownId + in: path + description: Internal or external ID of the Rundown the Segment belongs to. + required: true + schema: + type: string + - name: segmentId + in: path + description: Internal or external ID of the Segment to delete. + required: true + schema: + type: string + responses: + 202: + description: Request accepted. + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + parts: + get: + operationId: getParts + tags: + - ingest + summary: Gets all Parts belonging to a specified Segment. + parameters: + - name: studioId + in: path + description: ID of the studio that is performing ingest operation. + required: true + schema: + type: string + - name: playlistId + in: path + description: Internal or external ID of the Playlist the Parts belong to. + required: true + schema: + type: string + - name: rundownId + in: path + description: Internal or external ID of the Rundown the Parts belong to. + required: true + schema: + type: string + - name: segmentId + in: path + description: Internal or external ID of the Segment the Parts belong to. + required: true + schema: + type: string + responses: + 200: + description: Command successfully handled - returns an array of Parts. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/partResponse' + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + # - $ref: '#/components/responses/segmentNotFound' + post: + operationId: postPart + tags: + - ingest + summary: Creates a Part in a specified Segment. + parameters: + - name: studioId + in: path + description: ID of the studio that is performing ingest operation. + required: true + schema: + type: string + - name: playlistId + in: path + description: Internal or external ID of the Playlist the new Part belongs to. + required: true + schema: + type: string + - name: rundownId + in: path + description: Internal or external ID of the Rundown the new Part belongs to. + required: true + schema: + type: string + - name: segmentId + in: path + description: Internal or external ID of the Segment the new Part belongs to. + required: true + schema: + type: string + requestBody: + description: Contains the Parts data. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/part' + responses: + 202: + description: Request accepted. + 400: + description: Bad request. + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + # - $ref: '#/components/responses/partNotFound' + put: + operationId: putParts + tags: + - ingest + summary: Updates Parts belonging to a specified Segment. + parameters: + - name: studioId + in: path + description: ID of the studio that is performing ingest operation. + required: true + schema: + type: string + - name: playlistId + in: path + description: Internal or external ID of the Playlist the Parts to update belong to. + required: true + schema: + type: string + - name: rundownId + in: path + description: Internal or external ID of the Rundown the Parts to update belong to. + required: true + schema: + type: string + - name: segmentId + in: path + description: Internal or external ID of the Segment the Parts to update belong to. + required: true + schema: + type: string + requestBody: + description: Contains the Parts data. + required: true + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/part' + responses: + 202: + description: Request accepted. + 400: + description: Bad request. + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + # - $ref: '#/components/responses/partNotFound' + delete: + operationId: deleteParts + tags: + - ingest + summary: Deletes all Parts belonging to specified Segment. + parameters: + - name: studioId + in: path + description: ID of the studio that is performing ingest operation. + required: true + schema: + type: string + - name: playlistId + in: path + description: Internal or external ID of the Playlist the Parts belong to. + required: true + schema: + type: string + - name: rundownId + in: path + description: Internal or external ID of the Rundown the Parts belong to. + required: true + schema: + type: string + - name: segmentId + in: path + description: Internal or external ID of the Segment the Parts to delete belong to. + required: true + schema: + type: string + responses: + 202: + description: Request for deleting accepted. + 404: + $ref: '#/components/responses/idNotFound' + part: + get: + operationId: getPart + tags: + - ingest + summary: Gets the specified Part. + parameters: + - name: studioId + in: path + description: ID of the studio that is performing ingest operation. + required: true + schema: + type: string + - name: playlistId + in: path + description: Internal or external ID of the Playlist the Part belongs to. + required: true + schema: + type: string + - name: rundownId + in: path + description: Internal or external ID of the Rundown the Part belongs to. + required: true + schema: + type: string + - name: segmentId + in: path + description: Internal or external ID of the Segment the Part belongs to. + required: true + schema: + type: string + - name: partId + in: path + description: Internal or external ID of the Part to return. + required: true + schema: + type: string + responses: + 200: + description: Part is returned. + content: + application/json: + schema: + $ref: '#/components/schemas/partResponse' + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + # - $ref: '#/components/responses/segmentNotFound' + # - $ref: '#/components/responses/partNotFound' + put: + operationId: putPart + tags: + - ingest + summary: Updates an existing specified Part. + parameters: + - name: studioId + in: path + description: ID of the studio that is performing ingest operation. + required: true + schema: + type: string + - name: playlistId + in: path + description: Internal or external ID of the Playlist the Part to update belongs to. + required: true + schema: + type: string + - name: rundownId + in: path + description: Internal or external ID of the Rundown the Part to update belongs to. + required: true + schema: + type: string + - name: segmentId + in: path + description: Internal or external ID of the Segment the Part to update belongs to. + schema: + type: string + - name: partId + in: path + description: Internal or external ID of the Part to update. + schema: + type: string + requestBody: + description: Contains the Rundown data. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/part' + responses: + 202: + description: Request has been accepted. + 400: + description: Bad request. + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + # - $ref: '#/components/responses/segmentNotFound' + delete: + operationId: deletePart + tags: + - ingest + summary: Deletes a specified Part. + parameters: + - name: studioId + in: path + description: ID of the studio that is performing ingest operation. + required: true + schema: + type: string + - name: playlistId + in: path + description: Internal or external ID of the Playlist the Part belongs to. + required: true + schema: + type: string + - name: rundownId + in: path + description: Internal or external ID of the Rundown the Part belongs to. + required: true + schema: + type: string + - name: segmentId + in: path + description: Internal or external ID of the Segment the Part belongs to. + required: true + schema: + type: string + - name: partId + in: path + description: Internal or external ID of the Part to delete. + required: true + schema: + type: string + responses: + 202: + description: Request has been accepted. + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + # - $ref: '#/components/responses/segmentNotFound' + +components: + responses: + idNotFound: + # oneOf responses don't render correctly with current tools. Use this response as a replacement. + description: The specified resource does not exist. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 404 + example: 404 + message: + type: string + example: The specified resource was not found. + required: + - status + - message + additionalProperties: false + playlistNotFound: + description: The specified Playlist does not exist. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 404 + example: 404 + notFound: + type: string + const: playlist + example: playlist + message: + type: string + example: The specified Playlist was not found. + required: + - status + - notFound + - message + additionalProperties: false + rundownNotFound: + description: The specified Rundown does not exist. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 404 + example: 404 + message: + type: string + example: The specified Rundown was not found. + required: + - status + - notFound + - message + additionalProperties: false + segmentNotFound: + description: The specified Segment does not exist. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 404 + example: 404 + message: + type: string + example: The specified Segment was not found. + required: + - status + - notFound + - message + additionalProperties: false + partNotFound: + description: The specified Part does not exist. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 404 + example: 404 + message: + type: string + example: The specified Part was not found. + required: + - status + - notFound + - message + additionalProperties: false + badRequest: + description: Bad request. + schemas: + playlist: + type: object + properties: + name: + type: string + example: Playlist name + externalId: + type: string + example: playlist1 + rundownIds: + type: array + items: + type: string + example: + - rundown1 + - rundown2 + - rundown3 + required: + - name + additionalProperties: false + playlistResponse: + type: object + properties: + id: + type: string + externalId: + type: string + example: playlist1 + rundownIds: + type: array + items: + type: string + example: + - rundown1 + - rundown2 + - rundown3 + studioId: + type: string + example: studio0 + required: + - id + - externalId + - rundownIds + - studioId + additionalProperties: false + rundown: + type: object + properties: + externalId: + type: string + example: rundown1 + name: + type: string + example: Rundown 1 + type: + type: string + example: external + description: Value that defines the structure of the payload, must be known by Sofie. + resyncUrl: + type: string + example: http://nrcs-url/resync/rundownId + description: URL on which the Sofie will send the POST request to request re-syncing of the Rundown. + timing: + type: object + description: If type is "none", only expectedDuration can be optionally provided. If type is "forward-time", expectedStart must be provided while either duration or expectedEnd can be optionally provided. If type is "back-time", expectedEnd must be provided while either duration or expectedStart can be optionally provided. + properties: + type: + type: string + enum: + - none + - forward-time + - back-time + expectedStart: + type: number + description: Epoch timestamp in milliseconds. + example: 1705924800000 + expectedEnd: + type: number + description: Epoch timestamp in milliseconds. + example: 1705927500000 + expectedDuration: + type: number + description: Interval in milliseconds. + example: 3600000 + required: + - type + additionalProperties: false + segments: + type: array + items: + $ref: '#/components/schemas/segment' + required: + - externalId + - name + - type + - resyncUrl + - timing + additionalProperties: false + rundownResponse: + type: object + properties: + id: + type: string + externalId: + type: string + example: rundown1 + studioId: + type: string + example: studio0 + playlistId: + type: string + example: playlist1 + playlistExternalId: + type: string + example: playlistExternal1 + name: + type: string + example: Rundown 1 + type: + type: string + timing: + type: object + properties: + type: + type: string + enum: + - none + - forward-time + - back-time + expectedStart: + type: number + description: Epoch timestamp in milliseconds. + expectedDuration: + type: number + description: Epoch interval in milliseconds. + expectedEnd: + type: number + description: Epoch timestamp in milliseconds. + required: + - type + additionalProperties: false + required: + - id + - externalId + - studioId + - playlistId + - name + segment: + type: object + properties: + externalId: + type: string + example: segment1 + name: + type: string + example: Segment 1 + rank: + type: number + description: The position of the Segment in the parent Rundown. + inclusiveMinimum: 0.0 + example: 1 + isHidden: + type: boolean + example: false + description: If the Segment is hidden or not. + timing: + type: object + description: Segment timing. + properties: + expectedStart: + type: number + description: Epoch timestamp in milliseconds. + example: 1705924800000 + expectedEnd: + type: number + description: Epoch timestamp in milliseconds. + example: 1705927500000 + additionalProperties: false + parts: + type: array + items: + $ref: '#/components/schemas/part' + required: + - externalId + - name + - rank + additionalProperties: false + segmentResponse: + type: object + properties: + id: + type: string + example: segment1 + externalId: + type: string + example: segmentExternal1 + rundownId: + type: string + example: rundown11 + name: + type: string + example: Segment 1 + rank: + type: number + example: 1 + isHidden: + type: boolean + timing: + type: object + properties: + expectedStart: + type: number + description: Epoch timestamp in milliseconds. + expectedEnd: + type: number + description: Epoch timestamp in milliseconds. + additionalProperties: false + required: + - id + - externalId + - rundownId + - name + - rank + additionalProperties: false + part: + type: object + properties: + externalId: + type: string + example: part1 + name: + type: string + example: Part 1 + float: + type: boolean + example: false + autoNext: + type: boolean + example: false + rank: + type: number + description: Position of the Part in the Segment. + example: 0 + payload: + type: object + properties: + type: + type: string + enum: + - CAMERA + - FULL + - VO + - REMOTE + - COMPOSITION + - FULLSCREEN_GRAPHIC + - MACRO + script: + type: string + guest: + type: boolean + pieces: + type: array + items: + $ref: '#/components/schemas/piece' + additionalProperties: false + required: + - type + - pieces + required: + - externalId + - name + - rank + - payload + additionalProperties: false + partResponse: + type: object + properties: + id: + type: string + example: part1 + externalId: + type: string + example: partExternal1 + rundownId: + type: string + example: rundown1 + segmentId: + type: string + example: segment1 + name: + type: string + example: Part 1 + rank: + type: number + example: 0 + expectedDuration: + type: number + description: Calculated based on pieces. + example: 10000 + autoNext: + type: boolean + example: false + required: + - id + - externalId + - rundownId + - segmentId + - name + - rank + additionalProperties: false + piece: + type: object + properties: + id: + type: string + example: piece1 + objectType: + type: string + enum: + - CAMERA + - REMOTE + - FULL + - VO + - COMPOSITION + - FULLSCREEN_GRAPHIC + - OVERLAY_GRAPHIC + - STUDIO_GRAPHIC + - AUDIO + - BED + - MACRO + - AUX + - LIGHTING + objectTime: + type: string + duration: + type: object + description: If type is "duration", duration property must be provided. + properties: + type: + type: string + example: within-part + enum: + - within-part + - segment-end + - rundown-end + - showstyle-end + - duration + duration: + type: string + example: 00:00:10:00 + required: + - type + additionalProperties: false + resourceName: + type: string + example: camera1 + label: + type: string + example: Lower third + attributes: + type: object + description: Object with key-value pairs. + additionalProperties: true + transition: + type: string + example: cut + transitionDuration: + type: string + example: 00:00:00:00 + target: + type: string + example: pgm + required: + - id + - objectType + - resourceName + - attributes + additionalProperties: false diff --git a/packages/openapi/src/__tests__/ingest.spec.ts b/packages/openapi/src/__tests__/ingest.spec.ts new file mode 100644 index 0000000000..0b7ed133e1 --- /dev/null +++ b/packages/openapi/src/__tests__/ingest.spec.ts @@ -0,0 +1,470 @@ +import { Configuration, IngestApi, Part, RundownTimingTypeEnum } from '../../client/ts/index.js' +import { checkServer } from '../checkServer.js' +import Logging from '../httpLogging.js' + +const httpLogging = false +const studioId = 'studio0' + +describe('Ingest API', () => { + const config = new Configuration({ + basePath: process.env.SERVER_URL, + middleware: [new Logging(httpLogging)], + }) + + beforeAll(async () => await checkServer(config)) + + const ingestApi = new IngestApi(config) + + /** + * PLAYLISTS + */ + const playlistIds: string[] = [] + test('Can request all playlists', async () => { + const playlists = await ingestApi.getPlaylists({ studioId }) + + expect(playlists.length).toBeGreaterThanOrEqual(1) + playlists.forEach((playlist) => { + expect(typeof playlist).toBe('object') + expect(typeof playlist.id).toBe('string') + expect(typeof playlist.externalId).toBe('string') + expect(typeof playlist.studioId).toBe('string') + expect(typeof playlist.rundownIds).toBe('object') + playlist.rundownIds.forEach((rundownId) => { + expect(typeof rundownId).toBe('string') + }) + + playlistIds.push(playlist.externalId) + }) + }) + + test('Can request a playlist by id', async () => { + const playlist = await ingestApi.getPlaylist({ + studioId, + playlistId: playlistIds[0], + }) + + expect(typeof playlist).toBe('object') + expect(typeof playlist.id).toBe('string') + expect(typeof playlist.externalId).toBe('string') + expect(typeof playlist.studioId).toBe('string') + expect(typeof playlist.rundownIds).toBe('object') + playlist.rundownIds.forEach((rundownId) => { + expect(typeof rundownId).toBe('string') + }) + }) + + test('Can delete multiple playlists', async () => { + const result = await ingestApi.deletePlaylists({ studioId }) + expect(result).toBe(undefined) + }) + + test('Can delete playlist by id', async () => { + const result = await ingestApi.deletePlaylist({ + studioId, + playlistId: playlistIds[0], + }) + expect(result).toBe(undefined) + }) + + /** + * RUNDOWNS + */ + const rundownIds: string[] = [] + test('Can request all rundowns', async () => { + const rundowns = await ingestApi.getRundowns({ + studioId, + playlistId: playlistIds[0], + }) + + expect(rundowns.length).toBeGreaterThanOrEqual(1) + + rundowns.forEach((rundown) => { + expect(typeof rundown).toBe('object') + expect(rundown).toHaveProperty('id') + expect(rundown).toHaveProperty('externalId') + expect(rundown).toHaveProperty('name') + expect(rundown).toHaveProperty('studioId') + expect(rundown).toHaveProperty('playlistId') + expect(rundown).toHaveProperty('playlistExternalId') + expect(rundown).toHaveProperty('type') + expect(rundown).toHaveProperty('timing') + expect(rundown.timing).toHaveProperty('type') + expect(typeof rundown.id).toBe('string') + expect(typeof rundown.externalId).toBe('string') + expect(typeof rundown.name).toBe('string') + expect(typeof rundown.studioId).toBe('string') + expect(typeof rundown.playlistId).toBe('string') + expect(typeof rundown.playlistExternalId).toBe('string') + expect(typeof rundown.type).toBe('string') + expect(typeof rundown.timing).toBe('object') + expect(typeof rundown.timing.type).toBe('string') + rundownIds.push(rundown.externalId) + }) + }) + + test('Can request rundown by id', async () => { + const rundown = await ingestApi.getRundown({ + studioId, + playlistId: playlistIds[0], + rundownId: rundownIds[0], + }) + + expect(typeof rundown).toBe('object') + expect(rundown).toHaveProperty('id') + expect(rundown).toHaveProperty('externalId') + expect(rundown).toHaveProperty('name') + expect(rundown).toHaveProperty('studioId') + expect(rundown).toHaveProperty('playlistId') + expect(rundown).toHaveProperty('playlistExternalId') + expect(rundown).toHaveProperty('type') + expect(rundown).toHaveProperty('timing') + expect(rundown.timing).toHaveProperty('type') + expect(typeof rundown.id).toBe('string') + expect(typeof rundown.externalId).toBe('string') + expect(typeof rundown.name).toBe('string') + expect(typeof rundown.studioId).toBe('string') + expect(typeof rundown.playlistId).toBe('string') + expect(typeof rundown.playlistExternalId).toBe('string') + expect(typeof rundown.type).toBe('string') + expect(typeof rundown.timing).toBe('object') + expect(typeof rundown.timing.type).toBe('string') + }) + + const rundown = { + externalId: 'newRundown', + name: 'New rundown', + type: 'external', + resyncUrl: 'resyncUrl', + timing: { + type: RundownTimingTypeEnum.None, + expectedStart: 0, + expectedEnd: 0, + expectedDuration: 0, + }, + } + + test('Can create rundown', async () => { + const result = await ingestApi.postRundown({ studioId, playlistId: playlistIds[0], rundown }) + + expect(result).toBe(undefined) + }) + + test('Can update multiple rundowns', async () => { + const result = await ingestApi.putRundowns({ studioId, playlistId: playlistIds[0], rundown: [rundown] }) + expect(result).toBe(undefined) + }) + + const updatedRundownId = 'rundown3' + test('Can update single rundown', async () => { + const result = await ingestApi.putRundown({ + studioId, + playlistId: playlistIds[0], + rundownId: updatedRundownId, + rundown, + }) + expect(result).toBe(undefined) + }) + + test('Can delete multiple rundowns', async () => { + const result = await ingestApi.deleteRundowns({ studioId, playlistId: playlistIds[0] }) + expect(result).toBe(undefined) + }) + + test('Can delete rundown by id', async () => { + const result = await ingestApi.deleteRundown({ + studioId, + playlistId: playlistIds[0], + rundownId: updatedRundownId, + }) + expect(result).toBe(undefined) + }) + + /** + * INGEST SEGMENT + */ + const segmentIds: string[] = [] + test('Can request all segments', async () => { + const segments = await ingestApi.getSegments({ studioId, playlistId: playlistIds[0], rundownId: rundownIds[0] }) + + expect(segments.length).toBeGreaterThanOrEqual(1) + + segments.forEach((segment) => { + expect(typeof segment).toBe('object') + expect(typeof segment.id).toBe('string') + expect(typeof segment.externalId).toBe('string') + expect(typeof segment.rundownId).toBe('string') + expect(typeof segment.name).toBe('string') + expect(typeof segment.rank).toBe('number') + expect(typeof segment.timing).toBe('object') + expect(typeof segment.timing.expectedStart).toBe('number') + expect(typeof segment.timing.expectedEnd).toBe('number') + segmentIds.push(segment.externalId) + }) + }) + + test('Can request segment by id', async () => { + const segment = await ingestApi.getSegment({ + studioId, + playlistId: playlistIds[0], + rundownId: rundownIds[0], + segmentId: segmentIds[0], + }) + + expect(segment).toHaveProperty('id') + expect(segment).toHaveProperty('externalId') + expect(segment).toHaveProperty('rundownId') + expect(segment).toHaveProperty('name') + expect(segment).toHaveProperty('rank') + expect(segment).toHaveProperty('timing') + expect(segment.timing).toHaveProperty('expectedStart') + expect(segment.timing).toHaveProperty('expectedEnd') + expect(typeof segment.id).toBe('string') + expect(typeof segment.externalId).toBe('string') + expect(typeof segment.rundownId).toBe('string') + expect(typeof segment.name).toBe('string') + expect(typeof segment.rank).toBe('number') + expect(typeof segment.timing).toBe('object') + expect(typeof segment.timing.expectedStart).toBe('number') + expect(typeof segment.timing.expectedEnd).toBe('number') + }) + + const segment = { + externalId: 'segment1', + name: 'Segment 1', + rank: 0, + _float: true, + timing: { + expectedStart: 0, + expectedEnd: 0, + }, + } + + test('Can create segment', async () => { + const result = await ingestApi.postSegment({ + studioId, + playlistId: playlistIds[0], + rundownId: rundownIds[0], + segment, + }) + + expect(result).toBe(undefined) + }) + + test('Can update multiple segments', async () => { + const result = await ingestApi.putSegments({ + studioId, + playlistId: playlistIds[0], + rundownId: rundownIds[0], + segment: [segment], + }) + expect(result).toBe(undefined) + }) + + const updatedSegmentId = 'segment2' + test('Can update single segment', async () => { + const result = await ingestApi.putSegment({ + studioId, + playlistId: playlistIds[0], + rundownId: rundownIds[0], + segmentId: updatedSegmentId, + segment, + }) + expect(result).toBe(undefined) + }) + + test('Can delete multiple segments', async () => { + const result = await ingestApi.deleteSegments({ + studioId, + playlistId: playlistIds[0], + rundownId: rundownIds[0], + }) + expect(result).toBe(undefined) + }) + + test('Can delete segment by id', async () => { + const result = await ingestApi.deleteSegment({ + studioId, + playlistId: playlistIds[0], + rundownId: rundownIds[0], + segmentId: updatedSegmentId, + }) + expect(result).toBe(undefined) + }) + + /** + * INGEST PARTS + */ + const partIds: string[] = [] + test('Can request all parts', async () => { + const parts = await ingestApi.getParts({ + studioId, + playlistId: playlistIds[0], + rundownId: rundownIds[0], + segmentId: segmentIds[0], + }) + + expect(parts.length).toBeGreaterThanOrEqual(1) + + parts.forEach((part) => { + expect(typeof part).toBe('object') + expect(typeof part.externalId).toBe('string') + partIds.push(part.externalId) + }) + }) + + let newIngestPart: Part | undefined + test('Can request part by id', async () => { + const part = await ingestApi.getPart({ + studioId, + playlistId: playlistIds[0], + rundownId: rundownIds[0], + segmentId: segmentIds[0], + partId: partIds[0], + }) + + expect(part).toHaveProperty('id') + expect(part).toHaveProperty('externalId') + expect(part).toHaveProperty('rundownId') + expect(part).toHaveProperty('segmentId') + expect(part).toHaveProperty('name') + expect(part).toHaveProperty('expectedDuration') + expect(part).toHaveProperty('autoNext') + expect(part).toHaveProperty('rank') + expect(typeof part.id).toBe('string') + expect(typeof part.externalId).toBe('string') + expect(typeof part.rundownId).toBe('string') + expect(typeof part.segmentId).toBe('string') + expect(typeof part.name).toBe('string') + expect(typeof part.expectedDuration).toBe('number') + expect(typeof part.autoNext).toBe('boolean') + expect(typeof part.rank).toBe('number') + newIngestPart = JSON.parse(JSON.stringify(part)) + }) + + test('Can create part', async () => { + const result = await ingestApi.postPart({ + studioId, + playlistId: playlistIds[0], + rundownId: rundownIds[0], + segmentId: segmentIds[0], + part: { + externalId: 'part1', + name: 'Part 1', + rank: 0, + _float: true, + autoNext: true, + payload: { + type: 'CAMERA', + guest: true, + script: '', + pieces: [ + { + id: 'piece1', + objectType: 'CAMERA', + objectTime: '00:00:00:00', + duration: { + type: 'within-part', + duration: '00:00:10:00', + }, + resourceName: 'camera1', + label: 'Piece 1', + attributes: {}, + transition: 'cut', + transitionDuration: '00:00:00:00', + target: 'pgm', + }, + ], + }, + }, + }) + expect(result).toBe(undefined) + }) + + test('Can update multiple parts', async () => { + const result = await ingestApi.putParts({ + studioId, + playlistId: playlistIds[0], + rundownId: rundownIds[0], + segmentId: segmentIds[0], + part: [ + { + externalId: 'part1', + name: 'Part 1', + rank: 0, + _float: true, + autoNext: true, + payload: { + type: 'CAMERA', + guest: true, + script: '', + pieces: [ + { + id: 'piece1', + label: 'Piece 1', + attributes: {}, + objectType: 'CAMERA', + resourceName: 'camera1', + }, + ], + }, + }, + ], + }) + expect(result).toBe(undefined) + }) + + const updatedPartId = 'part2' + test('Can update a part', async () => { + newIngestPart.name = newIngestPart.name + ' added' + const result = await ingestApi.putPart({ + studioId, + playlistId: playlistIds[0], + rundownId: rundownIds[0], + segmentId: segmentIds[0], + partId: updatedPartId, + part: { + externalId: 'part1', + name: 'Part 1', + rank: 0, + _float: true, + autoNext: true, + payload: { + type: 'CAMERA', + guest: true, + script: '', + pieces: [ + { + id: 'piece1', + label: 'Piece 1', + attributes: {}, + objectType: 'CAMERA', + resourceName: 'camera1', + }, + ], + }, + }, + }) + expect(result).toBe(undefined) + }) + + test('Can delete multiple parts', async () => { + const result = await ingestApi.deleteParts({ + studioId, + playlistId: playlistIds[0], + rundownId: rundownIds[0], + segmentId: segmentIds[0], + }) + expect(result).toBe(undefined) + }) + + test('Can delete part by id', async () => { + const result = await ingestApi.deletePart({ + studioId, + playlistId: playlistIds[0], + rundownId: rundownIds[0], + segmentId: segmentIds[0], + partId: updatedPartId, + }) + expect(result).toBe(undefined) + }) +}) diff --git a/packages/shared-lib/src/peripheralDevice/ingest.ts b/packages/shared-lib/src/peripheralDevice/ingest.ts index c53739f87e..02a7bfa716 100644 --- a/packages/shared-lib/src/peripheralDevice/ingest.ts +++ b/packages/shared-lib/src/peripheralDevice/ingest.ts @@ -9,29 +9,40 @@ export interface IngestRundown[] + /** Rundown timing definition */ + timing?: { + type?: 'none' | 'forward-time' | 'back-time' + expectedStart?: number + expectedDuration?: number + expectedEnd?: number + } + /** Id of the playlist this rundown belongs to */ + playlistExternalId?: string } export interface IngestSegment { /** Id of the segment as reported by the ingest gateway. Must be unique for each segment in the rundown */ externalId: string /** Name of the segment */ name: string - /** Rank of the segment within the rundown */ - rank: number - /** Raw payload of segment metadata. Only used by the blueprints */ payload: TSegmentPayload - /** Array of parts in this segment */ parts: IngestPart[] + /** Rank of the segment in the rundown */ + rank: number + /** If segment is hidden */ + isHidden?: boolean + /** Timing definition */ + timing?: { + expectedStart?: number + expectedEnd?: number + } } export interface IngestPart { /** Id of the part as reported by the ingest gateway. Must be unique for each part in the rundown */ @@ -40,7 +51,10 @@ export interface IngestPart { name: string /** Rank of the part within the segment */ rank: number - + /** If part is floated or not */ + float?: boolean + /** If part should automatically take to the next one when finished */ + autoNext?: boolean /** Raw payload of the part. Only used by the blueprints */ payload: TPartPayload } @@ -50,7 +64,6 @@ export interface IngestAdlib { externalId: string /** Name of the adlib */ name: string - /** Type of the raw payload. Only used by the blueprints */ payloadType: string /** Raw payload of the adlib. Only used by the blueprints */