From c26ed5ff0b5d354490ad95c493b8be330d335c6e Mon Sep 17 00:00:00 2001 From: Baud Date: Wed, 23 Aug 2023 15:45:20 +0100 Subject: [PATCH 01/50] feat: First draft of Ingest API --- packages/openapi/api/actions.yaml | 17 + packages/openapi/api/definitions/ingest.yaml | 1502 ++++++++++++++++++ 2 files changed, 1519 insertions(+) create mode 100644 packages/openapi/api/definitions/ingest.yaml diff --git a/packages/openapi/api/actions.yaml b/packages/openapi/api/actions.yaml index f2b61596ad..9d236d89d5 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/ingestPlaylists' + /ingest/{studioId}/playlists/{playlistId}: + $ref: 'definitions/ingest.yaml#/resources/ingestPlaylist' + /ingest/{studioId}/playlists/{playlistId}/rundowns: + $ref: 'definitions/ingest.yaml#/resources/ingestRundowns' + /ingest/{studioId}/playlists/{playlistId}/rundowns/{rundownId}: + $ref: 'definitions/ingest.yaml#/resources/ingestRundown' + /ingest/{studioId}/playlists/{playlistId}/rundowns/{rundownId}/segments: + $ref: 'definitions/ingest.yaml#/resources/ingestSegments' + /ingest/{studioId}/playlists/{playlistId}/rundowns/{rundownId}/segments/{segmentId}: + $ref: 'definitions/ingest.yaml#/resources/ingestSegment' + /ingest/{studioId}/playlists/{playlistId}/rundowns/{rundownId}/segments/{segmentId}/parts: + $ref: 'definitions/ingest.yaml#/resources/ingestParts' + /ingest/{studioId}/playlists/{playlistId}/rundowns/{rundownId}/segments/{segmentId}/parts/{partId}: + $ref: 'definitions/ingest.yaml#/resources/ingestPart' diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml new file mode 100644 index 0000000000..56f6d56462 --- /dev/null +++ b/packages/openapi/api/definitions/ingest.yaml @@ -0,0 +1,1502 @@ +title: ingest +description: Ingest methods +resources: + ingestPlaylists: + get: + operationId: getIngestPlaylists + tags: + - ingest + summary: Gets ingest data for all Playlists in Sofie belonging to a Studio. + parameters: + - name: studioId + in: path + description: Studio the Playlist belongs to. + required: true + schema: + type: string + responses: + 200: + description: Command successfully handled - returns an array of Playlist Ids. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 200 + example: 200 + playlists: + type: array + items: + $ref: '#/components/schemas/ingestPlaylistItem' + example: + - id: '4e8fb4df-4d37-4ce5-adb7-009af7feb755' + externalId: 'playlist1' + - id: 'd0deb87b-e9fd-4283-977b-4cd3c9b559bd' + externalId: 'playlist2' + required: + - status + - playlists + additionalProperties: false + 404: + $ref: '#/components/responses/studioNotFound' + ingestPlaylist: + get: + operationId: getIngestPlaylist + tags: + - ingest + summary: Gets ingest data for a specific Playlist from Sofie. + parameters: + - name: studioId + in: path + description: Studio to ingest Playlist into. + required: true + schema: + type: string + - name: playlistId + in: path + description: Requested Playlist. May use external Id or Sofie internal Id. + required: true + schema: + type: string + responses: + 200: + description: Playlist is returned. + headers: + ETag: + schema: + type: string + description: Version of Playlist, if known. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 200 + playlist: + $ref: '#/components/schemas/ingestPlaylist' + example: + status: 200 + playlist: + name: playlist1 + externalId: playlist1 + required: + - status + - playlist + additionalProperties: false + 404: + description: Invalid studioId or playlistId + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/studioNotFound' + # - $ref: '#/components/responses/playlistNotFound' + put: + operationId: putIngestPlaylist + tags: + - ingest + summary: Creates a new or updates an existing Playlist. + parameters: + - name: studioId + in: path + description: Studio to ingest Playlist into. + required: true + schema: + type: string + - name: playlistId + in: path + description: Playlist to create/update. May use external Id or Sofie internal Id. + required: true + schema: + type: string + - name: If-None-Match + in: header + schema: + type: array + items: + type: string + description: If specified, the Playlist will only be updated if the specified ETag does not match. If unspecified, the new data will replace whatever currently exists, regardless of whether the data is actually the same. + - name: ETag + in: header + required: true + schema: + type: string + description: ETag to use as version information for Playlist. + requestBody: + description: Contains the Playlist data. + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: Name of the Playlist as shown to the user. + externalId: + type: string + description: The Id provided by a system external to Sofie that can be used by the same external system to identify this Rundown. + resyncURL: + type: string + description: The URL to POST a message to in order to request that the entire Playlist be re-sent to Sofie. This message will have no request body. + example: + name: playlist1 + externalId: playlist1 + required: + - name + - externalId + additionalProperties: false + responses: + 200: + description: Playlist has been updated. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 200 + example: 200 + required: + - status + additionalProperties: false + 201: + description: Playlist has been created. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 201 + example: 201 + required: + - status + additionalProperties: false + 404: + $ref: '#/components/responses/idNotFound' + 409: + $ref: '#/components/responses/externalIdConflict' + delete: + operationId: deleteIngestPlaylist + tags: + - ingest + summary: Deletes a specified ingest Playlist. Resources under the Playlist (e.g. Rundowns) will also be removed. + parameters: + - name: studioId + in: path + description: Studio the ingest Playlist belongs to. + required: true + schema: + type: string + - name: playlistId + in: path + description: Playlist to delete. May use external Id or Sofie internal Id. + required: true + schema: + type: string + responses: + 200: + description: Playlist removed. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 200 + example: 200 + required: + - status + additionalProperties: false + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/studioNotFound' + # - $ref: '#/components/responses/playlistNotFound' + ingestRundowns: + get: + operationId: getIngestRundowns + tags: + - ingest + summary: Gets ingest data for all Rundowns belonging to a Playlist. + parameters: + - name: studioId + in: path + description: Studio the Playlist belongs to. + required: true + schema: + type: string + - name: playlistId + in: path + description: Playlist to get all Rundowns for. May use external Id or Sofie internal Id. + required: true + schema: + type: string + responses: + 200: + description: Command successfully handled - returns an array of Rundowns. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 200 + example: 200 + rundowns: + type: array + items: + $ref: '#/components/schemas/ingestRundownItem' + example: + - id: 4e8fb4df-4d37-4ce5-adb7-009af7feb755 + externalId: rundown1 + required: + - status + - playlists + additionalProperties: false + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/studioNotFound' + # - $ref: '#/components/responses/playlistNotFound' + put: + operationId: putIngestRundowns + tags: + - ingest + summary: Creates/updates the Rundowns in a Playlist. Any existing Rundowns in the Playlist that are not included in this list will be deleted (including their Segments and Parts). Rundowns will be placed in the Playlist in the order specified by their individual ranks. If the creation/deletion/updating of any Rundown fails all changes will be discarded. ETags are not supported for bulk updates, no version information will be stored for any of the created/modified Rundowns. + parameters: + - name: studioId + in: path + description: Studio the Playlist belongs to. + required: true + schema: + type: string + - name: playlistId + in: path + description: Playlist to create/update all Rundowns for. May use external Id or Sofie internal Id. + required: true + schema: + type: string + - name: If-None-Match + in: header + schema: + type: array + items: + type: string + description: If specified, each Rundown will only be updated if its version does not match one of the specified ETags. If no ETag is found for a Rundown, the new data will replace whatever currently exists, regardless of whether the data is actually the same. + requestBody: + description: Contains the Rundown data. + required: true + content: + application/json: + schema: + type: object + properties: + rundowns: + type: array + items: + $ref: '#/components/schemas/ingestRundown' + example: + - name: rundown1 + source: 'Our Company - Some Product Name' + externalId: 4e8fb4df-4d37-4ce5-adb7-009af7feb755 + rank: 0 + required: + - rundowns + additionalProperties: false + responses: + 200: + description: Rundowns have been updated. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 200 + example: 200 + required: + - status + additionalProperties: false + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/studioNotFound' + # - $ref: '#/components/responses/playlistNotFound' + 409: + $ref: '#/components/responses/externalIdConflict' + ingestRundown: + get: + operationId: getIngestRundown + tags: + - ingest + summary: Gets ingest data for a specific Rundown. + parameters: + - name: studioId + in: path + description: Studio the Rundown belongs to. + required: true + schema: + type: string + - name: playlistId + in: path + description: Playlist the Rundown belongs to. May use external Id or Sofie internal Id. + required: true + schema: + type: string + - name: rundownId + in: path + description: Rundown to return. May use external Id or Sofie internal Id. + required: true + schema: + type: string + responses: + 200: + description: Rundown is returned. + headers: + ETag: + schema: + type: string + description: Version of Rundown, if known. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 200 + rundown: + $ref: '#/components/schemas/ingestRundown' + example: + status: 200 + rundown: + name: rundown1 + source: 'Our Company - Some Product Name' + externalId: 4e8fb4df-4d37-4ce5-adb7-009af7feb755 + rank: 0 + required: + - status + - rundown + additionalProperties: false + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/studioNotFound' + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + put: + operationId: putIngestRundown + tags: + - ingest + summary: Creates a new or updates an existing Rundown. + parameters: + - name: studioId + in: path + description: Studio to ingest Rundown into. + required: true + schema: + type: string + - name: playlistId + in: path + description: Playlist to ingest Rundown into. May use external Id or Sofie internal Id. + required: true + schema: + type: string + - name: rundownId + in: path + description: Rundown to create/update. May use external Id or Sofie internal Id. + required: true + schema: + type: string + - name: If-None-Match + in: header + schema: + type: array + items: + type: string + description: If specified, the Rundown will only be updated if the specified ETag does not match. If unspecified, the new data will replace whatever currently exists, regardless of whether the data is actually the same. + - name: ETag + in: header + required: true + schema: + type: string + description: ETag to use as version information for Rundown. + requestBody: + description: Contains the Rundown data. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ingestRundown' + example: + name: rundown1 + source: 'Our Company - Some Product Name' + externalId: 4e8fb4df-4d37-4ce5-adb7-009af7feb755 + rank: 0 + responses: + 200: + description: Rundown has been updated. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 200 + example: 200 + required: + - status + additionalProperties: false + 201: + description: Rundown has been created. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 201 + example: 201 + required: + - status + additionalProperties: false + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/studioNotFound' + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + 409: + $ref: '#/components/responses/externalIdConflict' + delete: + operationId: deleteIngestRundown + tags: + - ingest + summary: Deletes a specified ingest Rundown. Resources under the Rundown (e.g. Segments) will also be removed. + parameters: + - name: studioId + in: path + description: Studio the ingest Rundown belongs to. + required: true + schema: + type: string + - name: playlistId + in: path + description: Playlist the ingest Rundown belongs to. May use external Id or Sofie internal Id. + required: true + schema: + type: string + - name: rundownId + in: path + description: Rundown to delete. May use external Id or Sofie internal Id. + required: true + schema: + type: string + responses: + 200: + description: Rundown removed. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 200 + example: 200 + required: + - status + additionalProperties: false + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/studioNotFound' + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + ingestSegments: + get: + operationId: getIngestSegments + tags: + - ingest + summary: Gets the ingest data for all Segments belonging to a Rundown. + parameters: + - name: studioId + in: path + description: Studio the Segment belongs to. + required: true + schema: + type: string + - name: playlistId + in: path + description: Playlist the Rundown belongs to. May use external Id or Sofie internal Id. + required: true + schema: + type: string + - name: rundownId + in: path + description: Rundown to get Segments for. May use external Id or Sofie internal Id. + required: true + schema: + type: string + responses: + 200: + description: Command successfully handled - returns an array of Segments. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 200 + example: 200 + segments: + type: array + items: + $ref: '#/components/schemas/ingestSegmentItem' + example: + - id: 4e8fb4df-4d37-4ce5-adb7-009af7feb755 + externalId: segment1 + required: + - status + - segments + additionalProperties: false + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/studioNotFound' + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + put: + operationId: putIngestSegments + tags: + - ingest + summary: Creates/updates the Segments in a Rundown. Any existing Segments in the Rundown that are not included in this list will be deleted (including their Parts). Segments will be placed in the Rundown in the order specified by their individual ranks. If the creation/deletion/updating of any Segment fails all changes will be discarded. + parameters: + - name: studioId + in: path + description: Studio the Rundown belongs to. + required: true + schema: + type: string + - name: playlistId + in: path + description: Playlist the Rundown belongs to. May use external Id or Sofie internal Id. + required: true + schema: + type: string + - name: rundownId + in: path + description: Rundown to create/update all Segments for. May use external Id or Sofie internal Id. + required: true + schema: + type: string + - name: If-None-Match + in: header + schema: + type: array + items: + type: string + description: If specified, each Segment will only be updated if its version does not match one of the specified ETags. If no ETag is found for a Segment, the new data will replace whatever currently exists, regardless of whether the data is actually the same. ETags are not supported for bulk updates, no version information will be stored for any of the created/modified Rundowns. + requestBody: + description: Contains the Segment data. + required: true + content: + application/json: + schema: + type: object + properties: + segments: + type: array + items: + $ref: '#/components/schemas/ingestSegment' + example: + - name: segment1 + externalId: segment1 + rank: 0 + required: + - segments + additionalProperties: false + responses: + 200: + description: Segments have been updated. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 200 + example: 200 + required: + - status + additionalProperties: false + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/studioNotFound' + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + 409: + $ref: '#/components/responses/externalIdConflict' + ingestSegment: + get: + operationId: getIngestSegment + tags: + - ingest + summary: Gets ingest data for a specific Segment. + parameters: + - name: studioId + in: path + description: Studio the Segment belongs to. + required: true + schema: + type: string + - name: playlistId + in: path + description: Playlist the Segment belongs to. May use external Id or Sofie internal Id. + required: true + schema: + type: string + - name: rundownId + in: path + description: Rundown the Segment belongs to. May use external Id or Sofie internal Id. + required: true + schema: + type: string + - name: segmentId + in: path + description: Segment to create/update. May use external Id or Sofie internal Id. + required: true + schema: + type: string + responses: + 200: + description: Segment is returned. + headers: + ETag: + schema: + type: string + description: Version of Segment, if known. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 200 + segment: + $ref: '#/components/schemas/ingestSegment' + example: + status: 200 + segment: + name: segment1 + externalId: segment1 + rank: 0 + required: + - status + - segment + additionalProperties: false + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/studioNotFound' + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + # - $ref: '#/components/responses/segmentNotFound' + put: + operationId: putIngestSegment + tags: + - ingest + summary: Creates a new or updates an existing Segment. + parameters: + - name: studioId + in: path + description: Studio to ingest Segment into. + required: true + schema: + type: string + - name: playlistId + in: path + description: Playlist to ingest Segment into. May use external Id or Sofie internal Id. + required: true + schema: + type: string + - name: rundownId + in: path + description: Rundown to ingest Segment into. May use external Id or Sofie internal Id. + required: true + schema: + type: string + - name: segmentId + in: path + description: Segment to create/update. May use external Id or Sofie internal Id. + schema: + type: string + - name: If-None-Match + in: header + schema: + type: array + items: + type: string + description: If specified, the Segment will only be updated if the specified ETag does not match. If unspecified, the new data will replace whatever currently exists, regardless of whether the data is actually the same. + - name: ETag + in: header + required: true + schema: + type: string + description: ETag to use as version information for Segment. + requestBody: + description: Contains the Segment data. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ingestSegment' + example: + name: segment1 + externalId: segment1 + rank: 0 + responses: + 200: + description: Segment has been updated. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 200 + example: 200 + required: + - status + additionalProperties: false + 201: + description: Segment has been created. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 201 + example: 201 + required: + - status + additionalProperties: false + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/studioNotFound' + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + 409: + $ref: '#/components/responses/externalIdConflict' + delete: + operationId: deleteIngestSegment + tags: + - ingest + summary: Deletes a specified ingest Segment. Resources under the Segment (e.g. Parts) will also be removed. + parameters: + - name: studioId + in: path + description: Studio the ingest Segment belongs to. + required: true + schema: + type: string + - name: playlistId + in: path + description: Playlist the ingest Segment belongs to. May use external Id or Sofie internal Id. + required: true + schema: + type: string + - name: rundownId + in: path + description: Rundown the ingest Segment belongs to. May use external Id or Sofie internal Id. + required: true + schema: + type: string + - name: segmentId + in: path + description: Segment to delete. May use external Id or Sofie internal Id. + required: true + schema: + type: string + responses: + 200: + description: Segment removed. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 200 + example: 200 + required: + - status + additionalProperties: false + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/studioNotFound' + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + ingestParts: + get: + operationId: getIngestParts + tags: + - ingest + summary: Gets the ingest data for all Parts belonging to a Segment. + parameters: + - name: studioId + in: path + description: Studio the Segment belongs to. + required: true + schema: + type: string + - name: playlistId + in: path + description: Playlist the Segment belongs to. May use external Id or Sofie internal Id. + required: true + schema: + type: string + - name: rundownId + in: path + description: Rundown the Segment belongs to. May use external Id or Sofie internal Id. + required: true + schema: + type: string + - name: segmentId + in: path + description: Segment to get Parts for. May use external Id or Sofie internal Id. + required: true + schema: + type: string + responses: + 200: + description: Command successfully handled - returns an array of Parts. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 200 + example: 200 + parts: + type: array + items: + $ref: '#/components/schemas/ingestPartItem' + example: + - name: 4e8fb4df-4d37-4ce5-adb7-009af7feb755 + externalId: part1 + required: + - status + - parts + additionalProperties: false + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/studioNotFound' + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + # - $ref: '#/components/responses/segmentNotFound' + put: + operationId: putIngestParts + tags: + - ingest + summary: Creates/updates the Parts in a Segment. Any existing Parts in the Segment that are not included in this list will be deleted. Parts will be placed in the Segment in the order specified by their individual ranks. If the creation/deletion/updating of any Parts fails all changes will be discarded. + parameters: + - name: studioId + in: path + description: Studio the Segment belongs to. + required: true + schema: + type: string + - name: playlistId + in: path + description: Playlist the Segment belongs to. May use external Id or Sofie internal Id. + required: true + schema: + type: string + - name: rundownId + in: path + description: Rundown the Segment belongs to. May use external Id or Sofie internal Id. + required: true + schema: + type: string + - name: segmentId + in: path + description: Segment to create/update all Parts for. May use external Id or Sofie internal Id. + required: true + schema: + type: string + - name: If-None-Match + in: header + schema: + type: array + items: + type: string + description: If specified, each Part will only be updated if its version does not match one of the specified ETags. If no ETag is found for a Part, the new data will replace whatever currently exists, regardless of whether the data is actually the same. ETags are not supported for bulk updates, no version information will be stored for any of the created/modified Rundowns. + requestBody: + description: Contains the Part data. + required: true + content: + application/json: + schema: + type: object + properties: + parts: + type: array + items: + $ref: '#/components/schemas/ingestPartItem' + example: + - name: 4e8fb4df-4d37-4ce5-adb7-009af7feb755 + externalId: part1 + required: + - parts + additionalProperties: false + responses: + 200: + description: Parts have been updated. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 200 + example: 200 + required: + - status + additionalProperties: false + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/studioNotFound' + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + # - $ref: '#/components/responses/partNotFound' + 409: + $ref: '#/components/responses/externalIdConflict' + ingestPart: + get: + operationId: getIngestPart + tags: + - ingest + summary: Gets ingest data for a specific Part. + parameters: + - name: studioId + in: path + description: Studio the Part belongs to. + required: true + schema: + type: string + - name: playlistId + in: path + description: Playlist the Part belongs to. May use external Id or Sofie internal Id. + required: true + schema: + type: string + - name: rundownId + in: path + description: Rundown the Part belongs to. May use external Id or Sofie internal Id. + required: true + schema: + type: string + - name: segmentId + in: path + description: Segment the Part belongs to. May use external Id or Sofie internal Id. + required: true + schema: + type: string + - name: partId + in: path + description: Part to create/update. May use external Id or Sofie internal Id. + required: true + schema: + type: string + responses: + 200: + description: Part is returned. + headers: + ETag: + schema: + type: string + description: Version of Part, if known. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 200 + part: + $ref: '#/components/schemas/ingestPart' + example: + status: 200 + part: + name: part1 + externalId: part1 + rank: 0 + required: + - status + - part + additionalProperties: false + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/studioNotFound' + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + # - $ref: '#/components/responses/segmentNotFound' + # - $ref: '#/components/responses/partNotFound' + put: + operationId: putIngestPart + tags: + - ingest + summary: Creates a new or updates an existing Part. + parameters: + - name: studioId + in: path + description: Studio to ingest Part into. + required: true + schema: + type: string + - name: playlistId + in: path + description: Playlist to ingest Part into. May use external Id or Sofie internal Id. + required: true + schema: + type: string + - name: rundownId + in: path + description: Rundown to ingest Part into. May use external Id or Sofie internal Id. + required: true + schema: + type: string + - name: segmentId + in: path + description: Segment to ingest Part into. May use external Id or Sofie internal Id. + schema: + type: string + - name: partId + in: path + description: Part to update/create. May use external Id or Sofie internal Id. + schema: + type: string + - name: If-None-Match + in: header + schema: + type: array + items: + type: string + description: If specified, the Part will only be updated if the specified ETag does not match. If unspecified, the new data will replace whatever currently exists, regardless of whether the data is actually the same. + - name: ETag + in: header + required: true + schema: + type: string + description: ETag to use as version information for Part. + requestBody: + description: Contains the Rundown data. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ingestPart' + example: + name: part1 + externalId: part1 + rank: 0 + responses: + 200: + description: Part has been updated. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 200 + example: 200 + required: + - status + additionalProperties: false + 201: + description: Part has been created. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 201 + example: 201 + required: + - status + additionalProperties: false + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/studioNotFound' + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + # - $ref: '#/components/responses/segmentNotFound' + 409: + $ref: '#/components/responses/externalIdConflict' + delete: + operationId: deleteIngestPart + tags: + - ingest + summary: Deletes a specified ingest Part. + parameters: + - name: studioId + in: path + description: Studio the ingest Part belongs to. + required: true + schema: + type: string + - name: playlistId + in: path + description: Playlist the ingest Part belongs to. May use external Id or Sofie internal Id. + required: true + schema: + type: string + - name: rundownId + in: path + description: Rundown the ingest Part belongs to. May use external Id or Sofie internal Id. + required: true + schema: + type: string + - name: segmentId + in: path + description: Segment the ingest Part belongs to. May use external Id or Sofie internal Id. + required: true + schema: + type: string + - name: partId + in: path + description: Part to delete. May use external Id or Sofie internal Id. + required: true + schema: + type: string + responses: + 200: + description: Part removed. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 200 + example: 200 + required: + - status + additionalProperties: false + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/studioNotFound' + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + # - $ref: '#/components/responses/segmentNotFound' + +components: + responses: + idNotFound: + # oneOf responses like below don't render correctly with current tools - use studio as an example for the docs. + # oneOf: + # - $ref: '#/components/responses/studioNotFound' + # - $ref: '#/components/responses/playlistNotFound' + $ref: '#/components/responses/studioNotFound' + studioNotFound: + description: The specified Studio does not exist. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 404 + example: 404 + notFound: + type: string + const: studio + example: studio + message: + type: string + example: The specified Studio was not found. + required: + - status + - notFound + - 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 + notFound: + type: string + const: rundown + example: rundown + 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 + notFound: + type: string + const: segment + example: segment + 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 + notFound: + type: string + const: part + example: part + message: + type: string + example: The specified Part was not found. + required: + - status + - notFound + - message + additionalProperties: false + externalIdConflict: + description: The provided external Id is already in use by a different object with a different Sofie internal Id. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 409 + example: 409 + conflict: + type: string + const: externalId + example: externalId + message: + type: string + example: The externalId "b105625d-e1ab-4ce7-b99f-d720cdcdc519" is already in use by "808267a9-a074-4800-b6c7-bfcf7dc1f144" with externalId "9c8faa5d-07af-4e2b-a860-46c3b0f30b2e". + schemas: + ingestPlaylistItem: + type: object + properties: + id: + type: string + description: The Id used internally by Sofie. + externalId: + type: string + description: The Id provided by a system external to Sofie that can be used by the same external system to identify this Playlist. + required: + - id + - externalId + additionalProperties: false + ingestRundownItem: + type: object + properties: + id: + type: string + description: The Id used internally by Sofie. + externalId: + type: string + description: The Id provided by a system external to Sofie that can be used by the same external system to identify this Rundown. + required: + - id + - externalId + additionalProperties: false + ingestSegmentItem: + type: object + properties: + id: + type: string + description: The Id used internally by Sofie + externalId: + type: string + description: The Id provided by a system external to Sofie that can be used by the same external system to identify this Segment. + required: + - id + - externalId + additionalProperties: false + ingestPartItem: + type: object + properties: + name: + type: string + externalId: + type: string + description: The Id provided by a system external to Sofie that can be used by the same external system to identify this Part. + required: + - name + - externalId + additionalProperties: false + ingestPlaylist: + type: object + properties: + name: + type: string + externalId: + type: string + description: The Id provided by a system external to Sofie that can be used by the same external system to identify this Playlist. + resyncURL: + type: string + description: The URL to POST a message to in order to request that the entire Playlist be re-sent to Sofie. This message will have no request body. + required: + - name + - externalId + additionalProperties: false + ingestRundown: + type: object + properties: + name: + type: string + source: + type: string + description: A source type that can be displayed to the end-user. Should identify what type of system (e.g. vendor/product name) the data has been sent from. + examples: + - 'Some Product Name' + - 'Our Company - Some Product Name' + externalId: + type: string + description: The Id provided by a system external to Sofie that can be used by the same external system to identify this Rundown. + rank: + type: number + description: The position of the Rundown in the parent Playlist. + inclusiveMinimum: 0.0 + payload: + type: object + additionalProperties: true + required: + - name + - source + - externalId + - rank + additionalProperties: false + ingestSegment: + type: object + properties: + name: + type: string + externalId: + type: string + description: The Id provided by a system external to Sofie that can be used by the same external system to identify this Segment. + rank: + type: number + description: The position of the Segment in the parent Rundown. + inclusiveMinimum: 0.0 + payload: + type: object + additionalProperties: true + required: + - name + - externalId + - rank + additionalProperties: false + ingestPart: + type: object + properties: + name: + type: string + externalId: + type: string + description: The Id provided by a system external to Sofie that can be used by the same external system to identify this Part. + rank: + type: number + description: The position of the Part in the parent Segment. + payload: + type: object + additionalProperties: true + required: + - name + - externalId + - rank + additionalProperties: false From 73ef2e5ba8ba31e68dbfe1d8b644196f2a1b08be Mon Sep 17 00:00:00 2001 From: Baud Date: Thu, 31 Aug 2023 10:45:15 +0100 Subject: [PATCH 02/50] chore: Remove resyncURL --- packages/openapi/api/definitions/ingest.yaml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml index 56f6d56462..8a882fd95f 100644 --- a/packages/openapi/api/definitions/ingest.yaml +++ b/packages/openapi/api/definitions/ingest.yaml @@ -138,9 +138,6 @@ resources: externalId: type: string description: The Id provided by a system external to Sofie that can be used by the same external system to identify this Rundown. - resyncURL: - type: string - description: The URL to POST a message to in order to request that the entire Playlist be re-sent to Sofie. This message will have no request body. example: name: playlist1 externalId: playlist1 @@ -1427,9 +1424,6 @@ components: externalId: type: string description: The Id provided by a system external to Sofie that can be used by the same external system to identify this Playlist. - resyncURL: - type: string - description: The URL to POST a message to in order to request that the entire Playlist be re-sent to Sofie. This message will have no request body. required: - name - externalId From d279c7886b170227cf8c50622d1848036cc9272a Mon Sep 17 00:00:00 2001 From: Baud Date: Thu, 7 Sep 2023 13:37:56 +0100 Subject: [PATCH 03/50] chore: Remove internal Ids --- packages/openapi/api/definitions/ingest.yaml | 180 ++++++------------- 1 file changed, 50 insertions(+), 130 deletions(-) diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml index 8a882fd95f..f8b6d6219f 100644 --- a/packages/openapi/api/definitions/ingest.yaml +++ b/packages/openapi/api/definitions/ingest.yaml @@ -31,10 +31,8 @@ resources: items: $ref: '#/components/schemas/ingestPlaylistItem' example: - - id: '4e8fb4df-4d37-4ce5-adb7-009af7feb755' - externalId: 'playlist1' - - id: 'd0deb87b-e9fd-4283-977b-4cd3c9b559bd' - externalId: 'playlist2' + - externalId: 'playlist1' + - externalId: 'playlist2' required: - status - playlists @@ -56,7 +54,7 @@ resources: type: string - name: playlistId in: path - description: Requested Playlist. May use external Id or Sofie internal Id. + description: Requested Playlist. required: true schema: type: string @@ -82,7 +80,6 @@ resources: status: 200 playlist: name: playlist1 - externalId: playlist1 required: - status - playlist @@ -107,7 +104,7 @@ resources: type: string - name: playlistId in: path - description: Playlist to create/update. May use external Id or Sofie internal Id. + description: Playlist to create/update. required: true schema: type: string @@ -135,15 +132,10 @@ resources: name: type: string description: Name of the Playlist as shown to the user. - externalId: - type: string - description: The Id provided by a system external to Sofie that can be used by the same external system to identify this Rundown. - example: - name: playlist1 - externalId: playlist1 required: - name - - externalId + example: + name: playlist1 additionalProperties: false responses: 200: @@ -176,8 +168,6 @@ resources: additionalProperties: false 404: $ref: '#/components/responses/idNotFound' - 409: - $ref: '#/components/responses/externalIdConflict' delete: operationId: deleteIngestPlaylist tags: @@ -192,7 +182,7 @@ resources: type: string - name: playlistId in: path - description: Playlist to delete. May use external Id or Sofie internal Id. + description: Playlist to delete. required: true schema: type: string @@ -231,7 +221,7 @@ resources: type: string - name: playlistId in: path - description: Playlist to get all Rundowns for. May use external Id or Sofie internal Id. + description: Playlist to get all Rundowns for. required: true schema: type: string @@ -252,8 +242,7 @@ resources: items: $ref: '#/components/schemas/ingestRundownItem' example: - - id: 4e8fb4df-4d37-4ce5-adb7-009af7feb755 - externalId: rundown1 + - externalId: rundown1 required: - status - playlists @@ -277,7 +266,7 @@ resources: type: string - name: playlistId in: path - description: Playlist to create/update all Rundowns for. May use external Id or Sofie internal Id. + description: Playlist to create/update all Rundowns for. required: true schema: type: string @@ -303,7 +292,6 @@ resources: example: - name: rundown1 source: 'Our Company - Some Product Name' - externalId: 4e8fb4df-4d37-4ce5-adb7-009af7feb755 rank: 0 required: - rundowns @@ -328,8 +316,6 @@ resources: # oneOf: # - $ref: '#/components/responses/studioNotFound' # - $ref: '#/components/responses/playlistNotFound' - 409: - $ref: '#/components/responses/externalIdConflict' ingestRundown: get: operationId: getIngestRundown @@ -345,13 +331,13 @@ resources: type: string - name: playlistId in: path - description: Playlist the Rundown belongs to. May use external Id or Sofie internal Id. + description: Playlist the Rundown belongs to. required: true schema: type: string - name: rundownId in: path - description: Rundown to return. May use external Id or Sofie internal Id. + description: Rundown to return. required: true schema: type: string @@ -378,7 +364,6 @@ resources: rundown: name: rundown1 source: 'Our Company - Some Product Name' - externalId: 4e8fb4df-4d37-4ce5-adb7-009af7feb755 rank: 0 required: - status @@ -404,13 +389,13 @@ resources: type: string - name: playlistId in: path - description: Playlist to ingest Rundown into. May use external Id or Sofie internal Id. + description: Playlist to ingest Rundown into. required: true schema: type: string - name: rundownId in: path - description: Rundown to create/update. May use external Id or Sofie internal Id. + description: Rundown to create/update. required: true schema: type: string @@ -437,7 +422,6 @@ resources: example: name: rundown1 source: 'Our Company - Some Product Name' - externalId: 4e8fb4df-4d37-4ce5-adb7-009af7feb755 rank: 0 responses: 200: @@ -474,8 +458,6 @@ resources: # - $ref: '#/components/responses/studioNotFound' # - $ref: '#/components/responses/playlistNotFound' # - $ref: '#/components/responses/rundownNotFound' - 409: - $ref: '#/components/responses/externalIdConflict' delete: operationId: deleteIngestRundown tags: @@ -490,13 +472,13 @@ resources: type: string - name: playlistId in: path - description: Playlist the ingest Rundown belongs to. May use external Id or Sofie internal Id. + description: Playlist the ingest Rundown belongs to. required: true schema: type: string - name: rundownId in: path - description: Rundown to delete. May use external Id or Sofie internal Id. + description: Rundown to delete. required: true schema: type: string @@ -536,13 +518,13 @@ resources: type: string - name: playlistId in: path - description: Playlist the Rundown belongs to. May use external Id or Sofie internal Id. + description: Playlist the Rundown belongs to. required: true schema: type: string - name: rundownId in: path - description: Rundown to get Segments for. May use external Id or Sofie internal Id. + description: Rundown to get Segments for. required: true schema: type: string @@ -563,8 +545,7 @@ resources: items: $ref: '#/components/schemas/ingestSegmentItem' example: - - id: 4e8fb4df-4d37-4ce5-adb7-009af7feb755 - externalId: segment1 + - externalId: segment1 required: - status - segments @@ -589,13 +570,13 @@ resources: type: string - name: playlistId in: path - description: Playlist the Rundown belongs to. May use external Id or Sofie internal Id. + description: Playlist the Rundown belongs to. required: true schema: type: string - name: rundownId in: path - description: Rundown to create/update all Segments for. May use external Id or Sofie internal Id. + description: Rundown to create/update all Segments for. required: true schema: type: string @@ -620,7 +601,6 @@ resources: $ref: '#/components/schemas/ingestSegment' example: - name: segment1 - externalId: segment1 rank: 0 required: - segments @@ -646,8 +626,6 @@ resources: # - $ref: '#/components/responses/studioNotFound' # - $ref: '#/components/responses/playlistNotFound' # - $ref: '#/components/responses/rundownNotFound' - 409: - $ref: '#/components/responses/externalIdConflict' ingestSegment: get: operationId: getIngestSegment @@ -663,19 +641,19 @@ resources: type: string - name: playlistId in: path - description: Playlist the Segment belongs to. May use external Id or Sofie internal Id. + description: Playlist the Segment belongs to. required: true schema: type: string - name: rundownId in: path - description: Rundown the Segment belongs to. May use external Id or Sofie internal Id. + description: Rundown the Segment belongs to. required: true schema: type: string - name: segmentId in: path - description: Segment to create/update. May use external Id or Sofie internal Id. + description: Segment to create/update. required: true schema: type: string @@ -701,7 +679,6 @@ resources: status: 200 segment: name: segment1 - externalId: segment1 rank: 0 required: - status @@ -728,19 +705,19 @@ resources: type: string - name: playlistId in: path - description: Playlist to ingest Segment into. May use external Id or Sofie internal Id. + description: Playlist to ingest Segment into. required: true schema: type: string - name: rundownId in: path - description: Rundown to ingest Segment into. May use external Id or Sofie internal Id. + description: Rundown to ingest Segment into. required: true schema: type: string - name: segmentId in: path - description: Segment to create/update. May use external Id or Sofie internal Id. + description: Segment to create/update. schema: type: string - name: If-None-Match @@ -765,7 +742,6 @@ resources: $ref: '#/components/schemas/ingestSegment' example: name: segment1 - externalId: segment1 rank: 0 responses: 200: @@ -802,8 +778,6 @@ resources: # - $ref: '#/components/responses/studioNotFound' # - $ref: '#/components/responses/playlistNotFound' # - $ref: '#/components/responses/rundownNotFound' - 409: - $ref: '#/components/responses/externalIdConflict' delete: operationId: deleteIngestSegment tags: @@ -818,19 +792,19 @@ resources: type: string - name: playlistId in: path - description: Playlist the ingest Segment belongs to. May use external Id or Sofie internal Id. + description: Playlist the ingest Segment belongs to. required: true schema: type: string - name: rundownId in: path - description: Rundown the ingest Segment belongs to. May use external Id or Sofie internal Id. + description: Rundown the ingest Segment belongs to. required: true schema: type: string - name: segmentId in: path - description: Segment to delete. May use external Id or Sofie internal Id. + description: Segment to delete. required: true schema: type: string @@ -870,19 +844,19 @@ resources: type: string - name: playlistId in: path - description: Playlist the Segment belongs to. May use external Id or Sofie internal Id. + description: Playlist the Segment belongs to. required: true schema: type: string - name: rundownId in: path - description: Rundown the Segment belongs to. May use external Id or Sofie internal Id. + description: Rundown the Segment belongs to. required: true schema: type: string - name: segmentId in: path - description: Segment to get Parts for. May use external Id or Sofie internal Id. + description: Segment to get Parts for. required: true schema: type: string @@ -903,8 +877,7 @@ resources: items: $ref: '#/components/schemas/ingestPartItem' example: - - name: 4e8fb4df-4d37-4ce5-adb7-009af7feb755 - externalId: part1 + - externalId: part1 required: - status - parts @@ -930,19 +903,19 @@ resources: type: string - name: playlistId in: path - description: Playlist the Segment belongs to. May use external Id or Sofie internal Id. + description: Playlist the Segment belongs to. required: true schema: type: string - name: rundownId in: path - description: Rundown the Segment belongs to. May use external Id or Sofie internal Id. + description: Rundown the Segment belongs to. required: true schema: type: string - name: segmentId in: path - description: Segment to create/update all Parts for. May use external Id or Sofie internal Id. + description: Segment to create/update all Parts for. required: true schema: type: string @@ -966,8 +939,7 @@ resources: items: $ref: '#/components/schemas/ingestPartItem' example: - - name: 4e8fb4df-4d37-4ce5-adb7-009af7feb755 - externalId: part1 + - externalId: part1 required: - parts additionalProperties: false @@ -993,8 +965,6 @@ resources: # - $ref: '#/components/responses/playlistNotFound' # - $ref: '#/components/responses/rundownNotFound' # - $ref: '#/components/responses/partNotFound' - 409: - $ref: '#/components/responses/externalIdConflict' ingestPart: get: operationId: getIngestPart @@ -1010,25 +980,25 @@ resources: type: string - name: playlistId in: path - description: Playlist the Part belongs to. May use external Id or Sofie internal Id. + description: Playlist the Part belongs to. required: true schema: type: string - name: rundownId in: path - description: Rundown the Part belongs to. May use external Id or Sofie internal Id. + description: Rundown the Part belongs to. required: true schema: type: string - name: segmentId in: path - description: Segment the Part belongs to. May use external Id or Sofie internal Id. + description: Segment the Part belongs to. required: true schema: type: string - name: partId in: path - description: Part to create/update. May use external Id or Sofie internal Id. + description: Part to create/update. required: true schema: type: string @@ -1054,7 +1024,6 @@ resources: status: 200 part: name: part1 - externalId: part1 rank: 0 required: - status @@ -1082,24 +1051,24 @@ resources: type: string - name: playlistId in: path - description: Playlist to ingest Part into. May use external Id or Sofie internal Id. + description: Playlist to ingest Part into. required: true schema: type: string - name: rundownId in: path - description: Rundown to ingest Part into. May use external Id or Sofie internal Id. + description: Rundown to ingest Part into. required: true schema: type: string - name: segmentId in: path - description: Segment to ingest Part into. May use external Id or Sofie internal Id. + description: Segment to ingest Part into. schema: type: string - name: partId in: path - description: Part to update/create. May use external Id or Sofie internal Id. + description: Part to update/create. schema: type: string - name: If-None-Match @@ -1124,7 +1093,6 @@ resources: $ref: '#/components/schemas/ingestPart' example: name: part1 - externalId: part1 rank: 0 responses: 200: @@ -1162,8 +1130,6 @@ resources: # - $ref: '#/components/responses/playlistNotFound' # - $ref: '#/components/responses/rundownNotFound' # - $ref: '#/components/responses/segmentNotFound' - 409: - $ref: '#/components/responses/externalIdConflict' delete: operationId: deleteIngestPart tags: @@ -1178,25 +1144,25 @@ resources: type: string - name: playlistId in: path - description: Playlist the ingest Part belongs to. May use external Id or Sofie internal Id. + description: Playlist the ingest Part belongs to. required: true schema: type: string - name: rundownId in: path - description: Rundown the ingest Part belongs to. May use external Id or Sofie internal Id. + description: Rundown the ingest Part belongs to. required: true schema: type: string - name: segmentId in: path - description: Segment the ingest Part belongs to. May use external Id or Sofie internal Id. + description: Segment the ingest Part belongs to. required: true schema: type: string - name: partId in: path - description: Part to delete. May use external Id or Sofie internal Id. + description: Part to delete. required: true schema: type: string @@ -1346,62 +1312,32 @@ components: - notFound - message additionalProperties: false - externalIdConflict: - description: The provided external Id is already in use by a different object with a different Sofie internal Id. - content: - application/json: - schema: - type: object - properties: - status: - type: number - const: 409 - example: 409 - conflict: - type: string - const: externalId - example: externalId - message: - type: string - example: The externalId "b105625d-e1ab-4ce7-b99f-d720cdcdc519" is already in use by "808267a9-a074-4800-b6c7-bfcf7dc1f144" with externalId "9c8faa5d-07af-4e2b-a860-46c3b0f30b2e". schemas: ingestPlaylistItem: type: object properties: - id: - type: string - description: The Id used internally by Sofie. externalId: type: string description: The Id provided by a system external to Sofie that can be used by the same external system to identify this Playlist. required: - - id - externalId additionalProperties: false ingestRundownItem: type: object properties: - id: - type: string - description: The Id used internally by Sofie. externalId: type: string description: The Id provided by a system external to Sofie that can be used by the same external system to identify this Rundown. required: - - id - externalId additionalProperties: false ingestSegmentItem: type: object properties: - id: - type: string - description: The Id used internally by Sofie externalId: type: string description: The Id provided by a system external to Sofie that can be used by the same external system to identify this Segment. required: - - id - externalId additionalProperties: false ingestPartItem: @@ -1421,12 +1357,8 @@ components: properties: name: type: string - externalId: - type: string - description: The Id provided by a system external to Sofie that can be used by the same external system to identify this Playlist. required: - name - - externalId additionalProperties: false ingestRundown: type: object @@ -1439,9 +1371,6 @@ components: examples: - 'Some Product Name' - 'Our Company - Some Product Name' - externalId: - type: string - description: The Id provided by a system external to Sofie that can be used by the same external system to identify this Rundown. rank: type: number description: The position of the Rundown in the parent Playlist. @@ -1452,7 +1381,6 @@ components: required: - name - source - - externalId - rank additionalProperties: false ingestSegment: @@ -1460,9 +1388,6 @@ components: properties: name: type: string - externalId: - type: string - description: The Id provided by a system external to Sofie that can be used by the same external system to identify this Segment. rank: type: number description: The position of the Segment in the parent Rundown. @@ -1472,7 +1397,6 @@ components: additionalProperties: true required: - name - - externalId - rank additionalProperties: false ingestPart: @@ -1480,9 +1404,6 @@ components: properties: name: type: string - externalId: - type: string - description: The Id provided by a system external to Sofie that can be used by the same external system to identify this Part. rank: type: number description: The position of the Part in the parent Segment. @@ -1491,6 +1412,5 @@ components: additionalProperties: true required: - name - - externalId - rank additionalProperties: false From 92156b834105e35a28bf2000d7ae31b18aadde0c Mon Sep 17 00:00:00 2001 From: Baud Date: Thu, 7 Sep 2023 13:52:16 +0100 Subject: [PATCH 04/50] fix: Cleanup some errors --- packages/openapi/api/definitions/ingest.yaml | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml index f8b6d6219f..c219c1cdcc 100644 --- a/packages/openapi/api/definitions/ingest.yaml +++ b/packages/openapi/api/definitions/ingest.yaml @@ -126,17 +126,7 @@ resources: required: true content: application/json: - schema: - type: object - properties: - name: - type: string - description: Name of the Playlist as shown to the user. - required: - - name - example: - name: playlist1 - additionalProperties: false + $ref: '#/components/schemas/ingestPlaylist' responses: 200: description: Playlist has been updated. @@ -937,7 +927,7 @@ resources: parts: type: array items: - $ref: '#/components/schemas/ingestPartItem' + $ref: '#/components/schemas/ingestPart' example: - externalId: part1 required: From 800d9fcfefc2518fd66b2a8000fe9cebab8a55b2 Mon Sep 17 00:00:00 2001 From: Baud Date: Thu, 7 Sep 2023 15:25:38 +0100 Subject: [PATCH 05/50] feat: If-Match headers --- packages/openapi/api/definitions/ingest.yaml | 34 ++++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml index c219c1cdcc..22eaed55c8 100644 --- a/packages/openapi/api/definitions/ingest.yaml +++ b/packages/openapi/api/definitions/ingest.yaml @@ -114,7 +114,14 @@ resources: type: array items: type: string - description: If specified, the Playlist will only be updated if the specified ETag does not match. If unspecified, the new data will replace whatever currently exists, regardless of whether the data is actually the same. + description: If specified, the Playlist will only be updated if one of the specified ETags does not match. If unspecified, the new data will replace whatever currently exists, regardless of whether the data is actually the same. + - name: If-Match + in: header + schema: + type: array + items: + type: string + description: If specified, the Playlist will only be updated if one of the specified ETags matches. - name: ETag in: header required: true @@ -395,7 +402,14 @@ resources: type: array items: type: string - description: If specified, the Rundown will only be updated if the specified ETag does not match. If unspecified, the new data will replace whatever currently exists, regardless of whether the data is actually the same. + description: If specified, the Rundown will only be updated if one of the specified ETags does not match. If unspecified, the new data will replace whatever currently exists, regardless of whether the data is actually the same. + - name: If-Match + in: header + schema: + type: array + items: + type: string + description: If specified, the Rundown will only be updated if one of the specified ETags matches. - name: ETag in: header required: true @@ -716,7 +730,14 @@ resources: type: array items: type: string - description: If specified, the Segment will only be updated if the specified ETag does not match. If unspecified, the new data will replace whatever currently exists, regardless of whether the data is actually the same. + description: If specified, the Segment will only be updated if one of the specified ETags does not match. If unspecified, the new data will replace whatever currently exists, regardless of whether the data is actually the same. + - name: If-Match + in: header + schema: + type: array + items: + type: string + description: If specified, the Segment will only be updated if one of the specified ETags matches. - name: ETag in: header required: true @@ -1068,6 +1089,13 @@ resources: items: type: string description: If specified, the Part will only be updated if the specified ETag does not match. If unspecified, the new data will replace whatever currently exists, regardless of whether the data is actually the same. + - name: If-Match + in: header + schema: + type: array + items: + type: string + description: If specified, the Part will only be updated if one of the specified ETags matches. - name: ETag in: header required: true From f97491b4b9cbf1b9f254cc36684b4eef7fa7a0d8 Mon Sep 17 00:00:00 2001 From: Baud Date: Tue, 31 Oct 2023 16:40:19 +0000 Subject: [PATCH 06/50] chore: Remove studio Ids from ingest API --- packages/openapi/api/actions.yaml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/openapi/api/actions.yaml b/packages/openapi/api/actions.yaml index 9d236d89d5..015ba12c12 100644 --- a/packages/openapi/api/actions.yaml +++ b/packages/openapi/api/actions.yaml @@ -110,19 +110,19 @@ paths: /snapshots: $ref: 'definitions/snapshots.yaml#/resources/snapshots' # ingest operations - /ingest/{studioId}/playlists: + /ingest/playlists: $ref: 'definitions/ingest.yaml#/resources/ingestPlaylists' - /ingest/{studioId}/playlists/{playlistId}: + /ingest/playlists/{playlistId}: $ref: 'definitions/ingest.yaml#/resources/ingestPlaylist' - /ingest/{studioId}/playlists/{playlistId}/rundowns: + /ingest/playlists/{playlistId}/rundowns: $ref: 'definitions/ingest.yaml#/resources/ingestRundowns' - /ingest/{studioId}/playlists/{playlistId}/rundowns/{rundownId}: + /ingest/playlists/{playlistId}/rundowns/{rundownId}: $ref: 'definitions/ingest.yaml#/resources/ingestRundown' - /ingest/{studioId}/playlists/{playlistId}/rundowns/{rundownId}/segments: + /ingest/playlists/{playlistId}/rundowns/{rundownId}/segments: $ref: 'definitions/ingest.yaml#/resources/ingestSegments' - /ingest/{studioId}/playlists/{playlistId}/rundowns/{rundownId}/segments/{segmentId}: + /ingest/playlists/{playlistId}/rundowns/{rundownId}/segments/{segmentId}: $ref: 'definitions/ingest.yaml#/resources/ingestSegment' - /ingest/{studioId}/playlists/{playlistId}/rundowns/{rundownId}/segments/{segmentId}/parts: + /ingest/playlists/{playlistId}/rundowns/{rundownId}/segments/{segmentId}/parts: $ref: 'definitions/ingest.yaml#/resources/ingestParts' - /ingest/{studioId}/playlists/{playlistId}/rundowns/{rundownId}/segments/{segmentId}/parts/{partId}: + /ingest/playlists/{playlistId}/rundowns/{rundownId}/segments/{segmentId}/parts/{partId}: $ref: 'definitions/ingest.yaml#/resources/ingestPart' From 51dbf863e484307a52f64040956da6080b5ae346 Mon Sep 17 00:00:00 2001 From: Baud Date: Thu, 2 Nov 2023 13:37:29 +0000 Subject: [PATCH 07/50] chore: Remove references to studio --- packages/openapi/api/definitions/ingest.yaml | 166 +------------------ 1 file changed, 4 insertions(+), 162 deletions(-) diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml index 22eaed55c8..a794e9ae33 100644 --- a/packages/openapi/api/definitions/ingest.yaml +++ b/packages/openapi/api/definitions/ingest.yaml @@ -6,14 +6,7 @@ resources: operationId: getIngestPlaylists tags: - ingest - summary: Gets ingest data for all Playlists in Sofie belonging to a Studio. - parameters: - - name: studioId - in: path - description: Studio the Playlist belongs to. - required: true - schema: - type: string + summary: Gets ingest data for all Playlists in Sofie. responses: 200: description: Command successfully handled - returns an array of Playlist Ids. @@ -37,8 +30,6 @@ resources: - status - playlists additionalProperties: false - 404: - $ref: '#/components/responses/studioNotFound' ingestPlaylist: get: operationId: getIngestPlaylist @@ -46,12 +37,6 @@ resources: - ingest summary: Gets ingest data for a specific Playlist from Sofie. parameters: - - name: studioId - in: path - description: Studio to ingest Playlist into. - required: true - schema: - type: string - name: playlistId in: path description: Requested Playlist. @@ -85,10 +70,9 @@ resources: - playlist additionalProperties: false 404: - description: Invalid studioId or playlistId + description: Invalid playlistId $ref: '#/components/responses/idNotFound' # oneOf: - # - $ref: '#/components/responses/studioNotFound' # - $ref: '#/components/responses/playlistNotFound' put: operationId: putIngestPlaylist @@ -96,12 +80,6 @@ resources: - ingest summary: Creates a new or updates an existing Playlist. parameters: - - name: studioId - in: path - description: Studio to ingest Playlist into. - required: true - schema: - type: string - name: playlistId in: path description: Playlist to create/update. @@ -171,12 +149,6 @@ resources: - ingest summary: Deletes a specified ingest Playlist. Resources under the Playlist (e.g. Rundowns) will also be removed. parameters: - - name: studioId - in: path - description: Studio the ingest Playlist belongs to. - required: true - schema: - type: string - name: playlistId in: path description: Playlist to delete. @@ -201,7 +173,6 @@ resources: 404: $ref: '#/components/responses/idNotFound' # oneOf: - # - $ref: '#/components/responses/studioNotFound' # - $ref: '#/components/responses/playlistNotFound' ingestRundowns: get: @@ -210,12 +181,6 @@ resources: - ingest summary: Gets ingest data for all Rundowns belonging to a Playlist. parameters: - - name: studioId - in: path - description: Studio the Playlist belongs to. - required: true - schema: - type: string - name: playlistId in: path description: Playlist to get all Rundowns for. @@ -247,7 +212,6 @@ resources: 404: $ref: '#/components/responses/idNotFound' # oneOf: - # - $ref: '#/components/responses/studioNotFound' # - $ref: '#/components/responses/playlistNotFound' put: operationId: putIngestRundowns @@ -255,12 +219,6 @@ resources: - ingest summary: Creates/updates the Rundowns in a Playlist. Any existing Rundowns in the Playlist that are not included in this list will be deleted (including their Segments and Parts). Rundowns will be placed in the Playlist in the order specified by their individual ranks. If the creation/deletion/updating of any Rundown fails all changes will be discarded. ETags are not supported for bulk updates, no version information will be stored for any of the created/modified Rundowns. parameters: - - name: studioId - in: path - description: Studio the Playlist belongs to. - required: true - schema: - type: string - name: playlistId in: path description: Playlist to create/update all Rundowns for. @@ -311,7 +269,6 @@ resources: 404: $ref: '#/components/responses/idNotFound' # oneOf: - # - $ref: '#/components/responses/studioNotFound' # - $ref: '#/components/responses/playlistNotFound' ingestRundown: get: @@ -320,12 +277,6 @@ resources: - ingest summary: Gets ingest data for a specific Rundown. parameters: - - name: studioId - in: path - description: Studio the Rundown belongs to. - required: true - schema: - type: string - name: playlistId in: path description: Playlist the Rundown belongs to. @@ -369,7 +320,6 @@ resources: 404: $ref: '#/components/responses/idNotFound' # oneOf: - # - $ref: '#/components/responses/studioNotFound' # - $ref: '#/components/responses/playlistNotFound' # - $ref: '#/components/responses/rundownNotFound' put: @@ -378,12 +328,6 @@ resources: - ingest summary: Creates a new or updates an existing Rundown. parameters: - - name: studioId - in: path - description: Studio to ingest Rundown into. - required: true - schema: - type: string - name: playlistId in: path description: Playlist to ingest Rundown into. @@ -459,7 +403,6 @@ resources: 404: $ref: '#/components/responses/idNotFound' # oneOf: - # - $ref: '#/components/responses/studioNotFound' # - $ref: '#/components/responses/playlistNotFound' # - $ref: '#/components/responses/rundownNotFound' delete: @@ -468,12 +411,6 @@ resources: - ingest summary: Deletes a specified ingest Rundown. Resources under the Rundown (e.g. Segments) will also be removed. parameters: - - name: studioId - in: path - description: Studio the ingest Rundown belongs to. - required: true - schema: - type: string - name: playlistId in: path description: Playlist the ingest Rundown belongs to. @@ -504,7 +441,6 @@ resources: 404: $ref: '#/components/responses/idNotFound' # oneOf: - # - $ref: '#/components/responses/studioNotFound' # - $ref: '#/components/responses/playlistNotFound' # - $ref: '#/components/responses/rundownNotFound' ingestSegments: @@ -514,12 +450,6 @@ resources: - ingest summary: Gets the ingest data for all Segments belonging to a Rundown. parameters: - - name: studioId - in: path - description: Studio the Segment belongs to. - required: true - schema: - type: string - name: playlistId in: path description: Playlist the Rundown belongs to. @@ -557,7 +487,6 @@ resources: 404: $ref: '#/components/responses/idNotFound' # oneOf: - # - $ref: '#/components/responses/studioNotFound' # - $ref: '#/components/responses/playlistNotFound' # - $ref: '#/components/responses/rundownNotFound' put: @@ -566,12 +495,6 @@ resources: - ingest summary: Creates/updates the Segments in a Rundown. Any existing Segments in the Rundown that are not included in this list will be deleted (including their Parts). Segments will be placed in the Rundown in the order specified by their individual ranks. If the creation/deletion/updating of any Segment fails all changes will be discarded. parameters: - - name: studioId - in: path - description: Studio the Rundown belongs to. - required: true - schema: - type: string - name: playlistId in: path description: Playlist the Rundown belongs to. @@ -627,7 +550,6 @@ resources: 404: $ref: '#/components/responses/idNotFound' # oneOf: - # - $ref: '#/components/responses/studioNotFound' # - $ref: '#/components/responses/playlistNotFound' # - $ref: '#/components/responses/rundownNotFound' ingestSegment: @@ -637,12 +559,6 @@ resources: - ingest summary: Gets ingest data for a specific Segment. parameters: - - name: studioId - in: path - description: Studio the Segment belongs to. - required: true - schema: - type: string - name: playlistId in: path description: Playlist the Segment belongs to. @@ -691,7 +607,6 @@ resources: 404: $ref: '#/components/responses/idNotFound' # oneOf: - # - $ref: '#/components/responses/studioNotFound' # - $ref: '#/components/responses/playlistNotFound' # - $ref: '#/components/responses/rundownNotFound' # - $ref: '#/components/responses/segmentNotFound' @@ -701,12 +616,6 @@ resources: - ingest summary: Creates a new or updates an existing Segment. parameters: - - name: studioId - in: path - description: Studio to ingest Segment into. - required: true - schema: - type: string - name: playlistId in: path description: Playlist to ingest Segment into. @@ -786,7 +695,6 @@ resources: 404: $ref: '#/components/responses/idNotFound' # oneOf: - # - $ref: '#/components/responses/studioNotFound' # - $ref: '#/components/responses/playlistNotFound' # - $ref: '#/components/responses/rundownNotFound' delete: @@ -795,12 +703,6 @@ resources: - ingest summary: Deletes a specified ingest Segment. Resources under the Segment (e.g. Parts) will also be removed. parameters: - - name: studioId - in: path - description: Studio the ingest Segment belongs to. - required: true - schema: - type: string - name: playlistId in: path description: Playlist the ingest Segment belongs to. @@ -837,7 +739,6 @@ resources: 404: $ref: '#/components/responses/idNotFound' # oneOf: - # - $ref: '#/components/responses/studioNotFound' # - $ref: '#/components/responses/playlistNotFound' # - $ref: '#/components/responses/rundownNotFound' ingestParts: @@ -847,12 +748,6 @@ resources: - ingest summary: Gets the ingest data for all Parts belonging to a Segment. parameters: - - name: studioId - in: path - description: Studio the Segment belongs to. - required: true - schema: - type: string - name: playlistId in: path description: Playlist the Segment belongs to. @@ -896,7 +791,6 @@ resources: 404: $ref: '#/components/responses/idNotFound' # oneOf: - # - $ref: '#/components/responses/studioNotFound' # - $ref: '#/components/responses/playlistNotFound' # - $ref: '#/components/responses/rundownNotFound' # - $ref: '#/components/responses/segmentNotFound' @@ -906,12 +800,6 @@ resources: - ingest summary: Creates/updates the Parts in a Segment. Any existing Parts in the Segment that are not included in this list will be deleted. Parts will be placed in the Segment in the order specified by their individual ranks. If the creation/deletion/updating of any Parts fails all changes will be discarded. parameters: - - name: studioId - in: path - description: Studio the Segment belongs to. - required: true - schema: - type: string - name: playlistId in: path description: Playlist the Segment belongs to. @@ -972,7 +860,6 @@ resources: 404: $ref: '#/components/responses/idNotFound' # oneOf: - # - $ref: '#/components/responses/studioNotFound' # - $ref: '#/components/responses/playlistNotFound' # - $ref: '#/components/responses/rundownNotFound' # - $ref: '#/components/responses/partNotFound' @@ -983,12 +870,6 @@ resources: - ingest summary: Gets ingest data for a specific Part. parameters: - - name: studioId - in: path - description: Studio the Part belongs to. - required: true - schema: - type: string - name: playlistId in: path description: Playlist the Part belongs to. @@ -1043,7 +924,6 @@ resources: 404: $ref: '#/components/responses/idNotFound' # oneOf: - # - $ref: '#/components/responses/studioNotFound' # - $ref: '#/components/responses/playlistNotFound' # - $ref: '#/components/responses/rundownNotFound' # - $ref: '#/components/responses/segmentNotFound' @@ -1054,12 +934,6 @@ resources: - ingest summary: Creates a new or updates an existing Part. parameters: - - name: studioId - in: path - description: Studio to ingest Part into. - required: true - schema: - type: string - name: playlistId in: path description: Playlist to ingest Part into. @@ -1144,7 +1018,6 @@ resources: 404: $ref: '#/components/responses/idNotFound' # oneOf: - # - $ref: '#/components/responses/studioNotFound' # - $ref: '#/components/responses/playlistNotFound' # - $ref: '#/components/responses/rundownNotFound' # - $ref: '#/components/responses/segmentNotFound' @@ -1154,12 +1027,6 @@ resources: - ingest summary: Deletes a specified ingest Part. parameters: - - name: studioId - in: path - description: Studio the ingest Part belongs to. - required: true - schema: - type: string - name: playlistId in: path description: Playlist the ingest Part belongs to. @@ -1202,7 +1069,6 @@ resources: 404: $ref: '#/components/responses/idNotFound' # oneOf: - # - $ref: '#/components/responses/studioNotFound' # - $ref: '#/components/responses/playlistNotFound' # - $ref: '#/components/responses/rundownNotFound' # - $ref: '#/components/responses/segmentNotFound' @@ -1210,34 +1076,10 @@ resources: components: responses: idNotFound: - # oneOf responses like below don't render correctly with current tools - use studio as an example for the docs. + # oneOf responses like below don't render correctly with current tools - use playlist as an example for the docs. # oneOf: - # - $ref: '#/components/responses/studioNotFound' # - $ref: '#/components/responses/playlistNotFound' - $ref: '#/components/responses/studioNotFound' - studioNotFound: - description: The specified Studio does not exist. - content: - application/json: - schema: - type: object - properties: - status: - type: number - const: 404 - example: 404 - notFound: - type: string - const: studio - example: studio - message: - type: string - example: The specified Studio was not found. - required: - - status - - notFound - - message - additionalProperties: false + $ref: '#/components/responses/playlistNotFound' playlistNotFound: description: The specified Playlist does not exist. content: From defe14c68541053d0043b95d18cc9d78fc870a2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Wed, 15 Nov 2023 16:18:39 +0100 Subject: [PATCH 08/50] fix: missing property prevents build --- packages/openapi/api/definitions/ingest.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml index a794e9ae33..f117e966a4 100644 --- a/packages/openapi/api/definitions/ingest.yaml +++ b/packages/openapi/api/definitions/ingest.yaml @@ -111,7 +111,8 @@ resources: required: true content: application/json: - $ref: '#/components/schemas/ingestPlaylist' + schema: + $ref: '#/components/schemas/ingestPlaylist' responses: 200: description: Playlist has been updated. From 794d6ec12615df4ada9ddfc333dee40637573611 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Wed, 15 Nov 2023 16:29:55 +0100 Subject: [PATCH 09/50] feat: changes ingest/playlists response Lists internal playlistId and also lists all Rundowns with their externalIds --- packages/openapi/api/definitions/ingest.yaml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml index f117e966a4..d0d463e71a 100644 --- a/packages/openapi/api/definitions/ingest.yaml +++ b/packages/openapi/api/definitions/ingest.yaml @@ -9,7 +9,7 @@ resources: summary: Gets ingest data for all Playlists in Sofie. responses: 200: - description: Command successfully handled - returns an array of Playlist Ids. + description: Command successfully handled - returns an array of Playlists with their playlistIds and list of Rundow. content: application/json: schema: @@ -1177,11 +1177,17 @@ components: ingestPlaylistItem: type: object properties: - externalId: + playlistId: type: string - description: The Id provided by a system external to Sofie that can be used by the same external system to identify this Playlist. + description: The Id provided by Sofie. This Id will be used for /playlist commands for controlling playlist activations, playback etc. + rundowns: + type: array + description: All rundowns in a Playlist. + items: + $ref: '#/components/schemas/ingestRundownItem' required: - - externalId + - playlistId + - rundowns additionalProperties: false ingestRundownItem: type: object From 5ced41b962383454d069ee02d233c82fb6b534ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Wed, 15 Nov 2023 16:30:59 +0100 Subject: [PATCH 10/50] feat: ingest/playlists updated example Moves example to the referenced item for better safety when updating in the future --- packages/openapi/api/definitions/ingest.yaml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml index d0d463e71a..c4ec0eabfb 100644 --- a/packages/openapi/api/definitions/ingest.yaml +++ b/packages/openapi/api/definitions/ingest.yaml @@ -23,9 +23,6 @@ resources: type: array items: $ref: '#/components/schemas/ingestPlaylistItem' - example: - - externalId: 'playlist1' - - externalId: 'playlist2' required: - status - playlists @@ -1189,6 +1186,16 @@ components: - playlistId - rundowns additionalProperties: false + example: + - playlistId: 'playlist1' + rundowns: + - externalId: 'playlist1Rundown1' + - externalId: 'playlist1Rundown2' + - playlistId: 'playlist2' + rundowns: + - externalId: 'playlist2Rundown1' + - externalId: 'playlist2Rundown2' + - externalId: 'playlist2Rundown3' ingestRundownItem: type: object properties: From 739809611a0fbfe7fc70f0f346274fda4939f9d7 Mon Sep 17 00:00:00 2001 From: romain-garcia-rodriguez Date: Mon, 2 Sep 2024 11:52:08 +0200 Subject: [PATCH 11/50] test: create tests for ingest api --- packages/openapi/api/definitions/ingest.yaml | 94 +----- packages/openapi/src/__tests__/ingest.spec.ts | 291 ++++++++++++++++++ 2 files changed, 301 insertions(+), 84 deletions(-) create mode 100644 packages/openapi/src/__tests__/ingest.spec.ts diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml index c4ec0eabfb..f30619627c 100644 --- a/packages/openapi/api/definitions/ingest.yaml +++ b/packages/openapi/api/definitions/ingest.yaml @@ -23,10 +23,16 @@ resources: type: array items: $ref: '#/components/schemas/ingestPlaylistItem' - required: - - status - - playlists - additionalProperties: false + example: + - playlistId: 'playlist1' + rundowns: + - externalId: 'playlist1Rundown1' + - externalId: 'playlist1Rundown2' + - playlistId: 'playlist2' + rundowns: + - externalId: 'playlist2Rundown1' + - externalId: 'playlist2Rundown2' + - externalId: 'playlist2Rundown3' ingestPlaylist: get: operationId: getIngestPlaylist @@ -71,76 +77,6 @@ resources: $ref: '#/components/responses/idNotFound' # oneOf: # - $ref: '#/components/responses/playlistNotFound' - put: - operationId: putIngestPlaylist - tags: - - ingest - summary: Creates a new or updates an existing Playlist. - parameters: - - name: playlistId - in: path - description: Playlist to create/update. - required: true - schema: - type: string - - name: If-None-Match - in: header - schema: - type: array - items: - type: string - description: If specified, the Playlist will only be updated if one of the specified ETags does not match. If unspecified, the new data will replace whatever currently exists, regardless of whether the data is actually the same. - - name: If-Match - in: header - schema: - type: array - items: - type: string - description: If specified, the Playlist will only be updated if one of the specified ETags matches. - - name: ETag - in: header - required: true - schema: - type: string - description: ETag to use as version information for Playlist. - requestBody: - description: Contains the Playlist data. - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ingestPlaylist' - responses: - 200: - description: Playlist has been updated. - content: - application/json: - schema: - type: object - properties: - status: - type: number - const: 200 - example: 200 - required: - - status - additionalProperties: false - 201: - description: Playlist has been created. - content: - application/json: - schema: - type: object - properties: - status: - type: number - const: 201 - example: 201 - required: - - status - additionalProperties: false - 404: - $ref: '#/components/responses/idNotFound' delete: operationId: deleteIngestPlaylist tags: @@ -1186,16 +1122,6 @@ components: - playlistId - rundowns additionalProperties: false - example: - - playlistId: 'playlist1' - rundowns: - - externalId: 'playlist1Rundown1' - - externalId: 'playlist1Rundown2' - - playlistId: 'playlist2' - rundowns: - - externalId: 'playlist2Rundown1' - - externalId: 'playlist2Rundown2' - - externalId: 'playlist2Rundown3' ingestRundownItem: type: object properties: diff --git a/packages/openapi/src/__tests__/ingest.spec.ts b/packages/openapi/src/__tests__/ingest.spec.ts new file mode 100644 index 0000000000..fd7f57051e --- /dev/null +++ b/packages/openapi/src/__tests__/ingest.spec.ts @@ -0,0 +1,291 @@ +// eslint-disable-next-line node/no-missing-import +import { Configuration, IngestApi, IngestPart, IngestRundown, IngestSegment } from '../../client/ts' +import { checkServer } from '../checkServer' +import Logging from '../httpLogging' + +const httpLogging = false +// let testServer = false +// if (process.env.SERVER_TYPE === 'TEST') { +// testServer = true +// } + +describe('Network client', () => { + const config = new Configuration({ + basePath: process.env.SERVER_URL, + middleware: [new Logging(httpLogging)], + }) + + beforeAll(async () => await checkServer(config)) + + const ingestApi = new IngestApi(config) + + /** + * INGEST PLAYLIST + */ + const playlistIds: string[] = [] + test('Can request all ingest playlists in Sofie', async () => { + const ingestPlaylists = await ingestApi.getIngestPlaylists() + expect(ingestPlaylists.status).toBe(200) + expect(ingestPlaylists).toHaveProperty('playlists') + + expect(ingestPlaylists.playlists.length).toBeGreaterThanOrEqual(1) + ingestPlaylists.playlists.forEach((playlist) => { + expect(typeof playlist).toBe('object') + expect(typeof playlist.playlistId).toBe('string') + playlistIds.push(playlist.playlistId) + }) + }) + + test('Can request a playlist by id in Sofie', async () => { + const ingestPlaylist = await ingestApi.getIngestPlaylist({ + playlistId: playlistIds[0], + }) + expect(ingestPlaylist.status).toBe(200) + expect(ingestPlaylist).toHaveProperty('playlist') + + expect(ingestPlaylist.playlist).toHaveProperty('name') + expect(typeof ingestPlaylist.playlist.name).toBe('string') + }) + + /** + * INGEST RUNDOWS + */ + const rundownIds: string[] = [] + test('Can request all ingest rundowns in Sofie', async () => { + const ingestRundowns = await ingestApi.getIngestRundowns({ + playlistId: playlistIds[0], + }) + expect(ingestRundowns.status).toBe(200) + expect(ingestRundowns).toHaveProperty('rundowns') + + expect(ingestRundowns.rundowns.length).toBeGreaterThanOrEqual(1) + + ingestRundowns.rundowns.forEach((rundown) => { + expect(typeof rundown).toBe('object') + expect(typeof rundown.externalId).toBe('string') + rundownIds.push(rundown.externalId) + }) + }) + + let newIngestRundown: IngestRundown | undefined + test('Can request ingest rundown by id in Sofie', async () => { + const ingestRundown = await ingestApi.getIngestRundown({ + playlistId: playlistIds[0], + rundownId: rundownIds[0], + }) + expect(ingestRundown.status).toBe(200) + expect(ingestRundown).toHaveProperty('rundown') + + expect(ingestRundown.rundown).toHaveProperty('name') + expect(ingestRundown.rundown).toHaveProperty('rank') + expect(ingestRundown.rundown).toHaveProperty('source') + expect(typeof ingestRundown.rundown.name).toBe('string') + expect(typeof ingestRundown.rundown.rank).toBe('number') + expect(typeof ingestRundown.rundown.source).toBe('string') + newIngestRundown = JSON.parse(JSON.stringify(ingestRundown.rundown)) + }) + + test('Can add/update multiple rundowns in Sofie', async () => { + newIngestRundown.name = newIngestRundown.name + 'added' + newIngestRundown.rank = 2 + const ingestRundown = await ingestApi.putIngestRundowns({ + playlistId: playlistIds[0], + putIngestRundownsRequest: { + rundowns: [ + { + name: 'rundown1', + source: 'Our Company - Some Product Name', + rank: 0, + }, + { + name: 'rundown2', + source: 'Our Second Company - Some Product Name', + rank: 1, + }, + ], + }, + }) + expect(ingestRundown.status).toBe(200) + }) + + const testIngestRundownId = 'rundown3' + test('Can add/update an ingest rundown in Sofie', async () => { + const newPutIngestRundown = await ingestApi.putIngestRundown({ + playlistId: playlistIds[0], + rundownId: testIngestRundownId, + ingestRundown: { + name: 'rundown3', + source: 'Our Company - Some Product Name', + rank: 3, + }, + eTag: '1725268817', + }) + expect(newPutIngestRundown.status).toBe(200) + }) + + test('Can delete ingest rundown by id in Sofie', async () => { + const ingestRundown = await ingestApi.deleteIngestRundown({ + playlistId: playlistIds[0], + rundownId: testIngestRundownId, + }) + expect(ingestRundown.status).toBe(200) + }) + + /** + * INGEST SEGMENT + */ + const segmentIds: string[] = [] + test('Can request all ingest segments in Sofie', async () => { + const ingestSegments = await ingestApi.getIngestSegments({ + playlistId: playlistIds[0], + rundownId: rundownIds[0], + }) + expect(ingestSegments.status).toBe(200) + expect(ingestSegments).toHaveProperty('segments') + + expect(ingestSegments.segments.length).toBeGreaterThanOrEqual(1) + + ingestSegments.segments.forEach((segment) => { + expect(typeof segment).toBe('object') + expect(typeof segment.externalId).toBe('string') + segmentIds.push(segment.externalId) + }) + }) + + let newIngestSegment: IngestSegment | undefined + test('Can request ingest segment by id in Sofie', async () => { + const ingestSegment = await ingestApi.getIngestSegment({ + playlistId: playlistIds[0], + rundownId: rundownIds[0], + segmentId: segmentIds[0], + }) + expect(ingestSegment.status).toBe(200) + expect(ingestSegment).toHaveProperty('segment') + + expect(ingestSegment.segment).toHaveProperty('name') + expect(ingestSegment.segment).toHaveProperty('rank') + expect(typeof ingestSegment.segment.name).toBe('string') + expect(typeof ingestSegment.segment.rank).toBe('number') + newIngestSegment = JSON.parse(JSON.stringify(ingestSegment.segment)) + }) + + test('can add/update multiple ingest segments in Sofie', async () => { + const ingestSegment = await ingestApi.putIngestSegments({ + playlistId: playlistIds[0], + rundownId: rundownIds[0], + putIngestSegmentsRequest: { + segments: [ + { + name: 'segment1', + rank: 0, + }, + ], + }, + }) + expect(ingestSegment.status).toBe(200) + }) + + const testIngestSegmentId = 'segment2' + test('Can add/update an ingest segment in Sofie', async () => { + newIngestSegment.name = newIngestSegment.name + 'Added' + const ingestSegment = await ingestApi.putIngestSegment({ + playlistId: playlistIds[0], + rundownId: rundownIds[0], + segmentId: testIngestSegmentId, + eTag: '1725269223', + ingestSegment: newIngestSegment, + }) + expect(ingestSegment.status).toBe(200) + }) + + test('Can delete ingest segment by id in Sofie', async () => { + const ingestRundown = await ingestApi.deleteIngestSegment({ + playlistId: playlistIds[0], + rundownId: rundownIds[0], + segmentId: testIngestSegmentId, + }) + expect(ingestRundown.status).toBe(200) + }) + + /** + * INGEST PARTS + */ + const partIds: string[] = [] + test('Can request all ingest parts in Sofie', async () => { + const ingestParts = await ingestApi.getIngestParts({ + playlistId: playlistIds[0], + rundownId: rundownIds[0], + segmentId: segmentIds[0], + }) + + expect(ingestParts.status).toBe(200) + expect(ingestParts).toHaveProperty('parts') + + expect(ingestParts.parts.length).toBeGreaterThanOrEqual(1) + + ingestParts.parts.forEach((part) => { + expect(typeof part).toBe('object') + expect(typeof part.externalId).toBe('string') + partIds.push(part.externalId) + }) + }) + + let newIngestPart: IngestPart | undefined + test('Can request ingest part by id in Sofie', async () => { + const ingestPart = await ingestApi.getIngestPart({ + playlistId: playlistIds[0], + rundownId: rundownIds[0], + segmentId: segmentIds[0], + partId: partIds[0], + }) + expect(ingestPart.status).toBe(200) + expect(ingestPart).toHaveProperty('part') + + expect(ingestPart.part).toHaveProperty('name') + expect(ingestPart.part).toHaveProperty('rank') + expect(typeof ingestPart.part.name).toBe('string') + expect(typeof ingestPart.part.rank).toBe('number') + newIngestPart = JSON.parse(JSON.stringify(ingestPart.part)) + }) + + test('Can add/update multiple ingest parts in Sofie', async () => { + const ingestPart = await ingestApi.putIngestParts({ + playlistId: playlistIds[0], + rundownId: rundownIds[0], + segmentId: segmentIds[0], + putIngestPartsRequest: { + parts: [ + { + name: 'part1', + rank: 0, + }, + ], + }, + }) + expect(ingestPart.status).toBe(200) + }) + + const testIngestPartId = 'part2' + test('Can add/update an ingest part in Sofie', async () => { + newIngestPart.name = newIngestPart.name + 'Added' + const ingestPart = await ingestApi.putIngestPart({ + playlistId: playlistIds[0], + rundownId: rundownIds[0], + segmentId: segmentIds[0], + partId: testIngestPartId, + eTag: '1725269417', + ingestPart: newIngestPart, + }) + expect(ingestPart.status).toBe(200) + }) + + test('Can delete ingest part by id in Sofie', async () => { + const ingestRundown = await ingestApi.deleteIngestPart({ + playlistId: playlistIds[0], + rundownId: rundownIds[0], + segmentId: segmentIds[0], + partId: testIngestPartId, + }) + expect(ingestRundown.status).toBe(200) + }) +}) From 181a9ed9aeef25f7420e1bb28b20c624e7fe442c Mon Sep 17 00:00:00 2001 From: romain-garcia-rodriguez Date: Wed, 4 Sep 2024 10:45:54 +0200 Subject: [PATCH 12/50] test: add missing delete for ingest api --- packages/openapi/api/definitions/ingest.yaml | 119 ++++++++++++++++++ packages/openapi/src/__tests__/ingest.spec.ts | 40 +++++- 2 files changed, 155 insertions(+), 4 deletions(-) diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml index f30619627c..611a74ee55 100644 --- a/packages/openapi/api/definitions/ingest.yaml +++ b/packages/openapi/api/definitions/ingest.yaml @@ -33,6 +33,26 @@ resources: - externalId: 'playlist2Rundown1' - externalId: 'playlist2Rundown2' - externalId: 'playlist2Rundown3' + delete: + operationId: deleteIngestPlaylists + tags: + - ingest + summary: Delete multiple playlists. + responses: + 200: + description: Playlists removed. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 200 + example: 200 + required: + - status + additionalProperties: false ingestPlaylist: get: operationId: getIngestPlaylist @@ -204,6 +224,33 @@ resources: $ref: '#/components/responses/idNotFound' # oneOf: # - $ref: '#/components/responses/playlistNotFound' + delete: + operationId: deleteIngestRundowns + tags: + - ingest + summary: Delete multiple rundowns. + parameters: + - name: playlistId + in: path + description: Playlist the ingest Part belongs to. + required: true + schema: + type: string + responses: + 200: + description: Rundown removed. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 200 + example: 200 + required: + - status + additionalProperties: false ingestRundown: get: operationId: getIngestRundown @@ -486,6 +533,39 @@ resources: # oneOf: # - $ref: '#/components/responses/playlistNotFound' # - $ref: '#/components/responses/rundownNotFound' + delete: + operationId: deleteIngestSegments + tags: + - ingest + summary: Delete multiple segments. + parameters: + - name: playlistId + in: path + description: Playlist the ingest Part belongs to. + required: true + schema: + type: string + - name: rundownId + in: path + description: Rundown the ingest Part belongs to. + required: true + schema: + type: string + responses: + 200: + description: Segments removed. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 200 + example: 200 + required: + - status + additionalProperties: false ingestSegment: get: operationId: getIngestSegment @@ -797,6 +877,45 @@ resources: # - $ref: '#/components/responses/playlistNotFound' # - $ref: '#/components/responses/rundownNotFound' # - $ref: '#/components/responses/partNotFound' + delete: + operationId: deleteIngestParts + tags: + - ingest + summary: Delete multiple Parts. + parameters: + - name: playlistId + in: path + description: Playlist the ingest Part belongs to. + required: true + schema: + type: string + - name: rundownId + in: path + description: Rundown the ingest Part belongs to. + required: true + schema: + type: string + - name: segmentId + in: path + description: Segment the ingest Part belongs to. + required: true + schema: + type: string + responses: + 200: + description: Parts removed. + content: + application/json: + schema: + type: object + properties: + status: + type: number + const: 200 + example: 200 + required: + - status + additionalProperties: false ingestPart: get: operationId: getIngestPart diff --git a/packages/openapi/src/__tests__/ingest.spec.ts b/packages/openapi/src/__tests__/ingest.spec.ts index fd7f57051e..af2492897f 100644 --- a/packages/openapi/src/__tests__/ingest.spec.ts +++ b/packages/openapi/src/__tests__/ingest.spec.ts @@ -4,10 +4,6 @@ import { checkServer } from '../checkServer' import Logging from '../httpLogging' const httpLogging = false -// let testServer = false -// if (process.env.SERVER_TYPE === 'TEST') { -// testServer = true -// } describe('Network client', () => { const config = new Configuration({ @@ -47,6 +43,18 @@ describe('Network client', () => { expect(typeof ingestPlaylist.playlist.name).toBe('string') }) + test('Can delete multiple ingest playlists in Sofie', async () => { + const ingestRundown = await ingestApi.deleteIngestPlaylists() + expect(ingestRundown.status).toBe(200) + }) + + test('Can delete ingest playlist by id in Sofie', async () => { + const ingestRundown = await ingestApi.deleteIngestPlaylist({ + playlistId: playlistIds[0], + }) + expect(ingestRundown.status).toBe(200) + }) + /** * INGEST RUNDOWS */ @@ -123,6 +131,13 @@ describe('Network client', () => { expect(newPutIngestRundown.status).toBe(200) }) + test('Can delete multiple ingest rundowns in Sofie', async () => { + const ingestRundown = await ingestApi.deleteIngestRundowns({ + playlistId: playlistIds[0], + }) + expect(ingestRundown.status).toBe(200) + }) + test('Can delete ingest rundown by id in Sofie', async () => { const ingestRundown = await ingestApi.deleteIngestRundown({ playlistId: playlistIds[0], @@ -198,6 +213,14 @@ describe('Network client', () => { expect(ingestSegment.status).toBe(200) }) + test('Can delete multiple ingest segments in Sofie', async () => { + const ingestRundown = await ingestApi.deleteIngestSegments({ + playlistId: playlistIds[0], + rundownId: rundownIds[0], + }) + expect(ingestRundown.status).toBe(200) + }) + test('Can delete ingest segment by id in Sofie', async () => { const ingestRundown = await ingestApi.deleteIngestSegment({ playlistId: playlistIds[0], @@ -279,6 +302,15 @@ describe('Network client', () => { expect(ingestPart.status).toBe(200) }) + test('Can delete multiple ingest parts in Sofie', async () => { + const ingestRundown = await ingestApi.deleteIngestParts({ + playlistId: playlistIds[0], + rundownId: rundownIds[0], + segmentId: segmentIds[0], + }) + expect(ingestRundown.status).toBe(200) + }) + test('Can delete ingest part by id in Sofie', async () => { const ingestRundown = await ingestApi.deleteIngestPart({ playlistId: playlistIds[0], From c1eea17db01a19cf3e276ac4195b13472541bc4a Mon Sep 17 00:00:00 2001 From: romain-garcia-rodriguez Date: Wed, 4 Sep 2024 14:39:11 +0200 Subject: [PATCH 13/50] test: fix array header in api definition --- packages/openapi/api/definitions/ingest.yaml | 10 ++++++++++ packages/openapi/src/__tests__/ingest.spec.ts | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml index 611a74ee55..35b5164bd9 100644 --- a/packages/openapi/api/definitions/ingest.yaml +++ b/packages/openapi/api/definitions/ingest.yaml @@ -181,6 +181,7 @@ resources: type: string - name: If-None-Match in: header + style: simple schema: type: array items: @@ -323,6 +324,7 @@ resources: type: string - name: If-None-Match in: header + style: simple schema: type: array items: @@ -330,6 +332,7 @@ resources: description: If specified, the Rundown will only be updated if one of the specified ETags does not match. If unspecified, the new data will replace whatever currently exists, regardless of whether the data is actually the same. - name: If-Match in: header + style: simple schema: type: array items: @@ -340,6 +343,7 @@ resources: required: true schema: type: string + example: '123456789' description: ETag to use as version information for Rundown. requestBody: description: Contains the Rundown data. @@ -490,6 +494,7 @@ resources: type: string - name: If-None-Match in: header + style: simple schema: type: array items: @@ -649,6 +654,7 @@ resources: type: string - name: If-None-Match in: header + style: simple schema: type: array items: @@ -656,6 +662,7 @@ resources: description: If specified, the Segment will only be updated if one of the specified ETags does not match. If unspecified, the new data will replace whatever currently exists, regardless of whether the data is actually the same. - name: If-Match in: header + style: simple schema: type: array items: @@ -834,6 +841,7 @@ resources: type: string - name: If-None-Match in: header + style: simple schema: type: array items: @@ -1011,6 +1019,7 @@ resources: type: string - name: If-None-Match in: header + style: simple schema: type: array items: @@ -1018,6 +1027,7 @@ resources: description: If specified, the Part will only be updated if the specified ETag does not match. If unspecified, the new data will replace whatever currently exists, regardless of whether the data is actually the same. - name: If-Match in: header + style: simple schema: type: array items: diff --git a/packages/openapi/src/__tests__/ingest.spec.ts b/packages/openapi/src/__tests__/ingest.spec.ts index af2492897f..b2f7b0a1c8 100644 --- a/packages/openapi/src/__tests__/ingest.spec.ts +++ b/packages/openapi/src/__tests__/ingest.spec.ts @@ -126,7 +126,8 @@ describe('Network client', () => { source: 'Our Company - Some Product Name', rank: 3, }, - eTag: '1725268817', + eTag: '123456789', + ifNoneMatch: ['123456789', '1725453459'], }) expect(newPutIngestRundown.status).toBe(200) }) From d271cca7ef5cba154bc852585f88b7eff2937295 Mon Sep 17 00:00:00 2001 From: romain-garcia-rodriguez Date: Wed, 4 Sep 2024 14:41:42 +0200 Subject: [PATCH 14/50] test: add comments in api definition --- packages/openapi/api/definitions/ingest.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml index 35b5164bd9..62e905f8f7 100644 --- a/packages/openapi/api/definitions/ingest.yaml +++ b/packages/openapi/api/definitions/ingest.yaml @@ -181,6 +181,7 @@ resources: type: string - name: If-None-Match in: header + # Indicates that the array elements are serialized as a comma-separated list in the header. style: simple schema: type: array @@ -324,6 +325,7 @@ resources: type: string - name: If-None-Match in: header + # Indicates that the array elements are serialized as a comma-separated list in the header. style: simple schema: type: array @@ -332,6 +334,7 @@ resources: description: If specified, the Rundown will only be updated if one of the specified ETags does not match. If unspecified, the new data will replace whatever currently exists, regardless of whether the data is actually the same. - name: If-Match in: header + # Indicates that the array elements are serialized as a comma-separated list in the header. style: simple schema: type: array @@ -494,6 +497,7 @@ resources: type: string - name: If-None-Match in: header + # Indicates that the array elements are serialized as a comma-separated list in the header. style: simple schema: type: array @@ -654,6 +658,7 @@ resources: type: string - name: If-None-Match in: header + # Indicates that the array elements are serialized as a comma-separated list in the header. style: simple schema: type: array @@ -662,6 +667,7 @@ resources: description: If specified, the Segment will only be updated if one of the specified ETags does not match. If unspecified, the new data will replace whatever currently exists, regardless of whether the data is actually the same. - name: If-Match in: header + # Indicates that the array elements are serialized as a comma-separated list in the header. style: simple schema: type: array @@ -841,6 +847,7 @@ resources: type: string - name: If-None-Match in: header + # Indicates that the array elements are serialized as a comma-separated list in the header. style: simple schema: type: array @@ -1019,6 +1026,7 @@ resources: type: string - name: If-None-Match in: header + # Indicates that the array elements are serialized as a comma-separated list in the header. style: simple schema: type: array @@ -1027,6 +1035,7 @@ resources: description: If specified, the Part will only be updated if the specified ETag does not match. If unspecified, the new data will replace whatever currently exists, regardless of whether the data is actually the same. - name: If-Match in: header + # Indicates that the array elements are serialized as a comma-separated list in the header. style: simple schema: type: array From 7545052a3f2c9d8495a6d68d13118c239fcab5d9 Mon Sep 17 00:00:00 2001 From: romain-garcia-rodriguez Date: Wed, 4 Sep 2024 14:43:29 +0200 Subject: [PATCH 15/50] test: fix devices tests --- packages/openapi/src/__tests__/devices.spec.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/openapi/src/__tests__/devices.spec.ts b/packages/openapi/src/__tests__/devices.spec.ts index 788bee3fbb..3560cc2fd0 100644 --- a/packages/openapi/src/__tests__/devices.spec.ts +++ b/packages/openapi/src/__tests__/devices.spec.ts @@ -22,11 +22,15 @@ describe('Network client', () => { const devices = await devicesApi.devices() expect(devices.status).toBe(200) expect(devices).toHaveProperty('result') - devices.result.forEach((device) => { - expect(typeof device).toBe('object') - expect(device).toHaveProperty('id') - expect(typeof device.id).toBe('string') - deviceIds.push(device.id) + expect(devices.result).toHaveProperty('ingest') + expect(devices.result).toHaveProperty('liveStatus') + expect(devices.result).toHaveProperty('mediaManager') + expect(devices.result).toHaveProperty('packageManager') + expect(devices.result).toHaveProperty('playout') + expect(devices.result).toHaveProperty('triggerInput') + devices.result.playout.forEach((device) => { + expect(typeof device).toBe('string') + deviceIds.push(device) }) }) From 0bd5b318c9c59b209071d3a97bfe22e4f79bfa12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Wed, 4 Sep 2024 15:08:18 +0200 Subject: [PATCH 16/50] feat: basic Ingest API CRUD operations --- meteor/server/api/ingest/actions.ts | 5 + .../ingest/httpIngest/httpIngestController.ts | 351 +++++++++++++++++ .../ingest/httpIngest/httpIngestServices.ts | 360 ++++++++++++++++++ meteor/server/api/rest/api.ts | 3 + packages/corelib/src/dataModel/Rundown.ts | 11 +- packages/openapi/api/definitions/ingest.yaml | 55 +-- packages/openapi/src/__tests__/ingest.spec.ts | 18 +- 7 files changed, 756 insertions(+), 47 deletions(-) create mode 100644 meteor/server/api/ingest/httpIngest/httpIngestController.ts create mode 100644 meteor/server/api/ingest/httpIngest/httpIngestServices.ts diff --git a/meteor/server/api/ingest/actions.ts b/meteor/server/api/ingest/actions.ts index 6a6cf852bf..a00f021024 100644 --- a/meteor/server/api/ingest/actions.ts +++ b/meteor/server/api/ingest/actions.ts @@ -27,6 +27,11 @@ export namespace IngestActions { return TriggerReloadDataResponse.COMPLETED } + case 'httpIngest': { + console.log('TODO: RELOADING HTTP INGEST DATA') + + return TriggerReloadDataResponse.COMPLETED + } case 'testing': { await runIngestOperation(rundown.studioId, IngestJobs.CreateAdlibTestingRundownForShowStyleVariant, { showStyleVariantId: rundown.showStyleVariantId, diff --git a/meteor/server/api/ingest/httpIngest/httpIngestController.ts b/meteor/server/api/ingest/httpIngest/httpIngestController.ts new file mode 100644 index 0000000000..930f56ec79 --- /dev/null +++ b/meteor/server/api/ingest/httpIngest/httpIngestController.ts @@ -0,0 +1,351 @@ +import KoaRouter from '@koa/router' +import { IngestPart, IngestRundown, IngestSegment } from '@sofie-automation/blueprints-integration' +import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' +import Koa from 'koa' +import koaBodyParser from 'koa-bodyparser' +import { Meteor } from 'meteor/meteor' +import { check } from '../../../../lib/check' +import { logger } from '../../../../lib/logging' +import { + deletePart, + deletePlaylist, + deletePlaylists, + deleteRundown, + deleteRundowns, + deleteSegment, + deleteSegments, + getPart, + getParts, + getPlaylist, + getPlaylists, + getRundown, + getRundowns, + getSegment, + getSegments, + putParts, + putRundown, + putSegments, +} from './httpIngestServices' + +const router = new KoaRouter() +export const httpIngestRouter = router + +const bodyParser = koaBodyParser({ + jsonLimit: '200mb', +}) + +const validateBodyMiddleware = async (ctx: Koa.DefaultContext, next: () => Promise) => { + const contentType = 'application/json' + try { + if (ctx.request.type !== contentType) { + throw new Meteor.Error( + 400, + `Upload rundown: Invalid content-type, received ${ + ctx.request.type || 'undefined' + }, expected ${contentType}` + ) + } + await next() + } catch (e) { + handleError(e, ctx) + } +} + +const handle200 = (ctx: Koa.DefaultContext, data?: any) => { + ctx.response.type = 'application/json' + ctx.response.status = 200 + ctx.response.body = data || '' +} + +const handle201 = (ctx: Koa.DefaultContext, data?: any) => { + ctx.response.type = 'application/json' + ctx.response.status = 201 + ctx.response.body = data || '' +} + +const handleError = (e: unknown, ctx: Koa.DefaultContext) => { + ctx.response.type = 'text/plain' + ctx.response.status = e instanceof Meteor.Error && typeof e.error === 'number' ? e.error : 500 + ctx.response.body = 'Error: ' + stringifyError(e) + + if (ctx.response.status !== 404) { + logger.error(stringifyError(e)) + } +} + +// Playlists + +router.get('/playlists', async (ctx) => { + try { + const playlists = await getPlaylists() + handle200(ctx, playlists) + } catch (e) { + handleError(e, ctx) + } +}) + +router.delete('/playlists', async (ctx) => { + try { + await deletePlaylists() + handle200(ctx) + } catch (e) { + handleError(e, ctx) + } +}) + +router.get('/playlists/:playlistId', async (ctx) => { + const playlistId = ctx.params.playlistId + check(playlistId, String) + + try { + const playlist = await getPlaylist(playlistId) + handle200(ctx, playlist) + } catch (e) { + handleError(e, ctx) + } +}) + +router.delete('/playlists/:playlistId', async (ctx) => { + const playlistId = ctx.params.playlistId + check(playlistId, String) + + try { + await deletePlaylist(playlistId) + handle200(ctx) + } catch (e) { + handleError(e, ctx) + } +}) + +// Rundowns + +router.put('/playlists/:playlistId/rundowns', bodyParser, validateBodyMiddleware, async (ctx) => { + const playlistId = ctx.params.playlistId + check(playlistId, String) + + try { + const ingestRundown = ctx.request.body as IngestRundown + 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') + + await putRundown(playlistId, ingestRundown) + + handle201(ctx) + } catch (e) { + handleError(e, ctx) + } +}) + +router.get('/playlists/:playlistId/rundowns', async (ctx) => { + const playlistId = ctx.params.playlistId + check(playlistId, String) + + try { + const rundowns = await getRundowns(playlistId) + handle200(ctx, rundowns) + } catch (e) { + handleError(e, ctx) + } +}) + +router.delete('/playlists/:playlistId/rundowns', async (ctx) => { + const playlistId = ctx.params.playlistId + check(playlistId, String) + + try { + await deleteRundowns(playlistId) + handle200(ctx) + } catch (e) { + handleError(e, ctx) + } +}) + +router.get('/playlists/:playlistId/rundowns/:rundownId', async (ctx) => { + const playlistId = ctx.params.playlistId + check(playlistId, String) + const rundownId = ctx.params.rundownId + check(rundownId, String) + + try { + const rundown = await getRundown(playlistId, rundownId) + handle200(ctx, rundown) + } catch (e) { + handleError(e, ctx) + } +}) + +router.delete('/playlists/:playlistId/rundowns/:rundownId', async (ctx) => { + const playlistId = ctx.params.playlistId + check(playlistId, String) + const rundownId = ctx.params.rundownId + check(rundownId, String) + + try { + await deleteRundown(playlistId, rundownId) + handle200(ctx) + } catch (e) { + handleError(e, ctx) + } +}) + +// Segments + +router.get('/playlists/:playlistId/rundowns/:rundownId/segments', async (ctx) => { + const playlistId = ctx.params.playlistId + check(playlistId, String) + const rundownId = ctx.params.rundownId + check(rundownId, String) + + try { + const segments = await getSegments(playlistId, rundownId) + handle200(ctx, segments) + } catch (e) { + handleError(e, ctx) + } +}) + +router.delete('/playlists/:playlistId/rundowns/:rundownId/segments', async (ctx) => { + const playlistId = ctx.params.playlistId + check(playlistId, String) + const rundownId = ctx.params.rundownId + check(rundownId, String) + + try { + await deleteSegments(playlistId, rundownId) + handle200(ctx) + } catch (e) { + handleError(e, ctx) + } +}) + +router.get('/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId', async (ctx) => { + const playlistId = ctx.params.playlistId + check(playlistId, String) + const rundownId = ctx.params.rundownId + check(rundownId, String) + const segmentId = ctx.params.segmentId + check(segmentId, String) + + try { + const segment = await getSegment(playlistId, rundownId, segmentId) + handle200(ctx, segment) + } catch (e) { + handleError(e, ctx) + } +}) + +router.delete('/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId', async (ctx) => { + const playlistId = ctx.params.playlistId + check(playlistId, String) + const rundownId = ctx.params.rundownId + check(rundownId, String) + const segmentId = ctx.params.segmentId + check(segmentId, String) + + try { + await deleteSegment(playlistId, rundownId, segmentId) + handle200(ctx) + } catch (e) { + handleError(e, ctx) + } +}) + +// PUT on collection is an exception; it allows us to batch-update segments +router.put('/playlists/:playlistId/rundowns/:rundownId/segments', bodyParser, validateBodyMiddleware, async (ctx) => { + const playlistId = ctx.params.playlistId + check(playlistId, String) + const rundownId = ctx.params.rundownId + check(rundownId, String) + + try { + const ingestSegments = ctx.request.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') + + await putSegments(playlistId, rundownId, ingestSegments) + + handle201(ctx) + } catch (e) { + handleError(e, ctx) + } +}) + +// Parts + +router.get('/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts', async (ctx) => { + const playlistId = ctx.params.playlistId + check(playlistId, String) + const rundownId = ctx.params.rundownId + check(rundownId, String) + const segmentId = ctx.params.segmentId + check(segmentId, String) + + try { + const parts = await getParts(playlistId, rundownId, segmentId) + handle200(ctx, parts) + } catch (e) { + handleError(e, ctx) + } +}) + +router.get('/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts/:partId', async (ctx) => { + const playlistId = ctx.params.playlistId + check(playlistId, String) + const rundownId = ctx.params.rundownId + check(rundownId, String) + const segmentId = ctx.params.segmentId + check(segmentId, String) + const partId = ctx.params.partId + check(partId, String) + + try { + const part = await getPart(playlistId, rundownId, segmentId, partId) + handle200(ctx, part) + } catch (e) { + handleError(e, ctx) + } +}) + +router.put( + '/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts', + bodyParser, + validateBodyMiddleware, + async (ctx) => { + const playlistId = ctx.params.playlistId + check(playlistId, String) + const rundownId = ctx.params.rundownId + check(rundownId, String) + const segmentId = ctx.params.segmentId + check(segmentId, String) + + try { + const ingestParts = ctx.request.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') + + await putParts(playlistId, rundownId, segmentId, ingestParts) + + handle201(ctx) + } catch (e) { + handleError(e, ctx) + } + } +) + +router.delete('/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts/:partId', async (ctx) => { + const playlistId = ctx.params.playlistId + check(playlistId, String) + const rundownId = ctx.params.rundownId + check(rundownId, String) + const segmentId = ctx.params.segmentId + check(segmentId, String) + const partId = ctx.params.partId + check(partId, String) + + try { + const part = await deletePart(playlistId, rundownId, segmentId, partId) + handle200(ctx, part) + } catch (e) { + handleError(e, ctx) + } +}) diff --git a/meteor/server/api/ingest/httpIngest/httpIngestServices.ts b/meteor/server/api/ingest/httpIngest/httpIngestServices.ts new file mode 100644 index 0000000000..b5d5e1a670 --- /dev/null +++ b/meteor/server/api/ingest/httpIngest/httpIngestServices.ts @@ -0,0 +1,360 @@ +import { IngestPart, IngestRundown, IngestSegment } from '@sofie-automation/blueprints-integration' +import { PartId, RundownId, RundownPlaylistId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { getRundownNrcsName, Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' +import { getHash } from '@sofie-automation/corelib/dist/hash' +import { protectString } from '@sofie-automation/corelib/dist/protectedString' +import { IngestJobs } from '@sofie-automation/corelib/dist/worker/ingest' +import { Meteor } from 'meteor/meteor' +import { Parts, RundownPlaylists, Rundowns, Segments, Studios } from '../../../collections' +import { runIngestOperation } from '../lib' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' + +async function findPlaylist(playlistId: string) { + const playlist = await RundownPlaylists.findOneAsync({ + $or: [{ _id: protectString(playlistId) }, { externalId: playlistId }], + }) + if (!playlist) { + throw new Meteor.Error(404, `Playlist ID '${playlistId}' was not found`) + } + return playlist +} + +async function findRundown(playlistId: RundownPlaylistId, rundownId: string) { + const rundown = await Rundowns.findOneAsync({ + $or: [ + { + _id: protectString(rundownId), + playlistId, + }, + { + externalId: rundownId, + playlistId, + }, + ], + }) + if (!rundown) { + throw new Meteor.Error(404, `Rundown ID '${rundownId}' was not found`) + } + return rundown +} + +async function findSegment(rundownId: RundownId, segmentId: string) { + const segment = await Segments.findOneAsync({ + $or: [ + { + _id: protectString(segmentId), + rundownId: rundownId, + }, + { + externalId: segmentId, + rundownId: rundownId, + }, + ], + }) + if (!segment) { + throw new Meteor.Error(404, `Segment ID '${segmentId}' was not found`) + } + return segment +} + +async function findPart(segmentId: SegmentId, partId: string) { + const part = await Parts.findOneAsync({ + $or: [ + { _id: protectString(partId), segmentId }, + { + externalId: partId, + segmentId, + }, + ], + }) + + if (!part) { + throw new Meteor.Error(404, `Part ID '${partId}' was not found`) + } + return part +} + +async function findStudioId() { + const existingStudio = await Studios.findOneAsync({}) + if (!existingStudio) { + throw new Meteor.Error(500, `Studio does not exist`) + } + + return existingStudio._id +} + +function checkRundownSource(rundown: Rundown | undefined) { + if (rundown && rundown.source.type !== 'httpIngest') { + throw new Meteor.Error( + 403, + `Cannot replace existing rundown from source '${getRundownNrcsName( + rundown + )}' with new data from 'httpIngest' source` + ) + } +} + +function getRundownId(rundownExternalId: string): RundownId { + if (!rundownExternalId) throw new Meteor.Error(400, 'getRundownId: rundownExternalId must be set!') + return protectString(getHash(`${rundownExternalId}`)) +} + +// Playlists + +export async function getPlaylists(): Promise { + const playlists = await RundownPlaylists.findFetchAsync({}) + return playlists +} + +export async function deletePlaylists(): Promise { + const rundowns = await Rundowns.findFetchAsync({}) + const studioId = await findStudioId() + + for (const rundown of rundowns) { + await runIngestOperation(studioId, IngestJobs.RemoveRundown, { + rundownExternalId: rundown.externalId, + }) + } +} + +export async function getPlaylist(playlistId: string): Promise { + const playlist = findPlaylist(playlistId) + return playlist +} + +export async function deletePlaylist(playlistId: string): Promise { + await findPlaylist(playlistId) + + const rundowns = await Rundowns.findFetchAsync({ + $or: [{ playlistId: protectString(playlistId) }, { playlistExternalId: playlistId }], + }) + const studioId = await findStudioId() + + for (const rundown of rundowns) { + await runIngestOperation(studioId, IngestJobs.RemoveRundown, { + rundownExternalId: rundown.externalId, + }) + } +} + +// Rundowns + +export async function getRundowns(playlistId: string): Promise { + await findPlaylist(playlistId) + const rundowns = await Rundowns.findFetchAsync({ + $or: [ + { + playlistId: protectString(playlistId), + }, + { playlistExternalId: playlistId }, + ], + }) + return rundowns +} + +export async function getRundown(playlistId: string, rundownId: string): Promise { + const playlist = await findPlaylist(playlistId) + const rundown = findRundown(playlist._id, rundownId) + return rundown +} + +export async function deleteRundowns(playlistId: string): Promise { + const playlist = await findPlaylist(playlistId) + const rundowns = await Rundowns.findFetchAsync({ $or: [{ playlistId: playlist._id }] }) + const studioId = await findStudioId() + + for (const rundown of rundowns) { + await runIngestOperation(studioId, IngestJobs.RemoveRundown, { + rundownExternalId: rundown.externalId, + }) + } +} + +export async function putRundown(playlistId: string, ingestRundown: IngestRundown): Promise { + const rundownId = getRundownId(ingestRundown.externalId) + const studioId = await findStudioId() + + const existingRundown = await Rundowns.findOneAsync({ + $or: [ + { _id: rundownId, playlistId: protectString(playlistId) }, + { externalId: rundownId, playlistExternalId: playlistId }, + ], + }) + checkRundownSource(existingRundown) + + await runIngestOperation(studioId, IngestJobs.UpdateRundown, { + rundownExternalId: ingestRundown.externalId, + ingestRundown: ingestRundown, + isCreateAction: true, + rundownSource: { + type: 'httpIngest', + }, + }) +} + +export async function deleteRundown(playlistId: string, rundownId: string): Promise { + const playlist = await findPlaylist(playlistId) + const rundown = await findRundown(playlist._id, rundownId) + const studioId = await findStudioId() + + await runIngestOperation(studioId, IngestJobs.RemoveRundown, { + rundownExternalId: rundown.externalId, + }) +} + +// Segments + +export async function putSegments( + playlistId: string, + rundownId: string, + ingestSegments: IngestSegment[] +): Promise { + const playlist = await findPlaylist(playlistId) + const rundown = await findRundown(playlist._id, rundownId) + const studioId = await findStudioId() + checkRundownSource(rundown) + + const oldSegments = await Segments.findFetchAsync({ rundownId: rundown._id }) + for (const segment of oldSegments) { + const oldParts = await Parts.findFetchAsync({ rundownId: rundown._id, segmentId: segment._id }) + for (const part of oldParts) { + await runIngestOperation(studioId, IngestJobs.RemovePart, { + partExternalId: part.externalId, + rundownExternalId: rundown.externalId, + segmentExternalId: segment.externalId, + }) + } + + await runIngestOperation(studioId, IngestJobs.RemoveSegment, { + rundownExternalId: rundown.externalId, + segmentExternalId: segment.externalId, + }) + } + + for (const ingestSegment of ingestSegments) { + await runIngestOperation(studioId, IngestJobs.UpdateSegment, { + rundownExternalId: rundown.externalId, + isCreateAction: true, + ingestSegment, + }) + } +} + +export async function getSegments(playlistId: string, rundownId: string): Promise { + const playlist = await findPlaylist(playlistId) + const rundown = await findRundown(playlist._id, rundownId) + + const segments = await Segments.findFetchAsync({ + rundownId: rundown._id, + }) + + return segments +} + +export async function deleteSegments(playlistId: string, rundownId: string): Promise { + const playlist = await findPlaylist(playlistId) + const rundown = await findRundown(playlist._id, rundownId) + const studioId = await findStudioId() + + const segments = await Segments.findFetchAsync({ rundownId: rundown._id }) + + for (const segment of segments) { + await runIngestOperation(studioId, IngestJobs.RemoveSegment, { + rundownExternalId: rundown.externalId, + segmentExternalId: segment.externalId, + }) + } +} + +export async function getSegment(playlistId: string, rundownId: string, segmentId: string): Promise { + const playlist = await findPlaylist(playlistId) + const rundown = await findRundown(playlist._id, rundownId) + + const segment = findSegment(rundown._id, segmentId) + + return segment +} + +export async function deleteSegment(playlistId: string, rundownId: string, segmentId: string): Promise { + const playlist = await findPlaylist(playlistId) + const rundown = await findRundown(playlist._id, rundownId) + const studioId = await findStudioId() + + const segment = await findSegment(rundown._id, segmentId) + + await runIngestOperation(studioId, IngestJobs.RemoveSegment, { + segmentExternalId: segment.externalId, + rundownExternalId: rundown.externalId, + }) +} + +// Parts + +export async function getParts(playlistId: string, rundownId: string, segmentId: string): Promise { + const playlist = await findPlaylist(playlistId) + const rundown = await findRundown(playlist._id, rundownId) + const segment = await findSegment(rundown._id, segmentId) + + const parts = await Parts.findFetchAsync({ + segmentId: segment._id, + }) + + return parts +} + +export async function getPart(playlistId: string, rundownId: string, segmentId: string, partId: string): Promise { + const playlist = await findPlaylist(playlistId) + const rundown = await findRundown(playlist._id, rundownId) + const segment = await findSegment(rundown._id, segmentId) + + const part = findPart(segment._id, partId) + + return part +} + +export async function putParts( + playlistId: string, + rundownId: string, + segmentId: string, + ingestParts: IngestPart[] +): Promise { + const playlist = await findPlaylist(playlistId) + const rundown = await findRundown(playlist._id, rundownId) + const segment = await findSegment(rundown._id, segmentId) + + const studioId = await findStudioId() + checkRundownSource(rundown) + + for (const ingestPart of ingestParts) { + ingestPart.payload.segmentId = segment._id + + await runIngestOperation(studioId, IngestJobs.UpdatePart, { + segmentExternalId: segment.externalId, + rundownExternalId: rundown.externalId, + isCreateAction: true, + ingestPart, + }) + } +} + +export async function deletePart( + playlistId: string, + rundownId: string, + segmentId: string, + partId: string +): Promise { + const playlist = await findPlaylist(playlistId) + const rundown = await findRundown(playlist._id, rundownId) + const segment = await findSegment(rundown._id, segmentId) + const studioId = await findStudioId() + + const part = await findPart(segment._id, partId) + + await runIngestOperation(studioId, IngestJobs.RemovePart, { + rundownExternalId: rundown.externalId, + segmentExternalId: segment.externalId, + partExternalId: part.externalId, + }) + + return part +} diff --git a/meteor/server/api/rest/api.ts b/meteor/server/api/rest/api.ts index 4496cd493b..bb9a94fdc8 100644 --- a/meteor/server/api/rest/api.ts +++ b/meteor/server/api/rest/api.ts @@ -12,6 +12,7 @@ import { blueprintsRouter } from '../blueprints/http' import { createLegacyApiRouter } from './v0/index' import { heapSnapshotPrivateApiRouter } from '../heapSnapshot' import { getRootSubpath } from '../../lib' +import { httpIngestRouter } from '../ingest/httpIngest/httpIngestController' const LATEST_REST_API = 'v1.0' @@ -20,6 +21,8 @@ const apiRouter = new KoaRouter() apiRouter.get('/', redirectToLatest) apiRouter.get('/latest', redirectToLatest) +apiRouter.use('/v1.0/ingest', httpIngestRouter.routes(), httpIngestRouter.allowedMethods()) + apiRouter.use('/v1.0', apiV1Router.routes(), apiV1Router.allowedMethods()) apiRouter.use('/private/ingest', ingestRouter.routes(), ingestRouter.allowedMethods()) diff --git a/packages/corelib/src/dataModel/Rundown.ts b/packages/corelib/src/dataModel/Rundown.ts index 61b1159eb9..8688e6b773 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 + | RundownSourceHttpIngest /** A description of the external NRCS source of a Rundown */ export interface RundownSourceNrcs { @@ -119,6 +124,10 @@ 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 RundownSourceHttpIngest { + type: 'httpIngest' +} export function getRundownNrcsName(rundown: ReadonlyDeep> | undefined): string { if (rundown?.source?.type === 'nrcs' && rundown.source.nrcsName) { diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml index 62e905f8f7..acb62d69af 100644 --- a/packages/openapi/api/definitions/ingest.yaml +++ b/packages/openapi/api/definitions/ingest.yaml @@ -13,26 +13,19 @@ resources: content: application/json: schema: - type: object - properties: - status: - type: number - const: 200 - example: 200 - playlists: - type: array - items: - $ref: '#/components/schemas/ingestPlaylistItem' - example: - - playlistId: 'playlist1' - rundowns: - - externalId: 'playlist1Rundown1' - - externalId: 'playlist1Rundown2' - - playlistId: 'playlist2' - rundowns: - - externalId: 'playlist2Rundown1' - - externalId: 'playlist2Rundown2' - - externalId: 'playlist2Rundown3' + type: array + items: + $ref: '#/components/schemas/ingestPlaylist' + example: + - externalId: 'playlist1' + rundowns: + - externalId: 'playlist1Rundown1' + - externalId: 'playlist1Rundown2' + - externalId: 'playlist2' + rundowns: + - externalId: 'playlist2Rundown1' + - externalId: 'playlist2Rundown2' + - externalId: 'playlist2Rundown3' delete: operationId: deleteIngestPlaylists tags: @@ -77,21 +70,7 @@ resources: content: application/json: schema: - type: object - properties: - status: - type: number - const: 200 - playlist: - $ref: '#/components/schemas/ingestPlaylist' - example: - status: 200 - playlist: - name: playlist1 - required: - - status - - playlist - additionalProperties: false + $ref: '#/components/schemas/ingestPlaylist' 404: description: Invalid playlistId $ref: '#/components/responses/idNotFound' @@ -1295,6 +1274,12 @@ components: properties: name: type: string + externalId: + type: string + rundownIdsInOrder: + type: array + items: + type: string required: - name additionalProperties: false diff --git a/packages/openapi/src/__tests__/ingest.spec.ts b/packages/openapi/src/__tests__/ingest.spec.ts index b2f7b0a1c8..a5faca6fdd 100644 --- a/packages/openapi/src/__tests__/ingest.spec.ts +++ b/packages/openapi/src/__tests__/ingest.spec.ts @@ -5,7 +5,7 @@ import Logging from '../httpLogging' const httpLogging = false -describe('Network client', () => { +describe('Ingest API', () => { const config = new Configuration({ basePath: process.env.SERVER_URL, middleware: [new Logging(httpLogging)], @@ -21,14 +21,12 @@ describe('Network client', () => { const playlistIds: string[] = [] test('Can request all ingest playlists in Sofie', async () => { const ingestPlaylists = await ingestApi.getIngestPlaylists() - expect(ingestPlaylists.status).toBe(200) - expect(ingestPlaylists).toHaveProperty('playlists') - expect(ingestPlaylists.playlists.length).toBeGreaterThanOrEqual(1) - ingestPlaylists.playlists.forEach((playlist) => { + expect(ingestPlaylists.length).toBeGreaterThanOrEqual(1) + ingestPlaylists.forEach((playlist) => { expect(typeof playlist).toBe('object') - expect(typeof playlist.playlistId).toBe('string') - playlistIds.push(playlist.playlistId) + expect(typeof playlist.externalId).toBe('string') + playlistIds.push(playlist.externalId) }) }) @@ -36,11 +34,9 @@ describe('Network client', () => { const ingestPlaylist = await ingestApi.getIngestPlaylist({ playlistId: playlistIds[0], }) - expect(ingestPlaylist.status).toBe(200) - expect(ingestPlaylist).toHaveProperty('playlist') - expect(ingestPlaylist.playlist).toHaveProperty('name') - expect(typeof ingestPlaylist.playlist.name).toBe('string') + expect(ingestPlaylist).toHaveProperty('name') + expect(typeof ingestPlaylist.name).toBe('string') }) test('Can delete multiple ingest playlists in Sofie', async () => { From 421f8dbb9dcffea34d723f7323498ccc43859cd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Mon, 23 Sep 2024 14:42:30 +0200 Subject: [PATCH 17/50] wip: Ingest API cleanup + post methods --- packages/openapi/api/actions.yaml | 16 +- packages/openapi/api/definitions/ingest.yaml | 835 ++++++------------ packages/openapi/src/__tests__/ingest.spec.ts | 334 +++---- 3 files changed, 450 insertions(+), 735 deletions(-) diff --git a/packages/openapi/api/actions.yaml b/packages/openapi/api/actions.yaml index 015ba12c12..8d7d6a3083 100644 --- a/packages/openapi/api/actions.yaml +++ b/packages/openapi/api/actions.yaml @@ -111,18 +111,18 @@ paths: $ref: 'definitions/snapshots.yaml#/resources/snapshots' # ingest operations /ingest/playlists: - $ref: 'definitions/ingest.yaml#/resources/ingestPlaylists' + $ref: 'definitions/ingest.yaml#/resources/playlists' /ingest/playlists/{playlistId}: - $ref: 'definitions/ingest.yaml#/resources/ingestPlaylist' + $ref: 'definitions/ingest.yaml#/resources/playlist' /ingest/playlists/{playlistId}/rundowns: - $ref: 'definitions/ingest.yaml#/resources/ingestRundowns' + $ref: 'definitions/ingest.yaml#/resources/rundowns' /ingest/playlists/{playlistId}/rundowns/{rundownId}: - $ref: 'definitions/ingest.yaml#/resources/ingestRundown' + $ref: 'definitions/ingest.yaml#/resources/rundown' /ingest/playlists/{playlistId}/rundowns/{rundownId}/segments: - $ref: 'definitions/ingest.yaml#/resources/ingestSegments' + $ref: 'definitions/ingest.yaml#/resources/segments' /ingest/playlists/{playlistId}/rundowns/{rundownId}/segments/{segmentId}: - $ref: 'definitions/ingest.yaml#/resources/ingestSegment' + $ref: 'definitions/ingest.yaml#/resources/segment' /ingest/playlists/{playlistId}/rundowns/{rundownId}/segments/{segmentId}/parts: - $ref: 'definitions/ingest.yaml#/resources/ingestParts' + $ref: 'definitions/ingest.yaml#/resources/parts' /ingest/playlists/{playlistId}/rundowns/{rundownId}/segments/{segmentId}/parts/{partId}: - $ref: 'definitions/ingest.yaml#/resources/ingestPart' + $ref: 'definitions/ingest.yaml#/resources/part' diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml index acb62d69af..466f1f0adf 100644 --- a/packages/openapi/api/definitions/ingest.yaml +++ b/packages/openapi/api/definitions/ingest.yaml @@ -1,21 +1,21 @@ title: ingest description: Ingest methods resources: - ingestPlaylists: + playlists: get: - operationId: getIngestPlaylists + operationId: getPlaylists + summary: Gets all Playlists. tags: - ingest - summary: Gets ingest data for all Playlists in Sofie. responses: 200: - description: Command successfully handled - returns an array of Playlists with their playlistIds and list of Rundow. + description: Command successfully handled - returns an array of Playlists. content: application/json: schema: type: array items: - $ref: '#/components/schemas/ingestPlaylist' + $ref: '#/components/schemas/playlist' example: - externalId: 'playlist1' rundowns: @@ -27,31 +27,19 @@ resources: - externalId: 'playlist2Rundown2' - externalId: 'playlist2Rundown3' delete: - operationId: deleteIngestPlaylists + operationId: deletePlaylists tags: - ingest - summary: Delete multiple playlists. + summary: Delete multiple playlists. Resources under the Playlist (e.g. Rundowns) will also be removed. responses: - 200: - description: Playlists removed. - content: - application/json: - schema: - type: object - properties: - status: - type: number - const: 200 - example: 200 - required: - - status - additionalProperties: false - ingestPlaylist: + 202: + description: Request for deleting accepted. + playlist: get: - operationId: getIngestPlaylist + operationId: getPlaylist + summary: Gets a specific Playlist. tags: - ingest - summary: Gets ingest data for a specific Playlist from Sofie. parameters: - name: playlistId in: path @@ -62,25 +50,18 @@ resources: responses: 200: description: Playlist is returned. - headers: - ETag: - schema: - type: string - description: Version of Playlist, if known. content: application/json: schema: - $ref: '#/components/schemas/ingestPlaylist' + $ref: '#/components/schemas/playlist' 404: description: Invalid playlistId - $ref: '#/components/responses/idNotFound' - # oneOf: - # - $ref: '#/components/responses/playlistNotFound' + $ref: '#/components/responses/playlistNotFound' delete: - operationId: deleteIngestPlaylist + operationId: deletePlaylist + summary: Deletes a specified Playlist. Resources under the Playlist (e.g. Rundowns) will also be removed. tags: - ingest - summary: Deletes a specified ingest Playlist. Resources under the Playlist (e.g. Rundowns) will also be removed. parameters: - name: playlistId in: path @@ -89,30 +70,16 @@ resources: schema: type: string responses: - 200: - description: Playlist removed. - content: - application/json: - schema: - type: object - properties: - status: - type: number - const: 200 - example: 200 - required: - - status - additionalProperties: false + 202: + description: Request for deleting accepted. 404: - $ref: '#/components/responses/idNotFound' - # oneOf: - # - $ref: '#/components/responses/playlistNotFound' - ingestRundowns: + $ref: '#/components/responses/playlistNotFound' + rundowns: get: - operationId: getIngestRundowns + operationId: getRundowns + summary: Gets all Rundowns belonging to a Playlist. tags: - ingest - summary: Gets ingest data for all Rundowns belonging to a Playlist. parameters: - name: playlistId in: path @@ -126,31 +93,41 @@ resources: content: application/json: schema: - type: object - properties: - status: - type: number - const: 200 - example: 200 - rundowns: - type: array - items: - $ref: '#/components/schemas/ingestRundownItem' - example: - - externalId: rundown1 - required: - - status - - playlists - additionalProperties: false + type: array + items: + $ref: '#/components/schemas/rundown' 404: $ref: '#/components/responses/idNotFound' # oneOf: # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + post: + operationId: postRundown + summary: Creates the Rundowns in a Playlist. + tags: + - ingest + parameters: + - name: playlistId + in: path + description: Playlist to create Rundowns for. + 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. put: - operationId: putIngestRundowns + operationId: putRundowns + summary: Updates the Rundowns in a Playlist. Any existing Rundowns in the Playlist that are not included in this list will be deleted (including their Segments and Parts). Rundowns will be placed in the Playlist in the order specified by their individual ranks. If the creation/deletion/updating of any Rundown fails all changes will be discarded. tags: - ingest - summary: Creates/updates the Rundowns in a Playlist. Any existing Rundowns in the Playlist that are not included in this list will be deleted (including their Segments and Parts). Rundowns will be placed in the Playlist in the order specified by their individual ranks. If the creation/deletion/updating of any Rundown fails all changes will be discarded. ETags are not supported for bulk updates, no version information will be stored for any of the created/modified Rundowns. parameters: - name: playlistId in: path @@ -158,58 +135,27 @@ resources: required: true schema: type: string - - name: If-None-Match - in: header - # Indicates that the array elements are serialized as a comma-separated list in the header. - style: simple - schema: - type: array - items: - type: string - description: If specified, each Rundown will only be updated if its version does not match one of the specified ETags. If no ETag is found for a Rundown, the new data will replace whatever currently exists, regardless of whether the data is actually the same. requestBody: description: Contains the Rundown data. required: true content: application/json: schema: - type: object - properties: - rundowns: - type: array - items: - $ref: '#/components/schemas/ingestRundown' - example: - - name: rundown1 - source: 'Our Company - Some Product Name' - rank: 0 - required: - - rundowns - additionalProperties: false + type: array + items: + $ref: '#/components/schemas/rundown' responses: - 200: - description: Rundowns have been updated. - content: - application/json: - schema: - type: object - properties: - status: - type: number - const: 200 - example: 200 - required: - - status - additionalProperties: false + 202: + description: Request has been accepted. 404: $ref: '#/components/responses/idNotFound' # oneOf: # - $ref: '#/components/responses/playlistNotFound' delete: - operationId: deleteIngestRundowns + operationId: deleteRundowns tags: - ingest - summary: Delete multiple rundowns. + summary: Delete multiple rundowns. Resources under the Rundown (e.g. Segments) will also be removed. parameters: - name: playlistId in: path @@ -232,12 +178,12 @@ resources: required: - status additionalProperties: false - ingestRundown: + rundown: get: - operationId: getIngestRundown + operationId: getRundown + summary: Gets ingest data for a specific Rundown. tags: - ingest - summary: Gets ingest data for a specific Rundown. parameters: - name: playlistId in: path @@ -254,30 +200,10 @@ resources: responses: 200: description: Rundown is returned. - headers: - ETag: - schema: - type: string - description: Version of Rundown, if known. content: application/json: schema: - type: object - properties: - status: - type: number - const: 200 - rundown: - $ref: '#/components/schemas/ingestRundown' - example: - status: 200 - rundown: - name: rundown1 - source: 'Our Company - Some Product Name' - rank: 0 - required: - - status - - rundown + $ref: '#/components/schemas/rundown' additionalProperties: false 404: $ref: '#/components/responses/idNotFound' @@ -285,10 +211,10 @@ resources: # - $ref: '#/components/responses/playlistNotFound' # - $ref: '#/components/responses/rundownNotFound' put: - operationId: putIngestRundown + operationId: putRundown + summary: Updates an existing Rundown. tags: - ingest - summary: Creates a new or updates an existing Rundown. parameters: - name: playlistId in: path @@ -302,81 +228,26 @@ resources: required: true schema: type: string - - name: If-None-Match - in: header - # Indicates that the array elements are serialized as a comma-separated list in the header. - style: simple - schema: - type: array - items: - type: string - description: If specified, the Rundown will only be updated if one of the specified ETags does not match. If unspecified, the new data will replace whatever currently exists, regardless of whether the data is actually the same. - - name: If-Match - in: header - # Indicates that the array elements are serialized as a comma-separated list in the header. - style: simple - schema: - type: array - items: - type: string - description: If specified, the Rundown will only be updated if one of the specified ETags matches. - - name: ETag - in: header - required: true - schema: - type: string - example: '123456789' - description: ETag to use as version information for Rundown. requestBody: description: Contains the Rundown data. required: true content: application/json: schema: - $ref: '#/components/schemas/ingestRundown' - example: - name: rundown1 - source: 'Our Company - Some Product Name' - rank: 0 + $ref: '#/components/schemas/rundown' responses: - 200: - description: Rundown has been updated. - content: - application/json: - schema: - type: object - properties: - status: - type: number - const: 200 - example: 200 - required: - - status - additionalProperties: false - 201: - description: Rundown has been created. - content: - application/json: - schema: - type: object - properties: - status: - type: number - const: 201 - example: 201 - required: - - status - additionalProperties: false + 202: + description: Request has been accepted. 404: $ref: '#/components/responses/idNotFound' # oneOf: # - $ref: '#/components/responses/playlistNotFound' # - $ref: '#/components/responses/rundownNotFound' delete: - operationId: deleteIngestRundown + operationId: deleteRundown + summary: Deletes a specified ingest Rundown. Resources under the Rundown (e.g. Segments) will also be removed. tags: - ingest - summary: Deletes a specified ingest Rundown. Resources under the Rundown (e.g. Segments) will also be removed. parameters: - name: playlistId in: path @@ -391,31 +262,19 @@ resources: schema: type: string responses: - 200: - description: Rundown removed. - content: - application/json: - schema: - type: object - properties: - status: - type: number - const: 200 - example: 200 - required: - - status - additionalProperties: false + 202: + description: Request for deleting accepted. 404: $ref: '#/components/responses/idNotFound' # oneOf: # - $ref: '#/components/responses/playlistNotFound' # - $ref: '#/components/responses/rundownNotFound' - ingestSegments: + segments: get: - operationId: getIngestSegments + operationId: getSegments tags: - ingest - summary: Gets the ingest data for all Segments belonging to a Rundown. + summary: Gets all Segments belonging to a Rundown. parameters: - name: playlistId in: path @@ -435,32 +294,54 @@ resources: content: application/json: schema: - type: object - properties: - status: - type: number - const: 200 - example: 200 - segments: - type: array - items: - $ref: '#/components/schemas/ingestSegmentItem' - example: - - externalId: segment1 - required: - - status - - segments - additionalProperties: false + type: array + items: + $ref: '#/components/schemas/segment' + example: + - externalId: segment1 + 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 Rundown. + parameters: + - name: playlistId + in: path + description: Playlist the Segment belongs to. + required: true + schema: + type: string + - name: rundownId + in: path + description: Rundown the 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. 404: $ref: '#/components/responses/idNotFound' # oneOf: # - $ref: '#/components/responses/playlistNotFound' # - $ref: '#/components/responses/rundownNotFound' put: - operationId: putIngestSegments + operationId: putSegments tags: - ingest - summary: Creates/updates the Segments in a Rundown. Any existing Segments in the Rundown that are not included in this list will be deleted (including their Parts). Segments will be placed in the Rundown in the order specified by their individual ranks. If the creation/deletion/updating of any Segment fails all changes will be discarded. + summary: Updates the Segments in a Rundown. Any existing Segments in the Rundown that are not included in this list will be deleted (including their Parts). Segments will be placed in the Rundown in the order specified by their individual ranks. If the creation/deletion/updating of any Segment fails all changes will be discarded. parameters: - name: playlistId in: path @@ -474,58 +355,28 @@ resources: required: true schema: type: string - - name: If-None-Match - in: header - # Indicates that the array elements are serialized as a comma-separated list in the header. - style: simple - schema: - type: array - items: - type: string - description: If specified, each Segment will only be updated if its version does not match one of the specified ETags. If no ETag is found for a Segment, the new data will replace whatever currently exists, regardless of whether the data is actually the same. ETags are not supported for bulk updates, no version information will be stored for any of the created/modified Rundowns. requestBody: description: Contains the Segment data. required: true content: application/json: schema: - type: object - properties: - segments: - type: array - items: - $ref: '#/components/schemas/ingestSegment' - example: - - name: segment1 - rank: 0 - required: - - segments - additionalProperties: false + type: array + items: + $ref: '#/components/schemas/segment' responses: - 200: - description: Segments have been updated. - content: - application/json: - schema: - type: object - properties: - status: - type: number - const: 200 - example: 200 - required: - - status - additionalProperties: false + 202: + description: Request accepted. 404: $ref: '#/components/responses/idNotFound' # oneOf: # - $ref: '#/components/responses/playlistNotFound' # - $ref: '#/components/responses/rundownNotFound' delete: - operationId: deleteIngestSegments + operationId: deleteSegments tags: - ingest - summary: Delete multiple segments. + summary: Delete segment. parameters: - name: playlistId in: path @@ -540,26 +391,14 @@ resources: schema: type: string responses: - 200: - description: Segments removed. - content: - application/json: - schema: - type: object - properties: - status: - type: number - const: 200 - example: 200 - required: - - status - additionalProperties: false - ingestSegment: + 202: + description: Request accepted. + segment: get: - operationId: getIngestSegment + operationId: getSegment tags: - ingest - summary: Gets ingest data for a specific Segment. + summary: Gets specific Segment. parameters: - name: playlistId in: path @@ -582,30 +421,10 @@ resources: responses: 200: description: Segment is returned. - headers: - ETag: - schema: - type: string - description: Version of Segment, if known. content: application/json: schema: - type: object - properties: - status: - type: number - const: 200 - segment: - $ref: '#/components/schemas/ingestSegment' - example: - status: 200 - segment: - name: segment1 - rank: 0 - required: - - status - - segment - additionalProperties: false + $ref: '#/components/schemas/segment' 404: $ref: '#/components/responses/idNotFound' # oneOf: @@ -613,10 +432,10 @@ resources: # - $ref: '#/components/responses/rundownNotFound' # - $ref: '#/components/responses/segmentNotFound' put: - operationId: putIngestSegment + operationId: putSegment tags: - ingest - summary: Creates a new or updates an existing Segment. + summary: Updates an existing Segment. parameters: - name: playlistId in: path @@ -633,78 +452,26 @@ resources: - name: segmentId in: path description: Segment to create/update. - schema: - type: string - - name: If-None-Match - in: header - # Indicates that the array elements are serialized as a comma-separated list in the header. - style: simple - schema: - type: array - items: - type: string - description: If specified, the Segment will only be updated if one of the specified ETags does not match. If unspecified, the new data will replace whatever currently exists, regardless of whether the data is actually the same. - - name: If-Match - in: header - # Indicates that the array elements are serialized as a comma-separated list in the header. - style: simple - schema: - type: array - items: - type: string - description: If specified, the Segment will only be updated if one of the specified ETags matches. - - name: ETag - in: header required: true schema: type: string - description: ETag to use as version information for Segment. requestBody: description: Contains the Segment data. required: true content: application/json: schema: - $ref: '#/components/schemas/ingestSegment' - example: - name: segment1 - rank: 0 + $ref: '#/components/schemas/segment' responses: - 200: - description: Segment has been updated. - content: - application/json: - schema: - type: object - properties: - status: - type: number - const: 200 - example: 200 - required: - - status - additionalProperties: false - 201: - description: Segment has been created. - content: - application/json: - schema: - type: object - properties: - status: - type: number - const: 201 - example: 201 - required: - - status - additionalProperties: false + 202: + description: Request accepted. 404: $ref: '#/components/responses/idNotFound' # oneOf: # - $ref: '#/components/responses/playlistNotFound' # - $ref: '#/components/responses/rundownNotFound' delete: - operationId: deleteIngestSegment + operationId: deleteSegment tags: - ingest summary: Deletes a specified ingest Segment. Resources under the Segment (e.g. Parts) will also be removed. @@ -728,31 +495,19 @@ resources: schema: type: string responses: - 200: - description: Segment removed. - content: - application/json: - schema: - type: object - properties: - status: - type: number - const: 200 - example: 200 - required: - - status - additionalProperties: false + 202: + description: Request accepted. 404: $ref: '#/components/responses/idNotFound' # oneOf: # - $ref: '#/components/responses/playlistNotFound' # - $ref: '#/components/responses/rundownNotFound' - ingestParts: + parts: get: - operationId: getIngestParts + operationId: getParts tags: - ingest - summary: Gets the ingest data for all Parts belonging to a Segment. + summary: Gets the data for all Parts belonging to a Segment. parameters: - name: playlistId in: path @@ -778,33 +533,60 @@ resources: content: application/json: schema: - type: object - properties: - status: - type: number - const: 200 - example: 200 - parts: - type: array - items: - $ref: '#/components/schemas/ingestPartItem' - example: - - externalId: part1 - required: - - status - - parts - additionalProperties: false + type: array + items: + $ref: '#/components/schemas/part' 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 Part in a Segment. + parameters: + - name: playlistId + in: path + description: Playlist the Segment belongs to. + required: true + schema: + type: string + - name: rundownId + in: path + description: Rundown the Segment belongs to. + required: true + schema: + type: string + - name: segmentId + in: path + description: Segment the 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. + 404: + $ref: '#/components/responses/idNotFound' + # oneOf: + # - $ref: '#/components/responses/playlistNotFound' + # - $ref: '#/components/responses/rundownNotFound' + # - $ref: '#/components/responses/partNotFound' put: - operationId: putIngestParts + operationId: putParts tags: - ingest - summary: Creates/updates the Parts in a Segment. Any existing Parts in the Segment that are not included in this list will be deleted. Parts will be placed in the Segment in the order specified by their individual ranks. If the creation/deletion/updating of any Parts fails all changes will be discarded. + summary: Updates the Parts in a Segment. Any existing Parts in the Segment that are not included in this list will be deleted. Parts will be placed in the Segment in the order specified by their individual ranks. If the creation/deletion/updating of any Parts fails all changes will be discarded. parameters: - name: playlistId in: path @@ -824,47 +606,18 @@ resources: required: true schema: type: string - - name: If-None-Match - in: header - # Indicates that the array elements are serialized as a comma-separated list in the header. - style: simple - schema: - type: array - items: - type: string - description: If specified, each Part will only be updated if its version does not match one of the specified ETags. If no ETag is found for a Part, the new data will replace whatever currently exists, regardless of whether the data is actually the same. ETags are not supported for bulk updates, no version information will be stored for any of the created/modified Rundowns. requestBody: - description: Contains the Part data. + description: Contains the Parts data. required: true content: application/json: schema: - type: object - properties: - parts: - type: array - items: - $ref: '#/components/schemas/ingestPart' - example: - - externalId: part1 - required: - - parts - additionalProperties: false + type: array + items: + $ref: '#/components/schemas/part' responses: - 200: - description: Parts have been updated. - content: - application/json: - schema: - type: object - properties: - status: - type: number - const: 200 - example: 200 - required: - - status - additionalProperties: false + 202: + description: Request accepted. 404: $ref: '#/components/responses/idNotFound' # oneOf: @@ -872,50 +625,38 @@ resources: # - $ref: '#/components/responses/rundownNotFound' # - $ref: '#/components/responses/partNotFound' delete: - operationId: deleteIngestParts + operationId: deleteParts tags: - ingest summary: Delete multiple Parts. parameters: - name: playlistId in: path - description: Playlist the ingest Part belongs to. + description: Playlist the Part belongs to. required: true schema: type: string - name: rundownId in: path - description: Rundown the ingest Part belongs to. + description: Rundown the Part belongs to. required: true schema: type: string - name: segmentId in: path - description: Segment the ingest Part belongs to. + description: Segment the Part belongs to. required: true schema: type: string responses: - 200: - description: Parts removed. - content: - application/json: - schema: - type: object - properties: - status: - type: number - const: 200 - example: 200 - required: - - status - additionalProperties: false - ingestPart: + 202: + description: Request for deleting accepted. + part: get: - operationId: getIngestPart + operationId: getPart tags: - ingest - summary: Gets ingest data for a specific Part. + summary: Gets data for a specific Part. parameters: - name: playlistId in: path @@ -944,30 +685,10 @@ resources: responses: 200: description: Part is returned. - headers: - ETag: - schema: - type: string - description: Version of Part, if known. content: application/json: schema: - type: object - properties: - status: - type: number - const: 200 - part: - $ref: '#/components/schemas/ingestPart' - example: - status: 200 - part: - name: part1 - rank: 0 - required: - - status - - part - additionalProperties: false + $ref: '#/components/schemas/part' 404: $ref: '#/components/responses/idNotFound' # oneOf: @@ -976,10 +697,10 @@ resources: # - $ref: '#/components/responses/segmentNotFound' # - $ref: '#/components/responses/partNotFound' put: - operationId: putIngestPart + operationId: putPart tags: - ingest - summary: Creates a new or updates an existing Part. + summary: Updates an existing Part. parameters: - name: playlistId in: path @@ -1003,69 +724,16 @@ resources: description: Part to update/create. schema: type: string - - name: If-None-Match - in: header - # Indicates that the array elements are serialized as a comma-separated list in the header. - style: simple - schema: - type: array - items: - type: string - description: If specified, the Part will only be updated if the specified ETag does not match. If unspecified, the new data will replace whatever currently exists, regardless of whether the data is actually the same. - - name: If-Match - in: header - # Indicates that the array elements are serialized as a comma-separated list in the header. - style: simple - schema: - type: array - items: - type: string - description: If specified, the Part will only be updated if one of the specified ETags matches. - - name: ETag - in: header - required: true - schema: - type: string - description: ETag to use as version information for Part. requestBody: description: Contains the Rundown data. required: true content: application/json: schema: - $ref: '#/components/schemas/ingestPart' - example: - name: part1 - rank: 0 + $ref: '#/components/schemas/part' responses: - 200: - description: Part has been updated. - content: - application/json: - schema: - type: object - properties: - status: - type: number - const: 200 - example: 200 - required: - - status - additionalProperties: false - 201: - description: Part has been created. - content: - application/json: - schema: - type: object - properties: - status: - type: number - const: 201 - example: 201 - required: - - status - additionalProperties: false + 202: + description: Request has been accepted. 404: $ref: '#/components/responses/idNotFound' # oneOf: @@ -1073,26 +741,26 @@ resources: # - $ref: '#/components/responses/rundownNotFound' # - $ref: '#/components/responses/segmentNotFound' delete: - operationId: deleteIngestPart + operationId: deletePart tags: - ingest - summary: Deletes a specified ingest Part. + summary: Deletes a specified Part. parameters: - name: playlistId in: path - description: Playlist the ingest Part belongs to. + description: Playlist the Part belongs to. required: true schema: type: string - name: rundownId in: path - description: Rundown the ingest Part belongs to. + description: Rundown the Part belongs to. required: true schema: type: string - name: segmentId in: path - description: Segment the ingest Part belongs to. + description: Segment the Part belongs to. required: true schema: type: string @@ -1103,20 +771,8 @@ resources: schema: type: string responses: - 200: - description: Part removed. - content: - application/json: - schema: - type: object - properties: - status: - type: number - const: 200 - example: 200 - required: - - status - additionalProperties: false + 202: + description: Request has been accepted. 404: $ref: '#/components/responses/idNotFound' # oneOf: @@ -1127,10 +783,24 @@ resources: components: responses: idNotFound: - # oneOf responses like below don't render correctly with current tools - use playlist as an example for the docs. - # oneOf: - # - $ref: '#/components/responses/playlistNotFound' - $ref: '#/components/responses/playlistNotFound' + # 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: @@ -1165,10 +835,6 @@ components: type: number const: 404 example: 404 - notFound: - type: string - const: rundown - example: rundown message: type: string example: The specified Rundown was not found. @@ -1188,10 +854,6 @@ components: type: number const: 404 example: 404 - notFound: - type: string - const: segment - example: segment message: type: string example: The specified Segment was not found. @@ -1211,10 +873,6 @@ components: type: number const: 404 example: 404 - notFound: - type: string - const: part - example: part message: type: string example: The specified Part was not found. @@ -1248,7 +906,7 @@ components: required: - externalId additionalProperties: false - ingestSegmentItem: + segmentItem: type: object properties: externalId: @@ -1257,7 +915,7 @@ components: required: - externalId additionalProperties: false - ingestPartItem: + partItem: type: object properties: name: @@ -1269,25 +927,34 @@ components: - name - externalId additionalProperties: false - ingestPlaylist: + playlist: type: object properties: name: type: string externalId: type: string - rundownIdsInOrder: + example: playlist1 + rundownIds: type: array items: type: string + example: + - rundown1 + - rundown2 + - rundown3 required: - name additionalProperties: false - ingestRundown: + rundown: type: object properties: + externalId: + type: string + example: rundown1 name: type: string + example: Rundown 1 source: type: string description: A source type that can be displayed to the end-user. Should identify what type of system (e.g. vendor/product name) the data has been sent from. @@ -1298,42 +965,56 @@ components: type: number description: The position of the Rundown in the parent Playlist. inclusiveMinimum: 0.0 + example: 1 payload: type: object additionalProperties: true required: + - externalId - name - source - rank additionalProperties: false - ingestSegment: + 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 payload: type: object additionalProperties: true required: + - externalId - name - rank additionalProperties: false - ingestPart: + part: type: object properties: + externalId: + type: string + example: part1 name: type: string + example: Part 1 rank: type: number description: The position of the Part in the parent Segment. + example: 0 payload: type: object additionalProperties: true required: + - externalId - name - rank additionalProperties: false diff --git a/packages/openapi/src/__tests__/ingest.spec.ts b/packages/openapi/src/__tests__/ingest.spec.ts index a5faca6fdd..82aaac592d 100644 --- a/packages/openapi/src/__tests__/ingest.spec.ts +++ b/packages/openapi/src/__tests__/ingest.spec.ts @@ -1,5 +1,5 @@ // eslint-disable-next-line node/no-missing-import -import { Configuration, IngestApi, IngestPart, IngestRundown, IngestSegment } from '../../client/ts' +import { Configuration, IngestApi, Part } from '../../client/ts' import { checkServer } from '../checkServer' import Logging from '../httpLogging' @@ -16,11 +16,11 @@ describe('Ingest API', () => { const ingestApi = new IngestApi(config) /** - * INGEST PLAYLIST + * PLAYLISTS */ const playlistIds: string[] = [] - test('Can request all ingest playlists in Sofie', async () => { - const ingestPlaylists = await ingestApi.getIngestPlaylists() + test('Can request all playlists', async () => { + const ingestPlaylists = await ingestApi.getPlaylists() expect(ingestPlaylists.length).toBeGreaterThanOrEqual(1) ingestPlaylists.forEach((playlist) => { @@ -30,8 +30,8 @@ describe('Ingest API', () => { }) }) - test('Can request a playlist by id in Sofie', async () => { - const ingestPlaylist = await ingestApi.getIngestPlaylist({ + test('Can request a playlist by id', async () => { + const ingestPlaylist = await ingestApi.getPlaylist({ playlistId: playlistIds[0], }) @@ -39,282 +39,316 @@ describe('Ingest API', () => { expect(typeof ingestPlaylist.name).toBe('string') }) - test('Can delete multiple ingest playlists in Sofie', async () => { - const ingestRundown = await ingestApi.deleteIngestPlaylists() - expect(ingestRundown.status).toBe(200) + test('Can delete multiple playlists', async () => { + const result = await ingestApi.deletePlaylists() + expect(result).toBe(undefined) }) - test('Can delete ingest playlist by id in Sofie', async () => { - const ingestRundown = await ingestApi.deleteIngestPlaylist({ + test('Can delete playlist by id', async () => { + const result = await ingestApi.deletePlaylist({ playlistId: playlistIds[0], }) - expect(ingestRundown.status).toBe(200) + expect(result).toBe(undefined) }) /** - * INGEST RUNDOWS + * RUNDOWNS */ const rundownIds: string[] = [] - test('Can request all ingest rundowns in Sofie', async () => { - const ingestRundowns = await ingestApi.getIngestRundowns({ + test('Can request all rundowns', async () => { + const rundowns = await ingestApi.getRundowns({ playlistId: playlistIds[0], }) - expect(ingestRundowns.status).toBe(200) - expect(ingestRundowns).toHaveProperty('rundowns') - expect(ingestRundowns.rundowns.length).toBeGreaterThanOrEqual(1) + expect(rundowns.length).toBeGreaterThanOrEqual(1) - ingestRundowns.rundowns.forEach((rundown) => { + rundowns.forEach((rundown) => { expect(typeof rundown).toBe('object') + expect(rundown).toHaveProperty('name') + expect(rundown).toHaveProperty('rank') + expect(rundown).toHaveProperty('source') + expect(rundown).toHaveProperty('externalId') + expect(typeof rundown.name).toBe('string') + expect(typeof rundown.rank).toBe('number') + expect(typeof rundown.source).toBe('string') expect(typeof rundown.externalId).toBe('string') rundownIds.push(rundown.externalId) }) }) - let newIngestRundown: IngestRundown | undefined - test('Can request ingest rundown by id in Sofie', async () => { - const ingestRundown = await ingestApi.getIngestRundown({ + test('Can request rundown by id', async () => { + const rundown = await ingestApi.getRundown({ playlistId: playlistIds[0], rundownId: rundownIds[0], }) - expect(ingestRundown.status).toBe(200) - expect(ingestRundown).toHaveProperty('rundown') - - expect(ingestRundown.rundown).toHaveProperty('name') - expect(ingestRundown.rundown).toHaveProperty('rank') - expect(ingestRundown.rundown).toHaveProperty('source') - expect(typeof ingestRundown.rundown.name).toBe('string') - expect(typeof ingestRundown.rundown.rank).toBe('number') - expect(typeof ingestRundown.rundown.source).toBe('string') - newIngestRundown = JSON.parse(JSON.stringify(ingestRundown.rundown)) + + expect(rundown).toHaveProperty('name') + expect(rundown).toHaveProperty('rank') + expect(rundown).toHaveProperty('source') + expect(rundown).toHaveProperty('externalId') + expect(typeof rundown.name).toBe('string') + expect(typeof rundown.rank).toBe('number') + expect(typeof rundown.source).toBe('string') + expect(typeof rundown.externalId).toBe('string') }) - test('Can add/update multiple rundowns in Sofie', async () => { - newIngestRundown.name = newIngestRundown.name + 'added' - newIngestRundown.rank = 2 - const ingestRundown = await ingestApi.putIngestRundowns({ + test('Can create rundown', async () => { + const result = await ingestApi.postRundown({ playlistId: playlistIds[0], - putIngestRundownsRequest: { - rundowns: [ - { - name: 'rundown1', - source: 'Our Company - Some Product Name', - rank: 0, - }, - { - name: 'rundown2', - source: 'Our Second Company - Some Product Name', - rank: 1, - }, - ], + rundown: { + externalId: 'newRundown', + name: 'New rundown', + rank: 1, + source: 'nrcsId', }, }) - expect(ingestRundown.status).toBe(200) + + expect(result).toBe(undefined) + }) + + test('Can update multiple rundowns', async () => { + const result = await ingestApi.putRundowns({ + playlistId: playlistIds[0], + rundown: [ + { + externalId: 'rundown1', + name: 'Rundown 1', + source: 'Our Company - Some Product Name', + rank: 0, + }, + { + externalId: 'rundown2', + name: 'Rundown 2', + source: 'Our Second Company - Some Product Name', + rank: 1, + }, + ], + }) + expect(result).toBe(undefined) }) - const testIngestRundownId = 'rundown3' - test('Can add/update an ingest rundown in Sofie', async () => { - const newPutIngestRundown = await ingestApi.putIngestRundown({ + const updatedRundownId = 'rundown3' + test('Can update single rundown', async () => { + const result = await ingestApi.putRundown({ playlistId: playlistIds[0], - rundownId: testIngestRundownId, - ingestRundown: { - name: 'rundown3', + rundownId: updatedRundownId, + rundown: { + externalId: 'rundown3', + name: 'Rundown 3', source: 'Our Company - Some Product Name', rank: 3, }, - eTag: '123456789', - ifNoneMatch: ['123456789', '1725453459'], }) - expect(newPutIngestRundown.status).toBe(200) + expect(result).toBe(undefined) }) - test('Can delete multiple ingest rundowns in Sofie', async () => { - const ingestRundown = await ingestApi.deleteIngestRundowns({ + test('Can delete multiple rundowns', async () => { + const ingestRundown = await ingestApi.deleteRundowns({ playlistId: playlistIds[0], }) expect(ingestRundown.status).toBe(200) }) - test('Can delete ingest rundown by id in Sofie', async () => { - const ingestRundown = await ingestApi.deleteIngestRundown({ + test('Can delete rundown by id', async () => { + const result = await ingestApi.deleteRundown({ playlistId: playlistIds[0], - rundownId: testIngestRundownId, + rundownId: updatedRundownId, }) - expect(ingestRundown.status).toBe(200) + expect(result).toBe(undefined) }) /** * INGEST SEGMENT */ const segmentIds: string[] = [] - test('Can request all ingest segments in Sofie', async () => { - const ingestSegments = await ingestApi.getIngestSegments({ + test('Can request all segments', async () => { + const segments = await ingestApi.getSegments({ playlistId: playlistIds[0], rundownId: rundownIds[0], }) - expect(ingestSegments.status).toBe(200) - expect(ingestSegments).toHaveProperty('segments') - expect(ingestSegments.segments.length).toBeGreaterThanOrEqual(1) + expect(segments.length).toBeGreaterThanOrEqual(1) - ingestSegments.segments.forEach((segment) => { + segments.forEach((segment) => { expect(typeof segment).toBe('object') expect(typeof segment.externalId).toBe('string') segmentIds.push(segment.externalId) }) }) - let newIngestSegment: IngestSegment | undefined - test('Can request ingest segment by id in Sofie', async () => { - const ingestSegment = await ingestApi.getIngestSegment({ + test('Can request segment by id', async () => { + const segment = await ingestApi.getSegment({ playlistId: playlistIds[0], rundownId: rundownIds[0], segmentId: segmentIds[0], }) - expect(ingestSegment.status).toBe(200) - expect(ingestSegment).toHaveProperty('segment') - - expect(ingestSegment.segment).toHaveProperty('name') - expect(ingestSegment.segment).toHaveProperty('rank') - expect(typeof ingestSegment.segment.name).toBe('string') - expect(typeof ingestSegment.segment.rank).toBe('number') - newIngestSegment = JSON.parse(JSON.stringify(ingestSegment.segment)) + + expect(segment).toHaveProperty('name') + expect(segment).toHaveProperty('rank') + expect(typeof segment.name).toBe('string') + expect(typeof segment.rank).toBe('number') }) - test('can add/update multiple ingest segments in Sofie', async () => { - const ingestSegment = await ingestApi.putIngestSegments({ + test('Can create segment', async () => { + const result = await ingestApi.postSegment({ playlistId: playlistIds[0], rundownId: rundownIds[0], - putIngestSegmentsRequest: { - segments: [ - { - name: 'segment1', - rank: 0, - }, - ], + segment: { + externalId: 'segment1', + name: 'Segment 1', + rank: 0, }, }) - expect(ingestSegment.status).toBe(200) + + expect(result).toBe(undefined) }) - const testIngestSegmentId = 'segment2' - test('Can add/update an ingest segment in Sofie', async () => { - newIngestSegment.name = newIngestSegment.name + 'Added' - const ingestSegment = await ingestApi.putIngestSegment({ + test('Can update multiple segments', async () => { + const result = await ingestApi.putSegments({ playlistId: playlistIds[0], rundownId: rundownIds[0], - segmentId: testIngestSegmentId, - eTag: '1725269223', - ingestSegment: newIngestSegment, + segment: [ + { + externalId: 'segment1', + name: 'Segment 1', + rank: 0, + }, + ], }) - expect(ingestSegment.status).toBe(200) + expect(result).toBe(undefined) }) - test('Can delete multiple ingest segments in Sofie', async () => { - const ingestRundown = await ingestApi.deleteIngestSegments({ + const updatedSegmentId = 'segment2' + test('Can update single segment', async () => { + const result = await ingestApi.putSegment({ playlistId: playlistIds[0], rundownId: rundownIds[0], + segmentId: updatedSegmentId, + segment: { + externalId: 'segment2', + name: 'Segment 2', + rank: 1, + }, }) - expect(ingestRundown.status).toBe(200) + expect(result).toBe(undefined) }) - test('Can delete ingest segment by id in Sofie', async () => { - const ingestRundown = await ingestApi.deleteIngestSegment({ + test('Can delete multiple segments', async () => { + const result = await ingestApi.deleteSegments({ playlistId: playlistIds[0], rundownId: rundownIds[0], - segmentId: testIngestSegmentId, }) - expect(ingestRundown.status).toBe(200) + expect(result).toBe(undefined) + }) + + test('Can delete segment by id', async () => { + const result = await ingestApi.deleteSegment({ + playlistId: playlistIds[0], + rundownId: rundownIds[0], + segmentId: updatedSegmentId, + }) + expect(result).toBe(undefined) }) /** * INGEST PARTS */ const partIds: string[] = [] - test('Can request all ingest parts in Sofie', async () => { - const ingestParts = await ingestApi.getIngestParts({ + test('Can request all parts', async () => { + const parts = await ingestApi.getParts({ playlistId: playlistIds[0], rundownId: rundownIds[0], segmentId: segmentIds[0], }) - expect(ingestParts.status).toBe(200) - expect(ingestParts).toHaveProperty('parts') - - expect(ingestParts.parts.length).toBeGreaterThanOrEqual(1) + expect(parts.length).toBeGreaterThanOrEqual(1) - ingestParts.parts.forEach((part) => { + parts.forEach((part) => { expect(typeof part).toBe('object') expect(typeof part.externalId).toBe('string') partIds.push(part.externalId) }) }) - let newIngestPart: IngestPart | undefined - test('Can request ingest part by id in Sofie', async () => { - const ingestPart = await ingestApi.getIngestPart({ + let newIngestPart: Part | undefined + test('Can request part by id', async () => { + const part = await ingestApi.getPart({ playlistId: playlistIds[0], rundownId: rundownIds[0], segmentId: segmentIds[0], partId: partIds[0], }) - expect(ingestPart.status).toBe(200) - expect(ingestPart).toHaveProperty('part') - - expect(ingestPart.part).toHaveProperty('name') - expect(ingestPart.part).toHaveProperty('rank') - expect(typeof ingestPart.part.name).toBe('string') - expect(typeof ingestPart.part.rank).toBe('number') - newIngestPart = JSON.parse(JSON.stringify(ingestPart.part)) + + expect(part).toHaveProperty('name') + expect(part).toHaveProperty('rank') + expect(typeof part.name).toBe('string') + expect(typeof part.rank).toBe('number') + newIngestPart = JSON.parse(JSON.stringify(part)) }) - test('Can add/update multiple ingest parts in Sofie', async () => { - const ingestPart = await ingestApi.putIngestParts({ + test('Can create part', async () => { + const result = await ingestApi.postPart({ playlistId: playlistIds[0], rundownId: rundownIds[0], segmentId: segmentIds[0], - putIngestPartsRequest: { - parts: [ - { - name: 'part1', - rank: 0, - }, - ], + part: { + externalId: 'part1', + name: 'Part 1', + rank: 0, }, }) - expect(ingestPart.status).toBe(200) + expect(result).toBe(undefined) + }) + + test('Can update multiple parts', async () => { + const result = await ingestApi.putParts({ + playlistId: playlistIds[0], + rundownId: rundownIds[0], + segmentId: segmentIds[0], + part: [ + { + externalId: 'part1', + name: 'Part 1', + rank: 0, + }, + ], + }) + expect(result).toBe(undefined) }) - const testIngestPartId = 'part2' - test('Can add/update an ingest part in Sofie', async () => { - newIngestPart.name = newIngestPart.name + 'Added' - const ingestPart = await ingestApi.putIngestPart({ + const updatedPartId = 'part2' + test('Can update an part', async () => { + newIngestPart.name = newIngestPart.name + ' added' + const result = await ingestApi.putPart({ playlistId: playlistIds[0], rundownId: rundownIds[0], segmentId: segmentIds[0], - partId: testIngestPartId, - eTag: '1725269417', - ingestPart: newIngestPart, + partId: updatedPartId, + part: { + externalId: 'part1', + name: 'Part 1', + rank: 0, + }, }) - expect(ingestPart.status).toBe(200) + expect(result).toBe(undefined) }) - test('Can delete multiple ingest parts in Sofie', async () => { - const ingestRundown = await ingestApi.deleteIngestParts({ + test('Can delete multiple parts', async () => { + const result = await ingestApi.deleteParts({ playlistId: playlistIds[0], rundownId: rundownIds[0], segmentId: segmentIds[0], }) - expect(ingestRundown.status).toBe(200) + expect(result).toBe(undefined) }) - test('Can delete ingest part by id in Sofie', async () => { - const ingestRundown = await ingestApi.deleteIngestPart({ + test('Can delete part by id', async () => { + const result = await ingestApi.deletePart({ playlistId: playlistIds[0], rundownId: rundownIds[0], segmentId: segmentIds[0], - partId: testIngestPartId, + partId: updatedPartId, }) - expect(ingestRundown.status).toBe(200) + expect(result).toBe(undefined) }) }) From 019ac2791e5c519294f509ecb69b740364ccc8eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Wed, 25 Sep 2024 15:11:57 +0200 Subject: [PATCH 18/50] wip: ingest api resync url --- meteor/server/api/ingest/actions.ts | 12 +- .../ingest/httpIngest/httpIngestController.ts | 99 ++++++++-- .../ingest/httpIngest/httpIngestServices.ts | 92 +++++++-- .../api/ingest/httpIngest/httpIngestTypes.ts | 5 + packages/corelib/src/dataModel/Rundown.ts | 1 + packages/openapi/api/definitions/ingest.yaml | 177 ++++++++++++++++-- packages/openapi/src/__tests__/ingest.spec.ts | 166 +++++++++++----- 7 files changed, 448 insertions(+), 104 deletions(-) create mode 100644 meteor/server/api/ingest/httpIngest/httpIngestTypes.ts diff --git a/meteor/server/api/ingest/actions.ts b/meteor/server/api/ingest/actions.ts index a00f021024..11459baee1 100644 --- a/meteor/server/api/ingest/actions.ts +++ b/meteor/server/api/ingest/actions.ts @@ -28,9 +28,17 @@ export namespace IngestActions { return TriggerReloadDataResponse.COMPLETED } case 'httpIngest': { - console.log('TODO: RELOADING HTTP INGEST DATA') + console.log(`Should be sending POST request to resync URL`) + // const resyncUrl = rundown.source.resyncUrl + // fetch(resyncUrl, { method: 'POST', signal: AbortSignal.timeout(5000) }) + // .then(() => { + // console.log(`Resync request sent to ${resyncUrl}`) + // }) + // .catch(() => { + // throw new Meteor.Error(400, `Could not reload rundown using resync URL "${resyncUrl}"`) + // }) - return TriggerReloadDataResponse.COMPLETED + return TriggerReloadDataResponse.WORKING } case 'testing': { await runIngestOperation(rundown.studioId, IngestJobs.CreateAdlibTestingRundownForShowStyleVariant, { diff --git a/meteor/server/api/ingest/httpIngest/httpIngestController.ts b/meteor/server/api/ingest/httpIngest/httpIngestController.ts index 930f56ec79..6e9dbf7c3b 100644 --- a/meteor/server/api/ingest/httpIngest/httpIngestController.ts +++ b/meteor/server/api/ingest/httpIngest/httpIngestController.ts @@ -1,5 +1,5 @@ import KoaRouter from '@koa/router' -import { IngestPart, IngestRundown, IngestSegment } from '@sofie-automation/blueprints-integration' +import { IngestPart, IngestSegment } from '@sofie-automation/blueprints-integration' import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' import Koa from 'koa' import koaBodyParser from 'koa-bodyparser' @@ -22,10 +22,13 @@ import { getRundowns, getSegment, getSegments, + postRundown, putParts, putRundown, + putRundowns, putSegments, } from './httpIngestServices' +import { HttpIngestRundown } from './httpIngestTypes' const router = new KoaRouter() export const httpIngestRouter = router @@ -51,18 +54,33 @@ const validateBodyMiddleware = async (ctx: Koa.DefaultContext, next: () => Promi } } +/** + * OK. + */ const handle200 = (ctx: Koa.DefaultContext, data?: any) => { ctx.response.type = 'application/json' ctx.response.status = 200 ctx.response.body = data || '' } +/** + * Resource created. + */ const handle201 = (ctx: Koa.DefaultContext, data?: any) => { ctx.response.type = 'application/json' ctx.response.status = 201 ctx.response.body = data || '' } +/** + * Request accepted. + */ +const handle202 = (ctx: Koa.DefaultContext, data?: any) => { + ctx.response.type = 'application/json' + ctx.response.status = 202 + ctx.response.body = data || '' +} + const handleError = (e: unknown, ctx: Koa.DefaultContext) => { ctx.response.type = 'text/plain' ctx.response.status = e instanceof Meteor.Error && typeof e.error === 'number' ? e.error : 500 @@ -87,7 +105,7 @@ router.get('/playlists', async (ctx) => { router.delete('/playlists', async (ctx) => { try { await deletePlaylists() - handle200(ctx) + handle202(ctx) } catch (e) { handleError(e, ctx) } @@ -111,7 +129,7 @@ router.delete('/playlists/:playlistId', async (ctx) => { try { await deletePlaylist(playlistId) - handle200(ctx) + handle202(ctx) } catch (e) { handleError(e, ctx) } @@ -119,23 +137,22 @@ router.delete('/playlists/:playlistId', async (ctx) => { // Rundowns -router.put('/playlists/:playlistId/rundowns', bodyParser, validateBodyMiddleware, async (ctx) => { +// Get rundown +router.get('/playlists/:playlistId/rundowns/:rundownId', async (ctx) => { const playlistId = ctx.params.playlistId check(playlistId, String) + const rundownId = ctx.params.rundownId + check(rundownId, String) try { - const ingestRundown = ctx.request.body as IngestRundown - 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') - - await putRundown(playlistId, ingestRundown) - - handle201(ctx) + const rundown = await getRundown(playlistId, rundownId) + handle200(ctx, rundown) } catch (e) { handleError(e, ctx) } }) +// Get rundowns router.get('/playlists/:playlistId/rundowns', async (ctx) => { const playlistId = ctx.params.playlistId check(playlistId, String) @@ -148,32 +165,61 @@ router.get('/playlists/:playlistId/rundowns', async (ctx) => { } }) -router.delete('/playlists/:playlistId/rundowns', async (ctx) => { +// Create rundown +router.post('/playlists/:playlistId/rundowns', bodyParser, validateBodyMiddleware, async (ctx) => { const playlistId = ctx.params.playlistId check(playlistId, String) try { - await deleteRundowns(playlistId) - handle200(ctx) + const ingestRundown = ctx.request.body as HttpIngestRundown + 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') + + await postRundown(playlistId, ingestRundown) + + handle202(ctx) } catch (e) { handleError(e, ctx) } }) -router.get('/playlists/:playlistId/rundowns/:rundownId', async (ctx) => { +// Update rundown +router.put('/playlists/:playlistId/rundowns', bodyParser, validateBodyMiddleware, async (ctx) => { const playlistId = ctx.params.playlistId check(playlistId, String) - const rundownId = ctx.params.rundownId - check(rundownId, String) try { - const rundown = await getRundown(playlistId, rundownId) - handle200(ctx, rundown) + const ingestRundown = ctx.request.body as HttpIngestRundown + 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') + + await putRundown(playlistId, ingestRundown) + + handle201(ctx) + } catch (e) { + handleError(e, ctx) + } +}) + +// Update rundowns +router.put('/playlists/:playlistId/rundowns', bodyParser, validateBodyMiddleware, async (ctx) => { + const playlistId = ctx.params.playlistId + check(playlistId, String) + + try { + const ingestRundown = ctx.request.body as HttpIngestRundown[] + 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') + + await putRundowns(playlistId, ingestRundown) + + handle201(ctx) } catch (e) { handleError(e, ctx) } }) +// Delete rundown router.delete('/playlists/:playlistId/rundowns/:rundownId', async (ctx) => { const playlistId = ctx.params.playlistId check(playlistId, String) @@ -182,7 +228,20 @@ router.delete('/playlists/:playlistId/rundowns/:rundownId', async (ctx) => { try { await deleteRundown(playlistId, rundownId) - handle200(ctx) + handle202(ctx) + } catch (e) { + handleError(e, ctx) + } +}) + +// Delete rundowns +router.delete('/playlists/:playlistId/rundowns', async (ctx) => { + const playlistId = ctx.params.playlistId + check(playlistId, String) + + try { + await deleteRundowns(playlistId) + handle202(ctx) } catch (e) { handleError(e, ctx) } diff --git a/meteor/server/api/ingest/httpIngest/httpIngestServices.ts b/meteor/server/api/ingest/httpIngest/httpIngestServices.ts index b5d5e1a670..ad5b361316 100644 --- a/meteor/server/api/ingest/httpIngest/httpIngestServices.ts +++ b/meteor/server/api/ingest/httpIngest/httpIngestServices.ts @@ -1,13 +1,14 @@ -import { IngestPart, IngestRundown, IngestSegment } from '@sofie-automation/blueprints-integration' +import { IngestPart, IngestSegment } from '@sofie-automation/blueprints-integration' import { PartId, RundownId, RundownPlaylistId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { getRundownNrcsName, Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { getHash } from '@sofie-automation/corelib/dist/hash' import { protectString } from '@sofie-automation/corelib/dist/protectedString' import { IngestJobs } from '@sofie-automation/corelib/dist/worker/ingest' import { Meteor } from 'meteor/meteor' import { Parts, RundownPlaylists, Rundowns, Segments, Studios } from '../../../collections' import { runIngestOperation } from '../lib' -import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { HttpIngestRundown } from './httpIngestTypes' async function findPlaylist(playlistId: string) { const playlist = await RundownPlaylists.findOneAsync({ @@ -139,6 +140,14 @@ export async function deletePlaylist(playlistId: string): Promise { // Rundowns +// Get rundown +export async function getRundown(playlistId: string, rundownId: string): Promise { + const playlist = await findPlaylist(playlistId) + const rundown = findRundown(playlist._id, rundownId) + return rundown +} + +// Get rundowns export async function getRundowns(playlistId: string): Promise { await findPlaylist(playlistId) const rundowns = await Rundowns.findFetchAsync({ @@ -152,25 +161,32 @@ export async function getRundowns(playlistId: string): Promise { return rundowns } -export async function getRundown(playlistId: string, rundownId: string): Promise { - const playlist = await findPlaylist(playlistId) - const rundown = findRundown(playlist._id, rundownId) - return rundown -} - -export async function deleteRundowns(playlistId: string): Promise { - const playlist = await findPlaylist(playlistId) - const rundowns = await Rundowns.findFetchAsync({ $or: [{ playlistId: playlist._id }] }) +// Create rundown +export async function postRundown(playlistId: string, ingestRundown: HttpIngestRundown): Promise { + const rundownId = getRundownId(ingestRundown.externalId) const studioId = await findStudioId() - for (const rundown of rundowns) { - await runIngestOperation(studioId, IngestJobs.RemoveRundown, { - rundownExternalId: rundown.externalId, - }) - } + const existingRundown = await Rundowns.findOneAsync({ + $or: [ + { _id: rundownId, playlistId: protectString(playlistId) }, + { externalId: rundownId, playlistExternalId: playlistId }, + ], + }) + checkRundownSource(existingRundown) + + await runIngestOperation(studioId, IngestJobs.UpdateRundown, { + rundownExternalId: ingestRundown.externalId, + ingestRundown: ingestRundown, + isCreateAction: true, + rundownSource: { + type: 'httpIngest', + resyncUrl: ingestRundown.resyncUrl, + }, + }) } -export async function putRundown(playlistId: string, ingestRundown: IngestRundown): Promise { +// Update rundown +export async function putRundown(playlistId: string, ingestRundown: HttpIngestRundown): Promise { const rundownId = getRundownId(ingestRundown.externalId) const studioId = await findStudioId() @@ -188,10 +204,39 @@ export async function putRundown(playlistId: string, ingestRundown: IngestRundow isCreateAction: true, rundownSource: { type: 'httpIngest', + resyncUrl: ingestRundown.resyncUrl, }, }) } +// Update rundowns +export async function putRundowns(playlistId: string, ingestRundowns: HttpIngestRundown[]): Promise { + const studioId = await findStudioId() + + for (const ingestRundown of ingestRundowns) { + const rundownId = getRundownId(ingestRundown.externalId) + + const existingRundown = await Rundowns.findOneAsync({ + $or: [ + { _id: rundownId, playlistId: protectString(playlistId) }, + { externalId: rundownId, playlistExternalId: playlistId }, + ], + }) + checkRundownSource(existingRundown) + + await runIngestOperation(studioId, IngestJobs.UpdateRundown, { + rundownExternalId: ingestRundown.externalId, + ingestRundown: ingestRundown, + isCreateAction: true, + rundownSource: { + type: 'httpIngest', + resyncUrl: ingestRundown.resyncUrl, + }, + }) + } +} + +// Delete rundown export async function deleteRundown(playlistId: string, rundownId: string): Promise { const playlist = await findPlaylist(playlistId) const rundown = await findRundown(playlist._id, rundownId) @@ -202,6 +247,19 @@ export async function deleteRundown(playlistId: string, rundownId: string): Prom }) } +// Delete rundowns +export async function deleteRundowns(playlistId: string): Promise { + const playlist = await findPlaylist(playlistId) + const rundowns = await Rundowns.findFetchAsync({ $or: [{ playlistId: playlist._id }] }) + const studioId = await findStudioId() + + for (const rundown of rundowns) { + await runIngestOperation(studioId, IngestJobs.RemoveRundown, { + rundownExternalId: rundown.externalId, + }) + } +} + // Segments export async function putSegments( diff --git a/meteor/server/api/ingest/httpIngest/httpIngestTypes.ts b/meteor/server/api/ingest/httpIngest/httpIngestTypes.ts new file mode 100644 index 0000000000..0adb8ee82a --- /dev/null +++ b/meteor/server/api/ingest/httpIngest/httpIngestTypes.ts @@ -0,0 +1,5 @@ +import { IngestRundown } from '@sofie-automation/blueprints-integration' + +export type HttpIngestRundown = IngestRundown & { + resyncUrl: string +} diff --git a/packages/corelib/src/dataModel/Rundown.ts b/packages/corelib/src/dataModel/Rundown.ts index 8688e6b773..efbf64d71b 100644 --- a/packages/corelib/src/dataModel/Rundown.ts +++ b/packages/corelib/src/dataModel/Rundown.ts @@ -127,6 +127,7 @@ export interface RundownSourceTesting { /** A description of the source of a Rundown which was through the new HTTP ingest API */ export interface RundownSourceHttpIngest { type: 'httpIngest' + resyncUrl: string } export function getRundownNrcsName(rundown: ReadonlyDeep> | undefined): string { diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml index 466f1f0adf..de55cde994 100644 --- a/packages/openapi/api/definitions/ingest.yaml +++ b/packages/openapi/api/definitions/ingest.yaml @@ -955,25 +955,40 @@ components: name: type: string example: Rundown 1 - source: + type: type: string - description: A source type that can be displayed to the end-user. Should identify what type of system (e.g. vendor/product name) the data has been sent from. - examples: - - 'Some Product Name' - - 'Our Company - Some Product Name' - rank: - type: number - description: The position of the Rundown in the parent Playlist. - inclusiveMinimum: 0.0 - example: 1 + resyncUrl: + type: string + segments: + type: array + items: + $ref: '#/components/schemas/segment' payload: type: object + properties: + externalId: + type: string + name: + type: string + spreadsheetVersion: + type: string + expectedStart: + type: number + expectedEnd: + type: number + required: + - externalId + - name + - spreadsheetVersion + - expectedStart + - expectedEnd additionalProperties: true required: - externalId - name - - source - - rank + - type + - resyncUrl + - payload additionalProperties: false segment: type: object @@ -991,11 +1006,38 @@ components: example: 1 payload: type: object - additionalProperties: true + properties: + rundownId: + type: string + externalId: + type: string + rank: + type: number + name: + type: string + float: + type: boolean + tags: + type: array + items: + type: string + required: + - rundownId + - externalId + - rank + - name + - float + - tags + additionalProperties: false + parts: + type: array + items: + $ref: '#/components/schemas/part' required: - externalId - name - rank + - payload additionalProperties: false part: type: object @@ -1012,9 +1054,116 @@ components: example: 0 payload: type: object - additionalProperties: true + properties: + type: + type: string + enum: + - CAMERA + - FULL + - VO + - REMOTE + - COMPOSITION + - FULLSCREEN_GRAPHIC + - MACRO + segmentId: + type: string + externalId: + type: string + rank: + type: number + name: + type: string + float: + type: boolean + script: + type: string + pieces: + type: array + items: + $ref: '#/components/schemas/piece' + autoNext: + type: boolean + tags: + type: array + items: + type: string + guest: + type: boolean + additionalProperties: false + required: + - type + - segmentId + - externalId + - rank + - name + - float + - script + - pieces + - autoNext + - tags + - guest required: - externalId - name - rank + - payload + additionalProperties: false + piece: + type: object + properties: + externalId: + type: string + example: piece1 + id: + type: string + example: Piece 1 + 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: string + resourceName: + type: string + label: + type: string + attributes: + type: object + additionalProperties: true + position: + type: string + script: + type: string + transition: + type: string + transitionDuration: + type: string + target: + type: string + rank: + type: number + description: The position of the Part in the parent Segment. + example: 0 + required: + - externalId + - id + - objectType + - resourceName + - label + - attributes + - position additionalProperties: false diff --git a/packages/openapi/src/__tests__/ingest.spec.ts b/packages/openapi/src/__tests__/ingest.spec.ts index 82aaac592d..47663a4685 100644 --- a/packages/openapi/src/__tests__/ingest.spec.ts +++ b/packages/openapi/src/__tests__/ingest.spec.ts @@ -65,13 +65,15 @@ describe('Ingest API', () => { rundowns.forEach((rundown) => { expect(typeof rundown).toBe('object') expect(rundown).toHaveProperty('name') - expect(rundown).toHaveProperty('rank') - expect(rundown).toHaveProperty('source') expect(rundown).toHaveProperty('externalId') + expect(rundown).toHaveProperty('payload') + expect(rundown).toHaveProperty('resyncUrl') + expect(rundown).toHaveProperty('type') expect(typeof rundown.name).toBe('string') - expect(typeof rundown.rank).toBe('number') - expect(typeof rundown.source).toBe('string') expect(typeof rundown.externalId).toBe('string') + expect(typeof rundown.payload).toBe('object') + expect(typeof rundown.resyncUrl).toBe('string') + expect(typeof rundown.type).toBe('string') rundownIds.push(rundown.externalId) }) }) @@ -83,24 +85,35 @@ describe('Ingest API', () => { }) expect(rundown).toHaveProperty('name') - expect(rundown).toHaveProperty('rank') - expect(rundown).toHaveProperty('source') expect(rundown).toHaveProperty('externalId') + expect(rundown).toHaveProperty('payload') + expect(rundown).toHaveProperty('resyncUrl') + expect(rundown).toHaveProperty('type') expect(typeof rundown.name).toBe('string') - expect(typeof rundown.rank).toBe('number') - expect(typeof rundown.source).toBe('string') expect(typeof rundown.externalId).toBe('string') + expect(typeof rundown.payload).toBe('object') + expect(typeof rundown.resyncUrl).toBe('string') + expect(typeof rundown.type).toBe('string') }) + const rundown = { + externalId: 'newRundown', + name: 'New rundown', + type: 'TYPE', + resyncUrl: 'resyncUrl', + payload: { + name: 'Rundown 1', + expectedStart: 0, + expectedEnd: 0, + externalId: 'rundown1', + spreadsheetVersion: '1.1.1', + }, + } + test('Can create rundown', async () => { const result = await ingestApi.postRundown({ playlistId: playlistIds[0], - rundown: { - externalId: 'newRundown', - name: 'New rundown', - rank: 1, - source: 'nrcsId', - }, + rundown, }) expect(result).toBe(undefined) @@ -109,20 +122,7 @@ describe('Ingest API', () => { test('Can update multiple rundowns', async () => { const result = await ingestApi.putRundowns({ playlistId: playlistIds[0], - rundown: [ - { - externalId: 'rundown1', - name: 'Rundown 1', - source: 'Our Company - Some Product Name', - rank: 0, - }, - { - externalId: 'rundown2', - name: 'Rundown 2', - source: 'Our Second Company - Some Product Name', - rank: 1, - }, - ], + rundown: [rundown], }) expect(result).toBe(undefined) }) @@ -132,12 +132,7 @@ describe('Ingest API', () => { const result = await ingestApi.putRundown({ playlistId: playlistIds[0], rundownId: updatedRundownId, - rundown: { - externalId: 'rundown3', - name: 'Rundown 3', - source: 'Our Company - Some Product Name', - rank: 3, - }, + rundown, }) expect(result).toBe(undefined) }) @@ -189,15 +184,25 @@ describe('Ingest API', () => { expect(typeof segment.rank).toBe('number') }) + const segment = { + externalId: 'segment1', + name: 'Segment 1', + rank: 0, + payload: { + externalId: 'segment1', + name: 'Segment 1', + rank: 1, + rundownId: 'rundown1', + tags: [], + _float: true, + }, + } + test('Can create segment', async () => { const result = await ingestApi.postSegment({ playlistId: playlistIds[0], rundownId: rundownIds[0], - segment: { - externalId: 'segment1', - name: 'Segment 1', - rank: 0, - }, + segment, }) expect(result).toBe(undefined) @@ -207,13 +212,7 @@ describe('Ingest API', () => { const result = await ingestApi.putSegments({ playlistId: playlistIds[0], rundownId: rundownIds[0], - segment: [ - { - externalId: 'segment1', - name: 'Segment 1', - rank: 0, - }, - ], + segment: [segment], }) expect(result).toBe(undefined) }) @@ -224,11 +223,7 @@ describe('Ingest API', () => { playlistId: playlistIds[0], rundownId: rundownIds[0], segmentId: updatedSegmentId, - segment: { - externalId: 'segment2', - name: 'Segment 2', - rank: 1, - }, + segment, }) expect(result).toBe(undefined) }) @@ -295,6 +290,29 @@ describe('Ingest API', () => { externalId: 'part1', name: 'Part 1', rank: 0, + payload: { + externalId: 'part1', + name: 'Part 1', + segmentId: 'segment1', + type: 'CAMERA', + rank: 0, + _float: true, + autoNext: true, + guest: true, + pieces: [ + { + id: 'piece1', + externalId: 'piece1', + label: 'Piece 1', + attributes: {}, + objectType: 'CAMERA', + position: '', + resourceName: 'camera1', + }, + ], + script: '', + tags: [], + }, }, }) expect(result).toBe(undefined) @@ -310,6 +328,29 @@ describe('Ingest API', () => { externalId: 'part1', name: 'Part 1', rank: 0, + payload: { + externalId: 'part1', + name: 'Part 1', + segmentId: 'segment1', + type: 'CAMERA', + rank: 0, + _float: true, + autoNext: true, + guest: true, + pieces: [ + { + id: 'piece1', + externalId: 'piece1', + label: 'Piece 1', + attributes: {}, + objectType: 'CAMERA', + position: '', + resourceName: 'camera1', + }, + ], + script: '', + tags: [], + }, }, ], }) @@ -328,6 +369,29 @@ describe('Ingest API', () => { externalId: 'part1', name: 'Part 1', rank: 0, + payload: { + externalId: 'part1', + name: 'Part 1', + segmentId: 'segment1', + type: 'CAMERA', + rank: 0, + _float: true, + autoNext: true, + guest: true, + pieces: [ + { + id: 'piece1', + externalId: 'piece1', + label: 'Piece 1', + attributes: {}, + objectType: 'CAMERA', + position: '', + resourceName: 'camera1', + }, + ], + script: '', + tags: [], + }, }, }) expect(result).toBe(undefined) From 2a0408e1f0900c29e9017b07e8153cfa399553f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Wed, 25 Sep 2024 16:50:05 +0200 Subject: [PATCH 19/50] feat: ingest api reload rundown action --- meteor/.meteor/packages | 1 + meteor/server/api/ingest/actions.ts | 29 ++++++++++++++++++++--------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/meteor/.meteor/packages b/meteor/.meteor/packages index 3e586bdb11..edeb33ceb2 100644 --- a/meteor/.meteor/packages +++ b/meteor/.meteor/packages @@ -20,3 +20,4 @@ typescript@5.6.3 # Enable TypeScript syntax in .ts and .tsx modules tracker@1.3.4 # Meteor's client-side reactive programming library zodern:types +fetch diff --git a/meteor/server/api/ingest/actions.ts b/meteor/server/api/ingest/actions.ts index 11459baee1..ae3f1a6daa 100644 --- a/meteor/server/api/ingest/actions.ts +++ b/meteor/server/api/ingest/actions.ts @@ -7,6 +7,8 @@ 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 { fetch } from 'meteor/fetch' +import { logger } from '../../logging' /* This file contains actions that can be performed on an ingest-device @@ -28,15 +30,24 @@ export namespace IngestActions { return TriggerReloadDataResponse.COMPLETED } case 'httpIngest': { - console.log(`Should be sending POST request to resync URL`) - // const resyncUrl = rundown.source.resyncUrl - // fetch(resyncUrl, { method: 'POST', signal: AbortSignal.timeout(5000) }) - // .then(() => { - // console.log(`Resync request sent to ${resyncUrl}`) - // }) - // .catch(() => { - // throw new Meteor.Error(400, `Could not reload rundown using resync URL "${resyncUrl}"`) - // }) + const resyncUrl = rundown.source.resyncUrl + fetch(resyncUrl, { method: 'POST' }) + .then(() => { + logger.info(`Reload rundown: resync request sent to "${resyncUrl}"`) + }) + .catch((error) => { + if (error.errno === 'ECONNREFUSED') { + logger.error( + `Reload rundown: could not establish connection with "${resyncUrl}" (ECONNREFUSED)` + ) + return + } + logger.error( + `Reload rundown: error occured while sending resync request to "${resyncUrl}", error: "${JSON.stringify( + error + )}"` + ) + }) return TriggerReloadDataResponse.WORKING } From a08c570245ee37af9d41ae2f145ee6a2ae4dfaa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Wed, 25 Sep 2024 17:16:05 +0200 Subject: [PATCH 20/50] wip: ingest api cleanup --- packages/openapi/api/definitions/ingest.yaml | 35 ++----------------- packages/openapi/src/__tests__/ingest.spec.ts | 14 +------- 2 files changed, 3 insertions(+), 46 deletions(-) diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml index de55cde994..71d1f8085f 100644 --- a/packages/openapi/api/definitions/ingest.yaml +++ b/packages/openapi/api/definitions/ingest.yaml @@ -957,8 +957,10 @@ components: example: Rundown 1 type: type: string + example: external resyncUrl: type: string + example: http://nrcs-url/resync/rundownId segments: type: array items: @@ -966,20 +968,11 @@ components: payload: type: object properties: - externalId: - type: string - name: - type: string - spreadsheetVersion: - type: string expectedStart: type: number expectedEnd: type: number required: - - externalId - - name - - spreadsheetVersion - expectedStart - expectedEnd additionalProperties: true @@ -1007,14 +1000,6 @@ components: payload: type: object properties: - rundownId: - type: string - externalId: - type: string - rank: - type: number - name: - type: string float: type: boolean tags: @@ -1022,10 +1007,6 @@ components: items: type: string required: - - rundownId - - externalId - - rank - - name - float - tags additionalProperties: false @@ -1065,14 +1046,6 @@ components: - COMPOSITION - FULLSCREEN_GRAPHIC - MACRO - segmentId: - type: string - externalId: - type: string - rank: - type: number - name: - type: string float: type: boolean script: @@ -1092,10 +1065,6 @@ components: additionalProperties: false required: - type - - segmentId - - externalId - - rank - - name - float - script - pieces diff --git a/packages/openapi/src/__tests__/ingest.spec.ts b/packages/openapi/src/__tests__/ingest.spec.ts index 47663a4685..6b570bffe9 100644 --- a/packages/openapi/src/__tests__/ingest.spec.ts +++ b/packages/openapi/src/__tests__/ingest.spec.ts @@ -99,14 +99,11 @@ describe('Ingest API', () => { const rundown = { externalId: 'newRundown', name: 'New rundown', - type: 'TYPE', + type: 'external', resyncUrl: 'resyncUrl', payload: { - name: 'Rundown 1', expectedStart: 0, expectedEnd: 0, - externalId: 'rundown1', - spreadsheetVersion: '1.1.1', }, } @@ -291,11 +288,8 @@ describe('Ingest API', () => { name: 'Part 1', rank: 0, payload: { - externalId: 'part1', - name: 'Part 1', segmentId: 'segment1', type: 'CAMERA', - rank: 0, _float: true, autoNext: true, guest: true, @@ -329,11 +323,8 @@ describe('Ingest API', () => { name: 'Part 1', rank: 0, payload: { - externalId: 'part1', - name: 'Part 1', segmentId: 'segment1', type: 'CAMERA', - rank: 0, _float: true, autoNext: true, guest: true, @@ -370,11 +361,8 @@ describe('Ingest API', () => { name: 'Part 1', rank: 0, payload: { - externalId: 'part1', - name: 'Part 1', segmentId: 'segment1', type: 'CAMERA', - rank: 0, _float: true, autoNext: true, guest: true, From d64b59e52df9974ffd1e5e0810284309a1ecfa15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Thu, 26 Sep 2024 19:28:30 +0200 Subject: [PATCH 21/50] wip: ingest api details --- .../ingest/httpIngest/httpIngestController.ts | 326 ++++++++--- .../httpIngest/httpIngestResponseAdapters.ts | 50 ++ .../ingest/httpIngest/httpIngestServices.ts | 539 +++++++++++++----- .../api/ingest/httpIngest/httpIngestTypes.ts | 35 ++ packages/openapi/api/actions.yaml | 16 +- packages/openapi/api/definitions/ingest.yaml | 166 +++++- 6 files changed, 873 insertions(+), 259 deletions(-) create mode 100644 meteor/server/api/ingest/httpIngest/httpIngestResponseAdapters.ts diff --git a/meteor/server/api/ingest/httpIngest/httpIngestController.ts b/meteor/server/api/ingest/httpIngest/httpIngestController.ts index 6e9dbf7c3b..a2f50e33e8 100644 --- a/meteor/server/api/ingest/httpIngest/httpIngestController.ts +++ b/meteor/server/api/ingest/httpIngest/httpIngestController.ts @@ -8,6 +8,7 @@ import { check } from '../../../../lib/check' import { logger } from '../../../../lib/logging' import { deletePart, + deleteParts, deletePlaylist, deletePlaylists, deleteRundown, @@ -22,10 +23,14 @@ import { getRundowns, getSegment, getSegments, + postPart, postRundown, + postSegment, + putPart, putParts, putRundown, putRundowns, + putSegment, putSegments, } from './httpIngestServices' import { HttpIngestRundown } from './httpIngestTypes' @@ -63,15 +68,6 @@ const handle200 = (ctx: Koa.DefaultContext, data?: any) => { ctx.response.body = data || '' } -/** - * Resource created. - */ -const handle201 = (ctx: Koa.DefaultContext, data?: any) => { - ctx.response.type = 'application/json' - ctx.response.status = 201 - ctx.response.body = data || '' -} - /** * Request accepted. */ @@ -93,42 +89,52 @@ const handleError = (e: unknown, ctx: Koa.DefaultContext) => { // Playlists -router.get('/playlists', async (ctx) => { +router.get('/:studioId/playlists', async (ctx) => { + const studioId = ctx.params.studioId + check(studioId, String) + try { - const playlists = await getPlaylists() + const playlists = await getPlaylists(studioId) handle200(ctx, playlists) } catch (e) { handleError(e, ctx) } }) -router.delete('/playlists', async (ctx) => { +router.delete('/:studioId/playlists', async (ctx) => { + const studioId = ctx.params.studioId + check(studioId, String) + try { - await deletePlaylists() + await deletePlaylists(studioId) handle202(ctx) } catch (e) { handleError(e, ctx) } }) -router.get('/playlists/:playlistId', async (ctx) => { +router.get('/:studioId/playlists/:playlistId', async (ctx) => { + const studioId = ctx.params.studioId + check(studioId, String) const playlistId = ctx.params.playlistId check(playlistId, String) try { - const playlist = await getPlaylist(playlistId) + const playlist = await getPlaylist(studioId, playlistId) handle200(ctx, playlist) } catch (e) { handleError(e, ctx) } }) -router.delete('/playlists/:playlistId', async (ctx) => { +router.delete('/:studioId/playlists/:playlistId', async (ctx) => { + const studioId = ctx.params.studioId + check(studioId, String) const playlistId = ctx.params.playlistId check(playlistId, String) try { - await deletePlaylist(playlistId) + await deletePlaylist(studioId, playlistId) handle202(ctx) } catch (e) { handleError(e, ctx) @@ -138,14 +144,16 @@ router.delete('/playlists/:playlistId', async (ctx) => { // Rundowns // Get rundown -router.get('/playlists/:playlistId/rundowns/:rundownId', async (ctx) => { +router.get('/:studioId/playlists/:playlistId/rundowns/:rundownId', async (ctx) => { + const studioId = ctx.params.studioId + check(studioId, String) const playlistId = ctx.params.playlistId check(playlistId, String) const rundownId = ctx.params.rundownId check(rundownId, String) try { - const rundown = await getRundown(playlistId, rundownId) + const rundown = await getRundown(studioId, playlistId, rundownId) handle200(ctx, rundown) } catch (e) { handleError(e, ctx) @@ -153,12 +161,14 @@ router.get('/playlists/:playlistId/rundowns/:rundownId', async (ctx) => { }) // Get rundowns -router.get('/playlists/:playlistId/rundowns', async (ctx) => { +router.get('/:studioId/playlists/:playlistId/rundowns', async (ctx) => { + const studioId = ctx.params.studioId + check(studioId, String) const playlistId = ctx.params.playlistId check(playlistId, String) try { - const rundowns = await getRundowns(playlistId) + const rundowns = await getRundowns(studioId, playlistId) handle200(ctx, rundowns) } catch (e) { handleError(e, ctx) @@ -166,7 +176,9 @@ router.get('/playlists/:playlistId/rundowns', async (ctx) => { }) // Create rundown -router.post('/playlists/:playlistId/rundowns', bodyParser, validateBodyMiddleware, async (ctx) => { +router.post('/:studioId/playlists/:playlistId/rundowns', bodyParser, validateBodyMiddleware, async (ctx) => { + const studioId = ctx.params.studioId + check(studioId, String) const playlistId = ctx.params.playlistId check(playlistId, String) @@ -175,7 +187,7 @@ router.post('/playlists/:playlistId/rundowns', bodyParser, validateBodyMiddlewar 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') - await postRundown(playlistId, ingestRundown) + await postRundown(studioId, playlistId, ingestRundown) handle202(ctx) } catch (e) { @@ -184,25 +196,31 @@ router.post('/playlists/:playlistId/rundowns', bodyParser, validateBodyMiddlewar }) // Update rundown -router.put('/playlists/:playlistId/rundowns', bodyParser, validateBodyMiddleware, async (ctx) => { +router.put('/:studioId/playlists/:playlistId/rundowns/:rundownId', bodyParser, validateBodyMiddleware, async (ctx) => { + const studioId = ctx.params.studioId + check(studioId, String) const playlistId = ctx.params.playlistId check(playlistId, String) + const rundownId = ctx.params.rundownId + check(rundownId, String) try { const ingestRundown = ctx.request.body as HttpIngestRundown 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') - await putRundown(playlistId, ingestRundown) + await putRundown(studioId, playlistId, rundownId, ingestRundown) - handle201(ctx) + handle202(ctx) } catch (e) { handleError(e, ctx) } }) // Update rundowns -router.put('/playlists/:playlistId/rundowns', bodyParser, validateBodyMiddleware, async (ctx) => { +router.put('/:studioId/playlists/:playlistId/rundowns', bodyParser, validateBodyMiddleware, async (ctx) => { + const studioId = ctx.params.studioId + check(studioId, String) const playlistId = ctx.params.playlistId check(playlistId, String) @@ -211,23 +229,25 @@ router.put('/playlists/:playlistId/rundowns', bodyParser, validateBodyMiddleware 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') - await putRundowns(playlistId, ingestRundown) + await putRundowns(studioId, playlistId, ingestRundown) - handle201(ctx) + handle202(ctx) } catch (e) { handleError(e, ctx) } }) // Delete rundown -router.delete('/playlists/:playlistId/rundowns/:rundownId', async (ctx) => { +router.delete('/:studioId/playlists/:playlistId/rundowns/:rundownId', async (ctx) => { + const studioId = ctx.params.studioId + check(studioId, String) const playlistId = ctx.params.playlistId check(playlistId, String) const rundownId = ctx.params.rundownId check(rundownId, String) try { - await deleteRundown(playlistId, rundownId) + await deleteRundown(studioId, playlistId, rundownId) handle202(ctx) } catch (e) { handleError(e, ctx) @@ -235,12 +255,14 @@ router.delete('/playlists/:playlistId/rundowns/:rundownId', async (ctx) => { }) // Delete rundowns -router.delete('/playlists/:playlistId/rundowns', async (ctx) => { +router.delete('/:studioId/playlists/:playlistId/rundowns', async (ctx) => { + const studioId = ctx.params.studioId + check(studioId, String) const playlistId = ctx.params.playlistId check(playlistId, String) try { - await deleteRundowns(playlistId) + await deleteRundowns(studioId, playlistId) handle202(ctx) } catch (e) { handleError(e, ctx) @@ -249,51 +271,121 @@ router.delete('/playlists/:playlistId/rundowns', async (ctx) => { // Segments -router.get('/playlists/:playlistId/rundowns/:rundownId/segments', async (ctx) => { +router.get('/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId', async (ctx) => { + const studioId = ctx.params.studioId + check(studioId, String) const playlistId = ctx.params.playlistId check(playlistId, String) const rundownId = ctx.params.rundownId check(rundownId, String) + const segmentId = ctx.params.segmentId + check(segmentId, String) try { - const segments = await getSegments(playlistId, rundownId) - handle200(ctx, segments) + const segment = await getSegment(studioId, playlistId, rundownId, segmentId) + handle200(ctx, segment) } catch (e) { handleError(e, ctx) } }) -router.delete('/playlists/:playlistId/rundowns/:rundownId/segments', async (ctx) => { +router.get('/:studioId/playlists/:playlistId/rundowns/:rundownId/segments', async (ctx) => { + const studioId = ctx.params.studioId + check(studioId, String) const playlistId = ctx.params.playlistId check(playlistId, String) const rundownId = ctx.params.rundownId check(rundownId, String) try { - await deleteSegments(playlistId, rundownId) - handle200(ctx) + const segments = await getSegments(studioId, playlistId, rundownId) + handle200(ctx, segments) } catch (e) { handleError(e, ctx) } }) -router.get('/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId', async (ctx) => { - const playlistId = ctx.params.playlistId - check(playlistId, String) - const rundownId = ctx.params.rundownId - check(rundownId, String) - const segmentId = ctx.params.segmentId - check(segmentId, String) +router.post( + '/:studioId/playlists/:playlistId/rundowns/:rundownId/segments', + bodyParser, + validateBodyMiddleware, + async (ctx) => { + const studioId = ctx.params.studioId + check(studioId, String) + const playlistId = ctx.params.playlistId + check(playlistId, String) + const rundownId = ctx.params.rundownId + check(rundownId, String) - try { - const segment = await getSegment(playlistId, rundownId, segmentId) - handle200(ctx, segment) - } catch (e) { - handleError(e, ctx) + try { + const ingestSegment = ctx.request.body as IngestSegment + if (!ingestSegment) throw new Meteor.Error(400, 'Upload rundown: Missing request body') + + await postSegment(studioId, playlistId, rundownId, ingestSegment) + + handle202(ctx) + } catch (e) { + handleError(e, ctx) + } } -}) +) + +router.put( + '/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId', + bodyParser, + validateBodyMiddleware, + async (ctx) => { + const studioId = ctx.params.studioId + check(studioId, String) + const playlistId = ctx.params.playlistId + check(playlistId, String) + const rundownId = ctx.params.rundownId + check(rundownId, String) + const segmentId = ctx.params.segmentId + check(segmentId, String) + + try { + const ingestSegment = ctx.request.body as IngestSegment + if (!ingestSegment) throw new Meteor.Error(400, 'Upload rundown: Missing request body') + + await putSegment(studioId, playlistId, rundownId, segmentId, ingestSegment) + + handle202(ctx) + } catch (e) { + handleError(e, ctx) + } + } +) + +router.put( + '/:studioId/playlists/:playlistId/rundowns/:rundownId/segments', + bodyParser, + validateBodyMiddleware, + async (ctx) => { + const studioId = ctx.params.studioId + check(studioId, String) + const playlistId = ctx.params.playlistId + check(playlistId, String) + const rundownId = ctx.params.rundownId + check(rundownId, String) + + try { + const ingestSegments = ctx.request.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') + + await putSegments(studioId, playlistId, rundownId, ingestSegments) -router.delete('/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId', async (ctx) => { + handle202(ctx) + } catch (e) { + handleError(e, ctx) + } + } +) + +router.delete('/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId', async (ctx) => { + const studioId = ctx.params.studioId + check(studioId, String) const playlistId = ctx.params.playlistId check(playlistId, String) const rundownId = ctx.params.rundownId @@ -302,28 +394,24 @@ router.delete('/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId', check(segmentId, String) try { - await deleteSegment(playlistId, rundownId, segmentId) - handle200(ctx) + await deleteSegment(studioId, playlistId, rundownId, segmentId) + handle202(ctx) } catch (e) { handleError(e, ctx) } }) -// PUT on collection is an exception; it allows us to batch-update segments -router.put('/playlists/:playlistId/rundowns/:rundownId/segments', bodyParser, validateBodyMiddleware, async (ctx) => { +router.delete('/:studioId/playlists/:playlistId/rundowns/:rundownId/segments', async (ctx) => { + const studioId = ctx.params.studioId + check(studioId, String) const playlistId = ctx.params.playlistId check(playlistId, String) const rundownId = ctx.params.rundownId check(rundownId, String) try { - const ingestSegments = ctx.request.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') - - await putSegments(playlistId, rundownId, ingestSegments) - - handle201(ctx) + await deleteSegments(studioId, playlistId, rundownId) + handle202(ctx) } catch (e) { handleError(e, ctx) } @@ -331,45 +419,107 @@ router.put('/playlists/:playlistId/rundowns/:rundownId/segments', bodyParser, va // Parts -router.get('/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts', async (ctx) => { +router.get('/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts/:partId', async (ctx) => { + const studioId = ctx.params.studioId + check(studioId, String) const playlistId = ctx.params.playlistId check(playlistId, String) const rundownId = ctx.params.rundownId check(rundownId, String) const segmentId = ctx.params.segmentId check(segmentId, String) + const partId = ctx.params.partId + check(partId, String) try { - const parts = await getParts(playlistId, rundownId, segmentId) - handle200(ctx, parts) + const part = await getPart(studioId, playlistId, rundownId, segmentId, partId) + handle200(ctx, part) } catch (e) { handleError(e, ctx) } }) -router.get('/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts/:partId', async (ctx) => { +router.get('/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts', async (ctx) => { + const studioId = ctx.params.studioId + check(studioId, String) const playlistId = ctx.params.playlistId check(playlistId, String) const rundownId = ctx.params.rundownId check(rundownId, String) const segmentId = ctx.params.segmentId check(segmentId, String) - const partId = ctx.params.partId - check(partId, String) try { - const part = await getPart(playlistId, rundownId, segmentId, partId) - handle200(ctx, part) + const parts = await getParts(studioId, playlistId, rundownId, segmentId) + handle200(ctx, parts) } catch (e) { handleError(e, ctx) } }) +router.post( + '/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts', + bodyParser, + validateBodyMiddleware, + async (ctx) => { + const studioId = ctx.params.studioId + check(studioId, String) + const playlistId = ctx.params.playlistId + check(playlistId, String) + const rundownId = ctx.params.rundownId + check(rundownId, String) + const segmentId = ctx.params.segmentId + check(segmentId, String) + + try { + const ingestPart = ctx.request.body as IngestPart + if (!ingestPart) throw new Meteor.Error(400, 'Upload rundown: Missing request body') + + await postPart(studioId, playlistId, rundownId, segmentId, ingestPart) + + handle202(ctx) + } catch (e) { + handleError(e, ctx) + } + } +) + router.put( - '/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts', + '/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts/:partId', bodyParser, validateBodyMiddleware, async (ctx) => { + const studioId = ctx.params.studioId + check(studioId, String) + const playlistId = ctx.params.playlistId + check(playlistId, String) + const rundownId = ctx.params.rundownId + check(rundownId, String) + const segmentId = ctx.params.segmentId + check(segmentId, String) + const partId = ctx.params.partId + check(partId, String) + + try { + const ingestPart = ctx.request.body as IngestPart + if (!ingestPart) throw new Meteor.Error(400, 'Upload rundown: Missing request body') + + await putPart(studioId, playlistId, rundownId, segmentId, partId, ingestPart) + + handle202(ctx) + } catch (e) { + handleError(e, ctx) + } + } +) + +router.put( + '/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts', + bodyParser, + validateBodyMiddleware, + async (ctx) => { + const studioId = ctx.params.studioId + check(studioId, String) const playlistId = ctx.params.playlistId check(playlistId, String) const rundownId = ctx.params.rundownId @@ -382,16 +532,18 @@ router.put( 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') - await putParts(playlistId, rundownId, segmentId, ingestParts) + await putParts(studioId, playlistId, rundownId, segmentId, ingestParts) - handle201(ctx) + handle202(ctx) } catch (e) { handleError(e, ctx) } } ) -router.delete('/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts/:partId', async (ctx) => { +router.delete('/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts/:partId', async (ctx) => { + const studioId = ctx.params.studioId + check(studioId, String) const playlistId = ctx.params.playlistId check(playlistId, String) const rundownId = ctx.params.rundownId @@ -402,8 +554,26 @@ router.delete('/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/pa check(partId, String) try { - const part = await deletePart(playlistId, rundownId, segmentId, partId) - handle200(ctx, part) + await deletePart(studioId, playlistId, rundownId, segmentId, partId) + handle202(ctx) + } catch (e) { + handleError(e, ctx) + } +}) + +router.delete('/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts', async (ctx) => { + const studioId = ctx.params.studioId + check(studioId, String) + const playlistId = ctx.params.playlistId + check(playlistId, String) + const rundownId = ctx.params.rundownId + check(rundownId, String) + const segmentId = ctx.params.segmentId + check(segmentId, String) + + try { + await deleteParts(studioId, playlistId, rundownId, segmentId) + handle202(ctx) } catch (e) { handleError(e, ctx) } diff --git a/meteor/server/api/ingest/httpIngest/httpIngestResponseAdapters.ts b/meteor/server/api/ingest/httpIngest/httpIngestResponseAdapters.ts new file mode 100644 index 0000000000..66bbc268f4 --- /dev/null +++ b/meteor/server/api/ingest/httpIngest/httpIngestResponseAdapters.ts @@ -0,0 +1,50 @@ +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { literal } from '@sofie-automation/corelib/dist/lib' +import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' +import { PartResponse, PlaylistResponse, RundownResponse, SegmentResponse } from './httpIngestTypes' +import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' +import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' + +export const adaptPlaylist = (rawPlaylist: DBRundownPlaylist): PlaylistResponse => { + return literal({ + id: unprotectString(rawPlaylist._id), + externalId: rawPlaylist.externalId, + rundownIds: rawPlaylist.rundownIdsInOrder.map((id) => unprotectString(id)), + studioId: unprotectString(rawPlaylist.studioId), + }) +} + +export const adaptRundown = (rawRundown: Rundown): RundownResponse => { + return literal({ + id: unprotectString(rawRundown._id), + externalId: rawRundown.externalId, + playlistId: unprotectString(rawRundown.playlistId), + playlistExternalId: rawRundown.playlistExternalId, + studioId: unprotectString(rawRundown.studioId), + name: rawRundown.name, + }) +} + +export const adaptSegment = (rawSegment: DBSegment): SegmentResponse => { + return literal({ + id: unprotectString(rawSegment._id), + externalId: rawSegment.externalId, + name: rawSegment.name, + rank: rawSegment._rank, + rundownId: unprotectString(rawSegment.rundownId), + }) +} + +export const adaptPart = (rawPart: DBPart): PartResponse => { + return literal({ + id: unprotectString(rawPart._id), + externalId: rawPart.externalId, + title: rawPart.title, + rank: rawPart._rank, + rundownId: unprotectString(rawPart.rundownId), + autoNext: rawPart.autoNext, + expectedDuration: rawPart.expectedDuration, + segmentId: unprotectString(rawPart.segmentId), + }) +} diff --git a/meteor/server/api/ingest/httpIngest/httpIngestServices.ts b/meteor/server/api/ingest/httpIngest/httpIngestServices.ts index ad5b361316..ce3213fd45 100644 --- a/meteor/server/api/ingest/httpIngest/httpIngestServices.ts +++ b/meteor/server/api/ingest/httpIngest/httpIngestServices.ts @@ -1,18 +1,20 @@ import { IngestPart, IngestSegment } from '@sofie-automation/blueprints-integration' -import { PartId, RundownId, RundownPlaylistId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PartId, RundownId, RundownPlaylistId, SegmentId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { getRundownNrcsName, Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' -import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { getHash } from '@sofie-automation/corelib/dist/hash' import { protectString } from '@sofie-automation/corelib/dist/protectedString' import { IngestJobs } from '@sofie-automation/corelib/dist/worker/ingest' import { Meteor } from 'meteor/meteor' import { Parts, RundownPlaylists, Rundowns, Segments, Studios } from '../../../collections' import { runIngestOperation } from '../lib' -import { HttpIngestRundown } from './httpIngestTypes' +import { adaptPart, adaptPlaylist, adaptRundown, adaptSegment } from './httpIngestResponseAdapters' +import { HttpIngestRundown, PartResponse, PlaylistResponse, RundownResponse, SegmentResponse } from './httpIngestTypes' -async function findPlaylist(playlistId: string) { +async function findPlaylist(studioId: StudioId, playlistId: string) { const playlist = await RundownPlaylists.findOneAsync({ - $or: [{ _id: protectString(playlistId) }, { externalId: playlistId }], + $or: [ + { _id: protectString(playlistId), studioId }, + { externalId: playlistId, studioId }, + ], }) if (!playlist) { throw new Meteor.Error(404, `Playlist ID '${playlistId}' was not found`) @@ -20,16 +22,18 @@ async function findPlaylist(playlistId: string) { return playlist } -async function findRundown(playlistId: RundownPlaylistId, rundownId: string) { +async function findRundown(studioId: StudioId, playlistId: RundownPlaylistId, rundownId: string) { const rundown = await Rundowns.findOneAsync({ $or: [ { _id: protectString(rundownId), playlistId, + studioId, }, { externalId: rundownId, playlistId, + studioId, }, ], }) @@ -39,7 +43,20 @@ async function findRundown(playlistId: RundownPlaylistId, rundownId: string) { return rundown } -async function findSegment(rundownId: RundownId, segmentId: string) { +async function findRundowns(studioId: StudioId, playlistId: RundownPlaylistId) { + const rundowns = await Rundowns.findFetchAsync({ + $or: [ + { + playlistId, + studioId, + }, + ], + }) + + return rundowns +} + +async function softFindSegment(rundownId: RundownId, segmentId: string) { const segment = await Segments.findOneAsync({ $or: [ { @@ -52,13 +69,29 @@ async function findSegment(rundownId: RundownId, segmentId: string) { }, ], }) + return segment +} + +async function findSegment(rundownId: RundownId, segmentId: string) { + const segment = await softFindSegment(rundownId, segmentId) if (!segment) { throw new Meteor.Error(404, `Segment ID '${segmentId}' was not found`) } return segment } -async function findPart(segmentId: SegmentId, partId: string) { +async function findSegments(rundownId: RundownId) { + const segments = await Segments.findFetchAsync({ + $or: [ + { + rundownId: rundownId, + }, + ], + }) + return segments +} + +async function softFindPart(segmentId: SegmentId, partId: string) { const part = await Parts.findOneAsync({ $or: [ { _id: protectString(partId), segmentId }, @@ -68,20 +101,31 @@ async function findPart(segmentId: SegmentId, partId: string) { }, ], }) + return part +} +async function findPart(segmentId: SegmentId, partId: string) { + const part = await softFindPart(segmentId, partId) if (!part) { throw new Meteor.Error(404, `Part ID '${partId}' was not found`) } return part } -async function findStudioId() { - const existingStudio = await Studios.findOneAsync({}) - if (!existingStudio) { +async function findParts(segmentId: SegmentId) { + const parts = await Parts.findFetchAsync({ + $or: [{ segmentId }], + }) + return parts +} + +async function findStudio(studioId: string) { + const studio = await Studios.findOneAsync({ _id: protectString(studioId) }) + if (!studio) { throw new Meteor.Error(500, `Studio does not exist`) } - return existingStudio._id + return studio } function checkRundownSource(rundown: Rundown | undefined) { @@ -95,44 +139,45 @@ function checkRundownSource(rundown: Rundown | undefined) { } } -function getRundownId(rundownExternalId: string): RundownId { - if (!rundownExternalId) throw new Meteor.Error(400, 'getRundownId: rundownExternalId must be set!') - return protectString(getHash(`${rundownExternalId}`)) -} - // Playlists -export async function getPlaylists(): Promise { - const playlists = await RundownPlaylists.findFetchAsync({}) +export async function getPlaylists(studioId: string): Promise { + const studio = await findStudio(studioId) + const rawPlaylists = await RundownPlaylists.findFetchAsync({ studioId: studio._id }) + const playlists = rawPlaylists.map((rawPlaylist) => adaptPlaylist(rawPlaylist)) + return playlists } -export async function deletePlaylists(): Promise { +export async function getPlaylist(studioId: string, playlistId: string): Promise { + const studio = await findStudio(studioId) + const rawPlaylist = await findPlaylist(studio._id, playlistId) + const playlist = adaptPlaylist(rawPlaylist) + + return playlist +} + +export async function deletePlaylists(studioId: string): Promise { const rundowns = await Rundowns.findFetchAsync({}) - const studioId = await findStudioId() + const studio = await findStudio(studioId) for (const rundown of rundowns) { - await runIngestOperation(studioId, IngestJobs.RemoveRundown, { + await runIngestOperation(studio._id, IngestJobs.RemoveRundown, { rundownExternalId: rundown.externalId, }) } } -export async function getPlaylist(playlistId: string): Promise { - const playlist = findPlaylist(playlistId) - return playlist -} - -export async function deletePlaylist(playlistId: string): Promise { - await findPlaylist(playlistId) +export async function deletePlaylist(studioId: string, playlistId: string): Promise { + const studio = await findStudio(studioId) + await findPlaylist(studio._id, playlistId) const rundowns = await Rundowns.findFetchAsync({ $or: [{ playlistId: protectString(playlistId) }, { playlistExternalId: playlistId }], }) - const studioId = await findStudioId() for (const rundown of rundowns) { - await runIngestOperation(studioId, IngestJobs.RemoveRundown, { + await runIngestOperation(studio._id, IngestJobs.RemoveRundown, { rundownExternalId: rundown.externalId, }) } @@ -141,41 +186,54 @@ export async function deletePlaylist(playlistId: string): Promise { // Rundowns // Get rundown -export async function getRundown(playlistId: string, rundownId: string): Promise { - const playlist = await findPlaylist(playlistId) - const rundown = findRundown(playlist._id, rundownId) +export async function getRundown(studioId: string, playlistId: string, rundownId: string): Promise { + const studio = await findStudio(studioId) + const playlist = await findPlaylist(studio._id, playlistId) + const rawRundown = await findRundown(studio._id, playlist._id, rundownId) + const rundown = adaptRundown(rawRundown) + return rundown } // Get rundowns -export async function getRundowns(playlistId: string): Promise { - await findPlaylist(playlistId) - const rundowns = await Rundowns.findFetchAsync({ - $or: [ - { - playlistId: protectString(playlistId), - }, - { playlistExternalId: playlistId }, - ], - }) +export async function getRundowns(studioId: string, playlistId: string): Promise { + const studio = await findStudio(studioId) + const playlist = await findPlaylist(studio._id, playlistId) + const rawRundowns = await findRundowns(studio._id, playlist._id) + const rundowns = rawRundowns.map((rawRundown) => adaptRundown(rawRundown)) + return rundowns } // Create rundown -export async function postRundown(playlistId: string, ingestRundown: HttpIngestRundown): Promise { - const rundownId = getRundownId(ingestRundown.externalId) - const studioId = await findStudioId() +export async function postRundown( + studioId: string, + playlistId: string, + ingestRundown: HttpIngestRundown +): Promise { + const studio = await findStudio(studioId) + const rundownExternalId = ingestRundown.externalId const existingRundown = await Rundowns.findOneAsync({ $or: [ - { _id: rundownId, playlistId: protectString(playlistId) }, - { externalId: rundownId, playlistExternalId: playlistId }, + { + _id: protectString(rundownExternalId), + playlistId: protectString(playlistId), + studioId: studio._id, + }, + { + externalId: rundownExternalId, + playlistExternalId: playlistId, + studioId: studio._id, + }, ], }) - checkRundownSource(existingRundown) + if (existingRundown) { + throw new Meteor.Error(400, `Rundown '${rundownExternalId}' already exists`) + } - await runIngestOperation(studioId, IngestJobs.UpdateRundown, { - rundownExternalId: ingestRundown.externalId, + await runIngestOperation(studio._id, IngestJobs.UpdateRundown, { + rundownExternalId: rundownExternalId, ingestRundown: ingestRundown, isCreateAction: true, rundownSource: { @@ -186,20 +244,24 @@ export async function postRundown(playlistId: string, ingestRundown: HttpIngestR } // Update rundown -export async function putRundown(playlistId: string, ingestRundown: HttpIngestRundown): Promise { - const rundownId = getRundownId(ingestRundown.externalId) - const studioId = await findStudioId() +export async function putRundown( + studioId: string, + playlistId: string, + rundownId: string, + ingestRundown: HttpIngestRundown +): Promise { + const studio = await findStudio(studioId) + const playlist = await findPlaylist(studio._id, playlistId) + + const existingRundown = await findRundown(studio._id, playlist._id, rundownId) + if (!existingRundown) { + throw new Meteor.Error(400, `Rundown '${rundownId}' does not exist`) + } - const existingRundown = await Rundowns.findOneAsync({ - $or: [ - { _id: rundownId, playlistId: protectString(playlistId) }, - { externalId: rundownId, playlistExternalId: playlistId }, - ], - }) checkRundownSource(existingRundown) - await runIngestOperation(studioId, IngestJobs.UpdateRundown, { - rundownExternalId: ingestRundown.externalId, + await runIngestOperation(studio._id, IngestJobs.UpdateRundown, { + rundownExternalId: existingRundown.externalId, ingestRundown: ingestRundown, isCreateAction: true, rundownSource: { @@ -210,21 +272,24 @@ export async function putRundown(playlistId: string, ingestRundown: HttpIngestRu } // Update rundowns -export async function putRundowns(playlistId: string, ingestRundowns: HttpIngestRundown[]): Promise { - const studioId = await findStudioId() +export async function putRundowns( + studioId: string, + playlistId: string, + ingestRundowns: HttpIngestRundown[] +): Promise { + const studio = await findStudio(studioId) + const playlist = await findPlaylist(studio._id, playlistId) for (const ingestRundown of ingestRundowns) { - const rundownId = getRundownId(ingestRundown.externalId) + const rundownExternalId = ingestRundown.externalId + const existingRundown = await findRundown(studio._id, playlist._id, rundownExternalId) + if (!existingRundown) { + continue + } - const existingRundown = await Rundowns.findOneAsync({ - $or: [ - { _id: rundownId, playlistId: protectString(playlistId) }, - { externalId: rundownId, playlistExternalId: playlistId }, - ], - }) checkRundownSource(existingRundown) - await runIngestOperation(studioId, IngestJobs.UpdateRundown, { + await runIngestOperation(studio._id, IngestJobs.UpdateRundown, { rundownExternalId: ingestRundown.externalId, ingestRundown: ingestRundown, isCreateAction: true, @@ -237,24 +302,26 @@ export async function putRundowns(playlistId: string, ingestRundowns: HttpIngest } // Delete rundown -export async function deleteRundown(playlistId: string, rundownId: string): Promise { - const playlist = await findPlaylist(playlistId) - const rundown = await findRundown(playlist._id, rundownId) - const studioId = await findStudioId() +export async function deleteRundown(studioId: string, playlistId: string, rundownId: string): Promise { + const studio = await findStudio(studioId) + const playlist = await findPlaylist(studio._id, playlistId) + const rundown = await findRundown(studio._id, playlist._id, rundownId) + checkRundownSource(rundown) - await runIngestOperation(studioId, IngestJobs.RemoveRundown, { + await runIngestOperation(studio._id, IngestJobs.RemoveRundown, { rundownExternalId: rundown.externalId, }) } // Delete rundowns -export async function deleteRundowns(playlistId: string): Promise { - const playlist = await findPlaylist(playlistId) - const rundowns = await Rundowns.findFetchAsync({ $or: [{ playlistId: playlist._id }] }) - const studioId = await findStudioId() +export async function deleteRundowns(studioId: string, playlistId: string): Promise { + const studio = await findStudio(studioId) + const playlist = await findPlaylist(studio._id, playlistId) + const rundowns = await findRundowns(studio._id, playlist._id) for (const rundown of rundowns) { - await runIngestOperation(studioId, IngestJobs.RemoveRundown, { + checkRundownSource(rundown) + await runIngestOperation(studio._id, IngestJobs.RemoveRundown, { rundownExternalId: rundown.externalId, }) } @@ -262,35 +329,123 @@ export async function deleteRundowns(playlistId: string): Promise { // Segments +export async function getSegment( + studioId: string, + playlistId: string, + rundownId: string, + segmentId: string +): Promise { + const studio = await findStudio(studioId) + const playlist = await findPlaylist(studio._id, playlistId) + const rundown = await findRundown(studio._id, playlist._id, rundownId) + checkRundownSource(rundown) + + const rawSegment = await findSegment(rundown._id, segmentId) + const segment = adaptSegment(rawSegment) + + return segment +} + +export async function getSegments(studioId: string, playlistId: string, rundownId: string): Promise { + const studio = await findStudio(studioId) + const playlist = await findPlaylist(studio._id, playlistId) + const rundown = await findRundown(studio._id, playlist._id, rundownId) + checkRundownSource(rundown) + + const rawSegments = await findSegments(rundown._id) + const segments = rawSegments.map((rawSegment) => adaptSegment(rawSegment)) + + return segments +} + +export async function postSegment( + studioId: string, + playlistId: string, + rundownId: string, + ingestSegment: IngestSegment +): Promise { + const studio = await findStudio(studioId) + const playlist = await findPlaylist(studio._id, playlistId) + const rundown = await findRundown(studio._id, playlist._id, rundownId) + checkRundownSource(rundown) + + const segmentExternalId = ingestSegment.externalId + + const existingSegment = await softFindSegment(rundown._id, segmentExternalId) + if (existingSegment) { + throw new Meteor.Error(400, `Segment '${segmentExternalId}' already exists`) + } + + await runIngestOperation(studio._id, IngestJobs.UpdateSegment, { + rundownExternalId: rundown.externalId, + isCreateAction: true, + ingestSegment, + }) +} + +export async function putSegment( + studioId: string, + playlistId: string, + rundownId: string, + segmentId: string, + ingestSegment: IngestSegment +): Promise { + const studio = await findStudio(studioId) + const playlist = await findPlaylist(studio._id, playlistId) + const rundown = await findRundown(studio._id, playlist._id, rundownId) + checkRundownSource(rundown) + + const segment = await softFindSegment(rundown._id, segmentId) + if (!segment) { + throw new Meteor.Error(400, `Segment '${segmentId}' does not exist`) + } + + const parts = await findParts(segment._id) + for (const part of parts) { + await 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, + }) +} + export async function putSegments( + studioId: string, playlistId: string, rundownId: string, ingestSegments: IngestSegment[] ): Promise { - const playlist = await findPlaylist(playlistId) - const rundown = await findRundown(playlist._id, rundownId) - const studioId = await findStudioId() + const studio = await findStudio(studioId) + const playlist = await findPlaylist(studio._id, playlistId) + const rundown = await findRundown(studio._id, playlist._id, rundownId) checkRundownSource(rundown) - const oldSegments = await Segments.findFetchAsync({ rundownId: rundown._id }) - for (const segment of oldSegments) { - const oldParts = await Parts.findFetchAsync({ rundownId: rundown._id, segmentId: segment._id }) - for (const part of oldParts) { - await runIngestOperation(studioId, IngestJobs.RemovePart, { + const segments = await findSegments(rundown._id) + for (const segment of segments) { + const parts = await findParts(segment._id) + for (const part of parts) { + await runIngestOperation(studio._id, IngestJobs.RemovePart, { partExternalId: part.externalId, rundownExternalId: rundown.externalId, segmentExternalId: segment.externalId, }) } - - await runIngestOperation(studioId, IngestJobs.RemoveSegment, { - rundownExternalId: rundown.externalId, - segmentExternalId: segment.externalId, - }) } for (const ingestSegment of ingestSegments) { - await runIngestOperation(studioId, IngestJobs.UpdateSegment, { + const existingSegment = await softFindSegment(rundown._id, ingestSegment.externalId) + if (!existingSegment) { + continue + } + + await runIngestOperation(studio._id, IngestJobs.UpdateSegment, { rundownExternalId: rundown.externalId, isCreateAction: true, ingestSegment, @@ -298,95 +453,152 @@ export async function putSegments( } } -export async function getSegments(playlistId: string, rundownId: string): Promise { - const playlist = await findPlaylist(playlistId) - const rundown = await findRundown(playlist._id, rundownId) +export async function deleteSegment( + studioId: string, + playlistId: string, + rundownId: string, + segmentId: string +): Promise { + const studio = await findStudio(studioId) + const playlist = await findPlaylist(studio._id, playlistId) + const rundown = await findRundown(studio._id, playlist._id, rundownId) + checkRundownSource(rundown) + const segment = await findSegment(rundown._id, segmentId) - const segments = await Segments.findFetchAsync({ - rundownId: rundown._id, + await runIngestOperation(studio._id, IngestJobs.RemoveSegment, { + segmentExternalId: segment.externalId, + rundownExternalId: rundown.externalId, }) - - return segments } -export async function deleteSegments(playlistId: string, rundownId: string): Promise { - const playlist = await findPlaylist(playlistId) - const rundown = await findRundown(playlist._id, rundownId) - const studioId = await findStudioId() +export async function deleteSegments(studioId: string, playlistId: string, rundownId: string): Promise { + const studio = await findStudio(studioId) + const playlist = await findPlaylist(studio._id, playlistId) + const rundown = await findRundown(studio._id, playlist._id, rundownId) - const segments = await Segments.findFetchAsync({ rundownId: rundown._id }) + const segments = await findSegments(rundown._id) for (const segment of segments) { - await runIngestOperation(studioId, IngestJobs.RemoveSegment, { + await runIngestOperation(studio._id, IngestJobs.RemoveSegment, { rundownExternalId: rundown.externalId, segmentExternalId: segment.externalId, }) } } -export async function getSegment(playlistId: string, rundownId: string, segmentId: string): Promise { - const playlist = await findPlaylist(playlistId) - const rundown = await findRundown(playlist._id, rundownId) +// Parts - const segment = findSegment(rundown._id, segmentId) +export async function getParts( + studioId: string, + playlistId: string, + rundownId: string, + segmentId: string +): Promise { + const studio = await findStudio(studioId) + const playlist = await findPlaylist(studio._id, playlistId) + const rundown = await findRundown(studio._id, playlist._id, rundownId) + checkRundownSource(rundown) + const segment = await findSegment(rundown._id, segmentId) - return segment -} + const rawParts = await findParts(segment._id) + const parts = rawParts.map((rawPart) => adaptPart(rawPart)) -export async function deleteSegment(playlistId: string, rundownId: string, segmentId: string): Promise { - const playlist = await findPlaylist(playlistId) - const rundown = await findRundown(playlist._id, rundownId) - const studioId = await findStudioId() + return parts +} +export async function getPart( + studioId: string, + playlistId: string, + rundownId: string, + segmentId: string, + partId: string +): Promise { + const studio = await findStudio(studioId) + const playlist = await findPlaylist(studio._id, playlistId) + const rundown = await findRundown(studio._id, playlist._id, rundownId) + checkRundownSource(rundown) const segment = await findSegment(rundown._id, segmentId) - await runIngestOperation(studioId, IngestJobs.RemoveSegment, { - segmentExternalId: segment.externalId, - rundownExternalId: rundown.externalId, - }) -} + const rawPart = await findPart(segment._id, partId) + const part = adaptPart(rawPart) -// Parts + return part +} -export async function getParts(playlistId: string, rundownId: string, segmentId: string): Promise { - const playlist = await findPlaylist(playlistId) - const rundown = await findRundown(playlist._id, rundownId) +export async function postPart( + studioId: string, + playlistId: string, + rundownId: string, + segmentId: string, + ingestPart: IngestPart +): Promise { + const studio = await findStudio(studioId) + const playlist = await findPlaylist(studio._id, playlistId) + const rundown = await findRundown(studio._id, playlist._id, rundownId) + checkRundownSource(rundown) const segment = await findSegment(rundown._id, segmentId) + const partExternalId = ingestPart.externalId - const parts = await Parts.findFetchAsync({ - segmentId: segment._id, - }) + const existingPart = await softFindPart(segment._id, partExternalId) + if (existingPart) { + throw new Meteor.Error(400, `Part '${partExternalId}' already exists`) + } - return parts + await runIngestOperation(studio._id, IngestJobs.UpdatePart, { + rundownExternalId: rundown.externalId, + segmentExternalId: segment.externalId, + isCreateAction: true, + ingestPart, + }) } -export async function getPart(playlistId: string, rundownId: string, segmentId: string, partId: string): Promise { - const playlist = await findPlaylist(playlistId) - const rundown = await findRundown(playlist._id, rundownId) +export async function putPart( + studioId: string, + playlistId: string, + rundownId: string, + segmentId: string, + partId: string, + ingestPart: IngestPart +): Promise { + const studio = await findStudio(studioId) + const playlist = await findPlaylist(studio._id, playlistId) + const rundown = await findRundown(studio._id, playlist._id, rundownId) + checkRundownSource(rundown) const segment = await findSegment(rundown._id, segmentId) - const part = findPart(segment._id, partId) + const existingPart = await findPart(segment._id, partId) + if (!existingPart) { + throw new Meteor.Error(400, `Part '${partId}' does not exists`) + } - return part + await runIngestOperation(studio._id, IngestJobs.UpdatePart, { + rundownExternalId: rundown.externalId, + segmentExternalId: segment.externalId, + isCreateAction: true, + ingestPart, + }) } export async function putParts( + studioId: string, playlistId: string, rundownId: string, segmentId: string, ingestParts: IngestPart[] ): Promise { - const playlist = await findPlaylist(playlistId) - const rundown = await findRundown(playlist._id, rundownId) - const segment = await findSegment(rundown._id, segmentId) - - const studioId = await findStudioId() + const studio = await findStudio(studioId) + const playlist = await findPlaylist(studio._id, playlistId) + const rundown = await findRundown(studio._id, playlist._id, rundownId) checkRundownSource(rundown) + const segment = await findSegment(rundown._id, segmentId) for (const ingestPart of ingestParts) { - ingestPart.payload.segmentId = segment._id + const existingPart = await findPart(segment._id, ingestPart.externalId) + if (!existingPart) { + continue + } - await runIngestOperation(studioId, IngestJobs.UpdatePart, { + await runIngestOperation(studio._id, IngestJobs.UpdatePart, { segmentExternalId: segment.externalId, rundownExternalId: rundown.externalId, isCreateAction: true, @@ -396,23 +608,46 @@ export async function putParts( } export async function deletePart( + studioId: string, playlistId: string, rundownId: string, segmentId: string, partId: string -): Promise { - const playlist = await findPlaylist(playlistId) - const rundown = await findRundown(playlist._id, rundownId) +): Promise { + const studio = await findStudio(studioId) + const playlist = await findPlaylist(studio._id, playlistId) + const rundown = await findRundown(studio._id, playlist._id, rundownId) + checkRundownSource(rundown) const segment = await findSegment(rundown._id, segmentId) - const studioId = await findStudioId() const part = await findPart(segment._id, partId) - await runIngestOperation(studioId, IngestJobs.RemovePart, { + await runIngestOperation(studio._id, IngestJobs.RemovePart, { rundownExternalId: rundown.externalId, segmentExternalId: segment.externalId, partExternalId: part.externalId, }) +} - return part +export async function deleteParts( + studioId: string, + playlistId: string, + rundownId: string, + segmentId: string +): Promise { + const studio = await findStudio(studioId) + const playlist = await findPlaylist(studio._id, playlistId) + const rundown = await findRundown(studio._id, playlist._id, rundownId) + checkRundownSource(rundown) + const segment = await findSegment(rundown._id, segmentId) + + const parts = await findParts(segment._id) + + for (const part of parts) { + await runIngestOperation(studio._id, IngestJobs.RemovePart, { + rundownExternalId: rundown.externalId, + segmentExternalId: segment.externalId, + partExternalId: part.externalId, + }) + } } diff --git a/meteor/server/api/ingest/httpIngest/httpIngestTypes.ts b/meteor/server/api/ingest/httpIngest/httpIngestTypes.ts index 0adb8ee82a..5a77a07f49 100644 --- a/meteor/server/api/ingest/httpIngest/httpIngestTypes.ts +++ b/meteor/server/api/ingest/httpIngest/httpIngestTypes.ts @@ -3,3 +3,38 @@ import { IngestRundown } from '@sofie-automation/blueprints-integration' export type HttpIngestRundown = IngestRundown & { 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 +} + +export type PartResponse = { + id: string + externalId: string + rundownId: string + segmentId: string + title: string + expectedDuration?: number + autoNext?: boolean + rank: number +} diff --git a/packages/openapi/api/actions.yaml b/packages/openapi/api/actions.yaml index 8d7d6a3083..e0d9f826be 100644 --- a/packages/openapi/api/actions.yaml +++ b/packages/openapi/api/actions.yaml @@ -110,19 +110,19 @@ paths: /snapshots: $ref: 'definitions/snapshots.yaml#/resources/snapshots' # ingest operations - /ingest/playlists: + /ingest/{studioId}/playlists: $ref: 'definitions/ingest.yaml#/resources/playlists' - /ingest/playlists/{playlistId}: + /ingest/{studioId}/playlists/{playlistId}: $ref: 'definitions/ingest.yaml#/resources/playlist' - /ingest/playlists/{playlistId}/rundowns: + /ingest/{studioId}/playlists/{playlistId}/rundowns: $ref: 'definitions/ingest.yaml#/resources/rundowns' - /ingest/playlists/{playlistId}/rundowns/{rundownId}: + /ingest/{studioId}/playlists/{playlistId}/rundowns/{rundownId}: $ref: 'definitions/ingest.yaml#/resources/rundown' - /ingest/playlists/{playlistId}/rundowns/{rundownId}/segments: + /ingest/{studioId}/playlists/{playlistId}/rundowns/{rundownId}/segments: $ref: 'definitions/ingest.yaml#/resources/segments' - /ingest/playlists/{playlistId}/rundowns/{rundownId}/segments/{segmentId}: + /ingest/{studioId}/playlists/{playlistId}/rundowns/{rundownId}/segments/{segmentId}: $ref: 'definitions/ingest.yaml#/resources/segment' - /ingest/playlists/{playlistId}/rundowns/{rundownId}/segments/{segmentId}/parts: + /ingest/{studioId}/playlists/{playlistId}/rundowns/{rundownId}/segments/{segmentId}/parts: $ref: 'definitions/ingest.yaml#/resources/parts' - /ingest/playlists/{playlistId}/rundowns/{rundownId}/segments/{segmentId}/parts/{partId}: + /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 index 71d1f8085f..cb02bce285 100644 --- a/packages/openapi/api/definitions/ingest.yaml +++ b/packages/openapi/api/definitions/ingest.yaml @@ -15,17 +15,7 @@ resources: schema: type: array items: - $ref: '#/components/schemas/playlist' - example: - - externalId: 'playlist1' - rundowns: - - externalId: 'playlist1Rundown1' - - externalId: 'playlist1Rundown2' - - externalId: 'playlist2' - rundowns: - - externalId: 'playlist2Rundown1' - - externalId: 'playlist2Rundown2' - - externalId: 'playlist2Rundown3' + $ref: '#/components/schemas/playlistResponse' delete: operationId: deletePlaylists tags: @@ -53,7 +43,7 @@ resources: content: application/json: schema: - $ref: '#/components/schemas/playlist' + $ref: '#/components/schemas/playlistResponse' 404: description: Invalid playlistId $ref: '#/components/responses/playlistNotFound' @@ -95,7 +85,7 @@ resources: schema: type: array items: - $ref: '#/components/schemas/rundown' + $ref: '#/components/schemas/rundownResponse' 404: $ref: '#/components/responses/idNotFound' # oneOf: @@ -123,6 +113,8 @@ resources: responses: 202: description: Request has been accepted. + 400: + description: Bad request. put: operationId: putRundowns summary: Updates the Rundowns in a Playlist. Any existing Rundowns in the Playlist that are not included in this list will be deleted (including their Segments and Parts). Rundowns will be placed in the Playlist in the order specified by their individual ranks. If the creation/deletion/updating of any Rundown fails all changes will be discarded. @@ -147,6 +139,8 @@ resources: responses: 202: description: Request has been accepted. + 400: + description: Bad request. 404: $ref: '#/components/responses/idNotFound' # oneOf: @@ -178,6 +172,8 @@ resources: required: - status additionalProperties: false + 404: + $ref: '#/components/responses/idNotFound' rundown: get: operationId: getRundown @@ -203,8 +199,7 @@ resources: content: application/json: schema: - $ref: '#/components/schemas/rundown' - additionalProperties: false + $ref: '#/components/schemas/rundownResponse' 404: $ref: '#/components/responses/idNotFound' # oneOf: @@ -238,6 +233,8 @@ resources: responses: 202: description: Request has been accepted. + 400: + description: Bad request. 404: $ref: '#/components/responses/idNotFound' # oneOf: @@ -296,9 +293,7 @@ resources: schema: type: array items: - $ref: '#/components/schemas/segment' - example: - - externalId: segment1 + $ref: '#/components/schemas/segmentResponse' 404: $ref: '#/components/responses/idNotFound' # oneOf: @@ -332,6 +327,8 @@ resources: responses: 202: description: Request accepted. + 400: + description: Bad request. 404: $ref: '#/components/responses/idNotFound' # oneOf: @@ -367,6 +364,8 @@ resources: responses: 202: description: Request accepted. + 400: + description: Bad request. 404: $ref: '#/components/responses/idNotFound' # oneOf: @@ -393,6 +392,8 @@ resources: responses: 202: description: Request accepted. + 404: + $ref: '#/components/responses/idNotFound' segment: get: operationId: getSegment @@ -424,7 +425,7 @@ resources: content: application/json: schema: - $ref: '#/components/schemas/segment' + $ref: '#/components/schemas/segmentResponse' 404: $ref: '#/components/responses/idNotFound' # oneOf: @@ -465,6 +466,8 @@ resources: responses: 202: description: Request accepted. + 400: + description: Bad request. 404: $ref: '#/components/responses/idNotFound' # oneOf: @@ -535,7 +538,7 @@ resources: schema: type: array items: - $ref: '#/components/schemas/part' + $ref: '#/components/schemas/partResponse' 404: $ref: '#/components/responses/idNotFound' # oneOf: @@ -576,6 +579,8 @@ resources: responses: 202: description: Request accepted. + 400: + description: Bad request. 404: $ref: '#/components/responses/idNotFound' # oneOf: @@ -618,6 +623,8 @@ resources: responses: 202: description: Request accepted. + 400: + description: Bad request. 404: $ref: '#/components/responses/idNotFound' # oneOf: @@ -651,6 +658,8 @@ resources: responses: 202: description: Request for deleting accepted. + 404: + $ref: '#/components/responses/idNotFound' part: get: operationId: getPart @@ -688,7 +697,7 @@ resources: content: application/json: schema: - $ref: '#/components/schemas/part' + $ref: '#/components/schemas/partResponse' 404: $ref: '#/components/responses/idNotFound' # oneOf: @@ -734,6 +743,8 @@ resources: responses: 202: description: Request has been accepted. + 400: + description: Bad request. 404: $ref: '#/components/responses/idNotFound' # oneOf: @@ -881,6 +892,8 @@ components: - notFound - message additionalProperties: false + badRequest: + description: Bad request. schemas: ingestPlaylistItem: type: object @@ -946,6 +959,31 @@ components: 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: @@ -983,6 +1021,32 @@ components: - resyncUrl - payload 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 + required: + - id + - externalId + - studioId + - playlistId + - name segment: type: object properties: @@ -1020,6 +1084,31 @@ components: - rank - payload 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 + required: + - id + - externalId + - rundownId + - name + - rank + additionalProperties: false part: type: object properties: @@ -1077,6 +1166,41 @@ components: - 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 + title: + type: string + example: Part 1 + expectedDuration: + type: number + example: 10000 + autoNext: + type: boolean + example: true + rank: + type: number + example: 0 + required: + - id + - externalId + - rundownId + - segmentId + - title + - rank + additionalProperties: false piece: type: object properties: From 92a9fdb5f0fb6b23acdbe3698c00de47d4c7dfca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Fri, 27 Sep 2024 14:08:32 +0200 Subject: [PATCH 22/50] chore: fixing failing tests by doing a mock-mock (mock^2) --- meteor/__mocks__/_setupMocks.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/meteor/__mocks__/_setupMocks.ts b/meteor/__mocks__/_setupMocks.ts index 8cf580f95d..77ca346ddb 100644 --- a/meteor/__mocks__/_setupMocks.ts +++ b/meteor/__mocks__/_setupMocks.ts @@ -10,6 +10,7 @@ jest.mock('nanoid', (...args) => require('./random').setup(args), { virtual: tru // Add references to all "meteor" mocks below, so that jest resolves the imports properly. +jest.mock('meteor/fetch', () => null, { virtual: true }) jest.mock('meteor/meteor', (...args) => require('./meteor').setup(args), { virtual: true }) jest.mock('meteor/random', (...args) => require('./random').setup(args), { virtual: true }) jest.mock('meteor/check', (...args) => require('./check').setup(args), { virtual: true }) From 02cc75efa6a9b0e7911183c164c5d341597f0ae3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Mon, 30 Sep 2024 19:19:09 +0200 Subject: [PATCH 23/50] fix: ingest api tests and promises --- .../ingest/httpIngest/httpIngestServices.ts | 201 ++++++++++-------- packages/openapi/api/definitions/ingest.yaml | 4 + packages/openapi/src/__tests__/ingest.spec.ts | 82 ++++--- 3 files changed, 173 insertions(+), 114 deletions(-) diff --git a/meteor/server/api/ingest/httpIngest/httpIngestServices.ts b/meteor/server/api/ingest/httpIngest/httpIngestServices.ts index ce3213fd45..bf32e8f2e8 100644 --- a/meteor/server/api/ingest/httpIngest/httpIngestServices.ts +++ b/meteor/server/api/ingest/httpIngest/httpIngestServices.ts @@ -161,11 +161,13 @@ export async function deletePlaylists(studioId: string): Promise { const rundowns = await Rundowns.findFetchAsync({}) const studio = await findStudio(studioId) - for (const rundown of rundowns) { - await runIngestOperation(studio._id, IngestJobs.RemoveRundown, { - rundownExternalId: rundown.externalId, - }) - } + await Promise.all( + rundowns.map(async (rundown) => + runIngestOperation(studio._id, IngestJobs.RemoveRundown, { + rundownExternalId: rundown.externalId, + }) + ) + ) } export async function deletePlaylist(studioId: string, playlistId: string): Promise { @@ -176,11 +178,13 @@ export async function deletePlaylist(studioId: string, playlistId: string): Prom $or: [{ playlistId: protectString(playlistId) }, { playlistExternalId: playlistId }], }) - for (const rundown of rundowns) { - await runIngestOperation(studio._id, IngestJobs.RemoveRundown, { - rundownExternalId: rundown.externalId, - }) - } + await Promise.all( + rundowns.map(async (rundown) => + runIngestOperation(studio._id, IngestJobs.RemoveRundown, { + rundownExternalId: rundown.externalId, + }) + ) + ) } // Rundowns @@ -280,25 +284,27 @@ export async function putRundowns( const studio = await findStudio(studioId) const playlist = await findPlaylist(studio._id, playlistId) - for (const ingestRundown of ingestRundowns) { - const rundownExternalId = ingestRundown.externalId - const existingRundown = await findRundown(studio._id, playlist._id, rundownExternalId) - if (!existingRundown) { - continue - } - - checkRundownSource(existingRundown) - - await runIngestOperation(studio._id, IngestJobs.UpdateRundown, { - rundownExternalId: ingestRundown.externalId, - ingestRundown: ingestRundown, - isCreateAction: true, - rundownSource: { - type: 'httpIngest', - resyncUrl: ingestRundown.resyncUrl, - }, + await Promise.all( + ingestRundowns.map(async (ingestRundown) => { + const rundownExternalId = ingestRundown.externalId + const existingRundown = await findRundown(studio._id, playlist._id, rundownExternalId) + if (!existingRundown) { + return + } + + checkRundownSource(existingRundown) + + return runIngestOperation(studio._id, IngestJobs.UpdateRundown, { + rundownExternalId: ingestRundown.externalId, + ingestRundown: ingestRundown, + isCreateAction: true, + rundownSource: { + type: 'httpIngest', + resyncUrl: ingestRundown.resyncUrl, + }, + }) }) - } + ) } // Delete rundown @@ -319,12 +325,14 @@ export async function deleteRundowns(studioId: string, playlistId: string): Prom const playlist = await findPlaylist(studio._id, playlistId) const rundowns = await findRundowns(studio._id, playlist._id) - for (const rundown of rundowns) { - checkRundownSource(rundown) - await runIngestOperation(studio._id, IngestJobs.RemoveRundown, { - rundownExternalId: rundown.externalId, + await Promise.all( + rundowns.map(async (rundown) => { + checkRundownSource(rundown) + return runIngestOperation(studio._id, IngestJobs.RemoveRundown, { + rundownExternalId: rundown.externalId, + }) }) - } + ) } // Segments @@ -401,13 +409,16 @@ export async function putSegment( } const parts = await findParts(segment._id) - for (const part of parts) { - await runIngestOperation(studio._id, IngestJobs.RemovePart, { - partExternalId: part.externalId, - rundownExternalId: rundown.externalId, - segmentExternalId: segment.externalId, - }) - } + + 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, @@ -427,30 +438,40 @@ export async function putSegments( const rundown = await findRundown(studio._id, playlist._id, rundownId) checkRundownSource(rundown) - const segments = await findSegments(rundown._id) - for (const segment of segments) { - const parts = await findParts(segment._id) - for (const part of parts) { - await runIngestOperation(studio._id, IngestJobs.RemovePart, { - partExternalId: part.externalId, - rundownExternalId: rundown.externalId, - segmentExternalId: segment.externalId, - }) - } - } + await Promise.all( + ingestSegments.map(async (ingestSegment) => { + const segment = await findSegment(rundown._id, ingestSegment.externalId) + if (!segment) { + return + } + + const parts = await findParts(segment._id) + return Promise.all( + parts.map(async (part) => + runIngestOperation(studio._id, IngestJobs.RemovePart, { + partExternalId: part.externalId, + rundownExternalId: rundown.externalId, + segmentExternalId: segment.externalId, + }) + ) + ) + }) + ) - for (const ingestSegment of ingestSegments) { - const existingSegment = await softFindSegment(rundown._id, ingestSegment.externalId) - if (!existingSegment) { - continue - } + await Promise.all( + ingestSegments.map(async (ingestSegment) => { + const existingSegment = await softFindSegment(rundown._id, ingestSegment.externalId) + if (!existingSegment) { + return null + } - await runIngestOperation(studio._id, IngestJobs.UpdateSegment, { - rundownExternalId: rundown.externalId, - isCreateAction: true, - ingestSegment, + return runIngestOperation(studio._id, IngestJobs.UpdateSegment, { + rundownExternalId: rundown.externalId, + isCreateAction: true, + ingestSegment, + }) }) - } + ) } export async function deleteSegment( @@ -465,6 +486,7 @@ export async function deleteSegment( checkRundownSource(rundown) const segment = await findSegment(rundown._id, segmentId) + // This also removes linked Parts await runIngestOperation(studio._id, IngestJobs.RemoveSegment, { segmentExternalId: segment.externalId, rundownExternalId: rundown.externalId, @@ -478,12 +500,15 @@ export async function deleteSegments(studioId: string, playlistId: string, rundo const segments = await findSegments(rundown._id) - for (const segment of segments) { - await runIngestOperation(studio._id, IngestJobs.RemoveSegment, { - rundownExternalId: rundown.externalId, - segmentExternalId: segment.externalId, - }) - } + await Promise.all( + segments.map(async (segment) => + // This also removes linked Parts + runIngestOperation(studio._id, IngestJobs.RemoveSegment, { + rundownExternalId: rundown.externalId, + segmentExternalId: segment.externalId, + }) + ) + ) } // Parts @@ -592,19 +617,21 @@ export async function putParts( checkRundownSource(rundown) const segment = await findSegment(rundown._id, segmentId) - for (const ingestPart of ingestParts) { - const existingPart = await findPart(segment._id, ingestPart.externalId) - if (!existingPart) { - continue - } - - await runIngestOperation(studio._id, IngestJobs.UpdatePart, { - segmentExternalId: segment.externalId, - rundownExternalId: rundown.externalId, - isCreateAction: true, - ingestPart, + await Promise.all( + ingestParts.map(async (ingestPart) => { + const existingPart = await findPart(segment._id, ingestPart.externalId) + if (!existingPart) { + return + } + + return runIngestOperation(studio._id, IngestJobs.UpdatePart, { + segmentExternalId: segment.externalId, + rundownExternalId: rundown.externalId, + isCreateAction: true, + ingestPart, + }) }) - } + ) } export async function deletePart( @@ -643,11 +670,13 @@ export async function deleteParts( const parts = await findParts(segment._id) - for (const part of parts) { - await runIngestOperation(studio._id, IngestJobs.RemovePart, { - rundownExternalId: rundown.externalId, - segmentExternalId: segment.externalId, - partExternalId: part.externalId, - }) - } + await Promise.all( + parts.map(async (part) => + runIngestOperation(studio._id, IngestJobs.RemovePart, { + rundownExternalId: rundown.externalId, + segmentExternalId: segment.externalId, + partExternalId: part.externalId, + }) + ) + ) } diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml index cb02bce285..a9a714296b 100644 --- a/packages/openapi/api/definitions/ingest.yaml +++ b/packages/openapi/api/definitions/ingest.yaml @@ -1008,8 +1008,10 @@ components: properties: expectedStart: type: number + example: 1705924800000 expectedEnd: type: number + example: 1705927500000 required: - expectedStart - expectedEnd @@ -1041,6 +1043,8 @@ components: name: type: string example: Rundown 1 + type: + type: string required: - id - externalId diff --git a/packages/openapi/src/__tests__/ingest.spec.ts b/packages/openapi/src/__tests__/ingest.spec.ts index 6b570bffe9..6a6c548854 100644 --- a/packages/openapi/src/__tests__/ingest.spec.ts +++ b/packages/openapi/src/__tests__/ingest.spec.ts @@ -20,23 +20,36 @@ describe('Ingest API', () => { */ const playlistIds: string[] = [] test('Can request all playlists', async () => { - const ingestPlaylists = await ingestApi.getPlaylists() + const playlists = await ingestApi.getPlaylists() - expect(ingestPlaylists.length).toBeGreaterThanOrEqual(1) - ingestPlaylists.forEach((playlist) => { + 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 ingestPlaylist = await ingestApi.getPlaylist({ + const playlist = await ingestApi.getPlaylist({ playlistId: playlistIds[0], }) - expect(ingestPlaylist).toHaveProperty('name') - expect(typeof ingestPlaylist.name).toBe('string') + 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 () => { @@ -64,15 +77,19 @@ describe('Ingest API', () => { rundowns.forEach((rundown) => { expect(typeof rundown).toBe('object') - expect(rundown).toHaveProperty('name') + expect(rundown).toHaveProperty('id') expect(rundown).toHaveProperty('externalId') - expect(rundown).toHaveProperty('payload') - expect(rundown).toHaveProperty('resyncUrl') + expect(rundown).toHaveProperty('name') + expect(rundown).toHaveProperty('studioId') + expect(rundown).toHaveProperty('playlistId') + expect(rundown).toHaveProperty('playlistExternalId') expect(rundown).toHaveProperty('type') - expect(typeof rundown.name).toBe('string') + expect(typeof rundown.id).toBe('string') expect(typeof rundown.externalId).toBe('string') - expect(typeof rundown.payload).toBe('object') - expect(typeof rundown.resyncUrl).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') rundownIds.push(rundown.externalId) }) @@ -84,16 +101,20 @@ describe('Ingest API', () => { rundownId: rundownIds[0], }) - expect(rundown).toHaveProperty('name') + expect(typeof rundown).toBe('object') + expect(rundown).toHaveProperty('id') expect(rundown).toHaveProperty('externalId') - expect(rundown).toHaveProperty('payload') - expect(rundown).toHaveProperty('resyncUrl') + expect(rundown).toHaveProperty('name') + expect(rundown).toHaveProperty('studioId') + expect(rundown).toHaveProperty('playlistId') + expect(rundown).toHaveProperty('playlistExternalId') expect(rundown).toHaveProperty('type') - expect(typeof rundown.name).toBe('string') + expect(typeof rundown.id).toBe('string') expect(typeof rundown.externalId).toBe('string') - expect(typeof rundown.payload).toBe('object') - expect(typeof rundown.resyncUrl).toBe('string') - expect(typeof rundown.type).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') }) const rundown = { @@ -186,10 +207,6 @@ describe('Ingest API', () => { name: 'Segment 1', rank: 0, payload: { - externalId: 'segment1', - name: 'Segment 1', - rank: 1, - rundownId: 'rundown1', tags: [], _float: true, }, @@ -271,9 +288,21 @@ describe('Ingest API', () => { partId: partIds[0], }) - expect(part).toHaveProperty('name') + expect(part).toHaveProperty('id') + expect(part).toHaveProperty('externalId') + expect(part).toHaveProperty('rundownId') + expect(part).toHaveProperty('segmentId') + expect(part).toHaveProperty('title') + expect(part).toHaveProperty('expectedDuration') + expect(part).toHaveProperty('autoNext') expect(part).toHaveProperty('rank') - expect(typeof part.name).toBe('string') + 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.title).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)) }) @@ -288,7 +317,6 @@ describe('Ingest API', () => { name: 'Part 1', rank: 0, payload: { - segmentId: 'segment1', type: 'CAMERA', _float: true, autoNext: true, @@ -323,7 +351,6 @@ describe('Ingest API', () => { name: 'Part 1', rank: 0, payload: { - segmentId: 'segment1', type: 'CAMERA', _float: true, autoNext: true, @@ -361,7 +388,6 @@ describe('Ingest API', () => { name: 'Part 1', rank: 0, payload: { - segmentId: 'segment1', type: 'CAMERA', _float: true, autoNext: true, From 04900d7b36a0a2697ffbab27d74d3578de847269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Wed, 2 Oct 2024 16:31:00 +0200 Subject: [PATCH 24/50] wip: ingest api descriptions --- packages/openapi/api/definitions/ingest.yaml | 221 +++++++++--------- packages/openapi/src/__tests__/ingest.spec.ts | 19 +- 2 files changed, 112 insertions(+), 128 deletions(-) diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml index a9a714296b..b6ec1805b5 100644 --- a/packages/openapi/api/definitions/ingest.yaml +++ b/packages/openapi/api/definitions/ingest.yaml @@ -20,20 +20,20 @@ resources: operationId: deletePlaylists tags: - ingest - summary: Delete multiple playlists. Resources under the Playlist (e.g. Rundowns) will also be removed. + 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 a specific Playlist. + summary: Gets the specified Playlist. tags: - ingest parameters: - name: playlistId in: path - description: Requested Playlist. + description: Internal or external ID of the Playlist to return. required: true schema: type: string @@ -55,7 +55,7 @@ resources: parameters: - name: playlistId in: path - description: Playlist to delete. + description: Internal or external ID of the Playlist to delete. required: true schema: type: string @@ -67,13 +67,13 @@ resources: rundowns: get: operationId: getRundowns - summary: Gets all Rundowns belonging to a Playlist. + summary: Gets all Rundowns belonging to a specified Playlist. tags: - ingest parameters: - name: playlistId in: path - description: Playlist to get all Rundowns for. + description: Internal or external ID of the Playlist the Rundowns belong to. required: true schema: type: string @@ -93,13 +93,13 @@ resources: # - $ref: '#/components/responses/rundownNotFound' post: operationId: postRundown - summary: Creates the Rundowns in a Playlist. + summary: Creates a Rundown in a specified Playlist. tags: - ingest parameters: - name: playlistId in: path - description: Playlist to create Rundowns for. + description: Internal or external ID of the Playlist the new Rundown belongs to. required: true schema: type: string @@ -117,13 +117,13 @@ resources: description: Bad request. put: operationId: putRundowns - summary: Updates the Rundowns in a Playlist. Any existing Rundowns in the Playlist that are not included in this list will be deleted (including their Segments and Parts). Rundowns will be placed in the Playlist in the order specified by their individual ranks. If the creation/deletion/updating of any Rundown fails all changes will be discarded. + summary: Updates Rundowns belonging to a specified Playlist. tags: - ingest parameters: - name: playlistId in: path - description: Playlist to create/update all Rundowns for. + description: Internal or external ID of the Playlist the Rundowns to update belong to. required: true schema: type: string @@ -149,47 +149,35 @@ resources: operationId: deleteRundowns tags: - ingest - summary: Delete multiple rundowns. Resources under the Rundown (e.g. Segments) will also be removed. + summary: Deletes all Rundowns belonging to specified Playlist. Resources under the Rundowns (e.g. Segments) will also be removed. parameters: - name: playlistId in: path - description: Playlist the ingest Part belongs to. + description: Internal or external ID of the Playlist the Rundowns to delete belong to. required: true schema: type: string responses: - 200: - description: Rundown removed. - content: - application/json: - schema: - type: object - properties: - status: - type: number - const: 200 - example: 200 - required: - - status - additionalProperties: false + 202: + description: Request accepted. 404: $ref: '#/components/responses/idNotFound' rundown: get: operationId: getRundown - summary: Gets ingest data for a specific Rundown. + summary: Gets the specified Rundown. tags: - ingest parameters: - name: playlistId in: path - description: Playlist the Rundown belongs to. + description: Internal or external ID of the Playlist the Rundown belongs to. required: true schema: type: string - name: rundownId in: path - description: Rundown to return. + description: Internal or external ID of the Rundown to return. required: true schema: type: string @@ -207,19 +195,19 @@ resources: # - $ref: '#/components/responses/rundownNotFound' put: operationId: putRundown - summary: Updates an existing Rundown. + summary: Updates an existing specified Rundown. tags: - ingest parameters: - name: playlistId in: path - description: Playlist to ingest Rundown into. + description: Internal or external ID of the Playlist the Rundown to update belongs to. required: true schema: type: string - name: rundownId in: path - description: Rundown to create/update. + description: Internal or external ID of the Rundown to update. required: true schema: type: string @@ -242,19 +230,19 @@ resources: # - $ref: '#/components/responses/rundownNotFound' delete: operationId: deleteRundown - summary: Deletes a specified ingest Rundown. Resources under the Rundown (e.g. Segments) will also be removed. + summary: Deletes a specified Rundown. Resources under the Rundown (e.g. Segments) will also be removed. tags: - ingest parameters: - name: playlistId in: path - description: Playlist the ingest Rundown belongs to. + description: Internal or external ID of the Playlist the Rundown belongs to. required: true schema: type: string - name: rundownId in: path - description: Rundown to delete. + description: Internal or external ID of the Rundown to delete. required: true schema: type: string @@ -271,17 +259,17 @@ resources: operationId: getSegments tags: - ingest - summary: Gets all Segments belonging to a Rundown. + summary: Gets all Segments belonging to a specified Rundown. parameters: - name: playlistId in: path - description: Playlist the Rundown belongs to. + description: Internal or external ID of the Playlist the Segments belong to. required: true schema: type: string - name: rundownId in: path - description: Rundown to get Segments for. + description: Internal or external ID of the Rundown the Segments belong to. required: true schema: type: string @@ -303,17 +291,17 @@ resources: operationId: postSegment tags: - ingest - summary: Creates a Segment in a Rundown. + summary: Creates a Segment in a specified Rundown. parameters: - name: playlistId in: path - description: Playlist the Segment belongs to. + description: Internal or external ID of the Playlist the new Segment belongs to. required: true schema: type: string - name: rundownId in: path - description: Rundown the Segment belongs to. + description: Internal or external ID of the Rundown the new Segment belongs to. required: true schema: type: string @@ -338,17 +326,17 @@ resources: operationId: putSegments tags: - ingest - summary: Updates the Segments in a Rundown. Any existing Segments in the Rundown that are not included in this list will be deleted (including their Parts). Segments will be placed in the Rundown in the order specified by their individual ranks. If the creation/deletion/updating of any Segment fails all changes will be discarded. + summary: Updates Segments belonging to a specified Rundown. parameters: - name: playlistId in: path - description: Playlist the Rundown belongs to. + description: Internal or external ID of the Playlist the Segments to update belongs to. required: true schema: type: string - name: rundownId in: path - description: Rundown to create/update all Segments for. + description: Internal or external ID of the Rundown the Segments to update belong to. required: true schema: type: string @@ -375,17 +363,17 @@ resources: operationId: deleteSegments tags: - ingest - summary: Delete segment. + summary: Deletes all Segments belonging to specified Rundown. Resources under the Segments (e.g. Parts) will also be removed. parameters: - name: playlistId in: path - description: Playlist the ingest Part belongs to. + description: Internal or external ID of the Playlist the Segments belong to. required: true schema: type: string - name: rundownId in: path - description: Rundown the ingest Part belongs to. + description: Internal or external ID of the Rundown the Segments to delete belong to. required: true schema: type: string @@ -399,23 +387,23 @@ resources: operationId: getSegment tags: - ingest - summary: Gets specific Segment. + summary: Gets the specified Segment. parameters: - name: playlistId in: path - description: Playlist the Segment belongs to. + description: Internal or external ID of the Playlist the Segment belongs to. required: true schema: type: string - name: rundownId in: path - description: Rundown the Segment belongs to. + description: Internal or external ID of the Rundown the Segment belongs to. required: true schema: type: string - name: segmentId in: path - description: Segment to create/update. + description: Internal or external ID of the Segment to return. required: true schema: type: string @@ -436,23 +424,23 @@ resources: operationId: putSegment tags: - ingest - summary: Updates an existing Segment. + summary: Updates an existing specified Segment. parameters: - name: playlistId in: path - description: Playlist to ingest Segment into. + description: Internal or external ID of the Playlist the Segment to update belongs to. required: true schema: type: string - name: rundownId in: path - description: Rundown to ingest Segment into. + description: Internal or external ID of the Rundown the Segment to update belongs to. required: true schema: type: string - name: segmentId in: path - description: Segment to create/update. + description: Internal or external ID of the Segment to update. required: true schema: type: string @@ -477,23 +465,23 @@ resources: operationId: deleteSegment tags: - ingest - summary: Deletes a specified ingest Segment. Resources under the Segment (e.g. Parts) will also be removed. + summary: Deletes a specified Segment. Resources under the Segment (e.g. Parts) will also be removed. parameters: - name: playlistId in: path - description: Playlist the ingest Segment belongs to. + description: Internal or external ID of the Playlist the Segment belongs to. required: true schema: type: string - name: rundownId in: path - description: Rundown the ingest Segment belongs to. + description: Internal or external ID of the Rundown the Segment belongs to. required: true schema: type: string - name: segmentId in: path - description: Segment to delete. + description: Internal or external ID of the Segment to delete. required: true schema: type: string @@ -510,23 +498,23 @@ resources: operationId: getParts tags: - ingest - summary: Gets the data for all Parts belonging to a Segment. + summary: Gets all Parts belonging to a specified Segment. parameters: - name: playlistId in: path - description: Playlist the Segment belongs to. + description: Internal or external ID of the Playlist the Parts belong to. required: true schema: type: string - name: rundownId in: path - description: Rundown the Segment belongs to. + description: Internal or external ID of the Rundown the Parts belong to. required: true schema: type: string - name: segmentId in: path - description: Segment to get Parts for. + description: Internal or external ID of the Segment the Parts belong to. required: true schema: type: string @@ -549,23 +537,23 @@ resources: operationId: postPart tags: - ingest - summary: Creates Part in a Segment. + summary: Creates a Part in a specified Segment. parameters: - name: playlistId in: path - description: Playlist the Segment belongs to. + description: Internal or external ID of the Playlist the new Part belongs to. required: true schema: type: string - name: rundownId in: path - description: Rundown the Segment belongs to. + description: Internal or external ID of the Rundown the new Part belongs to. required: true schema: type: string - name: segmentId in: path - description: Segment the Part belongs to. + description: Internal or external ID of the Segment the new Part belongs to. required: true schema: type: string @@ -591,23 +579,23 @@ resources: operationId: putParts tags: - ingest - summary: Updates the Parts in a Segment. Any existing Parts in the Segment that are not included in this list will be deleted. Parts will be placed in the Segment in the order specified by their individual ranks. If the creation/deletion/updating of any Parts fails all changes will be discarded. + summary: Updates Parts belonging to a specified Segment. parameters: - name: playlistId in: path - description: Playlist the Segment belongs to. + description: Internal or external ID of the Playlist the Parts to update belong to. required: true schema: type: string - name: rundownId in: path - description: Rundown the Segment belongs to. + description: Internal or external ID of the Rundown the Parts to update belong to. required: true schema: type: string - name: segmentId in: path - description: Segment to create/update all Parts for. + description: Internal or external ID of the Segment the Parts to update belong to. required: true schema: type: string @@ -635,23 +623,23 @@ resources: operationId: deleteParts tags: - ingest - summary: Delete multiple Parts. + summary: Deletes all Parts belonging to specified Segment. parameters: - name: playlistId in: path - description: Playlist the Part belongs to. + description: Internal or external ID of the Playlist the Parts belong to. required: true schema: type: string - name: rundownId in: path - description: Rundown the Part belongs to. + description: Internal or external ID of the Rundown the Parts belong to. required: true schema: type: string - name: segmentId in: path - description: Segment the Part belongs to. + description: Internal or external ID of the Segment the Parts to delete belong to. required: true schema: type: string @@ -665,29 +653,29 @@ resources: operationId: getPart tags: - ingest - summary: Gets data for a specific Part. + summary: Gets the specified Part. parameters: - name: playlistId in: path - description: Playlist the Part belongs to. + description: Internal or external ID of the Playlist the Part belongs to. required: true schema: type: string - name: rundownId in: path - description: Rundown the Part belongs to. + description: Internal or external ID of the Rundown the Part belongs to. required: true schema: type: string - name: segmentId in: path - description: Segment the Part belongs to. + description: Internal or external ID of the Segment the Part belongs to. required: true schema: type: string - name: partId in: path - description: Part to create/update. + description: Internal or external ID of the Part to return. required: true schema: type: string @@ -709,28 +697,28 @@ resources: operationId: putPart tags: - ingest - summary: Updates an existing Part. + summary: Updates an existing specified Part. parameters: - name: playlistId in: path - description: Playlist to ingest Part into. + description: Internal or external ID of the Playlist the Part to update belongs to. required: true schema: type: string - name: rundownId in: path - description: Rundown to ingest Part into. + description: Internal or external ID of the Rundown the Part to update belongs to. required: true schema: type: string - name: segmentId in: path - description: Segment to ingest Part into. + description: Internal or external ID of the Segment the Part to update belongs to. schema: type: string - name: partId in: path - description: Part to update/create. + description: Internal or external ID of the Part to update. schema: type: string requestBody: @@ -759,25 +747,25 @@ resources: parameters: - name: playlistId in: path - description: Playlist the Part belongs to. + description: Internal or external ID of the Playlist the Part belongs to. required: true schema: type: string - name: rundownId in: path - description: Rundown the Part belongs to. + description: Internal or external ID of the Rundown the Part belongs to. required: true schema: type: string - name: segmentId in: path - description: Segment the Part belongs to. + description: Internal or external ID of the Segment the Part belongs to. required: true schema: type: string - name: partId in: path - description: Part to delete. + description: Internal or external ID of the Part to delete. required: true schema: type: string @@ -996,32 +984,46 @@ components: 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 - segments: - type: array - items: - $ref: '#/components/schemas/segment' - payload: + 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 time in milliseconds. example: 1705924800000 expectedEnd: type: number + description: Epoch time in milliseconds. example: 1705927500000 + expectedDuration: + type: number + description: Interval in milliseconds. + example: 3600000 required: - - expectedStart - - expectedEnd - additionalProperties: true + - timingType + additionalProperties: false + segments: + type: array + items: + $ref: '#/components/schemas/segment' required: - externalId - name - type - resyncUrl - - payload + - timing additionalProperties: false rundownResponse: type: object @@ -1065,19 +1067,10 @@ components: description: The position of the Segment in the parent Rundown. inclusiveMinimum: 0.0 example: 1 - payload: - type: object - properties: - float: - type: boolean - tags: - type: array - items: - type: string - required: - - float - - tags - additionalProperties: false + float: + type: boolean + example: false + description: If the Segment is visible or not. parts: type: array items: @@ -1086,7 +1079,7 @@ components: - externalId - name - rank - - payload + - float additionalProperties: false segmentResponse: type: object @@ -1149,10 +1142,6 @@ components: $ref: '#/components/schemas/piece' autoNext: type: boolean - tags: - type: array - items: - type: string guest: type: boolean additionalProperties: false diff --git a/packages/openapi/src/__tests__/ingest.spec.ts b/packages/openapi/src/__tests__/ingest.spec.ts index 6a6c548854..c4915c6586 100644 --- a/packages/openapi/src/__tests__/ingest.spec.ts +++ b/packages/openapi/src/__tests__/ingest.spec.ts @@ -1,5 +1,5 @@ // eslint-disable-next-line node/no-missing-import -import { Configuration, IngestApi, Part } from '../../client/ts' +import { Configuration, IngestApi, Part, RundownTimingTypeEnum } from '../../client/ts' import { checkServer } from '../checkServer' import Logging from '../httpLogging' @@ -122,7 +122,8 @@ describe('Ingest API', () => { name: 'New rundown', type: 'external', resyncUrl: 'resyncUrl', - payload: { + timing: { + type: RundownTimingTypeEnum.None, expectedStart: 0, expectedEnd: 0, }, @@ -156,10 +157,10 @@ describe('Ingest API', () => { }) test('Can delete multiple rundowns', async () => { - const ingestRundown = await ingestApi.deleteRundowns({ + const result = await ingestApi.deleteRundowns({ playlistId: playlistIds[0], }) - expect(ingestRundown.status).toBe(200) + expect(result).toBe(undefined) }) test('Can delete rundown by id', async () => { @@ -206,10 +207,7 @@ describe('Ingest API', () => { externalId: 'segment1', name: 'Segment 1', rank: 0, - payload: { - tags: [], - _float: true, - }, + _float: true, } test('Can create segment', async () => { @@ -333,7 +331,6 @@ describe('Ingest API', () => { }, ], script: '', - tags: [], }, }, }) @@ -367,7 +364,6 @@ describe('Ingest API', () => { }, ], script: '', - tags: [], }, }, ], @@ -377,7 +373,7 @@ describe('Ingest API', () => { const updatedPartId = 'part2' test('Can update an part', async () => { - newIngestPart.name = newIngestPart.name + ' added' + newIngestPart.name = newIngestPart.name + ' added' const result = await ingestApi.putPart({ playlistId: playlistIds[0], rundownId: rundownIds[0], @@ -404,7 +400,6 @@ describe('Ingest API', () => { }, ], script: '', - tags: [], }, }, }) From 0ce0f24e8d3adf9fd4f3ffce53f4355a38a501bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Thu, 3 Oct 2024 11:43:24 +0200 Subject: [PATCH 25/50] fix: add studio ID to ingest api definition --- packages/openapi/api/definitions/ingest.yaml | 152 ++++++++++++++++++ packages/openapi/src/__tests__/ingest.spec.ts | 43 +++-- 2 files changed, 178 insertions(+), 17 deletions(-) diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml index b6ec1805b5..5f8f62faf5 100644 --- a/packages/openapi/api/definitions/ingest.yaml +++ b/packages/openapi/api/definitions/ingest.yaml @@ -7,6 +7,13 @@ resources: 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. @@ -20,6 +27,13 @@ resources: 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: @@ -31,6 +45,12 @@ resources: 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. @@ -53,6 +73,12 @@ resources: 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. @@ -71,6 +97,12 @@ resources: 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. @@ -97,6 +129,12 @@ resources: 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. @@ -121,6 +159,12 @@ resources: 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. @@ -151,6 +195,12 @@ resources: - 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. @@ -169,6 +219,12 @@ resources: 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. @@ -199,6 +255,12 @@ resources: 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. @@ -234,6 +296,12 @@ resources: 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. @@ -261,6 +329,12 @@ resources: - 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. @@ -293,6 +367,12 @@ resources: - 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. @@ -328,6 +408,12 @@ resources: - 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. @@ -365,6 +451,12 @@ resources: - 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. @@ -389,6 +481,12 @@ resources: - 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. @@ -426,6 +524,12 @@ resources: - 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. @@ -467,6 +571,12 @@ resources: - 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. @@ -500,6 +610,12 @@ resources: - 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. @@ -539,6 +655,12 @@ resources: - 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. @@ -581,6 +703,12 @@ resources: - 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. @@ -625,6 +753,12 @@ resources: - 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. @@ -655,6 +789,12 @@ resources: - 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. @@ -699,6 +839,12 @@ resources: - 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. @@ -745,6 +891,12 @@ resources: - 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. diff --git a/packages/openapi/src/__tests__/ingest.spec.ts b/packages/openapi/src/__tests__/ingest.spec.ts index c4915c6586..644579572e 100644 --- a/packages/openapi/src/__tests__/ingest.spec.ts +++ b/packages/openapi/src/__tests__/ingest.spec.ts @@ -4,6 +4,7 @@ import { checkServer } from '../checkServer' import Logging from '../httpLogging' const httpLogging = false +const studioId = 'studio0' describe('Ingest API', () => { const config = new Configuration({ @@ -20,7 +21,7 @@ describe('Ingest API', () => { */ const playlistIds: string[] = [] test('Can request all playlists', async () => { - const playlists = await ingestApi.getPlaylists() + const playlists = await ingestApi.getPlaylists({ studioId }) expect(playlists.length).toBeGreaterThanOrEqual(1) playlists.forEach((playlist) => { @@ -39,6 +40,7 @@ describe('Ingest API', () => { test('Can request a playlist by id', async () => { const playlist = await ingestApi.getPlaylist({ + studioId, playlistId: playlistIds[0], }) @@ -53,12 +55,13 @@ describe('Ingest API', () => { }) test('Can delete multiple playlists', async () => { - const result = await ingestApi.deletePlaylists() + 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) @@ -70,6 +73,7 @@ describe('Ingest API', () => { const rundownIds: string[] = [] test('Can request all rundowns', async () => { const rundowns = await ingestApi.getRundowns({ + studioId, playlistId: playlistIds[0], }) @@ -97,6 +101,7 @@ describe('Ingest API', () => { test('Can request rundown by id', async () => { const rundown = await ingestApi.getRundown({ + studioId, playlistId: playlistIds[0], rundownId: rundownIds[0], }) @@ -130,25 +135,20 @@ describe('Ingest API', () => { } test('Can create rundown', async () => { - const result = await ingestApi.postRundown({ - playlistId: playlistIds[0], - rundown, - }) + 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({ - playlistId: playlistIds[0], - rundown: [rundown], - }) + 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, @@ -157,14 +157,13 @@ describe('Ingest API', () => { }) test('Can delete multiple rundowns', async () => { - const result = await ingestApi.deleteRundowns({ - playlistId: playlistIds[0], - }) + 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, }) @@ -176,10 +175,7 @@ describe('Ingest API', () => { */ const segmentIds: string[] = [] test('Can request all segments', async () => { - const segments = await ingestApi.getSegments({ - playlistId: playlistIds[0], - rundownId: rundownIds[0], - }) + const segments = await ingestApi.getSegments({ studioId, playlistId: playlistIds[0], rundownId: rundownIds[0] }) expect(segments.length).toBeGreaterThanOrEqual(1) @@ -192,6 +188,7 @@ describe('Ingest API', () => { test('Can request segment by id', async () => { const segment = await ingestApi.getSegment({ + studioId, playlistId: playlistIds[0], rundownId: rundownIds[0], segmentId: segmentIds[0], @@ -212,6 +209,7 @@ describe('Ingest API', () => { test('Can create segment', async () => { const result = await ingestApi.postSegment({ + studioId, playlistId: playlistIds[0], rundownId: rundownIds[0], segment, @@ -222,6 +220,7 @@ describe('Ingest API', () => { test('Can update multiple segments', async () => { const result = await ingestApi.putSegments({ + studioId, playlistId: playlistIds[0], rundownId: rundownIds[0], segment: [segment], @@ -232,6 +231,7 @@ describe('Ingest API', () => { const updatedSegmentId = 'segment2' test('Can update single segment', async () => { const result = await ingestApi.putSegment({ + studioId, playlistId: playlistIds[0], rundownId: rundownIds[0], segmentId: updatedSegmentId, @@ -242,6 +242,7 @@ describe('Ingest API', () => { test('Can delete multiple segments', async () => { const result = await ingestApi.deleteSegments({ + studioId, playlistId: playlistIds[0], rundownId: rundownIds[0], }) @@ -250,6 +251,7 @@ describe('Ingest API', () => { test('Can delete segment by id', async () => { const result = await ingestApi.deleteSegment({ + studioId, playlistId: playlistIds[0], rundownId: rundownIds[0], segmentId: updatedSegmentId, @@ -263,6 +265,7 @@ describe('Ingest API', () => { const partIds: string[] = [] test('Can request all parts', async () => { const parts = await ingestApi.getParts({ + studioId, playlistId: playlistIds[0], rundownId: rundownIds[0], segmentId: segmentIds[0], @@ -280,6 +283,7 @@ describe('Ingest API', () => { 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], @@ -307,6 +311,7 @@ describe('Ingest API', () => { test('Can create part', async () => { const result = await ingestApi.postPart({ + studioId, playlistId: playlistIds[0], rundownId: rundownIds[0], segmentId: segmentIds[0], @@ -339,6 +344,7 @@ describe('Ingest API', () => { test('Can update multiple parts', async () => { const result = await ingestApi.putParts({ + studioId, playlistId: playlistIds[0], rundownId: rundownIds[0], segmentId: segmentIds[0], @@ -375,6 +381,7 @@ describe('Ingest API', () => { test('Can update an part', async () => { newIngestPart.name = newIngestPart.name + ' added' const result = await ingestApi.putPart({ + studioId, playlistId: playlistIds[0], rundownId: rundownIds[0], segmentId: segmentIds[0], @@ -408,6 +415,7 @@ describe('Ingest API', () => { test('Can delete multiple parts', async () => { const result = await ingestApi.deleteParts({ + studioId, playlistId: playlistIds[0], rundownId: rundownIds[0], segmentId: segmentIds[0], @@ -417,6 +425,7 @@ describe('Ingest API', () => { test('Can delete part by id', async () => { const result = await ingestApi.deletePart({ + studioId, playlistId: playlistIds[0], rundownId: rundownIds[0], segmentId: segmentIds[0], From 4480f086690f0aa9d68836b7cf20c90a68155b77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Thu, 3 Oct 2024 15:52:19 +0200 Subject: [PATCH 26/50] wip: make playlist have own ID, add rundown timing to response --- packages/openapi/api/definitions/ingest.yaml | 25 +++++++++++++++++-- packages/openapi/src/__tests__/ingest.spec.ts | 14 +++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml index 5f8f62faf5..ad222fc7f7 100644 --- a/packages/openapi/api/definitions/ingest.yaml +++ b/packages/openapi/api/definitions/ingest.yaml @@ -1153,11 +1153,11 @@ components: - back-time expectedStart: type: number - description: Epoch time in milliseconds. + description: Epoch timestamp in milliseconds. example: 1705924800000 expectedEnd: type: number - description: Epoch time in milliseconds. + description: Epoch timestamp in milliseconds. example: 1705927500000 expectedDuration: type: number @@ -1199,6 +1199,27 @@ components: 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 diff --git a/packages/openapi/src/__tests__/ingest.spec.ts b/packages/openapi/src/__tests__/ingest.spec.ts index 644579572e..3b559a0bd7 100644 --- a/packages/openapi/src/__tests__/ingest.spec.ts +++ b/packages/openapi/src/__tests__/ingest.spec.ts @@ -1,5 +1,5 @@ // eslint-disable-next-line node/no-missing-import -import { Configuration, IngestApi, Part, RundownTimingTypeEnum } from '../../client/ts' +import { Configuration, IngestApi, Part } from '../../client/ts' import { checkServer } from '../checkServer' import Logging from '../httpLogging' @@ -88,6 +88,8 @@ describe('Ingest API', () => { 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') @@ -95,6 +97,8 @@ describe('Ingest API', () => { 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) }) }) @@ -114,12 +118,17 @@ describe('Ingest API', () => { 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 = { @@ -128,9 +137,10 @@ describe('Ingest API', () => { type: 'external', resyncUrl: 'resyncUrl', timing: { - type: RundownTimingTypeEnum.None, + type: 'none', expectedStart: 0, expectedEnd: 0, + expectedDuration: 0, }, } From 5b585d4f7b97faa8f78489c7e005e647573c9e71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Mon, 7 Oct 2024 14:16:25 +0200 Subject: [PATCH 27/50] feat: add playlistExternalId to IngestRundown --- meteor/server/api/ingest/httpIngest/httpIngestServices.ts | 6 +++--- packages/shared-lib/src/peripheralDevice/ingest.ts | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/meteor/server/api/ingest/httpIngest/httpIngestServices.ts b/meteor/server/api/ingest/httpIngest/httpIngestServices.ts index bf32e8f2e8..369cd97ff3 100644 --- a/meteor/server/api/ingest/httpIngest/httpIngestServices.ts +++ b/meteor/server/api/ingest/httpIngest/httpIngestServices.ts @@ -238,7 +238,7 @@ export async function postRundown( await runIngestOperation(studio._id, IngestJobs.UpdateRundown, { rundownExternalId: rundownExternalId, - ingestRundown: ingestRundown, + ingestRundown: { ...ingestRundown, playlistExternalId: playlistId }, isCreateAction: true, rundownSource: { type: 'httpIngest', @@ -266,7 +266,7 @@ export async function putRundown( await runIngestOperation(studio._id, IngestJobs.UpdateRundown, { rundownExternalId: existingRundown.externalId, - ingestRundown: ingestRundown, + ingestRundown: { ...ingestRundown, playlistExternalId: playlist.externalId }, isCreateAction: true, rundownSource: { type: 'httpIngest', @@ -296,7 +296,7 @@ export async function putRundowns( return runIngestOperation(studio._id, IngestJobs.UpdateRundown, { rundownExternalId: ingestRundown.externalId, - ingestRundown: ingestRundown, + ingestRundown: { ...ingestRundown, playlistExternalId: playlist.externalId }, isCreateAction: true, rundownSource: { type: 'httpIngest', diff --git a/packages/shared-lib/src/peripheralDevice/ingest.ts b/packages/shared-lib/src/peripheralDevice/ingest.ts index c53739f87e..7f733d9a16 100644 --- a/packages/shared-lib/src/peripheralDevice/ingest.ts +++ b/packages/shared-lib/src/peripheralDevice/ingest.ts @@ -18,6 +18,8 @@ export interface IngestRundown[] + + playlistExternalId?: string } export interface IngestSegment { /** Id of the segment as reported by the ingest gateway. Must be unique for each segment in the rundown */ From 532c5bd9a53917ced61089f4c9f74e3f39a972c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Mon, 7 Oct 2024 15:42:24 +0200 Subject: [PATCH 28/50] fix: ingest api tests --- packages/openapi/api/definitions/ingest.yaml | 3 +-- packages/openapi/src/__tests__/ingest.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml index ad222fc7f7..69ffffb547 100644 --- a/packages/openapi/api/definitions/ingest.yaml +++ b/packages/openapi/api/definitions/ingest.yaml @@ -1164,7 +1164,7 @@ components: description: Interval in milliseconds. example: 3600000 required: - - timingType + - type additionalProperties: false segments: type: array @@ -1324,7 +1324,6 @@ components: - script - pieces - autoNext - - tags - guest required: - externalId diff --git a/packages/openapi/src/__tests__/ingest.spec.ts b/packages/openapi/src/__tests__/ingest.spec.ts index 3b559a0bd7..c3452ed48c 100644 --- a/packages/openapi/src/__tests__/ingest.spec.ts +++ b/packages/openapi/src/__tests__/ingest.spec.ts @@ -1,5 +1,5 @@ // eslint-disable-next-line node/no-missing-import -import { Configuration, IngestApi, Part } from '../../client/ts' +import { Configuration, IngestApi, Part, RundownTimingTypeEnum } from '../../client/ts' import { checkServer } from '../checkServer' import Logging from '../httpLogging' @@ -137,7 +137,7 @@ describe('Ingest API', () => { type: 'external', resyncUrl: 'resyncUrl', timing: { - type: 'none', + type: RundownTimingTypeEnum.None, expectedStart: 0, expectedEnd: 0, expectedDuration: 0, From 703a4587fe80dac98a9c751af71625667b8d0249 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Mon, 7 Oct 2024 16:52:42 +0200 Subject: [PATCH 29/50] wip: make user actions api work with external ids --- meteor/server/api/rest/v1/playlists.ts | 4 +++- packages/job-worker/src/playout/lock.ts | 4 +++- .../playout/model/implementation/PlayoutRundownModelImpl.ts | 5 ++++- .../playout/model/implementation/PlayoutSegmentModelImpl.ts | 3 ++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/meteor/server/api/rest/v1/playlists.ts b/meteor/server/api/rest/v1/playlists.ts index db955dba05..e358b445c9 100644 --- a/meteor/server/api/rest/v1/playlists.ts +++ b/meteor/server/api/rest/v1/playlists.ts @@ -443,7 +443,9 @@ class PlaylistsServerAPI implements PlaylistsRestAPI { fromPartInstanceId: PartInstanceId | undefined ): Promise> { triggerWriteAccess() - const rundownPlaylist = await RundownPlaylists.findOneAsync(rundownPlaylistId) + const rundownPlaylist = await RundownPlaylists.findOneAsync({ + $or: [{ _id: rundownPlaylistId }, { externalId: rundownPlaylistId }], + }) if (!rundownPlaylist) throw new Error(`Rundown playlist ${rundownPlaylistId} does not exist`) return ServerClientAPI.runUserActionInLogForPlaylistOnWorker( diff --git a/packages/job-worker/src/playout/lock.ts b/packages/job-worker/src/playout/lock.ts index 0dad525617..74bb09a339 100644 --- a/packages/job-worker/src/playout/lock.ts +++ b/packages/job-worker/src/playout/lock.ts @@ -24,7 +24,9 @@ export async function runJobWithPlayoutModel( // We can lock before checking ownership, as the locks are scoped to the studio return runWithPlaylistLock(context, data.playlistId, async (playlistLock) => { - const playlist = await context.directCollections.RundownPlaylists.findOne(data.playlistId) + const playlist = await context.directCollections.RundownPlaylists.findOne({ + $or: [{ _id: data.playlistId }, { externalId: data.playlistId }], + }) if (!playlist || playlist.studioId !== context.studioId) { throw new Error(`Job playlist "${data.playlistId}" not found or for another studio`) } diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutRundownModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutRundownModelImpl.ts index 4f6b54aef6..1362d4b59a 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutRundownModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutRundownModelImpl.ts @@ -9,6 +9,7 @@ import { UserError, UserErrorMessage } from '@sofie-automation/corelib/dist/erro import { SegmentOrphanedReason } from '@sofie-automation/corelib/dist/dataModel/Segment' import { getRandomId } from '@sofie-automation/corelib/dist/lib' import { PlayoutSegmentModelImpl } from './PlayoutSegmentModelImpl.js' +import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' export class PlayoutRundownModelImpl implements PlayoutRundownModel { readonly rundown: ReadonlyDeep @@ -47,7 +48,9 @@ export class PlayoutRundownModelImpl implements PlayoutRundownModel { } getSegment(id: SegmentId): PlayoutSegmentModel | undefined { - return this.segments.find((segment) => segment.segment._id === id) + return this.segments.find( + (segment) => segment.segment._id === id || segment.segment.externalId === unprotectString(id) + ) } getSegmentIds(): SegmentId[] { diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutSegmentModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutSegmentModelImpl.ts index 1356244003..303febcfe1 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutSegmentModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutSegmentModelImpl.ts @@ -3,6 +3,7 @@ import { ReadonlyDeep } from 'type-fest' import { DBSegment, SegmentOrphanedReason } from '@sofie-automation/corelib/dist/dataModel/Segment' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import { PlayoutSegmentModel } from '../PlayoutSegmentModel.js' +import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' export class PlayoutSegmentModelImpl implements PlayoutSegmentModel { readonly #segment: DBSegment @@ -20,7 +21,7 @@ export class PlayoutSegmentModelImpl implements PlayoutSegmentModel { } getPart(id: PartId): ReadonlyDeep | undefined { - return this.parts.find((part) => part._id === id) + return this.parts.find((part) => part._id === id || part.externalId === unprotectString(id)) } getPartIds(): PartId[] { From 4a872ee5e955282dc08255a99fb33f7a04a0a95d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Tue, 8 Oct 2024 15:26:30 +0200 Subject: [PATCH 30/50] feat: add timing to IngestRundown interface --- packages/shared-lib/src/peripheralDevice/ingest.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/shared-lib/src/peripheralDevice/ingest.ts b/packages/shared-lib/src/peripheralDevice/ingest.ts index 7f733d9a16..312b0b8a99 100644 --- a/packages/shared-lib/src/peripheralDevice/ingest.ts +++ b/packages/shared-lib/src/peripheralDevice/ingest.ts @@ -19,6 +19,15 @@ 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 { From 0d05fbed5886e6596f16c71d818896b40f6eb5e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Thu, 10 Oct 2024 17:55:08 +0200 Subject: [PATCH 31/50] feat: segment timing --- .../ingest/httpIngest/httpIngestServices.ts | 2 +- packages/openapi/api/definitions/ingest.yaml | 23 +++++++++++++++++++ packages/openapi/src/__tests__/ingest.spec.ts | 23 +++++++++++++++++++ .../shared-lib/src/peripheralDevice/ingest.ts | 6 +++++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/meteor/server/api/ingest/httpIngest/httpIngestServices.ts b/meteor/server/api/ingest/httpIngest/httpIngestServices.ts index 369cd97ff3..180140f913 100644 --- a/meteor/server/api/ingest/httpIngest/httpIngestServices.ts +++ b/meteor/server/api/ingest/httpIngest/httpIngestServices.ts @@ -122,7 +122,7 @@ async function findParts(segmentId: SegmentId) { async function findStudio(studioId: string) { const studio = await Studios.findOneAsync({ _id: protectString(studioId) }) if (!studio) { - throw new Meteor.Error(500, `Studio does not exist`) + throw new Meteor.Error(500, `Studio '${studioId}' does not exist`) } return studio diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml index 69ffffb547..bfaeed4362 100644 --- a/packages/openapi/api/definitions/ingest.yaml +++ b/packages/openapi/api/definitions/ingest.yaml @@ -1244,6 +1244,19 @@ components: type: boolean example: false description: If the Segment is visible 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: @@ -1272,6 +1285,16 @@ components: rank: type: number example: 1 + 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 diff --git a/packages/openapi/src/__tests__/ingest.spec.ts b/packages/openapi/src/__tests__/ingest.spec.ts index c3452ed48c..d51714a2ba 100644 --- a/packages/openapi/src/__tests__/ingest.spec.ts +++ b/packages/openapi/src/__tests__/ingest.spec.ts @@ -191,7 +191,14 @@ describe('Ingest API', () => { 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) }) }) @@ -204,10 +211,22 @@ describe('Ingest API', () => { 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 = { @@ -215,6 +234,10 @@ describe('Ingest API', () => { name: 'Segment 1', rank: 0, _float: true, + timing: { + expectedStart: 0, + expectedEnd: 0, + }, } test('Can create segment', async () => { diff --git a/packages/shared-lib/src/peripheralDevice/ingest.ts b/packages/shared-lib/src/peripheralDevice/ingest.ts index 312b0b8a99..34c24e52b8 100644 --- a/packages/shared-lib/src/peripheralDevice/ingest.ts +++ b/packages/shared-lib/src/peripheralDevice/ingest.ts @@ -43,6 +43,12 @@ export interface IngestSegment[] + + /** 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 */ From fc88a6f81c0edd03703fdc2bb71eb9a19b0cb8d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Wed, 23 Oct 2024 17:14:18 +0200 Subject: [PATCH 32/50] wip: refine ingest api payload --- packages/openapi/api/definitions/ingest.yaml | 84 +++++-------------- packages/openapi/src/__tests__/ingest.spec.ts | 26 +++--- 2 files changed, 33 insertions(+), 77 deletions(-) diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml index bfaeed4362..b6a08fd228 100644 --- a/packages/openapi/api/definitions/ingest.yaml +++ b/packages/openapi/api/definitions/ingest.yaml @@ -1035,51 +1035,6 @@ components: badRequest: description: Bad request. schemas: - ingestPlaylistItem: - type: object - properties: - playlistId: - type: string - description: The Id provided by Sofie. This Id will be used for /playlist commands for controlling playlist activations, playback etc. - rundowns: - type: array - description: All rundowns in a Playlist. - items: - $ref: '#/components/schemas/ingestRundownItem' - required: - - playlistId - - rundowns - additionalProperties: false - ingestRundownItem: - type: object - properties: - externalId: - type: string - description: The Id provided by a system external to Sofie that can be used by the same external system to identify this Rundown. - required: - - externalId - additionalProperties: false - segmentItem: - type: object - properties: - externalId: - type: string - description: The Id provided by a system external to Sofie that can be used by the same external system to identify this Segment. - required: - - externalId - additionalProperties: false - partItem: - type: object - properties: - name: - type: string - externalId: - type: string - description: The Id provided by a system external to Sofie that can be used by the same external system to identify this Part. - required: - - name - - externalId - additionalProperties: false playlist: type: object properties: @@ -1308,12 +1263,16 @@ components: externalId: type: string example: part1 - name: + title: type: string example: Part 1 + float: + type: boolean + autoNext: + type: boolean rank: type: number - description: The position of the Part in the parent Segment. + description: Position of the Part in the Segment. example: 0 payload: type: object @@ -1328,30 +1287,26 @@ components: - COMPOSITION - FULLSCREEN_GRAPHIC - MACRO - float: - type: boolean script: type: string + guest: + type: boolean pieces: type: array items: $ref: '#/components/schemas/piece' - autoNext: - type: boolean - guest: - type: boolean additionalProperties: false required: - type - - float - script - - pieces - - autoNext - guest + - pieces required: - externalId - - name + - title - rank + - float + - autoNext - payload additionalProperties: false partResponse: @@ -1360,20 +1315,21 @@ components: id: type: string example: part1 - externalId: - type: string - example: partExternal1 rundownId: type: string example: rundown1 segmentId: type: string example: segment1 + externalId: + type: string + example: partExternal1 title: type: string example: Part 1 expectedDuration: type: number + description: Calculated based on pieces example: 10000 autoNext: type: boolean @@ -1392,12 +1348,12 @@ components: piece: type: object properties: - externalId: - type: string - example: piece1 id: type: string example: Piece 1 + externalId: + type: string + example: piece1 objectType: type: string enum: @@ -1440,8 +1396,8 @@ components: description: The position of the Part in the parent Segment. example: 0 required: - - externalId - id + - externalId - objectType - resourceName - label diff --git a/packages/openapi/src/__tests__/ingest.spec.ts b/packages/openapi/src/__tests__/ingest.spec.ts index d51714a2ba..ac856a41cb 100644 --- a/packages/openapi/src/__tests__/ingest.spec.ts +++ b/packages/openapi/src/__tests__/ingest.spec.ts @@ -350,13 +350,14 @@ describe('Ingest API', () => { segmentId: segmentIds[0], part: { externalId: 'part1', - name: 'Part 1', + title: 'Part 1', rank: 0, + _float: true, + autoNext: true, payload: { type: 'CAMERA', - _float: true, - autoNext: true, guest: true, + script: '', pieces: [ { id: 'piece1', @@ -368,7 +369,6 @@ describe('Ingest API', () => { resourceName: 'camera1', }, ], - script: '', }, }, }) @@ -384,13 +384,14 @@ describe('Ingest API', () => { part: [ { externalId: 'part1', - name: 'Part 1', + title: 'Part 1', rank: 0, + _float: true, + autoNext: true, payload: { type: 'CAMERA', - _float: true, - autoNext: true, guest: true, + script: '', pieces: [ { id: 'piece1', @@ -402,7 +403,6 @@ describe('Ingest API', () => { resourceName: 'camera1', }, ], - script: '', }, }, ], @@ -412,7 +412,7 @@ describe('Ingest API', () => { const updatedPartId = 'part2' test('Can update an part', async () => { - newIngestPart.name = newIngestPart.name + ' added' + newIngestPart.title = newIngestPart.title + ' added' const result = await ingestApi.putPart({ studioId, playlistId: playlistIds[0], @@ -421,13 +421,14 @@ describe('Ingest API', () => { partId: updatedPartId, part: { externalId: 'part1', - name: 'Part 1', + title: 'Part 1', rank: 0, + _float: true, + autoNext: true, payload: { type: 'CAMERA', - _float: true, - autoNext: true, guest: true, + script: '', pieces: [ { id: 'piece1', @@ -439,7 +440,6 @@ describe('Ingest API', () => { resourceName: 'camera1', }, ], - script: '', }, }, }) From 568dc0e1ddddfa0ecd7fb960321827e20811712b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Wed, 13 Nov 2024 14:36:46 +0100 Subject: [PATCH 33/50] feat: refine Ingest API and Ingest interfaces --- .../httpIngest/httpIngestResponseAdapters.ts | 24 +++++++++---------- .../api/ingest/httpIngest/httpIngestTypes.ts | 1 + packages/openapi/api/definitions/ingest.yaml | 23 ++++-------------- .../shared-lib/src/peripheralDevice/ingest.ts | 20 +++++++--------- 4 files changed, 25 insertions(+), 43 deletions(-) diff --git a/meteor/server/api/ingest/httpIngest/httpIngestResponseAdapters.ts b/meteor/server/api/ingest/httpIngest/httpIngestResponseAdapters.ts index 66bbc268f4..3496faec65 100644 --- a/meteor/server/api/ingest/httpIngest/httpIngestResponseAdapters.ts +++ b/meteor/server/api/ingest/httpIngest/httpIngestResponseAdapters.ts @@ -1,43 +1,43 @@ +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { literal } from '@sofie-automation/corelib/dist/lib' +import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' import { PartResponse, PlaylistResponse, RundownResponse, SegmentResponse } from './httpIngestTypes' -import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' -import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' -import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' export const adaptPlaylist = (rawPlaylist: DBRundownPlaylist): PlaylistResponse => { - return literal({ + return { id: unprotectString(rawPlaylist._id), externalId: rawPlaylist.externalId, rundownIds: rawPlaylist.rundownIdsInOrder.map((id) => unprotectString(id)), studioId: unprotectString(rawPlaylist.studioId), - }) + } } export const adaptRundown = (rawRundown: Rundown): RundownResponse => { - return literal({ + return { id: unprotectString(rawRundown._id), externalId: rawRundown.externalId, playlistId: unprotectString(rawRundown.playlistId), playlistExternalId: rawRundown.playlistExternalId, studioId: unprotectString(rawRundown.studioId), name: rawRundown.name, - }) + } } export const adaptSegment = (rawSegment: DBSegment): SegmentResponse => { - return literal({ + return { id: unprotectString(rawSegment._id), externalId: rawSegment.externalId, name: rawSegment.name, rank: rawSegment._rank, rundownId: unprotectString(rawSegment.rundownId), - }) + isHidden: rawSegment.isHidden, + } } export const adaptPart = (rawPart: DBPart): PartResponse => { - return literal({ + return { id: unprotectString(rawPart._id), externalId: rawPart.externalId, title: rawPart.title, @@ -46,5 +46,5 @@ export const adaptPart = (rawPart: DBPart): PartResponse => { autoNext: rawPart.autoNext, expectedDuration: rawPart.expectedDuration, segmentId: unprotectString(rawPart.segmentId), - }) + } } diff --git a/meteor/server/api/ingest/httpIngest/httpIngestTypes.ts b/meteor/server/api/ingest/httpIngest/httpIngestTypes.ts index 5a77a07f49..1c6f28445c 100644 --- a/meteor/server/api/ingest/httpIngest/httpIngestTypes.ts +++ b/meteor/server/api/ingest/httpIngest/httpIngestTypes.ts @@ -26,6 +26,7 @@ export type SegmentResponse = { rundownId: string name: string rank: number + isHidden?: boolean } export type PartResponse = { diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml index b6a08fd228..fec4750645 100644 --- a/packages/openapi/api/definitions/ingest.yaml +++ b/packages/openapi/api/definitions/ingest.yaml @@ -1195,10 +1195,9 @@ components: description: The position of the Segment in the parent Rundown. inclusiveMinimum: 0.0 example: 1 - float: + isHidden: type: boolean - example: false - description: If the Segment is visible or not. + description: If the Segment is hidden or not. timing: type: object description: Segment timing. @@ -1220,7 +1219,6 @@ components: - externalId - name - rank - - float additionalProperties: false segmentResponse: type: object @@ -1240,6 +1238,8 @@ components: rank: type: number example: 1 + isHidden: + type: boolean timing: type: object properties: @@ -1298,15 +1298,11 @@ components: additionalProperties: false required: - type - - script - - guest - pieces required: - externalId - title - rank - - float - - autoNext - payload additionalProperties: false partResponse: @@ -1333,7 +1329,6 @@ components: example: 10000 autoNext: type: boolean - example: true rank: type: number example: 0 @@ -1381,26 +1376,16 @@ components: attributes: type: object additionalProperties: true - position: - type: string - script: - type: string transition: type: string transitionDuration: type: string target: type: string - rank: - type: number - description: The position of the Part in the parent Segment. - example: 0 required: - id - externalId - objectType - resourceName - - label - attributes - - position additionalProperties: false diff --git a/packages/shared-lib/src/peripheralDevice/ingest.ts b/packages/shared-lib/src/peripheralDevice/ingest.ts index 34c24e52b8..02a7bfa716 100644 --- a/packages/shared-lib/src/peripheralDevice/ingest.ts +++ b/packages/shared-lib/src/peripheralDevice/ingest.ts @@ -9,16 +9,12 @@ export interface IngestRundown[] - /** Rundown timing definition */ timing?: { type?: 'none' | 'forward-time' | 'back-time' @@ -26,7 +22,6 @@ export interface IngestRundown[] - + /** Rank of the segment in the rundown */ + rank: number + /** If segment is hidden */ + isHidden?: boolean /** Timing definition */ timing?: { expectedStart?: number @@ -57,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 } @@ -67,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 */ From 4beb8de7aa9beb696b8c71f50b1324f740b91d00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Fri, 31 Jan 2025 15:58:05 +0100 Subject: [PATCH 34/50] fix: change part title to part name --- .../ingest/httpIngest/httpIngestResponseAdapters.ts | 2 +- .../server/api/ingest/httpIngest/httpIngestTypes.ts | 2 +- packages/openapi/api/definitions/ingest.yaml | 12 ++++++++++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/meteor/server/api/ingest/httpIngest/httpIngestResponseAdapters.ts b/meteor/server/api/ingest/httpIngest/httpIngestResponseAdapters.ts index 3496faec65..f75aa8ee79 100644 --- a/meteor/server/api/ingest/httpIngest/httpIngestResponseAdapters.ts +++ b/meteor/server/api/ingest/httpIngest/httpIngestResponseAdapters.ts @@ -40,7 +40,7 @@ export const adaptPart = (rawPart: DBPart): PartResponse => { return { id: unprotectString(rawPart._id), externalId: rawPart.externalId, - title: rawPart.title, + name: rawPart.title, rank: rawPart._rank, rundownId: unprotectString(rawPart.rundownId), autoNext: rawPart.autoNext, diff --git a/meteor/server/api/ingest/httpIngest/httpIngestTypes.ts b/meteor/server/api/ingest/httpIngest/httpIngestTypes.ts index 1c6f28445c..db117053c9 100644 --- a/meteor/server/api/ingest/httpIngest/httpIngestTypes.ts +++ b/meteor/server/api/ingest/httpIngest/httpIngestTypes.ts @@ -34,7 +34,7 @@ export type PartResponse = { externalId: string rundownId: string segmentId: string - title: string + name: string expectedDuration?: number autoNext?: boolean rank: number diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml index fec4750645..c2a4fad0dd 100644 --- a/packages/openapi/api/definitions/ingest.yaml +++ b/packages/openapi/api/definitions/ingest.yaml @@ -1040,6 +1040,7 @@ components: properties: name: type: string + example: Playlist name externalId: type: string example: playlist1 @@ -1197,6 +1198,7 @@ components: example: 1 isHidden: type: boolean + example: false description: If the Segment is hidden or not. timing: type: object @@ -1263,13 +1265,15 @@ components: externalId: type: string example: part1 - title: + 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. @@ -1320,7 +1324,7 @@ components: externalId: type: string example: partExternal1 - title: + name: type: string example: Part 1 expectedDuration: @@ -1329,6 +1333,7 @@ components: example: 10000 autoNext: type: boolean + example: false rank: type: number example: 0 @@ -1378,10 +1383,13 @@ components: additionalProperties: true transition: type: string + example: cut transitionDuration: type: string + example: 00:00:00:00 target: type: string + example: pgm required: - id - externalId From 095e39ce432d1f2a3dc39dc2a6fa559d7a644c46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Fri, 31 Jan 2025 19:01:29 +0100 Subject: [PATCH 35/50] fix: extend duration and remove externalId from ingest api piece --- packages/openapi/api/definitions/ingest.yaml | 23 +++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml index c2a4fad0dd..fe9065f022 100644 --- a/packages/openapi/api/definitions/ingest.yaml +++ b/packages/openapi/api/definitions/ingest.yaml @@ -1349,9 +1349,6 @@ components: type: object properties: id: - type: string - example: Piece 1 - externalId: type: string example: piece1 objectType: @@ -1373,7 +1370,24 @@ components: objectTime: type: string duration: - type: string + 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: number + example: 10000 + required: + - type + additionalProperties: false resourceName: type: string label: @@ -1392,7 +1406,6 @@ components: example: pgm required: - id - - externalId - objectType - resourceName - attributes From ffa0604ca0c9c91013a6ada8b07d2202003145f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Mon, 3 Feb 2025 09:31:54 +0100 Subject: [PATCH 36/50] fix: ingest api tests --- packages/openapi/api/definitions/ingest.yaml | 25 ++++++++------- packages/openapi/src/__tests__/ingest.spec.ts | 32 ++++++++++--------- 2 files changed, 31 insertions(+), 26 deletions(-) diff --git a/packages/openapi/api/definitions/ingest.yaml b/packages/openapi/api/definitions/ingest.yaml index fe9065f022..d59ab57913 100644 --- a/packages/openapi/api/definitions/ingest.yaml +++ b/packages/openapi/api/definitions/ingest.yaml @@ -1305,7 +1305,7 @@ components: - pieces required: - externalId - - title + - name - rank - payload additionalProperties: false @@ -1315,34 +1315,34 @@ components: id: type: string example: part1 + externalId: + type: string + example: partExternal1 rundownId: type: string example: rundown1 segmentId: type: string example: segment1 - externalId: - type: string - example: partExternal1 name: type: string example: Part 1 + rank: + type: number + example: 0 expectedDuration: type: number - description: Calculated based on pieces + description: Calculated based on pieces. example: 10000 autoNext: type: boolean example: false - rank: - type: number - example: 0 required: - id - externalId - rundownId - segmentId - - title + - name - rank additionalProperties: false piece: @@ -1383,17 +1383,20 @@ components: - showstyle-end - duration duration: - type: number - example: 10000 + 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 diff --git a/packages/openapi/src/__tests__/ingest.spec.ts b/packages/openapi/src/__tests__/ingest.spec.ts index ac856a41cb..72411be37e 100644 --- a/packages/openapi/src/__tests__/ingest.spec.ts +++ b/packages/openapi/src/__tests__/ingest.spec.ts @@ -327,7 +327,7 @@ describe('Ingest API', () => { expect(part).toHaveProperty('externalId') expect(part).toHaveProperty('rundownId') expect(part).toHaveProperty('segmentId') - expect(part).toHaveProperty('title') + expect(part).toHaveProperty('name') expect(part).toHaveProperty('expectedDuration') expect(part).toHaveProperty('autoNext') expect(part).toHaveProperty('rank') @@ -335,7 +335,7 @@ describe('Ingest API', () => { expect(typeof part.externalId).toBe('string') expect(typeof part.rundownId).toBe('string') expect(typeof part.segmentId).toBe('string') - expect(typeof part.title).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') @@ -350,7 +350,7 @@ describe('Ingest API', () => { segmentId: segmentIds[0], part: { externalId: 'part1', - title: 'Part 1', + name: 'Part 1', rank: 0, _float: true, autoNext: true, @@ -361,12 +361,18 @@ describe('Ingest API', () => { pieces: [ { id: 'piece1', - externalId: 'piece1', - label: 'Piece 1', - attributes: {}, objectType: 'CAMERA', - position: '', + 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', }, ], }, @@ -384,7 +390,7 @@ describe('Ingest API', () => { part: [ { externalId: 'part1', - title: 'Part 1', + name: 'Part 1', rank: 0, _float: true, autoNext: true, @@ -395,11 +401,9 @@ describe('Ingest API', () => { pieces: [ { id: 'piece1', - externalId: 'piece1', label: 'Piece 1', attributes: {}, objectType: 'CAMERA', - position: '', resourceName: 'camera1', }, ], @@ -411,8 +415,8 @@ describe('Ingest API', () => { }) const updatedPartId = 'part2' - test('Can update an part', async () => { - newIngestPart.title = newIngestPart.title + ' added' + test('Can update a part', async () => { + newIngestPart.name = newIngestPart.name + ' added' const result = await ingestApi.putPart({ studioId, playlistId: playlistIds[0], @@ -421,7 +425,7 @@ describe('Ingest API', () => { partId: updatedPartId, part: { externalId: 'part1', - title: 'Part 1', + name: 'Part 1', rank: 0, _float: true, autoNext: true, @@ -432,11 +436,9 @@ describe('Ingest API', () => { pieces: [ { id: 'piece1', - externalId: 'piece1', label: 'Piece 1', attributes: {}, objectType: 'CAMERA', - position: '', resourceName: 'camera1', }, ], From 4b2ae3dc581b23a89fd503e146e7da06fe6b0b80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Fri, 9 May 2025 13:44:17 +0200 Subject: [PATCH 37/50] fix: standardize ingest api --- .../ingest/httpIngest/httpIngestController.ts | 580 ------- .../httpIngest/httpIngestResponseAdapters.ts | 50 - .../ingest/httpIngest/httpIngestServices.ts | 682 -------- .../api/ingest/httpIngest/httpIngestTypes.ts | 41 - meteor/server/api/rest/api.ts | 3 - meteor/server/api/rest/v1/index.ts | 2 + meteor/server/api/rest/v1/ingest.ts | 1445 +++++++++++++++++ meteor/server/lib/rest/v1/ingest.ts | 273 ++++ 8 files changed, 1720 insertions(+), 1356 deletions(-) delete mode 100644 meteor/server/api/ingest/httpIngest/httpIngestController.ts delete mode 100644 meteor/server/api/ingest/httpIngest/httpIngestResponseAdapters.ts delete mode 100644 meteor/server/api/ingest/httpIngest/httpIngestServices.ts delete mode 100644 meteor/server/api/ingest/httpIngest/httpIngestTypes.ts create mode 100644 meteor/server/api/rest/v1/ingest.ts create mode 100644 meteor/server/lib/rest/v1/ingest.ts diff --git a/meteor/server/api/ingest/httpIngest/httpIngestController.ts b/meteor/server/api/ingest/httpIngest/httpIngestController.ts deleted file mode 100644 index a2f50e33e8..0000000000 --- a/meteor/server/api/ingest/httpIngest/httpIngestController.ts +++ /dev/null @@ -1,580 +0,0 @@ -import KoaRouter from '@koa/router' -import { IngestPart, IngestSegment } from '@sofie-automation/blueprints-integration' -import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' -import Koa from 'koa' -import koaBodyParser from 'koa-bodyparser' -import { Meteor } from 'meteor/meteor' -import { check } from '../../../../lib/check' -import { logger } from '../../../../lib/logging' -import { - deletePart, - deleteParts, - deletePlaylist, - deletePlaylists, - deleteRundown, - deleteRundowns, - deleteSegment, - deleteSegments, - getPart, - getParts, - getPlaylist, - getPlaylists, - getRundown, - getRundowns, - getSegment, - getSegments, - postPart, - postRundown, - postSegment, - putPart, - putParts, - putRundown, - putRundowns, - putSegment, - putSegments, -} from './httpIngestServices' -import { HttpIngestRundown } from './httpIngestTypes' - -const router = new KoaRouter() -export const httpIngestRouter = router - -const bodyParser = koaBodyParser({ - jsonLimit: '200mb', -}) - -const validateBodyMiddleware = async (ctx: Koa.DefaultContext, next: () => Promise) => { - const contentType = 'application/json' - try { - if (ctx.request.type !== contentType) { - throw new Meteor.Error( - 400, - `Upload rundown: Invalid content-type, received ${ - ctx.request.type || 'undefined' - }, expected ${contentType}` - ) - } - await next() - } catch (e) { - handleError(e, ctx) - } -} - -/** - * OK. - */ -const handle200 = (ctx: Koa.DefaultContext, data?: any) => { - ctx.response.type = 'application/json' - ctx.response.status = 200 - ctx.response.body = data || '' -} - -/** - * Request accepted. - */ -const handle202 = (ctx: Koa.DefaultContext, data?: any) => { - ctx.response.type = 'application/json' - ctx.response.status = 202 - ctx.response.body = data || '' -} - -const handleError = (e: unknown, ctx: Koa.DefaultContext) => { - ctx.response.type = 'text/plain' - ctx.response.status = e instanceof Meteor.Error && typeof e.error === 'number' ? e.error : 500 - ctx.response.body = 'Error: ' + stringifyError(e) - - if (ctx.response.status !== 404) { - logger.error(stringifyError(e)) - } -} - -// Playlists - -router.get('/:studioId/playlists', async (ctx) => { - const studioId = ctx.params.studioId - check(studioId, String) - - try { - const playlists = await getPlaylists(studioId) - handle200(ctx, playlists) - } catch (e) { - handleError(e, ctx) - } -}) - -router.delete('/:studioId/playlists', async (ctx) => { - const studioId = ctx.params.studioId - check(studioId, String) - - try { - await deletePlaylists(studioId) - handle202(ctx) - } catch (e) { - handleError(e, ctx) - } -}) - -router.get('/:studioId/playlists/:playlistId', async (ctx) => { - const studioId = ctx.params.studioId - check(studioId, String) - const playlistId = ctx.params.playlistId - check(playlistId, String) - - try { - const playlist = await getPlaylist(studioId, playlistId) - handle200(ctx, playlist) - } catch (e) { - handleError(e, ctx) - } -}) - -router.delete('/:studioId/playlists/:playlistId', async (ctx) => { - const studioId = ctx.params.studioId - check(studioId, String) - const playlistId = ctx.params.playlistId - check(playlistId, String) - - try { - await deletePlaylist(studioId, playlistId) - handle202(ctx) - } catch (e) { - handleError(e, ctx) - } -}) - -// Rundowns - -// Get rundown -router.get('/:studioId/playlists/:playlistId/rundowns/:rundownId', async (ctx) => { - const studioId = ctx.params.studioId - check(studioId, String) - const playlistId = ctx.params.playlistId - check(playlistId, String) - const rundownId = ctx.params.rundownId - check(rundownId, String) - - try { - const rundown = await getRundown(studioId, playlistId, rundownId) - handle200(ctx, rundown) - } catch (e) { - handleError(e, ctx) - } -}) - -// Get rundowns -router.get('/:studioId/playlists/:playlistId/rundowns', async (ctx) => { - const studioId = ctx.params.studioId - check(studioId, String) - const playlistId = ctx.params.playlistId - check(playlistId, String) - - try { - const rundowns = await getRundowns(studioId, playlistId) - handle200(ctx, rundowns) - } catch (e) { - handleError(e, ctx) - } -}) - -// Create rundown -router.post('/:studioId/playlists/:playlistId/rundowns', bodyParser, validateBodyMiddleware, async (ctx) => { - const studioId = ctx.params.studioId - check(studioId, String) - const playlistId = ctx.params.playlistId - check(playlistId, String) - - try { - const ingestRundown = ctx.request.body as HttpIngestRundown - 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') - - await postRundown(studioId, playlistId, ingestRundown) - - handle202(ctx) - } catch (e) { - handleError(e, ctx) - } -}) - -// Update rundown -router.put('/:studioId/playlists/:playlistId/rundowns/:rundownId', bodyParser, validateBodyMiddleware, async (ctx) => { - const studioId = ctx.params.studioId - check(studioId, String) - const playlistId = ctx.params.playlistId - check(playlistId, String) - const rundownId = ctx.params.rundownId - check(rundownId, String) - - try { - const ingestRundown = ctx.request.body as HttpIngestRundown - 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') - - await putRundown(studioId, playlistId, rundownId, ingestRundown) - - handle202(ctx) - } catch (e) { - handleError(e, ctx) - } -}) - -// Update rundowns -router.put('/:studioId/playlists/:playlistId/rundowns', bodyParser, validateBodyMiddleware, async (ctx) => { - const studioId = ctx.params.studioId - check(studioId, String) - const playlistId = ctx.params.playlistId - check(playlistId, String) - - try { - const ingestRundown = ctx.request.body as HttpIngestRundown[] - 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') - - await putRundowns(studioId, playlistId, ingestRundown) - - handle202(ctx) - } catch (e) { - handleError(e, ctx) - } -}) - -// Delete rundown -router.delete('/:studioId/playlists/:playlistId/rundowns/:rundownId', async (ctx) => { - const studioId = ctx.params.studioId - check(studioId, String) - const playlistId = ctx.params.playlistId - check(playlistId, String) - const rundownId = ctx.params.rundownId - check(rundownId, String) - - try { - await deleteRundown(studioId, playlistId, rundownId) - handle202(ctx) - } catch (e) { - handleError(e, ctx) - } -}) - -// Delete rundowns -router.delete('/:studioId/playlists/:playlistId/rundowns', async (ctx) => { - const studioId = ctx.params.studioId - check(studioId, String) - const playlistId = ctx.params.playlistId - check(playlistId, String) - - try { - await deleteRundowns(studioId, playlistId) - handle202(ctx) - } catch (e) { - handleError(e, ctx) - } -}) - -// Segments - -router.get('/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId', async (ctx) => { - const studioId = ctx.params.studioId - check(studioId, String) - const playlistId = ctx.params.playlistId - check(playlistId, String) - const rundownId = ctx.params.rundownId - check(rundownId, String) - const segmentId = ctx.params.segmentId - check(segmentId, String) - - try { - const segment = await getSegment(studioId, playlistId, rundownId, segmentId) - handle200(ctx, segment) - } catch (e) { - handleError(e, ctx) - } -}) - -router.get('/:studioId/playlists/:playlistId/rundowns/:rundownId/segments', async (ctx) => { - const studioId = ctx.params.studioId - check(studioId, String) - const playlistId = ctx.params.playlistId - check(playlistId, String) - const rundownId = ctx.params.rundownId - check(rundownId, String) - - try { - const segments = await getSegments(studioId, playlistId, rundownId) - handle200(ctx, segments) - } catch (e) { - handleError(e, ctx) - } -}) - -router.post( - '/:studioId/playlists/:playlistId/rundowns/:rundownId/segments', - bodyParser, - validateBodyMiddleware, - async (ctx) => { - const studioId = ctx.params.studioId - check(studioId, String) - const playlistId = ctx.params.playlistId - check(playlistId, String) - const rundownId = ctx.params.rundownId - check(rundownId, String) - - try { - const ingestSegment = ctx.request.body as IngestSegment - if (!ingestSegment) throw new Meteor.Error(400, 'Upload rundown: Missing request body') - - await postSegment(studioId, playlistId, rundownId, ingestSegment) - - handle202(ctx) - } catch (e) { - handleError(e, ctx) - } - } -) - -router.put( - '/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId', - bodyParser, - validateBodyMiddleware, - async (ctx) => { - const studioId = ctx.params.studioId - check(studioId, String) - const playlistId = ctx.params.playlistId - check(playlistId, String) - const rundownId = ctx.params.rundownId - check(rundownId, String) - const segmentId = ctx.params.segmentId - check(segmentId, String) - - try { - const ingestSegment = ctx.request.body as IngestSegment - if (!ingestSegment) throw new Meteor.Error(400, 'Upload rundown: Missing request body') - - await putSegment(studioId, playlistId, rundownId, segmentId, ingestSegment) - - handle202(ctx) - } catch (e) { - handleError(e, ctx) - } - } -) - -router.put( - '/:studioId/playlists/:playlistId/rundowns/:rundownId/segments', - bodyParser, - validateBodyMiddleware, - async (ctx) => { - const studioId = ctx.params.studioId - check(studioId, String) - const playlistId = ctx.params.playlistId - check(playlistId, String) - const rundownId = ctx.params.rundownId - check(rundownId, String) - - try { - const ingestSegments = ctx.request.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') - - await putSegments(studioId, playlistId, rundownId, ingestSegments) - - handle202(ctx) - } catch (e) { - handleError(e, ctx) - } - } -) - -router.delete('/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId', async (ctx) => { - const studioId = ctx.params.studioId - check(studioId, String) - const playlistId = ctx.params.playlistId - check(playlistId, String) - const rundownId = ctx.params.rundownId - check(rundownId, String) - const segmentId = ctx.params.segmentId - check(segmentId, String) - - try { - await deleteSegment(studioId, playlistId, rundownId, segmentId) - handle202(ctx) - } catch (e) { - handleError(e, ctx) - } -}) - -router.delete('/:studioId/playlists/:playlistId/rundowns/:rundownId/segments', async (ctx) => { - const studioId = ctx.params.studioId - check(studioId, String) - const playlistId = ctx.params.playlistId - check(playlistId, String) - const rundownId = ctx.params.rundownId - check(rundownId, String) - - try { - await deleteSegments(studioId, playlistId, rundownId) - handle202(ctx) - } catch (e) { - handleError(e, ctx) - } -}) - -// Parts - -router.get('/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts/:partId', async (ctx) => { - const studioId = ctx.params.studioId - check(studioId, String) - const playlistId = ctx.params.playlistId - check(playlistId, String) - const rundownId = ctx.params.rundownId - check(rundownId, String) - const segmentId = ctx.params.segmentId - check(segmentId, String) - const partId = ctx.params.partId - check(partId, String) - - try { - const part = await getPart(studioId, playlistId, rundownId, segmentId, partId) - handle200(ctx, part) - } catch (e) { - handleError(e, ctx) - } -}) - -router.get('/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts', async (ctx) => { - const studioId = ctx.params.studioId - check(studioId, String) - const playlistId = ctx.params.playlistId - check(playlistId, String) - const rundownId = ctx.params.rundownId - check(rundownId, String) - const segmentId = ctx.params.segmentId - check(segmentId, String) - - try { - const parts = await getParts(studioId, playlistId, rundownId, segmentId) - handle200(ctx, parts) - } catch (e) { - handleError(e, ctx) - } -}) - -router.post( - '/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts', - bodyParser, - validateBodyMiddleware, - async (ctx) => { - const studioId = ctx.params.studioId - check(studioId, String) - const playlistId = ctx.params.playlistId - check(playlistId, String) - const rundownId = ctx.params.rundownId - check(rundownId, String) - const segmentId = ctx.params.segmentId - check(segmentId, String) - - try { - const ingestPart = ctx.request.body as IngestPart - if (!ingestPart) throw new Meteor.Error(400, 'Upload rundown: Missing request body') - - await postPart(studioId, playlistId, rundownId, segmentId, ingestPart) - - handle202(ctx) - } catch (e) { - handleError(e, ctx) - } - } -) - -router.put( - '/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts/:partId', - bodyParser, - validateBodyMiddleware, - async (ctx) => { - const studioId = ctx.params.studioId - check(studioId, String) - const playlistId = ctx.params.playlistId - check(playlistId, String) - const rundownId = ctx.params.rundownId - check(rundownId, String) - const segmentId = ctx.params.segmentId - check(segmentId, String) - const partId = ctx.params.partId - check(partId, String) - - try { - const ingestPart = ctx.request.body as IngestPart - if (!ingestPart) throw new Meteor.Error(400, 'Upload rundown: Missing request body') - - await putPart(studioId, playlistId, rundownId, segmentId, partId, ingestPart) - - handle202(ctx) - } catch (e) { - handleError(e, ctx) - } - } -) - -router.put( - '/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts', - bodyParser, - validateBodyMiddleware, - async (ctx) => { - const studioId = ctx.params.studioId - check(studioId, String) - const playlistId = ctx.params.playlistId - check(playlistId, String) - const rundownId = ctx.params.rundownId - check(rundownId, String) - const segmentId = ctx.params.segmentId - check(segmentId, String) - - try { - const ingestParts = ctx.request.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') - - await putParts(studioId, playlistId, rundownId, segmentId, ingestParts) - - handle202(ctx) - } catch (e) { - handleError(e, ctx) - } - } -) - -router.delete('/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts/:partId', async (ctx) => { - const studioId = ctx.params.studioId - check(studioId, String) - const playlistId = ctx.params.playlistId - check(playlistId, String) - const rundownId = ctx.params.rundownId - check(rundownId, String) - const segmentId = ctx.params.segmentId - check(segmentId, String) - const partId = ctx.params.partId - check(partId, String) - - try { - await deletePart(studioId, playlistId, rundownId, segmentId, partId) - handle202(ctx) - } catch (e) { - handleError(e, ctx) - } -}) - -router.delete('/:studioId/playlists/:playlistId/rundowns/:rundownId/segments/:segmentId/parts', async (ctx) => { - const studioId = ctx.params.studioId - check(studioId, String) - const playlistId = ctx.params.playlistId - check(playlistId, String) - const rundownId = ctx.params.rundownId - check(rundownId, String) - const segmentId = ctx.params.segmentId - check(segmentId, String) - - try { - await deleteParts(studioId, playlistId, rundownId, segmentId) - handle202(ctx) - } catch (e) { - handleError(e, ctx) - } -}) diff --git a/meteor/server/api/ingest/httpIngest/httpIngestResponseAdapters.ts b/meteor/server/api/ingest/httpIngest/httpIngestResponseAdapters.ts deleted file mode 100644 index f75aa8ee79..0000000000 --- a/meteor/server/api/ingest/httpIngest/httpIngestResponseAdapters.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' -import { 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 { unprotectString } from '@sofie-automation/corelib/dist/protectedString' -import { PartResponse, PlaylistResponse, RundownResponse, SegmentResponse } from './httpIngestTypes' - -export const adaptPlaylist = (rawPlaylist: DBRundownPlaylist): PlaylistResponse => { - return { - id: unprotectString(rawPlaylist._id), - externalId: rawPlaylist.externalId, - rundownIds: rawPlaylist.rundownIdsInOrder.map((id) => unprotectString(id)), - studioId: unprotectString(rawPlaylist.studioId), - } -} - -export const 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, - } -} - -export const 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, - } -} - -export const 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), - } -} diff --git a/meteor/server/api/ingest/httpIngest/httpIngestServices.ts b/meteor/server/api/ingest/httpIngest/httpIngestServices.ts deleted file mode 100644 index 180140f913..0000000000 --- a/meteor/server/api/ingest/httpIngest/httpIngestServices.ts +++ /dev/null @@ -1,682 +0,0 @@ -import { IngestPart, IngestSegment } from '@sofie-automation/blueprints-integration' -import { PartId, RundownId, RundownPlaylistId, SegmentId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { getRundownNrcsName, Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' -import { protectString } from '@sofie-automation/corelib/dist/protectedString' -import { IngestJobs } from '@sofie-automation/corelib/dist/worker/ingest' -import { Meteor } from 'meteor/meteor' -import { Parts, RundownPlaylists, Rundowns, Segments, Studios } from '../../../collections' -import { runIngestOperation } from '../lib' -import { adaptPart, adaptPlaylist, adaptRundown, adaptSegment } from './httpIngestResponseAdapters' -import { HttpIngestRundown, PartResponse, PlaylistResponse, RundownResponse, SegmentResponse } from './httpIngestTypes' - -async function 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 -} - -async function 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 -} - -async function findRundowns(studioId: StudioId, playlistId: RundownPlaylistId) { - const rundowns = await Rundowns.findFetchAsync({ - $or: [ - { - playlistId, - studioId, - }, - ], - }) - - return rundowns -} - -async function softFindSegment(rundownId: RundownId, segmentId: string) { - const segment = await Segments.findOneAsync({ - $or: [ - { - _id: protectString(segmentId), - rundownId: rundownId, - }, - { - externalId: segmentId, - rundownId: rundownId, - }, - ], - }) - return segment -} - -async function findSegment(rundownId: RundownId, segmentId: string) { - const segment = await softFindSegment(rundownId, segmentId) - if (!segment) { - throw new Meteor.Error(404, `Segment ID '${segmentId}' was not found`) - } - return segment -} - -async function findSegments(rundownId: RundownId) { - const segments = await Segments.findFetchAsync({ - $or: [ - { - rundownId: rundownId, - }, - ], - }) - return segments -} - -async function softFindPart(segmentId: SegmentId, partId: string) { - const part = await Parts.findOneAsync({ - $or: [ - { _id: protectString(partId), segmentId }, - { - externalId: partId, - segmentId, - }, - ], - }) - return part -} - -async function findPart(segmentId: SegmentId, partId: string) { - const part = await softFindPart(segmentId, partId) - if (!part) { - throw new Meteor.Error(404, `Part ID '${partId}' was not found`) - } - return part -} - -async function findParts(segmentId: SegmentId) { - const parts = await Parts.findFetchAsync({ - $or: [{ segmentId }], - }) - return parts -} - -async function findStudio(studioId: string) { - const studio = await Studios.findOneAsync({ _id: protectString(studioId) }) - if (!studio) { - throw new Meteor.Error(500, `Studio '${studioId}' does not exist`) - } - - return studio -} - -function checkRundownSource(rundown: Rundown | undefined) { - if (rundown && rundown.source.type !== 'httpIngest') { - throw new Meteor.Error( - 403, - `Cannot replace existing rundown from source '${getRundownNrcsName( - rundown - )}' with new data from 'httpIngest' source` - ) - } -} - -// Playlists - -export async function getPlaylists(studioId: string): Promise { - const studio = await findStudio(studioId) - const rawPlaylists = await RundownPlaylists.findFetchAsync({ studioId: studio._id }) - const playlists = rawPlaylists.map((rawPlaylist) => adaptPlaylist(rawPlaylist)) - - return playlists -} - -export async function getPlaylist(studioId: string, playlistId: string): Promise { - const studio = await findStudio(studioId) - const rawPlaylist = await findPlaylist(studio._id, playlistId) - const playlist = adaptPlaylist(rawPlaylist) - - return playlist -} - -export async function deletePlaylists(studioId: string): Promise { - const rundowns = await Rundowns.findFetchAsync({}) - const studio = await findStudio(studioId) - - await Promise.all( - rundowns.map(async (rundown) => - runIngestOperation(studio._id, IngestJobs.RemoveRundown, { - rundownExternalId: rundown.externalId, - }) - ) - ) -} - -export async function deletePlaylist(studioId: string, playlistId: string): Promise { - const studio = await findStudio(studioId) - await 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, - }) - ) - ) -} - -// Rundowns - -// Get rundown -export async function getRundown(studioId: string, playlistId: string, rundownId: string): Promise { - const studio = await findStudio(studioId) - const playlist = await findPlaylist(studio._id, playlistId) - const rawRundown = await findRundown(studio._id, playlist._id, rundownId) - const rundown = adaptRundown(rawRundown) - - return rundown -} - -// Get rundowns -export async function getRundowns(studioId: string, playlistId: string): Promise { - const studio = await findStudio(studioId) - const playlist = await findPlaylist(studio._id, playlistId) - const rawRundowns = await findRundowns(studio._id, playlist._id) - const rundowns = rawRundowns.map((rawRundown) => adaptRundown(rawRundown)) - - return rundowns -} - -// Create rundown -export async function postRundown( - studioId: string, - playlistId: string, - ingestRundown: HttpIngestRundown -): Promise { - const studio = await findStudio(studioId) - const rundownExternalId = ingestRundown.externalId - - const existingRundown = await Rundowns.findOneAsync({ - $or: [ - { - _id: protectString(rundownExternalId), - playlistId: protectString(playlistId), - studioId: studio._id, - }, - { - externalId: rundownExternalId, - playlistExternalId: playlistId, - studioId: studio._id, - }, - ], - }) - if (existingRundown) { - throw new Meteor.Error(400, `Rundown '${rundownExternalId}' already exists`) - } - - await runIngestOperation(studio._id, IngestJobs.UpdateRundown, { - rundownExternalId: rundownExternalId, - ingestRundown: { ...ingestRundown, playlistExternalId: playlistId }, - isCreateAction: true, - rundownSource: { - type: 'httpIngest', - resyncUrl: ingestRundown.resyncUrl, - }, - }) -} - -// Update rundown -export async function putRundown( - studioId: string, - playlistId: string, - rundownId: string, - ingestRundown: HttpIngestRundown -): Promise { - const studio = await findStudio(studioId) - const playlist = await findPlaylist(studio._id, playlistId) - - const existingRundown = await findRundown(studio._id, playlist._id, rundownId) - if (!existingRundown) { - throw new Meteor.Error(400, `Rundown '${rundownId}' does not exist`) - } - - checkRundownSource(existingRundown) - - await runIngestOperation(studio._id, IngestJobs.UpdateRundown, { - rundownExternalId: existingRundown.externalId, - ingestRundown: { ...ingestRundown, playlistExternalId: playlist.externalId }, - isCreateAction: true, - rundownSource: { - type: 'httpIngest', - resyncUrl: ingestRundown.resyncUrl, - }, - }) -} - -// Update rundowns -export async function putRundowns( - studioId: string, - playlistId: string, - ingestRundowns: HttpIngestRundown[] -): Promise { - const studio = await findStudio(studioId) - const playlist = await findPlaylist(studio._id, playlistId) - - await Promise.all( - ingestRundowns.map(async (ingestRundown) => { - const rundownExternalId = ingestRundown.externalId - const existingRundown = await findRundown(studio._id, playlist._id, rundownExternalId) - if (!existingRundown) { - return - } - - checkRundownSource(existingRundown) - - return runIngestOperation(studio._id, IngestJobs.UpdateRundown, { - rundownExternalId: ingestRundown.externalId, - ingestRundown: { ...ingestRundown, playlistExternalId: playlist.externalId }, - isCreateAction: true, - rundownSource: { - type: 'httpIngest', - resyncUrl: ingestRundown.resyncUrl, - }, - }) - }) - ) -} - -// Delete rundown -export async function deleteRundown(studioId: string, playlistId: string, rundownId: string): Promise { - const studio = await findStudio(studioId) - const playlist = await findPlaylist(studio._id, playlistId) - const rundown = await findRundown(studio._id, playlist._id, rundownId) - checkRundownSource(rundown) - - await runIngestOperation(studio._id, IngestJobs.RemoveRundown, { - rundownExternalId: rundown.externalId, - }) -} - -// Delete rundowns -export async function deleteRundowns(studioId: string, playlistId: string): Promise { - const studio = await findStudio(studioId) - const playlist = await findPlaylist(studio._id, playlistId) - const rundowns = await findRundowns(studio._id, playlist._id) - - await Promise.all( - rundowns.map(async (rundown) => { - checkRundownSource(rundown) - return runIngestOperation(studio._id, IngestJobs.RemoveRundown, { - rundownExternalId: rundown.externalId, - }) - }) - ) -} - -// Segments - -export async function getSegment( - studioId: string, - playlistId: string, - rundownId: string, - segmentId: string -): Promise { - const studio = await findStudio(studioId) - const playlist = await findPlaylist(studio._id, playlistId) - const rundown = await findRundown(studio._id, playlist._id, rundownId) - checkRundownSource(rundown) - - const rawSegment = await findSegment(rundown._id, segmentId) - const segment = adaptSegment(rawSegment) - - return segment -} - -export async function getSegments(studioId: string, playlistId: string, rundownId: string): Promise { - const studio = await findStudio(studioId) - const playlist = await findPlaylist(studio._id, playlistId) - const rundown = await findRundown(studio._id, playlist._id, rundownId) - checkRundownSource(rundown) - - const rawSegments = await findSegments(rundown._id) - const segments = rawSegments.map((rawSegment) => adaptSegment(rawSegment)) - - return segments -} - -export async function postSegment( - studioId: string, - playlistId: string, - rundownId: string, - ingestSegment: IngestSegment -): Promise { - const studio = await findStudio(studioId) - const playlist = await findPlaylist(studio._id, playlistId) - const rundown = await findRundown(studio._id, playlist._id, rundownId) - checkRundownSource(rundown) - - const segmentExternalId = ingestSegment.externalId - - const existingSegment = await softFindSegment(rundown._id, segmentExternalId) - if (existingSegment) { - throw new Meteor.Error(400, `Segment '${segmentExternalId}' already exists`) - } - - await runIngestOperation(studio._id, IngestJobs.UpdateSegment, { - rundownExternalId: rundown.externalId, - isCreateAction: true, - ingestSegment, - }) -} - -export async function putSegment( - studioId: string, - playlistId: string, - rundownId: string, - segmentId: string, - ingestSegment: IngestSegment -): Promise { - const studio = await findStudio(studioId) - const playlist = await findPlaylist(studio._id, playlistId) - const rundown = await findRundown(studio._id, playlist._id, rundownId) - checkRundownSource(rundown) - - const segment = await softFindSegment(rundown._id, segmentId) - if (!segment) { - throw new Meteor.Error(400, `Segment '${segmentId}' does not exist`) - } - - const parts = await 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, - }) -} - -export async function putSegments( - studioId: string, - playlistId: string, - rundownId: string, - ingestSegments: IngestSegment[] -): Promise { - const studio = await findStudio(studioId) - const playlist = await findPlaylist(studio._id, playlistId) - const rundown = await findRundown(studio._id, playlist._id, rundownId) - checkRundownSource(rundown) - - await Promise.all( - ingestSegments.map(async (ingestSegment) => { - const segment = await findSegment(rundown._id, ingestSegment.externalId) - if (!segment) { - return - } - - const parts = await 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 softFindSegment(rundown._id, ingestSegment.externalId) - if (!existingSegment) { - return null - } - - return runIngestOperation(studio._id, IngestJobs.UpdateSegment, { - rundownExternalId: rundown.externalId, - isCreateAction: true, - ingestSegment, - }) - }) - ) -} - -export async function deleteSegment( - studioId: string, - playlistId: string, - rundownId: string, - segmentId: string -): Promise { - const studio = await findStudio(studioId) - const playlist = await findPlaylist(studio._id, playlistId) - const rundown = await findRundown(studio._id, playlist._id, rundownId) - checkRundownSource(rundown) - const segment = await findSegment(rundown._id, segmentId) - - // This also removes linked Parts - await runIngestOperation(studio._id, IngestJobs.RemoveSegment, { - segmentExternalId: segment.externalId, - rundownExternalId: rundown.externalId, - }) -} - -export async function deleteSegments(studioId: string, playlistId: string, rundownId: string): Promise { - const studio = await findStudio(studioId) - const playlist = await findPlaylist(studio._id, playlistId) - const rundown = await findRundown(studio._id, playlist._id, rundownId) - - const segments = await 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, - }) - ) - ) -} - -// Parts - -export async function getParts( - studioId: string, - playlistId: string, - rundownId: string, - segmentId: string -): Promise { - const studio = await findStudio(studioId) - const playlist = await findPlaylist(studio._id, playlistId) - const rundown = await findRundown(studio._id, playlist._id, rundownId) - checkRundownSource(rundown) - const segment = await findSegment(rundown._id, segmentId) - - const rawParts = await findParts(segment._id) - const parts = rawParts.map((rawPart) => adaptPart(rawPart)) - - return parts -} - -export async function getPart( - studioId: string, - playlistId: string, - rundownId: string, - segmentId: string, - partId: string -): Promise { - const studio = await findStudio(studioId) - const playlist = await findPlaylist(studio._id, playlistId) - const rundown = await findRundown(studio._id, playlist._id, rundownId) - checkRundownSource(rundown) - const segment = await findSegment(rundown._id, segmentId) - - const rawPart = await findPart(segment._id, partId) - const part = adaptPart(rawPart) - - return part -} - -export async function postPart( - studioId: string, - playlistId: string, - rundownId: string, - segmentId: string, - ingestPart: IngestPart -): Promise { - const studio = await findStudio(studioId) - const playlist = await findPlaylist(studio._id, playlistId) - const rundown = await findRundown(studio._id, playlist._id, rundownId) - checkRundownSource(rundown) - const segment = await findSegment(rundown._id, segmentId) - const partExternalId = ingestPart.externalId - - const existingPart = await softFindPart(segment._id, partExternalId) - if (existingPart) { - throw new Meteor.Error(400, `Part '${partExternalId}' already exists`) - } - - await runIngestOperation(studio._id, IngestJobs.UpdatePart, { - rundownExternalId: rundown.externalId, - segmentExternalId: segment.externalId, - isCreateAction: true, - ingestPart, - }) -} - -export async function putPart( - studioId: string, - playlistId: string, - rundownId: string, - segmentId: string, - partId: string, - ingestPart: IngestPart -): Promise { - const studio = await findStudio(studioId) - const playlist = await findPlaylist(studio._id, playlistId) - const rundown = await findRundown(studio._id, playlist._id, rundownId) - checkRundownSource(rundown) - const segment = await findSegment(rundown._id, segmentId) - - const existingPart = await 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, - }) -} - -export async function putParts( - studioId: string, - playlistId: string, - rundownId: string, - segmentId: string, - ingestParts: IngestPart[] -): Promise { - const studio = await findStudio(studioId) - const playlist = await findPlaylist(studio._id, playlistId) - const rundown = await findRundown(studio._id, playlist._id, rundownId) - checkRundownSource(rundown) - const segment = await findSegment(rundown._id, segmentId) - - await Promise.all( - ingestParts.map(async (ingestPart) => { - const existingPart = await findPart(segment._id, ingestPart.externalId) - if (!existingPart) { - return - } - - return runIngestOperation(studio._id, IngestJobs.UpdatePart, { - segmentExternalId: segment.externalId, - rundownExternalId: rundown.externalId, - isCreateAction: true, - ingestPart, - }) - }) - ) -} - -export async function deletePart( - studioId: string, - playlistId: string, - rundownId: string, - segmentId: string, - partId: string -): Promise { - const studio = await findStudio(studioId) - const playlist = await findPlaylist(studio._id, playlistId) - const rundown = await findRundown(studio._id, playlist._id, rundownId) - checkRundownSource(rundown) - const segment = await findSegment(rundown._id, segmentId) - - const part = await findPart(segment._id, partId) - - await runIngestOperation(studio._id, IngestJobs.RemovePart, { - rundownExternalId: rundown.externalId, - segmentExternalId: segment.externalId, - partExternalId: part.externalId, - }) -} - -export async function deleteParts( - studioId: string, - playlistId: string, - rundownId: string, - segmentId: string -): Promise { - const studio = await findStudio(studioId) - const playlist = await findPlaylist(studio._id, playlistId) - const rundown = await findRundown(studio._id, playlist._id, rundownId) - checkRundownSource(rundown) - const segment = await findSegment(rundown._id, segmentId) - - const parts = await findParts(segment._id) - - await Promise.all( - parts.map(async (part) => - runIngestOperation(studio._id, IngestJobs.RemovePart, { - rundownExternalId: rundown.externalId, - segmentExternalId: segment.externalId, - partExternalId: part.externalId, - }) - ) - ) -} diff --git a/meteor/server/api/ingest/httpIngest/httpIngestTypes.ts b/meteor/server/api/ingest/httpIngest/httpIngestTypes.ts deleted file mode 100644 index db117053c9..0000000000 --- a/meteor/server/api/ingest/httpIngest/httpIngestTypes.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { IngestRundown } from '@sofie-automation/blueprints-integration' - -export type HttpIngestRundown = IngestRundown & { - 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/meteor/server/api/rest/api.ts b/meteor/server/api/rest/api.ts index bb9a94fdc8..4496cd493b 100644 --- a/meteor/server/api/rest/api.ts +++ b/meteor/server/api/rest/api.ts @@ -12,7 +12,6 @@ import { blueprintsRouter } from '../blueprints/http' import { createLegacyApiRouter } from './v0/index' import { heapSnapshotPrivateApiRouter } from '../heapSnapshot' import { getRootSubpath } from '../../lib' -import { httpIngestRouter } from '../ingest/httpIngest/httpIngestController' const LATEST_REST_API = 'v1.0' @@ -21,8 +20,6 @@ const apiRouter = new KoaRouter() apiRouter.get('/', redirectToLatest) apiRouter.get('/latest', redirectToLatest) -apiRouter.use('/v1.0/ingest', httpIngestRouter.routes(), httpIngestRouter.allowedMethods()) - apiRouter.use('/v1.0', apiV1Router.routes(), apiV1Router.allowedMethods()) apiRouter.use('/private/ingest', ingestRouter.routes(), ingestRouter.allowedMethods()) 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..8345fc213a --- /dev/null +++ b/meteor/server/api/rest/v1/ingest.ts @@ -0,0 +1,1445 @@ +import { IngestPart, IngestSegment } from '@sofie-automation/blueprints-integration' +import { 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 '../../../../lib/api/client' +import { + HttpIngestRundown, + IngestRestAPI, + PartResponse, + PlaylistResponse, + RundownResponse, + SegmentResponse, +} from '../../../../lib/api/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 { APIFactory, APIRegisterHook, ServerAPIContext } from './types' + +class IngestServerAPI implements IngestRestAPI { + constructor(private context: ServerAPIContext) {} + + adaptPlaylist(rawPlaylist: DBRundownPlaylist): PlaylistResponse { + return { + id: unprotectString(rawPlaylist._id), + externalId: rawPlaylist.externalId, + rundownIds: rawPlaylist.rundownIdsInOrder.map((id) => unprotectString(id)), + studioId: unprotectString(rawPlaylist.studioId), + } + } + + 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, + } + } + + 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, + } + } + + 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 !== 'httpIngest') { + throw new Meteor.Error( + 403, + `Cannot replace existing rundown from source '${getRundownNrcsName( + rundown + )}' with new data from 'httpIngest' source` + ) + } + } + + // Playlists + + async getPlaylists( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId + ): Promise>> { + 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> { + 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> { + 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> { + 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>> { + 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> { + 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: HttpIngestRundown + ): Promise> { + const studio = await this.findStudio(studioId) + const rundownExternalId = ingestRundown.externalId + + const existingRundown = await Rundowns.findOneAsync({ + $or: [ + { + _id: protectString(rundownExternalId), + playlistId: protectString(playlistId), + studioId: studio._id, + }, + { + externalId: rundownExternalId, + playlistExternalId: playlistId, + studioId: studio._id, + }, + ], + }) + if (existingRundown) { + throw new Meteor.Error(400, `Rundown '${rundownExternalId}' already exists`) + } + + await runIngestOperation(studio._id, IngestJobs.UpdateRundown, { + rundownExternalId: rundownExternalId, + ingestRundown: { ...ingestRundown, playlistExternalId: playlistId }, + isCreateAction: true, + rundownSource: { + type: 'httpIngest', + resyncUrl: ingestRundown.resyncUrl, + }, + }) + + return ClientAPI.responseSuccess(undefined) + } + + async putRundowns( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + ingestRundowns: HttpIngestRundown[] + ): Promise> { + const studio = await this.findStudio(studioId) + 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: 'httpIngest', + resyncUrl: ingestRundown.resyncUrl, + }, + }) + }) + ) + + return ClientAPI.responseSuccess(undefined) + } + + async putRundown( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string, + ingestRundown: HttpIngestRundown + ): Promise> { + const studio = await this.findStudio(studioId) + 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: 'httpIngest', + resyncUrl: ingestRundown.resyncUrl, + }, + }) + + return ClientAPI.responseSuccess(undefined) + } + + async deleteRundowns( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string + ): Promise> { + 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> { + 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>> { + 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> { + 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> { + 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 segmentExternalId = ingestSegment.externalId + + const existingSegment = await this.softFindSegment(rundown._id, segmentExternalId) + if (existingSegment) { + throw new Meteor.Error(400, `Segment '${segmentExternalId}' 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> { + 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 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> { + 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.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> { + 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> { + 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>> { + 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> { + 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> { + 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 partExternalId = ingestPart.externalId + + const existingPart = await this.softFindPart(segment._id, partExternalId) + if (existingPart) { + throw new Meteor.Error(400, `Part '${partExternalId}' 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> { + 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) + + 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> { + 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 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> { + 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> { + 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(context) + } +} + +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 HttpIngestRundown + 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 HttpIngestRundown[] + 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 HttpIngestRundown + 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/lib/rest/v1/ingest.ts b/meteor/server/lib/rest/v1/ingest.ts new file mode 100644 index 0000000000..cfe5c47d86 --- /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 '../../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: HttpIngestRundown + ): Promise> + + putRundowns( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + ingestRundowns: HttpIngestRundown[] + ): Promise> + + putRundown( + _connection: Meteor.Connection, + _event: string, + studioId: StudioId, + playlistId: string, + rundownId: string, + ingestRundown: HttpIngestRundown + ): 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 HttpIngestRundown = IngestRundown & { + 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 +} From 476b77c5c242fb3104f607d0168fb54b301dd0c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Thu, 15 May 2025 16:14:25 +0200 Subject: [PATCH 38/50] feat: implement ingest api validation --- meteor/server/api/rest/v1/ingest.ts | 320 ++++++++++++++++-- meteor/server/api/rest/v1/typeConversion.ts | 37 +- meteor/server/lib/rest/v1/ingest.ts | 2 +- .../blueprints-integration/src/api/studio.ts | 3 + 4 files changed, 316 insertions(+), 46 deletions(-) diff --git a/meteor/server/api/rest/v1/ingest.ts b/meteor/server/api/rest/v1/ingest.ts index 8345fc213a..369b0db89b 100644 --- a/meteor/server/api/rest/v1/ingest.ts +++ b/meteor/server/api/rest/v1/ingest.ts @@ -1,5 +1,12 @@ -import { IngestPart, IngestSegment } from '@sofie-automation/blueprints-integration' -import { PartId, RundownId, RundownPlaylistId, SegmentId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' +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' @@ -20,12 +27,112 @@ import { check } from '../../../../lib/check' import { Parts, RundownPlaylists, Rundowns, Segments, Studios } from '../../../collections' import { logger } from '../../../logging' import { runIngestOperation } from '../../ingest/lib' +import { validateAPIPartPayload } from './typeConversion' import { APIFactory, APIRegisterHook, ServerAPIContext } from './types' class IngestServerAPI implements IngestRestAPI { - constructor(private context: ServerAPIContext) {} + private async validateAPIPartPayloadForRundown( + blueprintId: BlueprintId | undefined, + ingestRundown: IngestRundown, + indexes?: { + rundown?: number + } + ) { + return Promise.all( + ingestRundown.segments.map(async (segment, index) => { + return this.validateAPIPartPayloadForSegment(blueprintId, segment, { + ...indexes, + segment: index, + }) + }) + ) + } + + private async validateAPIPartPayloadForSegment( + blueprintId: BlueprintId | undefined, + segment: IngestRundown['segments'][number], + indexes?: { + rundown?: number + segment?: number + } + ) { + return Promise.all( + segment.parts.map(async (part, index) => { + return this.validateAPIPartPayloadForPart(blueprintId, part, { ...indexes, part: index }) + }) + ) + } + + private async validateAPIPartPayloadForPart( + blueprintId: BlueprintId | undefined, + part: IngestRundown['segments'][number]['parts'][number], + indexes?: { + rundown?: number + segment?: number + part?: number + } + ) { + const validationResult = await validateAPIPartPayload(blueprintId, part.payload) + if (validationResult && validationResult.length > 0) { + const parts = [] + if (indexes?.rundown !== undefined) parts.push(`rundowns[${indexes.rundown}]`) + if (indexes?.segment !== undefined) parts.push(`segments[${indexes.segment}]`) + if (indexes?.part !== undefined) parts.push(`parts[${indexes.part}]`) + let msg = `Part payload validation failed` + if (parts.length > 0) msg += ` for ${parts.join('.')}` + + logger.error(`${msg} with errors: ${validationResult}`) + throw new Meteor.Error(409, msg, JSON.stringify(validationResult)) + } + } + + private validateRundown(ingestRundown: HttpIngestRundown) { + 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) - adaptPlaylist(rawPlaylist: DBRundownPlaylist): PlaylistResponse { + 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, @@ -34,7 +141,7 @@ class IngestServerAPI implements IngestRestAPI { } } - adaptRundown(rawRundown: Rundown): RundownResponse { + private adaptRundown(rawRundown: Rundown): RundownResponse { return { id: unprotectString(rawRundown._id), externalId: rawRundown.externalId, @@ -45,7 +152,7 @@ class IngestServerAPI implements IngestRestAPI { } } - adaptSegment(rawSegment: DBSegment): SegmentResponse { + private adaptSegment(rawSegment: DBSegment): SegmentResponse { return { id: unprotectString(rawSegment._id), externalId: rawSegment.externalId, @@ -56,7 +163,7 @@ class IngestServerAPI implements IngestRestAPI { } } - adaptPart(rawPart: DBPart): PartResponse { + private adaptPart(rawPart: DBPart): PartResponse { return { id: unprotectString(rawPart._id), externalId: rawPart.externalId, @@ -206,6 +313,8 @@ class IngestServerAPI implements IngestRestAPI { _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)) @@ -219,6 +328,9 @@ class IngestServerAPI implements IngestRestAPI { 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) @@ -231,6 +343,8 @@ class IngestServerAPI implements IngestRestAPI { _event: string, studioId: StudioId ): Promise> { + check(studioId, String) + const rundowns = await Rundowns.findFetchAsync({}) const studio = await this.findStudio(studioId) @@ -251,6 +365,9 @@ class IngestServerAPI implements IngestRestAPI { studioId: StudioId, playlistId: string ): Promise> { + check(studioId, String) + check(playlistId, String) + const studio = await this.findStudio(studioId) await this.findPlaylist(studio._id, playlistId) @@ -277,6 +394,9 @@ class IngestServerAPI implements IngestRestAPI { 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) @@ -292,6 +412,10 @@ class IngestServerAPI implements IngestRestAPI { 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) @@ -307,29 +431,35 @@ class IngestServerAPI implements IngestRestAPI { playlistId: string, ingestRundown: HttpIngestRundown ): Promise> { + check(studioId, String) + check(playlistId, String) + check(ingestRundown, Object) + const studio = await this.findStudio(studioId) - const rundownExternalId = ingestRundown.externalId + + this.validateRundown(ingestRundown) + await this.validateAPIPartPayloadForRundown(studio.blueprintId, ingestRundown) const existingRundown = await Rundowns.findOneAsync({ $or: [ { - _id: protectString(rundownExternalId), + _id: protectString(ingestRundown.externalId), playlistId: protectString(playlistId), studioId: studio._id, }, { - externalId: rundownExternalId, + externalId: ingestRundown.externalId, playlistExternalId: playlistId, studioId: studio._id, }, ], }) if (existingRundown) { - throw new Meteor.Error(400, `Rundown '${rundownExternalId}' already exists`) + throw new Meteor.Error(400, `Rundown '${ingestRundown.externalId}' already exists`) } await runIngestOperation(studio._id, IngestJobs.UpdateRundown, { - rundownExternalId: rundownExternalId, + rundownExternalId: ingestRundown.externalId, ingestRundown: { ...ingestRundown, playlistExternalId: playlistId }, isCreateAction: true, rundownSource: { @@ -348,7 +478,19 @@ class IngestServerAPI implements IngestRestAPI { playlistId: string, ingestRundowns: HttpIngestRundown[] ): 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.validateAPIPartPayloadForRundown(studio.blueprintId, ingestRundown, { rundown: index }) + }) + ) + const playlist = await this.findPlaylist(studio._id, playlistId) await Promise.all( @@ -384,14 +526,21 @@ class IngestServerAPI implements IngestRestAPI { rundownId: string, ingestRundown: HttpIngestRundown ): Promise> { + check(studioId, String) + check(playlistId, String) + check(rundownId, String) + check(ingestRundown, Object) + const studio = await this.findStudio(studioId) - const playlist = await this.findPlaylist(studio._id, playlistId) + this.validateRundown(ingestRundown) + await this.validateAPIPartPayloadForRundown(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, { @@ -413,6 +562,9 @@ class IngestServerAPI implements IngestRestAPI { 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) @@ -436,6 +588,10 @@ class IngestServerAPI implements IngestRestAPI { 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) @@ -457,11 +613,14 @@ class IngestServerAPI implements IngestRestAPI { 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)) @@ -476,11 +635,15 @@ class IngestServerAPI implements IngestRestAPI { 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) @@ -495,16 +658,22 @@ class IngestServerAPI implements IngestRestAPI { 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.validateAPIPartPayloadForSegment(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 segmentExternalId = ingestSegment.externalId - - const existingSegment = await this.softFindSegment(rundown._id, segmentExternalId) + const existingSegment = await this.softFindSegment(rundown._id, ingestSegment.externalId) if (existingSegment) { - throw new Meteor.Error(400, `Segment '${segmentExternalId}' already exists`) + throw new Meteor.Error(400, `Segment '${ingestSegment.externalId}' already exists`) } await runIngestOperation(studio._id, IngestJobs.UpdateSegment, { @@ -524,7 +693,22 @@ class IngestServerAPI implements IngestRestAPI { 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.validateAPIPartPayloadForSegment(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) @@ -576,16 +760,24 @@ class IngestServerAPI implements IngestRestAPI { 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.validateAPIPartPayloadForSegment(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( @@ -614,10 +806,13 @@ class IngestServerAPI implements IngestRestAPI { 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( @@ -641,6 +836,11 @@ class IngestServerAPI implements IngestRestAPI { 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) @@ -666,12 +866,16 @@ class IngestServerAPI implements IngestRestAPI { 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)) @@ -687,12 +891,17 @@ class IngestServerAPI implements IngestRestAPI { 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) @@ -708,16 +917,24 @@ class IngestServerAPI implements IngestRestAPI { 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.validateAPIPartPayloadForPart(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 partExternalId = ingestPart.externalId - - const existingPart = await this.softFindPart(segment._id, partExternalId) + const existingPart = await this.softFindPart(segment._id, ingestPart.externalId) if (existingPart) { - throw new Meteor.Error(400, `Part '${partExternalId}' already exists`) + throw new Meteor.Error(400, `Part '${ingestPart.externalId}' already exists`) } await runIngestOperation(studio._id, IngestJobs.UpdatePart, { @@ -739,7 +956,21 @@ class IngestServerAPI implements IngestRestAPI { 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.validateAPIPartPayloadForPart(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) @@ -774,12 +1005,22 @@ class IngestServerAPI implements IngestRestAPI { 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.validateAPIPartPayloadForPart(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`) @@ -803,12 +1044,16 @@ class IngestServerAPI implements IngestRestAPI { 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( @@ -833,12 +1078,17 @@ class IngestServerAPI implements IngestRestAPI { 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, { @@ -852,8 +1102,8 @@ class IngestServerAPI implements IngestRestAPI { } class IngestAPIFactory implements APIFactory { - createServerAPI(context: ServerAPIContext): IngestRestAPI { - return new IngestServerAPI(context) + createServerAPI(_context: ServerAPIContext): IngestRestAPI { + return new IngestServerAPI() } } diff --git a/meteor/server/api/rest/v1/typeConversion.ts b/meteor/server/api/rest/v1/typeConversion.ts index db5f7e4030..e4c5e25000 100644 --- a/meteor/server/api/rest/v1/typeConversion.ts +++ b/meteor/server/api/rest/v1/typeConversion.ts @@ -9,7 +9,6 @@ import { StatusCode, StudioBlueprintManifest, } from '@sofie-automation/blueprints-integration' -import { PeripheralDevice, PeripheralDeviceType } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' import { Blueprint } from '@sofie-automation/corelib/dist/dataModel/Blueprint' import { BlueprintId, @@ -18,17 +17,24 @@ import { ShowStyleVariantId, StudioId, } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PeripheralDevice, PeripheralDeviceType } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' +import { DBShowStyleBase } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' +import { DBShowStyleVariant } from '@sofie-automation/corelib/dist/dataModel/ShowStyleVariant' import { DBStudio, IStudioSettings } from '@sofie-automation/corelib/dist/dataModel/Studio' import { assertNever, Complete, getRandomId, literal } from '@sofie-automation/corelib/dist/lib' import { protectString, unprotectString } from '@sofie-automation/corelib/dist/protectedString' import { applyAndValidateOverrides, + convertObjectIntoOverrides, ObjectOverrideSetOp, + updateOverrides, wrapDefaultObject, updateOverrides, convertObjectIntoOverrides, ObjectWithOverrides, } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' +import { DEFAULT_MINIMUM_TAKE_SPAN } from '@sofie-automation/shared-lib/dist/core/constants' +import { Meteor } from 'meteor/meteor' import { APIBlueprint, APIBucket, @@ -46,17 +52,11 @@ import { import { DBShowStyleBase } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' import { DBShowStyleVariant } from '@sofie-automation/corelib/dist/dataModel/ShowStyleVariant' import { Blueprints, ShowStyleBases, Studios } from '../../../collections' -import { Meteor } from 'meteor/meteor' -import { evalBlueprint } from '../../blueprints/cache' +import { logger } from '../../../logging' import { CommonContext } from '../../../migration/upgrades/context' import { logger } from '../../../logging' -import { - DEFAULT_MINIMUM_TAKE_SPAN, - DEFAULT_FALLBACK_PART_DURATION, -} from '@sofie-automation/shared-lib/dist/core/constants' -import { Bucket } from '@sofie-automation/corelib/dist/dataModel/Bucket' -import { ForceQuickLoopAutoNext } from '@sofie-automation/shared-lib/dist/core/model/StudioSettings' -import { PlaylistSnapshotOptions, SystemSnapshotOptions } from '@sofie-automation/meteor-lib/dist/api/shapshot' +import { DEFAULT_MINIMUM_TAKE_SPAN } from '@sofie-automation/shared-lib/dist/core/constants' +import { Bucket } from '../../../../lib/collections/Buckets' /* This file contains functions that convert between the internal Sofie-Core types and types exposed to the external API. @@ -727,3 +727,20 @@ export function playlistSnapshotOptionsFrom(options: APIPlaylistSnapshotOptions) withTimeline: !!options.withTimeline, } } + +export async function validateAPIPartPayload( + blueprintId: BlueprintId | undefined, + partPayload: Object +): 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 index cfe5c47d86..a78e7f2da3 100644 --- a/meteor/server/lib/rest/v1/ingest.ts +++ b/meteor/server/lib/rest/v1/ingest.ts @@ -232,7 +232,7 @@ export interface IngestRestAPI { ): Promise> } -export type HttpIngestRundown = IngestRundown & { +export type HttpIngestRundown = Omit & { resyncUrl: string } diff --git a/packages/blueprints-integration/src/api/studio.ts b/packages/blueprints-integration/src/api/studio.ts index d1e6f42a8e..1c6bf1d842 100644 --- a/packages/blueprints-integration/src/api/studio.ts +++ b/packages/blueprints-integration/src/api/studio.ts @@ -103,6 +103,9 @@ export interface StudioBlueprintManifest Array + /** Validate the part payload passed to this blueprint according to the API schema, returning a list of error messages. */ + validatePartPayloadFromAPI?: (context: ICommonContext, payload: object) => 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 From fb7a560c90a3e704273981d3c7fd2407c4ed3177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Wed, 11 Jun 2025 18:47:46 +0200 Subject: [PATCH 39/50] fix: update ingest api for r53 --- meteor/server/api/rest/v1/ingest.ts | 6 ++--- meteor/server/api/rest/v1/typeConversion.ts | 22 +++++++++---------- meteor/server/lib/rest/v1/ingest.ts | 2 +- .../ingest/MutableIngestPartImpl.ts | 8 +++++++ .../ingest/MutableIngestRundownImpl.ts | 2 ++ .../ingest/MutableIngestSegmentImpl.ts | 10 +++++++++ .../job-worker/src/ingest/runOperation.ts | 2 ++ 7 files changed, 37 insertions(+), 15 deletions(-) diff --git a/meteor/server/api/rest/v1/ingest.ts b/meteor/server/api/rest/v1/ingest.ts index 369b0db89b..8d936c96d9 100644 --- a/meteor/server/api/rest/v1/ingest.ts +++ b/meteor/server/api/rest/v1/ingest.ts @@ -14,7 +14,7 @@ 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 '../../../../lib/api/client' +import { ClientAPI } from '@sofie-automation/meteor-lib/dist/api/client' import { HttpIngestRundown, IngestRestAPI, @@ -22,8 +22,8 @@ import { PlaylistResponse, RundownResponse, SegmentResponse, -} from '../../../../lib/api/rest/v1/ingest' -import { check } from '../../../../lib/check' +} 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' diff --git a/meteor/server/api/rest/v1/typeConversion.ts b/meteor/server/api/rest/v1/typeConversion.ts index e4c5e25000..8f9617b8d8 100644 --- a/meteor/server/api/rest/v1/typeConversion.ts +++ b/meteor/server/api/rest/v1/typeConversion.ts @@ -9,6 +9,7 @@ import { StatusCode, StudioBlueprintManifest, } from '@sofie-automation/blueprints-integration' +import { PeripheralDevice, PeripheralDeviceType } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' import { Blueprint } from '@sofie-automation/corelib/dist/dataModel/Blueprint' import { BlueprintId, @@ -17,24 +18,17 @@ import { ShowStyleVariantId, StudioId, } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { PeripheralDevice, PeripheralDeviceType } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' -import { DBShowStyleBase } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' -import { DBShowStyleVariant } from '@sofie-automation/corelib/dist/dataModel/ShowStyleVariant' import { DBStudio, IStudioSettings } from '@sofie-automation/corelib/dist/dataModel/Studio' import { assertNever, Complete, getRandomId, literal } from '@sofie-automation/corelib/dist/lib' import { protectString, unprotectString } from '@sofie-automation/corelib/dist/protectedString' import { applyAndValidateOverrides, - convertObjectIntoOverrides, ObjectOverrideSetOp, - updateOverrides, wrapDefaultObject, updateOverrides, convertObjectIntoOverrides, ObjectWithOverrides, } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' -import { DEFAULT_MINIMUM_TAKE_SPAN } from '@sofie-automation/shared-lib/dist/core/constants' -import { Meteor } from 'meteor/meteor' import { APIBlueprint, APIBucket, @@ -52,11 +46,17 @@ import { import { DBShowStyleBase } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' import { DBShowStyleVariant } from '@sofie-automation/corelib/dist/dataModel/ShowStyleVariant' import { Blueprints, ShowStyleBases, Studios } from '../../../collections' -import { logger } from '../../../logging' +import { Meteor } from 'meteor/meteor' +import { evalBlueprint } from '../../blueprints/cache' import { CommonContext } from '../../../migration/upgrades/context' import { logger } from '../../../logging' -import { DEFAULT_MINIMUM_TAKE_SPAN } from '@sofie-automation/shared-lib/dist/core/constants' -import { Bucket } from '../../../../lib/collections/Buckets' +import { + DEFAULT_MINIMUM_TAKE_SPAN, + DEFAULT_FALLBACK_PART_DURATION, +} from '@sofie-automation/shared-lib/dist/core/constants' +import { Bucket } from '@sofie-automation/corelib/dist/dataModel/Bucket' +import { ForceQuickLoopAutoNext } from '@sofie-automation/shared-lib/dist/core/model/StudioSettings' +import { PlaylistSnapshotOptions, SystemSnapshotOptions } from '@sofie-automation/meteor-lib/dist/api/shapshot' /* This file contains functions that convert between the internal Sofie-Core types and types exposed to the external API. @@ -730,7 +730,7 @@ export function playlistSnapshotOptionsFrom(options: APIPlaylistSnapshotOptions) export async function validateAPIPartPayload( blueprintId: BlueprintId | undefined, - partPayload: Object + partPayload: object ): Promise { const blueprint = await getBlueprint(blueprintId, BlueprintManifestType.STUDIO) const blueprintManifest = evalBlueprint(blueprint) as StudioBlueprintManifest diff --git a/meteor/server/lib/rest/v1/ingest.ts b/meteor/server/lib/rest/v1/ingest.ts index a78e7f2da3..b604303c38 100644 --- a/meteor/server/lib/rest/v1/ingest.ts +++ b/meteor/server/lib/rest/v1/ingest.ts @@ -1,7 +1,7 @@ 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 '../../client' +import { ClientAPI } from '@sofie-automation/meteor-lib/dist/api/client' import { IngestRundown } from '@sofie-automation/blueprints-integration' /* ************************************************************************* 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/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, From 1acaecbee4f1310b2408632e2d68a38bc7967ede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Thu, 12 Jun 2025 10:24:17 +0200 Subject: [PATCH 40/50] fix: change part payload type to unknown --- meteor/server/api/rest/v1/typeConversion.ts | 2 +- packages/blueprints-integration/src/api/studio.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/meteor/server/api/rest/v1/typeConversion.ts b/meteor/server/api/rest/v1/typeConversion.ts index 8f9617b8d8..02d532e763 100644 --- a/meteor/server/api/rest/v1/typeConversion.ts +++ b/meteor/server/api/rest/v1/typeConversion.ts @@ -730,7 +730,7 @@ export function playlistSnapshotOptionsFrom(options: APIPlaylistSnapshotOptions) export async function validateAPIPartPayload( blueprintId: BlueprintId | undefined, - partPayload: object + partPayload: unknown ): Promise { const blueprint = await getBlueprint(blueprintId, BlueprintManifestType.STUDIO) const blueprintManifest = evalBlueprint(blueprint) as StudioBlueprintManifest diff --git a/packages/blueprints-integration/src/api/studio.ts b/packages/blueprints-integration/src/api/studio.ts index 1c6bf1d842..29d200ea8c 100644 --- a/packages/blueprints-integration/src/api/studio.ts +++ b/packages/blueprints-integration/src/api/studio.ts @@ -104,7 +104,7 @@ export interface StudioBlueprintManifest Array /** Validate the part payload passed to this blueprint according to the API schema, returning a list of error messages. */ - validatePartPayloadFromAPI?: (context: ICommonContext, payload: object) => Array + 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. From 828d64b683201cf3242794c78a68819f7d7eb8f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Thu, 12 Jun 2025 11:38:10 +0200 Subject: [PATCH 41/50] feat: make user actions work with external ids --- meteor/server/api/rest/v1/playlists.ts | 22 +++++++++++++++------- meteor/server/security/check.ts | 19 +++++++++++-------- packages/job-worker/src/playout/lock.ts | 4 +++- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/meteor/server/api/rest/v1/playlists.ts b/meteor/server/api/rest/v1/playlists.ts index e358b445c9..69f7b0bd01 100644 --- a/meteor/server/api/rest/v1/playlists.ts +++ b/meteor/server/api/rest/v1/playlists.ts @@ -122,9 +122,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( @@ -160,9 +163,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( @@ -470,7 +476,9 @@ class PlaylistsServerAPI implements PlaylistsRestAPI { rundownPlaylistId: RundownPlaylistId, sourceLayerIds: string[] ): Promise> { - const rundownPlaylist = await RundownPlaylists.findOneAsync(rundownPlaylistId) + const rundownPlaylist = await RundownPlaylists.findOneAsync({ + $or: [{ _id: rundownPlaylistId }, { externalId: rundownPlaylistId }], + }) if (!rundownPlaylist) return ClientAPI.responseError( UserError.from( diff --git a/meteor/server/security/check.ts b/meteor/server/security/check.ts index da6d38ad1d..7c43da165a 100644 --- a/meteor/server/security/check.ts +++ b/meteor/server/security/check.ts @@ -20,14 +20,17 @@ export async function checkAccessToPlaylist( ): Promise { assertConnectionHasOneOfPermissions(cred, 'studio') - const playlist = (await RundownPlaylists.findOneAsync(playlistId, { - projection: { - _id: 1, - studioId: 1, - organizationId: 1, - name: 1, - }, - })) as Pick | undefined + const playlist = (await RundownPlaylists.findOneAsync( + { $or: [{ _id: playlistId }, { externalId: playlistId }] }, + { + projection: { + _id: 1, + studioId: 1, + organizationId: 1, + name: 1, + }, + } + )) as Pick | undefined if (!playlist) throw new Meteor.Error(404, `RundownPlaylist "${playlistId}" not found`) return playlist diff --git a/packages/job-worker/src/playout/lock.ts b/packages/job-worker/src/playout/lock.ts index 74bb09a339..99e9eeba94 100644 --- a/packages/job-worker/src/playout/lock.ts +++ b/packages/job-worker/src/playout/lock.ts @@ -50,7 +50,9 @@ export async function runJobWithPlaylistLock( // We can lock before checking ownership, as the locks are scoped to the studio return runWithPlaylistLock(context, data.playlistId, async (lock) => { - const playlist = await context.directCollections.RundownPlaylists.findOne(data.playlistId) + const playlist = await context.directCollections.RundownPlaylists.findOne({ + $or: [{ _id: data.playlistId }, { externalId: data.playlistId }], + }) if (playlist && playlist.studioId !== context.studioId) { throw new Error(`Job playlist "${data.playlistId}" not found or for another studio`) } From 2b43bb3d4ebd66c45c63bb96e8eb63cf6158a63f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Thu, 12 Jun 2025 12:16:15 +0200 Subject: [PATCH 42/50] fix: revert peripheral devices tests changes --- packages/openapi/src/__tests__/devices.spec.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/openapi/src/__tests__/devices.spec.ts b/packages/openapi/src/__tests__/devices.spec.ts index 3560cc2fd0..788bee3fbb 100644 --- a/packages/openapi/src/__tests__/devices.spec.ts +++ b/packages/openapi/src/__tests__/devices.spec.ts @@ -22,15 +22,11 @@ describe('Network client', () => { const devices = await devicesApi.devices() expect(devices.status).toBe(200) expect(devices).toHaveProperty('result') - expect(devices.result).toHaveProperty('ingest') - expect(devices.result).toHaveProperty('liveStatus') - expect(devices.result).toHaveProperty('mediaManager') - expect(devices.result).toHaveProperty('packageManager') - expect(devices.result).toHaveProperty('playout') - expect(devices.result).toHaveProperty('triggerInput') - devices.result.playout.forEach((device) => { - expect(typeof device).toBe('string') - deviceIds.push(device) + devices.result.forEach((device) => { + expect(typeof device).toBe('object') + expect(device).toHaveProperty('id') + expect(typeof device.id).toBe('string') + deviceIds.push(device.id) }) }) From 5bc5b92b6b273606cad447a36b5d3768416e1418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Thu, 12 Jun 2025 13:11:53 +0200 Subject: [PATCH 43/50] fix: update r53 imports in ingest api tests --- packages/openapi/src/__tests__/ingest.spec.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/openapi/src/__tests__/ingest.spec.ts b/packages/openapi/src/__tests__/ingest.spec.ts index 72411be37e..0b7ed133e1 100644 --- a/packages/openapi/src/__tests__/ingest.spec.ts +++ b/packages/openapi/src/__tests__/ingest.spec.ts @@ -1,7 +1,6 @@ -// eslint-disable-next-line node/no-missing-import -import { Configuration, IngestApi, Part, RundownTimingTypeEnum } from '../../client/ts' -import { checkServer } from '../checkServer' -import Logging from '../httpLogging' +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' From 209035fa8cacc3eec56c3adb525fe0dfa3d97f22 Mon Sep 17 00:00:00 2001 From: Simon Rogers Date: Fri, 30 May 2025 10:13:58 +0100 Subject: [PATCH 44/50] Fix ingest tests --- .../MutableIngestRundownImpl.spec.ts | 42 +++++++++++++++++++ .../MutableIngestSegmentImpl.spec.ts | 14 +++++++ 2 files changed, 56 insertions(+) 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', }, From 1d89ac3de7a5d88ec6bcd2bfb22042deaa417a86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Mon, 16 Jun 2025 12:42:47 +0200 Subject: [PATCH 45/50] fix: rename rundown source httpIngest to restApi --- meteor/server/api/ingest/actions.ts | 2 +- meteor/server/api/rest/v1/ingest.ts | 26 +++++++++++------------ meteor/server/lib/rest/v1/ingest.ts | 8 +++---- packages/corelib/src/dataModel/Rundown.ts | 6 +++--- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/meteor/server/api/ingest/actions.ts b/meteor/server/api/ingest/actions.ts index ae3f1a6daa..3952ea2a1d 100644 --- a/meteor/server/api/ingest/actions.ts +++ b/meteor/server/api/ingest/actions.ts @@ -29,7 +29,7 @@ export namespace IngestActions { return TriggerReloadDataResponse.COMPLETED } - case 'httpIngest': { + case 'restApi': { const resyncUrl = rundown.source.resyncUrl fetch(resyncUrl, { method: 'POST' }) .then(() => { diff --git a/meteor/server/api/rest/v1/ingest.ts b/meteor/server/api/rest/v1/ingest.ts index 8d936c96d9..a648d762b1 100644 --- a/meteor/server/api/rest/v1/ingest.ts +++ b/meteor/server/api/rest/v1/ingest.ts @@ -16,7 +16,7 @@ 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 { - HttpIngestRundown, + RestApiIngestRundown, IngestRestAPI, PartResponse, PlaylistResponse, @@ -86,7 +86,7 @@ class IngestServerAPI implements IngestRestAPI { } } - private validateRundown(ingestRundown: HttpIngestRundown) { + private validateRundown(ingestRundown: RestApiIngestRundown) { check(ingestRundown, Object) check(ingestRundown.externalId, String) check(ingestRundown.name, String) @@ -296,12 +296,12 @@ class IngestServerAPI implements IngestRestAPI { } private checkRundownSource(rundown: Rundown | undefined) { - if (rundown && rundown.source.type !== 'httpIngest') { + if (rundown && rundown.source.type !== 'restApi') { throw new Meteor.Error( 403, `Cannot replace existing rundown from source '${getRundownNrcsName( rundown - )}' with new data from 'httpIngest' source` + )}' with new data from 'restApi' source` ) } } @@ -429,7 +429,7 @@ class IngestServerAPI implements IngestRestAPI { _event: string, studioId: StudioId, playlistId: string, - ingestRundown: HttpIngestRundown + ingestRundown: RestApiIngestRundown ): Promise> { check(studioId, String) check(playlistId, String) @@ -463,7 +463,7 @@ class IngestServerAPI implements IngestRestAPI { ingestRundown: { ...ingestRundown, playlistExternalId: playlistId }, isCreateAction: true, rundownSource: { - type: 'httpIngest', + type: 'restApi', resyncUrl: ingestRundown.resyncUrl, }, }) @@ -476,7 +476,7 @@ class IngestServerAPI implements IngestRestAPI { _event: string, studioId: StudioId, playlistId: string, - ingestRundowns: HttpIngestRundown[] + ingestRundowns: RestApiIngestRundown[] ): Promise> { check(studioId, String) check(playlistId, String) @@ -508,7 +508,7 @@ class IngestServerAPI implements IngestRestAPI { ingestRundown: { ...ingestRundown, playlistExternalId: playlist.externalId }, isCreateAction: true, rundownSource: { - type: 'httpIngest', + type: 'restApi', resyncUrl: ingestRundown.resyncUrl, }, }) @@ -524,7 +524,7 @@ class IngestServerAPI implements IngestRestAPI { studioId: StudioId, playlistId: string, rundownId: string, - ingestRundown: HttpIngestRundown + ingestRundown: RestApiIngestRundown ): Promise> { check(studioId, String) check(playlistId, String) @@ -548,7 +548,7 @@ class IngestServerAPI implements IngestRestAPI { ingestRundown: { ...ingestRundown, playlistExternalId: playlist.externalId }, isCreateAction: true, rundownSource: { - type: 'httpIngest', + type: 'restApi', resyncUrl: ingestRundown.resyncUrl, }, }) @@ -1234,7 +1234,7 @@ export function registerRoutes(registerRoute: APIRegisterHook): v const playlistId = params.playlistId check(playlistId, String) - const ingestRundown = body as HttpIngestRundown + 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') @@ -1256,7 +1256,7 @@ export function registerRoutes(registerRoute: APIRegisterHook): v const playlistId = params.playlistId check(playlistId, String) - const ingestRundowns = body as HttpIngestRundown[] + 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') @@ -1280,7 +1280,7 @@ export function registerRoutes(registerRoute: APIRegisterHook): v const rundownId = params.rundownId check(rundownId, String) - const ingestRundown = body as HttpIngestRundown + 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') diff --git a/meteor/server/lib/rest/v1/ingest.ts b/meteor/server/lib/rest/v1/ingest.ts index b604303c38..2469541a94 100644 --- a/meteor/server/lib/rest/v1/ingest.ts +++ b/meteor/server/lib/rest/v1/ingest.ts @@ -61,7 +61,7 @@ export interface IngestRestAPI { _event: string, studioId: StudioId, playlistId: string, - ingestRundown: HttpIngestRundown + ingestRundown: RestApiIngestRundown ): Promise> putRundowns( @@ -69,7 +69,7 @@ export interface IngestRestAPI { _event: string, studioId: StudioId, playlistId: string, - ingestRundowns: HttpIngestRundown[] + ingestRundowns: RestApiIngestRundown[] ): Promise> putRundown( @@ -78,7 +78,7 @@ export interface IngestRestAPI { studioId: StudioId, playlistId: string, rundownId: string, - ingestRundown: HttpIngestRundown + ingestRundown: RestApiIngestRundown ): Promise> deleteRundowns( @@ -232,7 +232,7 @@ export interface IngestRestAPI { ): Promise> } -export type HttpIngestRundown = Omit & { +export type RestApiIngestRundown = Omit & { resyncUrl: string } diff --git a/packages/corelib/src/dataModel/Rundown.ts b/packages/corelib/src/dataModel/Rundown.ts index efbf64d71b..005f54d9c3 100644 --- a/packages/corelib/src/dataModel/Rundown.ts +++ b/packages/corelib/src/dataModel/Rundown.ts @@ -98,7 +98,7 @@ export type RundownSource = | RundownSourceSnapshot | RundownSourceHttp | RundownSourceTesting - | RundownSourceHttpIngest + | RundownSourceRestApi /** A description of the external NRCS source of a Rundown */ export interface RundownSourceNrcs { @@ -125,8 +125,8 @@ export interface RundownSourceTesting { showStyleVariantId: ShowStyleVariantId } /** A description of the source of a Rundown which was through the new HTTP ingest API */ -export interface RundownSourceHttpIngest { - type: 'httpIngest' +export interface RundownSourceRestApi { + type: 'restApi' resyncUrl: string } From 65dcb2498162ba0d3139b246d7f2e0ed4237d2ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Mon, 16 Jun 2025 13:47:32 +0200 Subject: [PATCH 46/50] fix: remove fetch from meteor packages --- meteor/.meteor/packages | 1 - 1 file changed, 1 deletion(-) diff --git a/meteor/.meteor/packages b/meteor/.meteor/packages index edeb33ceb2..3e586bdb11 100644 --- a/meteor/.meteor/packages +++ b/meteor/.meteor/packages @@ -20,4 +20,3 @@ typescript@5.6.3 # Enable TypeScript syntax in .ts and .tsx modules tracker@1.3.4 # Meteor's client-side reactive programming library zodern:types -fetch From 35aef703ebc85623f99d3bcb92ff5901e32b30b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Mon, 16 Jun 2025 15:23:55 +0200 Subject: [PATCH 47/50] fix: handle ENOTFOUND rest api reload error --- meteor/server/api/ingest/actions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/meteor/server/api/ingest/actions.ts b/meteor/server/api/ingest/actions.ts index 3952ea2a1d..79810b6710 100644 --- a/meteor/server/api/ingest/actions.ts +++ b/meteor/server/api/ingest/actions.ts @@ -36,9 +36,9 @@ export namespace IngestActions { logger.info(`Reload rundown: resync request sent to "${resyncUrl}"`) }) .catch((error) => { - if (error.errno === 'ECONNREFUSED') { + if (error.errno === 'ECONNREFUSED' || error.errno === 'ENOTFOUND') { logger.error( - `Reload rundown: could not establish connection with "${resyncUrl}" (ECONNREFUSED)` + `Reload rundown: could not establish connection with "${resyncUrl}" (${error.errno})` ) return } From fc8712585269d2af3dbe4c61991f52882966828e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Tue, 17 Jun 2025 13:33:49 +0200 Subject: [PATCH 48/50] feat: enable rundown and segment payload validation --- meteor/server/api/rest/v1/ingest.ts | 87 +++++++++++++------ meteor/server/api/rest/v1/typeConversion.ts | 34 ++++++++ .../blueprints-integration/src/api/studio.ts | 6 ++ 3 files changed, 100 insertions(+), 27 deletions(-) diff --git a/meteor/server/api/rest/v1/ingest.ts b/meteor/server/api/rest/v1/ingest.ts index a648d762b1..ee27c1edc5 100644 --- a/meteor/server/api/rest/v1/ingest.ts +++ b/meteor/server/api/rest/v1/ingest.ts @@ -27,20 +27,28 @@ import { check } from '../../../lib/check' import { Parts, RundownPlaylists, Rundowns, Segments, Studios } from '../../../collections' import { logger } from '../../../logging' import { runIngestOperation } from '../../ingest/lib' -import { validateAPIPartPayload } from './typeConversion' +import { validateAPIPartPayload, validateAPIRundownPayload, validateAPISegmentPayload } from './typeConversion' import { APIFactory, APIRegisterHook, ServerAPIContext } from './types' class IngestServerAPI implements IngestRestAPI { - private async validateAPIPartPayloadForRundown( + private async validateAPIPayloadsForRundown( blueprintId: BlueprintId | undefined, - ingestRundown: IngestRundown, + 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( - ingestRundown.segments.map(async (segment, index) => { - return this.validateAPIPartPayloadForSegment(blueprintId, segment, { + rundown.segments.map(async (segment, index) => { + return this.validateAPIPayloadsForSegment(blueprintId, segment, { ...indexes, segment: index, }) @@ -48,7 +56,7 @@ class IngestServerAPI implements IngestRestAPI { ) } - private async validateAPIPartPayloadForSegment( + private async validateAPIPayloadsForSegment( blueprintId: BlueprintId | undefined, segment: IngestRundown['segments'][number], indexes?: { @@ -56,14 +64,22 @@ class IngestServerAPI implements IngestRestAPI { 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.validateAPIPartPayloadForPart(blueprintId, part, { ...indexes, part: index }) + return this.validateAPIPayloadsForPart(blueprintId, part, { ...indexes, part: index }) }) ) } - private async validateAPIPartPayloadForPart( + private async validateAPIPayloadsForPart( blueprintId: BlueprintId | undefined, part: IngestRundown['segments'][number]['parts'][number], indexes?: { @@ -73,19 +89,36 @@ class IngestServerAPI implements IngestRestAPI { } ) { const validationResult = await validateAPIPartPayload(blueprintId, part.payload) - if (validationResult && validationResult.length > 0) { - const parts = [] - if (indexes?.rundown !== undefined) parts.push(`rundowns[${indexes.rundown}]`) - if (indexes?.segment !== undefined) parts.push(`segments[${indexes.segment}]`) - if (indexes?.part !== undefined) parts.push(`parts[${indexes.part}]`) - let msg = `Part payload validation failed` - if (parts.length > 0) msg += ` for ${parts.join('.')}` - - logger.error(`${msg} with errors: ${validationResult}`) - throw new Meteor.Error(409, msg, JSON.stringify(validationResult)) + 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) @@ -438,7 +471,7 @@ class IngestServerAPI implements IngestRestAPI { const studio = await this.findStudio(studioId) this.validateRundown(ingestRundown) - await this.validateAPIPartPayloadForRundown(studio.blueprintId, ingestRundown) + await this.validateAPIPayloadsForRundown(studio.blueprintId, ingestRundown) const existingRundown = await Rundowns.findOneAsync({ $or: [ @@ -487,7 +520,7 @@ class IngestServerAPI implements IngestRestAPI { await Promise.all( ingestRundowns.map(async (ingestRundown, index) => { this.validateRundown(ingestRundown) - return this.validateAPIPartPayloadForRundown(studio.blueprintId, ingestRundown, { rundown: index }) + return this.validateAPIPayloadsForRundown(studio.blueprintId, ingestRundown, { rundown: index }) }) ) @@ -534,7 +567,7 @@ class IngestServerAPI implements IngestRestAPI { const studio = await this.findStudio(studioId) this.validateRundown(ingestRundown) - await this.validateAPIPartPayloadForRundown(studio.blueprintId, 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) @@ -666,7 +699,7 @@ class IngestServerAPI implements IngestRestAPI { const studio = await this.findStudio(studioId) this.validateSegment(ingestSegment) - await this.validateAPIPartPayloadForSegment(studio.blueprintId, 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) @@ -703,7 +736,7 @@ class IngestServerAPI implements IngestRestAPI { await Promise.all( ingestSegments.map(async (ingestSegment, index) => { this.validateSegment(ingestSegment) - return await this.validateAPIPartPayloadForSegment(studio.blueprintId, ingestSegment, { + return await this.validateAPIPayloadsForSegment(studio.blueprintId, ingestSegment, { segment: index, }) }) @@ -769,7 +802,7 @@ class IngestServerAPI implements IngestRestAPI { const studio = await this.findStudio(studioId) this.validateSegment(ingestSegment) - await this.validateAPIPartPayloadForSegment(studio.blueprintId, 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) @@ -926,7 +959,7 @@ class IngestServerAPI implements IngestRestAPI { const studio = await this.findStudio(studioId) this.validatePart(ingestPart) - await this.validateAPIPartPayloadForPart(studio.blueprintId, 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) @@ -967,7 +1000,7 @@ class IngestServerAPI implements IngestRestAPI { await Promise.all( ingestParts.map(async (ingestPart, index) => { this.validatePart(ingestPart) - return this.validateAPIPartPayloadForPart(studio.blueprintId, ingestPart, { part: index }) + return this.validateAPIPayloadsForPart(studio.blueprintId, ingestPart, { part: index }) }) ) @@ -1015,7 +1048,7 @@ class IngestServerAPI implements IngestRestAPI { const studio = await this.findStudio(studioId) this.validatePart(ingestPart) - await this.validateAPIPartPayloadForPart(studio.blueprintId, 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) diff --git a/meteor/server/api/rest/v1/typeConversion.ts b/meteor/server/api/rest/v1/typeConversion.ts index 02d532e763..2a3b008c58 100644 --- a/meteor/server/api/rest/v1/typeConversion.ts +++ b/meteor/server/api/rest/v1/typeConversion.ts @@ -728,6 +728,40 @@ export function playlistSnapshotOptionsFrom(options: APIPlaylistSnapshotOptions) } } +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 diff --git a/packages/blueprints-integration/src/api/studio.ts b/packages/blueprints-integration/src/api/studio.ts index 29d200ea8c..e902446754 100644 --- a/packages/blueprints-integration/src/api/studio.ts +++ b/packages/blueprints-integration/src/api/studio.ts @@ -103,6 +103,12 @@ 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 From 5611679ca65283385c1c7f5544ab237ee52d1621 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Tue, 17 Jun 2025 16:06:25 +0200 Subject: [PATCH 49/50] fix: convert user actions external to internal ids on api layer --- meteor/server/api/rest/v1/playlists.ts | 197 ++++++++++++------ meteor/server/security/check.ts | 19 +- packages/job-worker/src/playout/lock.ts | 8 +- .../implementation/PlayoutRundownModelImpl.ts | 5 +- .../implementation/PlayoutSegmentModelImpl.ts | 3 +- 5 files changed, 145 insertions(+), 87 deletions(-) diff --git a/meteor/server/api/rest/v1/playlists.ts b/meteor/server/api/rest/v1/playlists.ts index 69f7b0bd01..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, @@ -146,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, @@ -199,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, @@ -221,6 +271,7 @@ class PlaylistsServerAPI implements PlaylistsRestAPI { ) } } + async executeBucketAdLib( connection: Meteor.Connection, event: string, @@ -229,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( @@ -262,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, @@ -284,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, } @@ -331,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) => { @@ -348,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 ) } @@ -360,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, } ) } @@ -380,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, } ) } @@ -402,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, } ) } @@ -425,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, } ) } @@ -449,23 +521,20 @@ class PlaylistsServerAPI implements PlaylistsRestAPI { fromPartInstanceId: PartInstanceId | undefined ): Promise> { triggerWriteAccess() - const rundownPlaylist = await RundownPlaylists.findOneAsync({ - $or: [{ _id: rundownPlaylistId }, { externalId: 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, } ) } @@ -476,10 +545,8 @@ class PlaylistsServerAPI implements PlaylistsRestAPI { rundownPlaylistId: RundownPlaylistId, sourceLayerIds: string[] ): Promise> { - const rundownPlaylist = await RundownPlaylists.findOneAsync({ - $or: [{ _id: rundownPlaylistId }, { externalId: rundownPlaylistId }], - }) - if (!rundownPlaylist) + const playlist = await this.findPlaylist(rundownPlaylistId) + if (!playlist) return ClientAPI.responseError( UserError.from( Error(`Rundown playlist ${rundownPlaylistId} does not exist`), @@ -487,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 @@ -500,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, } ) @@ -520,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/security/check.ts b/meteor/server/security/check.ts index 7c43da165a..da6d38ad1d 100644 --- a/meteor/server/security/check.ts +++ b/meteor/server/security/check.ts @@ -20,17 +20,14 @@ export async function checkAccessToPlaylist( ): Promise { assertConnectionHasOneOfPermissions(cred, 'studio') - const playlist = (await RundownPlaylists.findOneAsync( - { $or: [{ _id: playlistId }, { externalId: playlistId }] }, - { - projection: { - _id: 1, - studioId: 1, - organizationId: 1, - name: 1, - }, - } - )) as Pick | undefined + const playlist = (await RundownPlaylists.findOneAsync(playlistId, { + projection: { + _id: 1, + studioId: 1, + organizationId: 1, + name: 1, + }, + })) as Pick | undefined if (!playlist) throw new Meteor.Error(404, `RundownPlaylist "${playlistId}" not found`) return playlist diff --git a/packages/job-worker/src/playout/lock.ts b/packages/job-worker/src/playout/lock.ts index 99e9eeba94..0dad525617 100644 --- a/packages/job-worker/src/playout/lock.ts +++ b/packages/job-worker/src/playout/lock.ts @@ -24,9 +24,7 @@ export async function runJobWithPlayoutModel( // We can lock before checking ownership, as the locks are scoped to the studio return runWithPlaylistLock(context, data.playlistId, async (playlistLock) => { - const playlist = await context.directCollections.RundownPlaylists.findOne({ - $or: [{ _id: data.playlistId }, { externalId: data.playlistId }], - }) + const playlist = await context.directCollections.RundownPlaylists.findOne(data.playlistId) if (!playlist || playlist.studioId !== context.studioId) { throw new Error(`Job playlist "${data.playlistId}" not found or for another studio`) } @@ -50,9 +48,7 @@ export async function runJobWithPlaylistLock( // We can lock before checking ownership, as the locks are scoped to the studio return runWithPlaylistLock(context, data.playlistId, async (lock) => { - const playlist = await context.directCollections.RundownPlaylists.findOne({ - $or: [{ _id: data.playlistId }, { externalId: data.playlistId }], - }) + const playlist = await context.directCollections.RundownPlaylists.findOne(data.playlistId) if (playlist && playlist.studioId !== context.studioId) { throw new Error(`Job playlist "${data.playlistId}" not found or for another studio`) } diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutRundownModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutRundownModelImpl.ts index 1362d4b59a..4f6b54aef6 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutRundownModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutRundownModelImpl.ts @@ -9,7 +9,6 @@ import { UserError, UserErrorMessage } from '@sofie-automation/corelib/dist/erro import { SegmentOrphanedReason } from '@sofie-automation/corelib/dist/dataModel/Segment' import { getRandomId } from '@sofie-automation/corelib/dist/lib' import { PlayoutSegmentModelImpl } from './PlayoutSegmentModelImpl.js' -import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' export class PlayoutRundownModelImpl implements PlayoutRundownModel { readonly rundown: ReadonlyDeep @@ -48,9 +47,7 @@ export class PlayoutRundownModelImpl implements PlayoutRundownModel { } getSegment(id: SegmentId): PlayoutSegmentModel | undefined { - return this.segments.find( - (segment) => segment.segment._id === id || segment.segment.externalId === unprotectString(id) - ) + return this.segments.find((segment) => segment.segment._id === id) } getSegmentIds(): SegmentId[] { diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutSegmentModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutSegmentModelImpl.ts index 303febcfe1..1356244003 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutSegmentModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutSegmentModelImpl.ts @@ -3,7 +3,6 @@ import { ReadonlyDeep } from 'type-fest' import { DBSegment, SegmentOrphanedReason } from '@sofie-automation/corelib/dist/dataModel/Segment' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import { PlayoutSegmentModel } from '../PlayoutSegmentModel.js' -import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' export class PlayoutSegmentModelImpl implements PlayoutSegmentModel { readonly #segment: DBSegment @@ -21,7 +20,7 @@ export class PlayoutSegmentModelImpl implements PlayoutSegmentModel { } getPart(id: PartId): ReadonlyDeep | undefined { - return this.parts.find((part) => part._id === id || part.externalId === unprotectString(id)) + return this.parts.find((part) => part._id === id) } getPartIds(): PartId[] { From 4e48f4a041ab4808901b7524d829dfbe914cab94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Mareti=C4=87?= Date: Wed, 18 Jun 2025 12:34:58 +0200 Subject: [PATCH 50/50] fix: replace meteor fetch function with global fetch --- meteor/__mocks__/_setupMocks.ts | 1 - meteor/server/api/ingest/actions.ts | 9 +++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/meteor/__mocks__/_setupMocks.ts b/meteor/__mocks__/_setupMocks.ts index 77ca346ddb..8cf580f95d 100644 --- a/meteor/__mocks__/_setupMocks.ts +++ b/meteor/__mocks__/_setupMocks.ts @@ -10,7 +10,6 @@ jest.mock('nanoid', (...args) => require('./random').setup(args), { virtual: tru // Add references to all "meteor" mocks below, so that jest resolves the imports properly. -jest.mock('meteor/fetch', () => null, { virtual: true }) jest.mock('meteor/meteor', (...args) => require('./meteor').setup(args), { virtual: true }) jest.mock('meteor/random', (...args) => require('./random').setup(args), { virtual: true }) jest.mock('meteor/check', (...args) => require('./check').setup(args), { virtual: true }) diff --git a/meteor/server/api/ingest/actions.ts b/meteor/server/api/ingest/actions.ts index 79810b6710..29fc700312 100644 --- a/meteor/server/api/ingest/actions.ts +++ b/meteor/server/api/ingest/actions.ts @@ -7,7 +7,6 @@ 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 { fetch } from 'meteor/fetch' import { logger } from '../../logging' /* @@ -36,16 +35,14 @@ export namespace IngestActions { logger.info(`Reload rundown: resync request sent to "${resyncUrl}"`) }) .catch((error) => { - if (error.errno === 'ECONNREFUSED' || error.errno === 'ENOTFOUND') { + if (error.cause.code === 'ECONNREFUSED' || error.cause.code === 'ENOTFOUND') { logger.error( - `Reload rundown: could not establish connection with "${resyncUrl}" (${error.errno})` + `Reload rundown: could not establish connection with "${resyncUrl}" (${error.cause.code})` ) return } logger.error( - `Reload rundown: error occured while sending resync request to "${resyncUrl}", error: "${JSON.stringify( - error - )}"` + `Reload rundown: error occured while sending resync request to "${resyncUrl}", message: ${error.message}, cause: ${JSON.stringify(error.cause)}` ) })