From e19a1aaec00c18e166c68915caa8496b6541adaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Drie=C3=9Fen?= Date: Wed, 8 Jan 2025 15:05:57 +0100 Subject: [PATCH] feat: add new radio text event --- CHANGELOG.md | 4 + docs/EVENTS.md | 4 + openapi.json | 304 +++++++++++++++++++++++++--- openapi.yaml | 131 ++++++++++++ src/ingest/events/post.js | 3 +- src/ingest/ingest.test.js | 81 ++++++++ src/utils/events/processServices.js | 14 +- 7 files changed, 511 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdad9b6..dbee9c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.9.3] - 2025-01-07 + +- feat: add new radio text event + ## [1.9.2] - 2024-10-28 - feat: stop storing the message in the datastore diff --git a/docs/EVENTS.md b/docs/EVENTS.md index 491fb91..d8cbc47 100644 --- a/docs/EVENTS.md +++ b/docs/EVENTS.md @@ -10,3 +10,7 @@ This event marks the beginning of a new playing element/ track for the providing The `next` event is similar to `playing` but only signalizes the next scheduled upcoming track. The next element can be replaced by a new next element before a playing element to signalize a new scheduled track. A pair of next and playing events should have a reference between each other (`playlistId`), so subscribers can link these two incoming events. + +## `de.ard.eventhub.v1.radio.text` + +This event sets the live encoder text diff --git a/openapi.json b/openapi.json index 3f0ef75..93b161a 100644 --- a/openapi.json +++ b/openapi.json @@ -44,7 +44,9 @@ "paths": { "/auth/login": { "post": { - "tags": ["auth"], + "tags": [ + "auth" + ], "summary": "Swap login credentials for a token", "operationId": "authLoginPost", "requestBody": { @@ -103,7 +105,9 @@ }, "/auth/refresh": { "post": { - "tags": ["auth"], + "tags": [ + "auth" + ], "summary": "Swap refresh token for new id token", "operationId": "authRefreshPost", "requestBody": { @@ -158,7 +162,9 @@ }, "/auth/reset": { "post": { - "tags": ["auth"], + "tags": [ + "auth" + ], "summary": "Request password reset email", "operationId": "authResetPost", "requestBody": { @@ -206,7 +212,9 @@ }, "/events/de.ard.eventhub.v1.radio.track.next": { "post": { - "tags": ["events"], + "tags": [ + "events" + ], "summary": "Distribute a next track", "operationId": "eventPostV1RadioTrackNext", "security": [ @@ -264,9 +272,73 @@ } } }, + "/events/de.ard.eventhub.v1.radio.text": { + "post": { + "tags": [ + "events" + ], + "summary": "Set a live encoder text for a track", + "operationId": "eventPostV1RadioText", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/eventPostV1RadioText" + }, + "responses": { + "201": { + "$ref": "#/components/responses/eventPostV1RadioText" + }, + "400": { + "description": "Bad Request (invalid input)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/errorBadRequest" + } + } + } + }, + "401": { + "description": "Missing authentication", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/errorUnauthorized" + } + } + } + }, + "403": { + "description": "Invalid authorization", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/errorForbidden" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/errorInternalServerError" + } + } + } + } + } + } + }, "/events/de.ard.eventhub.v1.radio.track.playing": { "post": { - "tags": ["events"], + "tags": [ + "events" + ], "summary": "Distribute a now-playing track", "operationId": "eventPostV1RadioTrackPlaying", "security": [ @@ -326,7 +398,9 @@ }, "/subscriptions": { "get": { - "tags": ["subscriptions"], + "tags": [ + "subscriptions" + ], "summary": "List all subscriptions for this user", "operationId": "subscriptionList", "security": [ @@ -378,7 +452,9 @@ } }, "post": { - "tags": ["subscriptions"], + "tags": [ + "subscriptions" + ], "summary": "Add a new subscription", "operationId": "subscriptionPost", "security": [ @@ -463,7 +539,9 @@ }, "/subscriptions/{name}": { "get": { - "tags": ["subscriptions"], + "tags": [ + "subscriptions" + ], "summary": "Get details about a single subscription from this user", "operationId": "subscriptionsGet", "security": [ @@ -541,7 +619,9 @@ } }, "delete": { - "tags": ["subscriptions"], + "tags": [ + "subscriptions" + ], "summary": "Remove a single subscription by this user", "operationId": "subscriptionsDelete", "security": [ @@ -618,7 +698,9 @@ }, "/topics": { "get": { - "tags": ["topics"], + "tags": [ + "topics" + ], "summary": "List all available topics", "operationId": "topicsGet", "security": [ @@ -663,6 +745,17 @@ } }, "required": true + }, + "eventPostV1RadioText": { + "description": "New event to be distributed to subscribers.\nThe Eventhub format validation expects only a subset of these variables as minimum set. All other fields are technically optional, but **highly encouraged** to be included, so a best-possible metadata exchange is possible.\nThe subset is defined in the list of required fields of Schemas `eventV1PostRadioTextBody`, resulting in this body:\n```json\n{\n \"event\": \"music\",\n \"start\": \"2020-01-19T06:00:00+01:00\",\n \"validUntil\": \"2026-01-19T06:00:00+01:00\",\n \"text\": \"Catchy one Liner\",\n \"services\": [ { ... } ]\n }\n```\nRequired fields not specified in the Schema, will cause your request to fail.\n", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/eventV1PostRadioTextBody" + } + } + }, + "required": true } }, "responses": { @@ -675,6 +768,16 @@ } } } + }, + "eventPostV1RadioText": { + "description": "Event created\n*Note:* The first request of an event for an externalId that is not registered yet, will return the status `failed: 1`. This indicates that a new topic for the externalId has been created, and the request needs to be repeated:\n```json\n\"statuses\": {\n \"published\": 0,\n \"blocked\": 0,\n \"failed\": 1\n}\n```\nIf the request returns the status `blocked: 1`, it indicates that you are not allowed to publish events under the given publisherId.\n", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/eventV1PostRadioTextResBody" + } + } + } } }, "securitySchemes": { @@ -849,12 +952,19 @@ }, "services": { "type": "object", - "required": ["type", "externalId", "publisherId"], + "required": [ + "type", + "externalId", + "publisherId" + ], "properties": { "type": { "type": "string", "example": "PermanentLivestream", - "enum": ["EventLivestream", "PermanentLivestream"] + "enum": [ + "EventLivestream", + "PermanentLivestream" + ] }, "externalId": { "type": "string", @@ -875,11 +985,22 @@ "reference": { "type": "object", "additionalProperties": false, - "required": ["type", "externalId"], + "required": [ + "type", + "externalId" + ], "properties": { "type": { "type": "string", - "enum": ["Episode", "Section", "Publication", "Broadcast", "Show", "Season", "Article"] + "enum": [ + "Episode", + "Section", + "Publication", + "Broadcast", + "Show", + "Season", + "Article" + ] }, "id": { "type": "string", @@ -909,7 +1030,13 @@ }, "eventV1PostBody": { "additionalProperties": false, - "required": ["type", "start", "title", "services", "playlistItemId"], + "required": [ + "type", + "start", + "title", + "services", + "playlistItemId" + ], "type": "object", "description": "**Please also note the details in the `POST /events/v1` endpoint above!**\n", "properties": { @@ -917,13 +1044,25 @@ "type": "string", "description": "If set, it needs to match the URL event parameter", "example": "de.ard.eventhub.v1.radio.track.playing", - "enum": ["de.ard.eventhub.v1.radio.track.playing", "de.ard.eventhub.v1.radio.track.next"] + "enum": [ + "de.ard.eventhub.v1.radio.track.playing", + "de.ard.eventhub.v1.radio.track.next" + ] }, "type": { "type": "string", "description": "The type of the element that triggered this event. See additional file in docs for details.", "example": "music", - "enum": ["audio", "commercial", "jingle", "live", "music", "news", "traffic", "weather"] + "enum": [ + "audio", + "commercial", + "jingle", + "live", + "music", + "news", + "traffic", + "weather" + ] }, "start": { "type": "string", @@ -955,7 +1094,10 @@ "nullable": true, "items": { "type": "object", - "required": ["name", "role"], + "required": [ + "name", + "role" + ], "properties": { "name": { "type": "string", @@ -1048,7 +1190,10 @@ "items": { "type": "string" }, - "example": ["swrhfdb1.KONF.12345", "zskhfdb1.KONF.12345"] + "example": [ + "swrhfdb1.KONF.12345", + "zskhfdb1.KONF.12345" + ] }, "externalId": { "type": "string", @@ -1077,12 +1222,22 @@ "description": "Can contain an array of media files like cover, artist, etc.", "nullable": true, "items": { - "required": ["type", "url", "description"], + "required": [ + "type", + "url", + "description" + ], "type": "object", "properties": { "type": { "type": "string", - "enum": ["cover", "artist", "anchor", "audio", "video"], + "enum": [ + "cover", + "artist", + "anchor", + "audio", + "video" + ], "example": "cover" }, "url": { @@ -1127,6 +1282,56 @@ } } }, + "eventV1PostRadioTextBody": { + "additionalProperties": false, + "required": [ + "start", + "validUntil", + "services", + "text" + ], + "type": "object", + "description": "**Please also note the details in the `POST /events/v1` endpoint above!**\n", + "properties": { + "event": { + "type": "string", + "description": "If set, it needs to match the URL event parameter", + "example": "de.ard.eventhub.v1.radio.text", + "enum": [ + "de.ard.eventhub.v1.radio.text" + ] + }, + "start": { + "type": "string", + "description": "ISO8601 compliant timestamp", + "format": "iso8601-timestamp", + "example": "2020-01-19T06:00:00+01:00" + }, + "validUntil": { + "type": "string", + "description": "ISO8601 compliant timestamp how long this text can be displayed (fallback to title - artist)", + "format": "iso8601-timestamp", + "example": "2020-01-19T06:00:00+01:00" + }, + "text": { + "type": "string", + "description": "one line of Radiotext for live encoder (limited in length)", + "example": "Catchy one Liner" + }, + "services": { + "type": "array", + "description": "The playing stations unique Service-IDs. Do not include the Service-Type suffix.", + "items": { + "minItems": 1, + "allOf": [ + { + "$ref": "#/components/schemas/services" + } + ] + } + } + } + }, "eventV1ResBody": { "type": "object", "properties": { @@ -1156,18 +1361,57 @@ } } }, + "eventV1PostRadioTextResBody": { + "type": "object", + "properties": { + "statuses": { + "type": "object", + "properties": { + "published": { + "type": "integer", + "example": 1 + }, + "blocked": { + "type": "integer", + "example": 0 + }, + "failed": { + "type": "integer", + "example": 0 + } + } + }, + "event": { + "$ref": "#/components/schemas/eventV1PostRadioTextBody" + }, + "trace": { + "type": "string", + "example": null + } + } + }, "subscriptionPost": { - "required": ["type", "method", "url", "contact", "topic"], + "required": [ + "type", + "method", + "url", + "contact", + "topic" + ], "type": "object", "properties": { "type": { "type": "string", - "enum": ["PUBSUB"], + "enum": [ + "PUBSUB" + ], "example": "PUBSUB" }, "method": { "type": "string", - "enum": ["PUSH"], + "enum": [ + "PUSH" + ], "example": "PUSH" }, "url": { @@ -1202,12 +1446,16 @@ "properties": { "type": { "type": "string", - "enum": ["PUBSUB"], + "enum": [ + "PUBSUB" + ], "example": "PUBSUB" }, "method": { "type": "string", - "enum": ["PUSH"], + "enum": [ + "PUSH" + ], "example": "PUSH" }, "name": { @@ -1287,7 +1535,9 @@ "properties": { "type": { "type": "string", - "enum": ["PUBSUB"], + "enum": [ + "PUBSUB" + ], "example": "PUBSUB" }, "id": { diff --git a/openapi.yaml b/openapi.yaml index 193de46..85bc2d0 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -168,6 +168,43 @@ paths: application/json: schema: $ref: '#/components/schemas/errorInternalServerError' + /events/de.ard.eventhub.v1.radio.text: + post: + tags: + - events + summary: Set a live encoder text for a track + operationId: eventPostV1RadioText + security: + - bearerAuth: [] + requestBody: + $ref: '#/components/requestBodies/eventPostV1RadioText' + responses: + '201': + $ref: '#/components/responses/eventPostV1RadioText' + '400': + description: Bad Request (invalid input) + content: + application/json: + schema: + $ref: '#/components/schemas/errorBadRequest' + '401': + description: Missing authentication + content: + application/json: + schema: + $ref: '#/components/schemas/errorUnauthorized' + '403': + description: Invalid authorization + content: + application/json: + schema: + $ref: '#/components/schemas/errorForbidden' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/errorInternalServerError' /events/de.ard.eventhub.v1.radio.track.playing: post: tags: @@ -444,6 +481,26 @@ components: schema: $ref: '#/components/schemas/eventV1PostBody' required: true + eventPostV1RadioText: + description: | + New event to be distributed to subscribers. + The Eventhub format validation expects only a subset of these variables as minimum set. All other fields are technically optional, but **highly encouraged** to be included, so a best-possible metadata exchange is possible. + The subset is defined in the list of required fields of Schemas `eventV1PostRadioTextBody`, resulting in this body: + ```json + { + "event": "music", + "start": "2020-01-19T06:00:00+01:00", + "validUntil": "2026-01-19T06:00:00+01:00", + "text": "Catchy one Liner", + "services": [ { ... } ] + } + ``` + Required fields not specified in the Schema, will cause your request to fail. + content: + application/json: + schema: + $ref: '#/components/schemas/eventV1PostRadioTextBody' + required: true responses: eventV1RadioTrack: description: > @@ -470,6 +527,22 @@ components: application/json: schema: $ref: '#/components/schemas/eventV1ResBody' + eventPostV1RadioText: + description: | + Event created + *Note:* The first request of an event for an externalId that is not registered yet, will return the status `failed: 1`. This indicates that a new topic for the externalId has been created, and the request needs to be repeated: + ```json + "statuses": { + "published": 0, + "blocked": 0, + "failed": 1 + } + ``` + If the request returns the status `blocked: 1`, it indicates that you are not allowed to publish events under the given publisherId. + content: + application/json: + schema: + $ref: '#/components/schemas/eventV1PostRadioTextResBody' securitySchemes: bearerAuth: type: http @@ -868,6 +941,44 @@ components: be a true string in the future, do not expect this string to remain numbers only! example: '1234567890' + eventV1PostRadioTextBody: + additionalProperties: false + required: + - start + - validUntil + - services + - text + type: object + description: | + **Please also note the details in the `POST /events/v1` endpoint above!** + properties: + event: + type: string + description: If set, it needs to match the URL event parameter + example: de.ard.eventhub.v1.radio.text + enum: + - de.ard.eventhub.v1.radio.text + start: + type: string + description: ISO8601 compliant timestamp + format: iso8601-timestamp + example: '2020-01-19T06:00:00+01:00' + validUntil: + type: string + description: ISO8601 compliant timestamp how long this text can be displayed (fallback to title - artist) + format: iso8601-timestamp + example: '2020-01-19T06:00:00+01:00' + text: + type: string + description: one line of Radiotext for live encoder (limited in length) + example: Catchy one Liner + services: + type: array + description: The playing stations unique Service-IDs. Do not include the Service-Type suffix. + items: + minItems: 1 + allOf: + - $ref: '#/components/schemas/services' eventV1ResBody: type: object properties: @@ -888,6 +999,26 @@ components: trace: type: string example: null + eventV1PostRadioTextResBody: + type: object + properties: + statuses: + type: object + properties: + published: + type: integer + example: 1 + blocked: + type: integer + example: 0 + failed: + type: integer + example: 0 + event: + $ref: '#/components/schemas/eventV1PostRadioTextBody' + trace: + type: string + example: null subscriptionPost: required: - type diff --git a/src/ingest/events/post.js b/src/ingest/events/post.js index b052385..8477287 100644 --- a/src/ingest/events/post.js +++ b/src/ingest/events/post.js @@ -122,7 +122,8 @@ module.exports = async (req, res) => { message.services = newServices // send event to common topic - if (IS_COMMON_TOPIC_ENABLED) { + // if it is not a radio text event + if (IS_COMMON_TOPIC_ENABLED && req.body.event !== 'de.ard.eventhub.v1.radio.text') { // prepare common post const topicName = pubsub.buildId( eventName.replace('de.ard.eventhub.', '') diff --git a/src/ingest/ingest.test.js b/src/ingest/ingest.test.js index 5af3be7..75cff5f 100644 --- a/src/ingest/ingest.test.js +++ b/src/ingest/ingest.test.js @@ -264,6 +264,87 @@ describe(`POST ${eventPath}`, () => { }) }) +const eventRadioTextName = 'de.ard.eventhub.v1.radio.text' +const eventRadioTextPath = `/events/${eventRadioTextName}` + +const eventRadioText = { + event: eventRadioTextName, + start: DateTime.now().toISO(), + validUntil: DateTime.now().toISO(), + text: 'Unit Test Song', + services: [ + { + type: 'PermanentLivestream', + externalId: 'crid://swr.de/282310/unit', + publisherId: '282310', + }, + ], +} + +describe(`POST ${eventRadioTextPath}`, () => { + it('test invalid auth for POST /event', (done) => { + chai + .request(server) + .post(eventRadioTextPath) + .send(eventRadioText) + .end((_err, res) => { + testMissingAuth(res) + done() + }) + }) + + it('test invalid auth for POST /event', (done) => { + chai + .request(server) + .post(eventRadioTextPath) + .set('Authorization', `Bearer invalid${accessToken}`) + .send(eventRadioText) + .end((_err, res) => { + testFailedAuth(res) + done() + }) + }) + + it('publish a new event', (done) => { + chai + .request(server) + .post(eventRadioTextPath) + .set('Authorization', `Bearer ${accessToken}`) + .send(eventRadioText) + .end((_err, res) => { + testResponse(res, 201) + testEventKeys(res.body) + done() + }) + }) + + it('publish a new event with expired time', (done) => { + eventRadioText.start = DateTime.now().minus({ minutes: 3 }).toISO() + chai + .request(server) + .post(eventRadioTextPath) + .set('Authorization', `Bearer ${accessToken}`) + .send(eventRadioText) + .end((_err, res) => { + testResponse(res, 400) + done() + }) + }) + + it('publish a new event with invalid time', (done) => { + eventRadioText.start = `${DateTime.now().toISO()}00` + chai + .request(server) + .post(eventRadioTextPath) + .set('Authorization', `Bearer ${accessToken}`) + .send(eventRadioText) + .end((_err, res) => { + testResponse(res, 400) + done() + }) + }) +}) + /* TOPICS - Access to topics details */ diff --git a/src/utils/events/processServices.js b/src/utils/events/processServices.js index cb46d0a..f52bb59 100644 --- a/src/utils/events/processServices.js +++ b/src/utils/events/processServices.js @@ -22,7 +22,13 @@ const URN_PUBLISHER_REGEX = /(?=urn:ard:publisher:[a-z0-9]{16})/g module.exports = async (service, req) => { // fetch prefix from configured list - const urnPrefix = coreIdPrefixes[service.type] + let urnPrefix = coreIdPrefixes[service.type] + + // add a different suffix for radio text topics to not confuse subscribers with new event + if (req.body.event === 'de.ard.eventhub.v1.radio.text') { + urnPrefix = `RadioText:${urnPrefix}` + } + const topicId = `${urnPrefix}${createHashedId(service.externalId)}` // create hash based on prefix and id @@ -75,7 +81,11 @@ module.exports = async (service, req) => { level: 'warning', message: `User unauthorized for service > ${service.externalId} (${service.publisherId})`, source, - data: { service, user: req.user, publisher: publisher?.institution }, + data: { + service, + user: req.user, + publisher: publisher?.institution, + }, }) // stop processing