From 54278d904c4e7468a3c74b7be7f990a38a598e14 Mon Sep 17 00:00:00 2001 From: dnmeid Date: Fri, 26 May 2023 15:14:12 +0200 Subject: [PATCH] feat: add markers, add layout data get/set --- .eslintrc | 38 ++- CHANGELOG.md | 11 + actions.js | 294 ++++++++++++++++----- feedbacks.js | 64 +++-- index.js | 734 +++++++++++++++++++-------------------------------- package.json | 2 +- presets.js | 63 +++-- 7 files changed, 614 insertions(+), 592 deletions(-) diff --git a/.eslintrc b/.eslintrc index 74c5200..7859748 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,22 +1,20 @@ { - "globals": { - "angular": false, - "module": false, - "inject": false, - "document": false - }, - "env": { - "es6": true, - "browser": true, - "amd": true, - "node": true - }, - "extends": [ - "eslint:recommended" - ], - "parserOptions": { - "ecmaVersion": 9, - "sourceType": "module" - }, - "rules": {} + "globals": { + "angular": false, + "module": false, + "inject": false, + "document": false + }, + "env": { + "es6": true, + "browser": true, + "amd": true, + "node": true + }, + "extends": ["eslint:recommended"], + "parserOptions": { + "ecmaVersion": 13, + "sourceType": "module" + }, + "rules": {} } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cc0c06..f3bf9ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] --- +## [2.1.0] (2023-05-26) + +## New Features +* Added action to insert chapter markers in recordings +* Added action to get layout data and store it in a variable +* Added action to set layout data to device +* Added options to toggle streaming and recording based on current state +* Added preset for recorder reset action +* Don't show "All streams" any more if there are no individual streams in a channel +* completely redone internal handling of polling and updating the connection data, improved error handling + ## [2.0.0] (2023-05-24) ## Major diff --git a/actions.js b/actions.js index c876651..6c05e99 100644 --- a/actions.js +++ b/actions.js @@ -9,24 +9,6 @@ module.exports = { * @returns {Object} the action definitions */ get_actions() { - const startStopOption = { - type: 'dropdown', - id: 'startStopAction', - label: 'Action', - choices: this.CHOICES_STARTSTOP, - default: this.CHOICES_STARTSTOP[0].id, - } - - // Companion has difficulties with the first 'default' selected value. - let channels = [{ id: '0-0', label: '---' }] - Array.prototype.push.apply(channels, this.CHOICES_CHANNELS_LAYOUTS) - - let publishers = [{ id: '0-0', label: '---' }] - Array.prototype.push.apply(publishers, this.CHOICES_CHANNELS_PUBLISHERS) - - let recorders = [{ id: '0', label: '---' }] - Array.prototype.push.apply(recorders, this.CHOICES_RECORDERS) - const actions = {} actions['channelChangeLayout'] = { name: 'Change channel layout', @@ -35,8 +17,8 @@ module.exports = { type: 'dropdown', id: 'channelIdlayoutId', label: 'Change layout to:', - choices: channels, - default: channels[0].id, + choices: this.choicesChannelLayout(), + default: this.firstId(this.choicesChannelLayout()), }, ], callback: (action) => { @@ -45,8 +27,8 @@ module.exports = { body, callback - if (!action.options.channelIdlayoutId || !action.options.channelIdlayoutId.includes('-')) { - this._setStatus( + if (typeof action.options.channelIdlayoutId !== 'string' || !action.options.channelIdlayoutId.includes('-')) { + this.setStatus( InstanceStatus.UnknownWarning, 'Channel and layout are not known! Please review your button config' ) @@ -55,16 +37,16 @@ module.exports = { } const [channelId, layoutId] = action.options.channelIdlayoutId.split('-') - if (!this._getChannelById(channelId)) { - this._setStatus( + if (!this.state.channels[channelId]) { + this.setStatus( InstanceStatus.UnknownWarning, 'Action on non existing channel! Please review your button config' ) this.log('error', 'Action on non existing channel: ' + channelId) return } - if (!this._checkValidLayoutId(channelId, layoutId)) { - this._setStatus( + if (!this.state.channels[channelId].layouts[layoutId]) { + this.setStatus( InstanceStatus.UnknownWarning, 'Action on non existing layout! Please review your button config' ) @@ -77,12 +59,12 @@ module.exports = { body = { id: Number(layoutId) } callback = (response) => { if (response && response.status === 'ok') { - this._updateActiveChannelLayout(channelId, layoutId) + this.updateActiveChannelLayout(channelId, layoutId) } } // Send request - this._sendRequest(type, url, body).then(callback) + this.sendRequest(type, url, body).then(callback) }, } @@ -93,62 +75,86 @@ module.exports = { type: 'dropdown', label: 'Channel publishers', id: 'channelIdpublisherId', - choices: publishers, - default: publishers[0].id, + choices: this.choicesChannelPublishers(), + default: this.firstId(this.choicesChannelPublishers()), tooltip: 'If a channel has only one "publisher" or "stream" then you just select all. Else you can pick the "publisher" you want to start/stop', }, - startStopOption, + { + type: 'dropdown', + id: 'startStopAction', + label: 'Action', + choices: [ + { id: 99, label: '---' }, + { id: 1, label: 'Start' }, + { id: 0, label: 'Stop' }, + { id: 3, label: 'Toggle Start/Stop' }, + ], + default: 1, + }, ], callback: (action) => { - let type = 'get', + let type = 'post', url, - body, - callback + body - if (!action.options.channelIdpublisherId || !action.options.channelIdpublisherId.includes('-')) { - this._setStatus( + if ( + typeof action.options.channelIdpublisherId !== 'string' || + !action.options.channelIdpublisherId.includes('-') + ) { + this.setStatus( InstanceStatus.UnknownWarning, - 'Channel and Publisher are not known! Please review your button config' + 'Channel or Publisher are not valid! Please review your button config' ) this.debug('Undefined channelIdpublisherId ... ' + action.options.channelIdpublisherId) return } - const [channelId, publishersId] = action.options.channelIdpublisherId.split('-') - if (!this._getChannelById(channelId)) { - this._setStatus( + const [channelId, publisherId] = action.options.channelIdpublisherId.split('-') + if (!this.state.channels[channelId]) { + this.setStatus( InstanceStatus.UnknownWarning, 'Action on non existing channel! Please review your button config.' ) this.log('error', 'Action on non existing channel: ' + channelId) return } - if (publishersId !== 'all' && !this._checkValidPublisherId(channelId, publishersId)) { - this._setStatus( + if (publisherId !== 'all' && !this.state.channels[channelId].publishers[publisherId]) { + this.setStatus( InstanceStatus.UnknownWarning, 'Action on non existing publisher! Please review your button config.' ) - this.log('error', 'Action on non existing publisher ' + publishersId + ' on channel ' + channelId) + this.log('error', 'Action on non existing publisher ' + publisherId + ' on channel ' + channelId) return } - const startStopAction = this._getStartStopActionFromOptions(action.options) - if (startStopAction === null) { - this._setStatus(InstanceStatus.UnknownWarning, 'Called an unknown action! Please review your button config.') - this.log('error', 'Called an unknown action: ' + action.options.startStopAction) - return + if (action.options.startStopAction === 99) return + + let startStopAction = action.options.startStopAction === 1 ? 'start' : 'stop' + + if (action.options.startStopAction === 3) { + // toggle + let isStreaming + const channel = this.state.channels[channelId] + if (publisherId !== 'all') { + isStreaming = channel.publishers[publisherId].status.state === 'started' + } else { + // if we should toggle all, check if there is at least one not streaming and then turn it on + isStreaming = !Object.keys(channel.publishers) + .map((id) => channel.publishers[id].status.state) + .some((state) => state !== 'started') + } + startStopAction = isStreaming ? 'stop' : 'start' } - type = 'post' - if (publishersId !== 'all') { - url = '/api/channels/' + channelId + '/publishers/' + publishersId + '/control/' + startStopAction + if (publisherId !== 'all') { + url = '/api/channels/' + channelId + '/publishers/' + publisherId + '/control/' + startStopAction } else { url = '/api/channels/' + channelId + '/publishers/control/' + startStopAction } // Send request - this._sendRequest(type, url, body).then(callback) + this.sendRequest(type, url, body) }, } @@ -159,8 +165,8 @@ module.exports = { type: 'dropdown', label: 'Recorder', id: 'recorderId', - choices: this.CHOICES_RECORDERS, - default: this.CHOICES_RECORDERS.length > 0 ? this.CHOICES_RECORDERS[0].id : '', + choices: this.choicesRecorders(), + default: this.firstId(this.choicesRecorders()), }, { type: 'dropdown', @@ -171,6 +177,7 @@ module.exports = { { id: 1, label: 'Start' }, { id: 0, label: 'Stop' }, { id: 2, label: 'Reset' }, + { id: 3, label: 'Toggle Start/Stop' }, ], default: 1, }, @@ -182,16 +189,25 @@ module.exports = { callback const recorderId = action.options.recorderId - if (!this._getRecorderById(recorderId)) { - this._setStatus( + if (!this.state.recorders[recorderId]) { + this.setStatus( InstanceStatus.UnknownWarning, 'Action on non existing recorder! Please review your button config.' ) - this.log('error', 'Action on non existing recorder ' + recorderId) + this.log('warn', 'Action on non existing recorder ' + recorderId) return } - const startStopAction = action.options.startStopAction + let startStopAction = action.options.startStopAction + if (startStopAction === 3) { + // Toggle + if (this.state.recorders[recorderId]?.status?.state === 'started') { + startStopAction = 0 + } else { + startStopAction = 1 + } + } + if (startStopAction === 0) { url = `/api/recorders/${recorderId}/control/stop` } else if (startStopAction === 1) { @@ -201,20 +217,176 @@ module.exports = { } else if (startStopAction === 99) { return } else { - this._setStatus(InstanceStatus.UnknownWarning, 'Called an unknown action! Please review your button config.') + this.setStatus(InstanceStatus.UnknownWarning, 'Called an unknown action! Please review your button config.') this.log('error', 'Called an unknown action: ' + action.options.startStopAction) return } callback = async (response) => { if (response && response.status === 'ok') { - await this._updateRecorderStatus(recorderId) + this.updateRecorderStatus(recorderId) } } // Send request - this._sendRequest(type, url, body).then(callback) + this.sendRequest(type, url, body).then(callback) }, } + actions['insertMarker'] = { + name: 'Insert Marker', + options: [ + { + type: 'dropdown', + label: 'Channel', + id: 'channel', + choices: this.choicesChannel(), + default: this.firstId(this.choicesChannel()), + }, + { + type: 'textinput', + id: 'markertext', + label: 'Marker text', + useVariables: true, + default: '', + tooltip: 'You can use variables in this field like current time', + }, + ], + callback: async (action) => { + let type = 'post' + let url = `/api/channels/${action.options.channel}/bookmarks` + let body = { text: await this.parseVariablesInString(action.options.markertext) } + + // Send request + try { + await this.sendRequest(type, url, body) + this.log('info', 'marker successful sent: ' + body.text) + } catch (error) { + this.log('error', 'marker could not be set') + } + }, + } + actions['getLayoutData'] = { + name: 'Get layout data', + options: [ + { + type: 'dropdown', + id: 'channelIdlayoutId', + label: 'Layout to get', + choices: this.choicesChannelLayout(), + default: this.firstId(this.choicesChannelLayout()), + }, + { + id: 'destination', + type: 'custom-variable', + label: 'Destination Variable', + }, + ], + callback: async (action) => { + if (typeof action.options.channelIdlayoutId !== 'string' || !action.options.channelIdlayoutId.includes('-')) { + this.setStatus( + InstanceStatus.UnknownWarning, + 'Channel and layout are not known! Please review your button config' + ) + this.debug('channelIdlayoutId: ' + action.options.channelIdlayoutId) + return + } + + const [channelId, layoutId] = action.options.channelIdlayoutId.split('-') + if (!this.state.channels[channelId]) { + this.setStatus( + InstanceStatus.UnknownWarning, + 'Action on non existing channel! Please review your button config' + ) + this.log('error', 'Action on non existing channel: ' + channelId) + return + } + if (!this.state.channels[channelId].layouts[layoutId]) { + this.setStatus( + InstanceStatus.UnknownWarning, + 'Action on non existing layout! Please review your button config' + ) + this.log('error', 'Action on non existing layout ' + layoutId + ' on channel ' + channelId) + return + } + + //get the data + const url = '/api/channels/' + channelId + '/layouts/' + layoutId + '/settings' + try { + const layoutData = JSON.stringify(await this.sendRequest('GET', url, {})) + this.log( + 'debug', + `Layout Data retrieved for Channel ${this.state.channels[channelId].name}, Layout ${this.state.channels[channelId].layouts[layoutId].name}:\n${layoutData}` + ) + this.setCustomVariableValue(action.options.destination, layoutData) + } catch (error) { + this.log('error', 'Layout data could not be retrieved or stored') + } + }, + } + actions['setLayoutData'] = { + name: 'Set layout data', + options: [ + { + type: 'dropdown', + id: 'channelIdlayoutId', + label: 'Layout to set', + choices: this.choicesChannelLayout(), + default: this.firstId(this.choicesChannelLayout()), + }, + { + id: 'source', + type: 'textinput', + label: 'Layout Data', + useVariables: true, + default: '{}', + tooltip: + 'this text needs to hold a JSON-string describing a Pearl layout, you can retrieve a valid string with the according get action, variables are allowed in this option', + }, + ], + callback: async (action) => { + if (typeof action.options.channelIdlayoutId !== 'string' || !action.options.channelIdlayoutId.includes('-')) { + this.setStatus( + InstanceStatus.UnknownWarning, + 'Channel and layout are not known! Please review your button config' + ) + this.debug('channelIdlayoutId: ' + action.options.channelIdlayoutId) + return + } + + const [channelId, layoutId] = action.options.channelIdlayoutId.split('-') + if (!this.state.channels[channelId]) { + this.setStatus( + InstanceStatus.UnknownWarning, + 'Action on non existing channel! Please review your button config' + ) + this.log('error', 'Action on non existing channel: ' + channelId) + return + } + if (!this.state.channels[channelId].layouts[layoutId]) { + this.setStatus( + InstanceStatus.UnknownWarning, + 'Action on non existing layout! Please review your button config' + ) + this.log('error', 'Action on non existing layout ' + layoutId + ' on channel ' + channelId) + return + } + + //set the data + const url = '/api/channels/' + channelId + '/layouts/' + layoutId + '/settings' + let body = {} + try { + body = JSON.parse(await this.parseVariablesInString(action.options.source)) + } catch (error) { + this.log('error', 'Option is no valid JSON') + return + } + try { + await this.sendRequest('PUT', url, body) + } catch (error) { + this.log('error', 'Layout data could not be sent') + } + }, + } + return actions }, } diff --git a/feedbacks.js b/feedbacks.js index 6b62985..3e3e68f 100644 --- a/feedbacks.js +++ b/feedbacks.js @@ -24,8 +24,8 @@ module.exports = { type: 'dropdown', label: 'Channel', id: 'channelIdlayoutId', - choices: this.CHOICES_CHANNELS_LAYOUTS, - default: this.CHOICES_CHANNELS_LAYOUTS.length > 0 ? this.CHOICES_CHANNELS_LAYOUTS[0].id : '', + choices: this.choicesChannelLayout(), + default: this.firstId(this.choicesChannelLayout()), }, ], callback: (feedback) => { @@ -34,13 +34,16 @@ module.exports = { } const [channelId, layoutId] = feedback.options.channelIdlayoutId.split('-') - const layout = this._getLayoutFromChannelById(this._getChannelById(channelId), layoutId) - if (!layout) { - return false - } - if (layout.active) { - return true + try { + if (this.state.channels[channelId]?.layouts[layoutId]?.active) { + return true + } + } catch (error) { + this.log( + 'error', + `trying to read feedback for a non-existing layout (Channel ${channelId}, Layout ${layoutId})` + ) } return false }, @@ -59,8 +62,8 @@ module.exports = { type: 'dropdown', label: 'Channel publisher', id: 'channelIdpublisherId', - choices: this.CHOICES_CHANNELS_PUBLISHERS, - default: this.CHOICES_CHANNELS_PUBLISHERS.length > 0 ? this.CHOICES_CHANNELS_PUBLISHERS[0].id : '', + choices: this.choicesChannelPublishers(), + default: this.firstId(this.choicesChannelPublishers()), }, ], callback: (feedback) => { @@ -69,21 +72,23 @@ module.exports = { } const [channelId, publisherId] = feedback.options.channelIdpublisherId.split('-') - const channel = this._getChannelById(channelId) - if (!channel) { - return false - } - - const publisher = this._getPublisherFromChannelById(channel, publisherId) - let isStreaming = false - if (publisherId === 'all') { - isStreaming = this._getActivePublishersFromChannel(channel) - } + const channel = this.state.channels[channelId] - if (this._isPublisherStreaming(publisher) || isStreaming) { - return true + try { + if (publisherId === 'all') { + return !Object.keys(channel.publishers) + .map((id) => channel.publishers[id].status.state) + .some((state) => state !== 'started') + } else { + return channel.publishers[publisherId].status.state === 'started' + } + } catch (error) { + this.log( + 'error', + `trying to read feedback for a non-existing publisher (Channel ${channelId}, Publisher ${publisherId})` + ) + return false } - return false }, } @@ -100,16 +105,17 @@ module.exports = { type: 'dropdown', label: 'Recorders', id: 'recorderId', - choices: this.CHOICES_RECORDERS, - default: this.CHOICES_RECORDERS.length > 0 ? this.CHOICES_RECORDERS[0].id : '', + choices: this.choicesRecorders(), + default: this.firstId(this.choicesRecorders()), }, ], callback: (feedback) => { - const recorder = this._getRecorderById(feedback.options.recorderId) - if (this._isRecorderRecording(recorder)) { - return true + try { + return this.state.recorders[feedback.options.recorderId]?.status?.state === 'started' + } catch (error) { + this.log('error', `trying to read feedback for a non-existing recorder (${feedback.options.recorderId})`) + return false } - return false }, } diff --git a/index.js b/index.js index 97e2c47..f9eeb31 100644 --- a/index.js +++ b/index.js @@ -13,16 +13,12 @@ const feedbacks = require('./feedbacks') const presets = require('./presets') const { get_config_fields } = require('./config') -const TYPE_LAYOUT = 1 -const TYPE_PUBLISHER = 2 - /** * Companion instance class for the Epiphan Pearl. * - * @extends instanceSkel - * @version 1.0.3 + * @extends InstanceBase + * @version 2.1.0 * @since 1.0.0 - * @author Marc Hagen */ class EpiphanPearl extends InstanceBase { /** @@ -38,31 +34,13 @@ class EpiphanPearl extends InstanceBase { super(internal) /** - * Array with channel objects containing channel information like layouts - * recorders and encoders with the states of these properties - * @type {Array} - */ - this.CHANNEL_STATES = [] - this.RECORDER_STATES = {} - - /* - * Array with channel objects containing channel information like layouts - * @type {Array} + * Object holding all the state of the pearl + * structure is similar to the api nodes */ - this.CHOICES_CHANNELS_LAYOUTS = [] - this.CHOICES_CHANNELS_PUBLISHERS = [] - - /** - * Array with recorders objects - * @type {Array} - */ - this.CHOICES_RECORDERS = [] - - this.CHOICES_STARTSTOP = [ - { id: 99, label: '---', action: '' }, - { id: 1, label: 'Start', action: 'start' }, - { id: 0, label: 'Stop', action: 'stop' }, - ] + this.state = { + channels: {}, + recorders: {}, + } Object.assign(this, { ...actions, @@ -108,8 +86,8 @@ class EpiphanPearl extends InstanceBase { this.updateStatus(InstanceStatus.Connecting) await this.configUpdated(config) - this._updateSystem() - this._initInterval() + this.updateSystem() + this.initInterval() } // noinspection JSUnusedGlobalSymbols @@ -146,7 +124,7 @@ class EpiphanPearl extends InstanceBase { if (oldconfig.pollfreq !== this.config.pollfreq) { // polling frequency has changed, update interval clearInterval(this.timer) - this._initInterval() + this.initInterval() } } } @@ -159,105 +137,14 @@ class EpiphanPearl extends InstanceBase { * @param {Number} level * @param {?String} message */ - _setStatus(level, message = '') { + setStatus(level, message = '') { this.updateStatus(level, message) if (level === 'error') { this.log('error', message) - } else if (level === 'warning') { - this.log('warning', message) - } - } - - /** - * INTERNAL: Get action from the options for start and stop - * - * @private - * @since 1.0.0 - * @param {Object} options - Option object gotten from a performed action [action()] - */ - _getStartStopActionFromOptions(options) { - const startStopActionId = parseInt(options.startStopAction) - const startStopActionObj = this.CHOICES_STARTSTOP.find((obj) => obj.id === startStopActionId) - - return typeof startStopActionObj !== 'undefined' ? startStopActionObj.action : null - } - - /** - * INTERNAL: Get channel by id - * - * @private - * @since 1.0.0 - * @param {String|Number} id - */ - _getChannelById(id) { - if (!id) { - return - } - if (typeof id !== 'number') { - id = parseInt(id) - } - return this.CHANNEL_STATES.find((obj) => obj.id === id) - } - - /** - * INTERNAL: Get publisher by id from channel - * - * @private - * @since 1.0.0 - * @param {Number} type - 1 for layout, 2 for publisher. Use const. - * @param {Object|Number} channel - * @param {String|Number} id - */ - _getTypeFromChannelById(type, channel, id) { - if (!channel || !id) { - return - } else if (typeof channel === 'number') { - channel = this._getChannelById(channel) - if (!channel) { - return - } - } - - if (typeof id !== 'number') { - id = parseInt(id) - } - - if (type === TYPE_LAYOUT) { - type = channel.layouts - } else if (type === TYPE_PUBLISHER) { - type = channel.publishers - } else { - return - } - - return type.find((obj) => obj.id === id) - } - - /** - * INTERNAL: Get layout by id from channel - * - * @private - * @since 1.0.0 - * @param {Object|Number} channel - * @param {String|Number} id - */ - _getLayoutFromChannelById(channel, id) { - return this._getTypeFromChannelById(TYPE_LAYOUT, channel, id) - } - - /** - * INTERNAL: Get current active layout for channel - * - * @private - * @since 1.0.0 - * @param {Object} channel - */ - _getActiveLayoutFromChannel(channel) { - if (!channel) { - return + } else if (level === 'warn') { + this.log('warn', message) } - return channel.layouts.find((obj) => obj.active === true) } /** @@ -266,146 +153,26 @@ class EpiphanPearl extends InstanceBase { * @private * @since 1.0.0 * @param {String|Number} channelId - * @param {String|Number} newLayoutId */ - _updateActiveChannelLayout(channelId, newLayoutId) { - let channel = this._getChannelById(channelId) - if (!channel) { - return - } - - let activeLayout = this._getActiveLayoutFromChannel(channel) - let newActiveLayout = this._getLayoutFromChannelById(channel, newLayoutId) - if (!activeLayout || !newActiveLayout) { - return - } - - // Be positive and switch channels in advance. - activeLayout.active = false - newActiveLayout.active = true + async updateActiveChannelLayout(channelId) { + channelId = channelId.toString() + const layouts = await this.sendRequest('get', '/api/channels/' + channelId + '/layouts', {}) + layouts.forEach((layout) => { + this.state.channels[channelId].layouts[layout.id].active = layout.active + }) this.checkFeedbacks('channelLayout') } /** - * INTERNAL: Get publisher by id from channel - * - * @private - * @since 1.0.0 - * @param {Object|Number} channel - * @param {String|Number} id - */ - _getPublisherFromChannelById(channel, id) { - return this._getTypeFromChannelById(TYPE_PUBLISHER, channel, id) - } - - /** - * INTERNAL: Get active publishers for channel - * - * @private - * @since 1.0.0 - * @param {Object} publisher - */ - _isPublisherStreaming(publisher) { - if (!publisher) { - return false - } - - return publisher.status.isStreaming ? publisher.status.isStreaming : false - } - - /** - * INTERNAL: Get active publishers for channel - * - * @private - * @since 1.0.0 - * @param {Object} channel - */ - _getActivePublishersFromChannel(channel) { - if (!channel) { - return - } - return channel.publishers.find((obj) => obj.status.isStreaming === true) - } - - /** - * INTERNAL: Get recorder by id - * - * @private - * @since 1.0.0 - * @param {String|Number} id Can have a "m" in the id - */ - _getRecorderById(id) { - // eslint-disable-next-line no-prototype-builtins - if (id && this.RECORDER_STATES.hasOwnProperty(id.toString())) { - return this.RECORDER_STATES[id.toString()] - } else { - return undefined - } - } - - /** - * INTERNAL: Is recorder recording? - * - * @private - * @since 1.0.0 - * @param {Object} recorder - */ - _isRecorderRecording(recorder) { - if (!recorder) { - return false - } - return recorder.status.isRecording ? recorder.status.isRecording : false - } - - /** - * INTERNAL: Check if the publisher id we get from the button is valid. - * - * @private - * @since 1.0.3 - * @param {String|Number} channelId - * @param {String|Number} publisherId - */ - _checkValidPublisherId(channelId, publisherId) { - const channel = this._getChannelById(channelId) - if (!channel) { - return - } - - if (publisherId === 'all') { - // We can start and stop all encoders at the same time. - return true - } - - return this._getTypeFromChannelById(TYPE_PUBLISHER, channel, publisherId) - } - - /** - * INTERNAL: Check if the layout id we get from the button is valid. - * - * @private - * @since 1.0.3 - * @param {String|Number} channelId - * @param {String|Number} layoutId - */ - _checkValidLayoutId(channelId, layoutId) { - const channel = this._getChannelById(channelId) - if (!channel) { - return - } - - return this._getTypeFromChannelById(TYPE_LAYOUT, channel, layoutId) - } - - /** - * INTERNAL: Get action from the options for start and stop + * INTERNAL: Update all the companion bits when configuration changes * * @private * @since 1.0.0 */ - _updateSystem() { + updateSystem() { this.setActionDefinitions(this.get_actions()) - this._updateFeedbacks() - this._updatePresets() + this.updateFeedbacks() + this.updatePresets() } /** @@ -417,20 +184,20 @@ class EpiphanPearl extends InstanceBase { * @param {String} url - Full URL to send request to * @param {?Object} body - Optional body to send */ - async _sendRequest(type, url, body = {}) { + async sendRequest(type, url, body = {}) { const apiHost = this.config.host, apiPort = this.config.host_port, baseUrl = 'http://' + apiHost + ':' + apiPort if (url === null || url === '') { - this._setStatus(InstanceStatus.BadConfig, 'No URL given for _sendRequest') - this.log('error', 'No URL given for _sendRequest') + this.setStatus(InstanceStatus.BadConfig, 'No URL given for sendRequest') + this.log('error', 'No URL given for sendRequest') return false } type = type.toUpperCase() if (['GET', 'POST', 'PUT'].indexOf(type) === -1) { - this._setStatus(InstanceStatus.UnknownError, 'Wrong request type: ' + type) + this.setStatus(InstanceStatus.UnknownError, 'Wrong request type: ' + type) this.log('error', 'Wrong request type: ' + type) return false } @@ -441,8 +208,7 @@ class EpiphanPearl extends InstanceBase { } const requestUrl = baseUrl + url - this.log('debug', 'Starting request to: ' + type + ' ' + baseUrl + url) - this.log('debug', 'body: ' + JSON.stringify(body)) + //this.log('debug', 'Starting request to: ' + type + ' ' + baseUrl + url + ' body: ' + JSON.stringify(body)) let response try { @@ -462,31 +228,31 @@ class EpiphanPearl extends InstanceBase { response = await fetch(requestUrl, options) } catch (error) { if (error.name === 'AbortError') { - this._setStatus( + this.setStatus( InstanceStatus.ConnectionFailure, 'Request was aborted: ' + requestUrl + ' reason: ' + error.message ) this.log('debug', error.message) - return + throw new Error(error) } - this._setStatus(InstanceStatus.ConnectionFailure, error.message) + this.setStatus(InstanceStatus.ConnectionFailure, error.message) this.log('debug', error.message) - return + throw new Error(error) } if (!response.ok) { - this._setStatus( + this.setStatus( InstanceStatus.ConnectionFailure, 'Non-successful response status code: ' + http.STATUS_CODES[response.status] + ' ' + requestUrl ) this.log('debug', 'Non-successful response status code: ' + http.STATUS_CODES[response.status] + ' ' + requestUrl) - return + throw new Error('Non-successful response status code: ' + http.STATUS_CODES[response.status]) } const responseBody = await response.json() if (responseBody && responseBody.status && responseBody.status !== 'ok') { - this._setStatus( + this.setStatus( InstanceStatus.ConnectionFailure, 'Non-successful response from pearl: ' + requestUrl + ' - ' + (body.message ? body.message : 'No error message') ) @@ -494,7 +260,9 @@ class EpiphanPearl extends InstanceBase { 'debug', 'Non-successful response from pearl: ' + requestUrl + ' - ' + (body.message ? body.message : 'No error message') ) - return + throw new Error( + 'Non-successful response from pearl: ' + requestUrl + ' - ' + (body.message ? body.message : 'No error message') + ) } let result = responseBody @@ -502,7 +270,7 @@ class EpiphanPearl extends InstanceBase { result = responseBody.result } - this._setStatus(InstanceStatus.Ok) + this.setStatus(InstanceStatus.Ok) return result } @@ -512,7 +280,7 @@ class EpiphanPearl extends InstanceBase { * @private * @since 1.0.0 */ - _updateFeedbacks() { + updateFeedbacks() { this.setFeedbackDefinitions(this.getFeedbacks()) } @@ -522,7 +290,7 @@ class EpiphanPearl extends InstanceBase { * @private * @since [Unreleased] */ - _updatePresets() { + updatePresets() { this.setPresetDefinitions(this.getPresets()) } @@ -533,11 +301,11 @@ class EpiphanPearl extends InstanceBase { * @private * @since 1.0.0 */ - _initInterval() { + initInterval() { // Run one time first - this._dataPoller() + this.dataPoller() // Poll data from pearl regulary - this.timer = setInterval(this._dataPoller.bind(this), Math.ceil(this.config.pollfreq * 1000) || 10000) + this.timer = setInterval(this.dataPoller.bind(this), Math.ceil(this.config.pollfreq * 1000) || 10000) } /** @@ -548,200 +316,265 @@ class EpiphanPearl extends InstanceBase { * @private * @since 1.0.0 */ - async _dataPoller() { - // Get all channels available - const channels = await this._sendRequest('get', '/api/channels?publishers=yes&encoders=yes', {}) + async dataPoller() { + const state = { + channels: {}, + recorders: {}, + } // start with a fresh object, during the update some properties will be unavailable, so it is best to not do live updates + + // Get all channels and recorders available (in parallel) + let channels, recorders, recorders_status + try { + [channels, recorders, recorders_status] = await Promise.all([ + this.sendRequest('get', '/api/channels?publishers=yes&encoders=yes', {}), + this.sendRequest('get', '/api/recorders', {}), + this.sendRequest('get', '/api/recorders/status', {}), + ]) + } catch (error) { + this.log('error', 'No valid answer from device') + return + } - for (const a in channels) { - const channel = channels[a] + channels.forEach((channel) => { + state.channels[channel.id] = { ...channel } + state.channels[channel.id].layouts = {} + state.channels[channel.id].publishers = {} + }) - const channelUpdate = { - id: parseInt(channel.id), - label: channel.name, - } + recorders.forEach((recorder) => { + state.recorders[recorder.id] = { ...recorder } + }) - let currentChannel = this._getChannelById(channelUpdate.id) - if (currentChannel === undefined) { - currentChannel = { - ...channelUpdate, - ...{ - layouts: [], - publishers: [], - encoders: [], - }, - } - this.CHANNEL_STATES.push(currentChannel) - } else { - currentChannel = { ...currentChannel, ...channelUpdate } - } + recorders_status.forEach((recorder) => { + if (state.recorders[recorder.id] === undefined) state.recorders[recorder.id] = {} // just for the event a recorder has been created between call to recorders and recorders/status + state.recorders[recorder.id].status = recorder.status + }) - for (const b in channel.publishers) { - const publisher = channel.publishers[b] - let currentPublisher = currentChannel.publishers.find((obj) => obj.id === parseInt(publisher.id)) + // Get all layouts and publishers for all channels and all recorder states (in parallel) + await Promise.allSettled([ + ...channels.map(async (channel) => { + const layouts = await this.sendRequest('get', '/api/channels/' + channel.id + '/layouts', {}) + layouts.forEach((layout) => { + state.channels[channel.id].layouts[layout.id] = { ...layout } + }) + }), + ...channels.map(async (channel) => { + const publishers = await this.sendRequest('get', '/api/channels/' + channel.id + '/publishers/type', {}) + publishers.forEach((publisher) => { + if (state.channels[channel.id].publishers[publisher.id] === undefined) + state.channels[channel.id].publishers[publisher.id] = {} + state.channels[channel.id].publishers[publisher.id].id = publisher.id + state.channels[channel.id].publishers[publisher.id].type = publisher.type + state.channels[channel.id].publishers[publisher.id].name = publisher.name + }) + }), + ...channels.map(async (channel) => { + const publishersstatus = await this.sendRequest('get', '/api/channels/' + channel.id + '/publishers/status', {}) + publishersstatus.forEach((publisher) => { + if (state.channels[channel.id].publishers[publisher.id] === undefined) + state.channels[channel.id].publishers[publisher.id] = {} + state.channels[channel.id].publishers[publisher.id].status = publisher.status + }) + }), + ]) + + // now that we have an updated state object, let's see where we have to react + + const channelIds = Object.keys(state.channels) + const recorderIds = Object.keys(state.recorders) + + let updateNeeded = false // this is to mark if choices or presets needs to be updated + + if (JSON.stringify(channelIds) !== JSON.stringify(Object.keys(this.state.channels))) { + updateNeeded = true + } else if (JSON.stringify(recorderIds) !== JSON.stringify(Object.keys(this.state.recorders))) { + updateNeeded = true + } else if ( + channelIds.reduce((acc, curr) => `${acc},${state.channels[curr].name}`, '') !== + channelIds.reduce((acc, curr) => `${acc},${this.state.channels[curr].name}`, '') + ) { + updateNeeded = true + } else if ( + recorderIds.reduce((acc, curr) => `${acc},${state.recorders[curr].name}`, '') !== + recorderIds.reduce((acc, curr) => `${acc},${this.state.recorders[curr].name}`, '') + ) { + updateNeeded = true + } else if ( + channelIds.reduce( + (acc, curr) => + `${acc},${Object.keys(state.channels[curr].publishers).map( + (id) => state.channels[curr].publishers[id].name + )}`, + '' + ) !== + channelIds.reduce( + (acc, curr) => + `${acc},${Object.keys(this.state.channels[curr].publishers).map( + (id) => this.state.channels[curr].publishers[id].name + )}`, + '' + ) + ) { + updateNeeded = true + } else if ( + channelIds.reduce( + (acc, curr) => + `${acc},${Object.keys(state.channels[curr].layouts).map((id) => state.channels[curr].layouts[id].name)}`, + '' + ) !== + channelIds.reduce( + (acc, curr) => + `${acc},${Object.keys(this.state.channels[curr].layouts).map( + (id) => this.state.channels[curr].layouts[id].name + )}`, + '' + ) + ) { + updateNeeded = true + } - const updatedPublisher = { - id: parseInt(publisher.id), - label: publisher.name, - } - if (currentPublisher === undefined) { - currentChannel.publishers.push({ - ...updatedPublisher, - ...{ - status: { - isStreaming: false, - duration: 0, - }, - }, - }) - } else { - currentPublisher = { ...currentPublisher, ...updatedPublisher } - } + let feedbacksToCheck = [] // this feedbacks need to be updated + if (updateNeeded) { + //console.log('update is needed: new', JSON.stringify(state), '\n old', JSON.stringify(this.state)) + feedbacksToCheck = ['channelLayout', 'channelStreaming', 'recorderRecording'] // recheck everything after reconfiguration, could be more fine grained but not worth for such a small amount of feedbacks + } else { + if ( + channelIds.reduce( + (acc, curr) => + `${acc},${Object.keys(state.channels[curr].layouts).map((id) => state.channels[curr].layouts[id].active)}`, + '' + ) !== + channelIds.reduce( + (acc, curr) => + `${acc},${Object.keys(this.state.channels[curr].layouts).map( + (id) => this.state.channels[curr].layouts[id].active + )}`, + '' + ) + ) { + feedbacksToCheck.push('channelLayout') + } + if ( + channelIds.reduce( + (acc, curr) => + `${acc},${Object.keys(state.channels[curr].layouts).map((id) => state.channels[curr].layouts[id].active)}`, + '' + ) !== + channelIds.reduce( + (acc, curr) => + `${acc},${Object.keys(this.state.channels[curr].layouts).map( + (id) => this.state.channels[curr].layouts[id].active + )}`, + '' + ) + ) { + feedbacksToCheck.push('channelLayout') + } + if ( + channelIds.reduce( + (acc, curr) => + `${acc},${Object.keys(state.channels[curr].publishers).map((id) => + JSON.stringify(state.channels[curr].publishers[id].status) + )}`, + '' + ) !== + channelIds.reduce( + (acc, curr) => + `${acc},${Object.keys(this.state.channels[curr].publishers).map((id) => + JSON.stringify(this.state.channels[curr].publishers[id].status) + )}`, + '' + ) + ) { + feedbacksToCheck.push('channelStreaming') + } + if ( + recorderIds.reduce((acc, curr) => `${acc},${JSON.stringify(state.recorders[curr].status.state)}`, '') !== + recorderIds.reduce((acc, curr) => `${acc},${JSON.stringify(this.state.recorders[curr].status.state)}`, '') + ) { + feedbacksToCheck.push('recorderRecording') } } - this.log('debug', 'Updating CHANNEL_STATES') - - // Update layouts and publishers - await this._updateChannelLayouts() - await this._updateChannelPublishers() - - // Get all recorders - this.log('debug', 'Updating RECORDER_STATES') - let tempRecorders = [] - const recorders = await this._sendRequest('get', '/api/recorders', {}) + // now finally swap the state object + this.state = { ...state } + //console.log('feedbacks to check', feedbacksToCheck) + if (feedbacksToCheck.length > 0) this.checkFeedbacks(...feedbacksToCheck) + if (updateNeeded) { + this.updateSystem() + this.log('info', 'Pearl configuration has changed, Choices and Presets updated.') + } + } - if (Array.isArray(recorders)) { - this.RECORDER_STATES = {} - for (const recorder of recorders) { - const updatedRecorder = { - id: recorder.id, - label: recorder.name, - } - tempRecorders.push(updatedRecorder) - - this.RECORDER_STATES[recorder.id] = { - id: recorder.id, - label: recorder.name, - status: { - state: '', - isRecording: false, - duration: 0, - }, - } - } + /** + * Return the id of the first item of dropdown choices array + * + * @param arr {{id: string|number, label: string}[]} the dropdown array + * @returns {string|number} + */ + firstId(arr) { + if (Array.isArray(arr) && arr.length > 0 && (typeof arr[0].id === 'string' || typeof arr[0].id === 'number')) { + return arr[0].id } else { - this.log('error', 'Got no valid response for recorders ' + JSON.stringify(recorders)) + return '' } + } - this.log('debug', 'Updating CHOICES_RECORDERS') - this.CHOICES_RECORDERS = tempRecorders.slice() - - // Update status - await this._updateRecorderStatus() - - this.log('debug', 'Call _updateSystem() for update') - this._updateSystem() + /** + * Return dropdown choices for recorders + */ + choicesRecorders() { + return Object.keys(this.state.recorders).map((id) => { + return { id, label: this.state.recorders[id].name } + }) } /** - * Part of poller - * INTERNAL: Update the layout data from every tracked channel - * - * @private - * @since 1.0.0 + * Return dropdown choices for channels */ - async _updateChannelLayouts() { - // For every channel get layouts and populate/update actions() - let tempLayouts = [] - for (const a in this.CHANNEL_STATES) { - let channel = this.CHANNEL_STATES[a] - - const layouts = await this._sendRequest('get', '/api/channels/' + channel.id + '/layouts', {}) - if (!layouts) { - return - } + choicesChannel() { + return Object.keys(this.state.channels).map((id) => { + return { id, label: this.state.channels[id].name } + }) + } - for (const b in layouts) { - const layout = layouts[b] - tempLayouts.push({ - id: channel.id + '-' + layout.id, - label: channel.label + ' - ' + layout.name, - channelLabel: channel.label, - layoutLabel: layout.name, + /** + * Return dropdown choices for channel-layout combination + */ + choicesChannelLayout() { + const choices = [] + for (const channel of Object.keys(this.state.channels)) { + for (const layout of Object.keys(this.state.channels[channel].layouts)) { + choices.push({ + id: `${channel}-${layout}`, + label: `${this.state.channels[channel].name} - ${this.state.channels[channel].layouts[layout].name}`, }) - - const objIndex = channel.layouts.findIndex((obj) => obj.id === parseInt(layout.id)) - const updatedLayout = { - id: parseInt(layout.id), - label: layout.name, - active: layout.active, - } - if (objIndex === -1) { - channel.layouts.push(updatedLayout) - } else { - channel.layouts[objIndex] = updatedLayout - } } - - this.log('debug', 'Updating CHOICES_CHANNELS_LAYOUTS and then call checkFeedbacks(channelLayout)') - this.CHOICES_CHANNELS_LAYOUTS = tempLayouts.slice() - this.checkFeedbacks('channelLayout') } + return choices } /** - * Part of poller - * INTERNAL: Update the publisher data from every tracked channel - * - * @private - * @since 1.0.0 + * Return dropdown choices for channel-publishers combination */ - async _updateChannelPublishers() { - // For get publishers and encoders - let tempPublishers = [] - const channels = await this._sendRequest('get', '/api/channels/status?publishers=yes&encoders=yes', {}) - if (!channels) { - return - } - - for (const a in channels) { - const apiChannel = channels[a] - const currentChannel = this._getChannelById(apiChannel.id) - if (!currentChannel) { - continue - } - - tempPublishers.push({ - id: currentChannel.id + '-all', - label: currentChannel.label + ' - All encoders', - channelLabel: currentChannel.label, - publisherLabel: 'All encoders', - }) - for (const b in apiChannel.publishers) { - const publisher = apiChannel.publishers[b] - const currentPublisher = currentChannel.publishers.find((obj) => obj.id === parseInt(publisher.id)) - if (!currentPublisher) { - continue - } - - tempPublishers.push({ - id: currentChannel.id + '-' + currentPublisher.id, - label: currentChannel.label + ' - ' + currentPublisher.label, - channelLabel: currentChannel.label, - publisherLabel: currentPublisher.label, + choicesChannelPublishers() { + const choices = [] + for (const channel of Object.keys(this.state.channels)) { + if (Object.keys(this.state.channels[channel].publishers).length > 0) { + choices.push({ + id: `${channel}-all`, + label: `${this.state.channels[channel].name} - All Streams`, }) - - const status = publisher.status - currentPublisher.status = { - isStreaming: status.started && status.state === 'started', - duration: status.started && status.state === 'started' ? parseInt(status.duration) : 0, + for (const publisher of Object.keys(this.state.channels[channel].publishers)) { + choices.push({ + id: `${channel}-${publisher}`, + label: `${this.state.channels[channel].name} - ${this.state.channels[channel].publishers[publisher].name}`, + }) } } } - this.log('debug', 'Updating CHOICES_CHANNELS_PUBLISHERS and then call checkFeedbacks(channelStreaming)') - this.CHOICES_CHANNELS_PUBLISHERS = tempPublishers.slice() - this.checkFeedbacks('channelStreaming') + return choices } /** @@ -751,25 +584,14 @@ class EpiphanPearl extends InstanceBase { * @private * @since 1.0.0 */ - async _updateRecorderStatus() { - // For get status for recorders - const recoders = await this._sendRequest('get', '/api/recorders/status', {}) + async updateRecorderStatus() { + const recoders = await this.sendRequest('get', '/api/recorders/status', {}) if (!recoders) { return } for (const recorder of recoders) { - const currentRecorder = this._getRecorderById(recorder.id) - if (currentRecorder === undefined) { - continue - } - - const status = recorder.status - currentRecorder.status = { - state: status.state, - isRecording: status.state !== 'stopped', - duration: status.state !== 'stopped' ? parseInt(status.duration) : 0, - } + this.state.recorders[recorder.id].status = recorder.status } this.log('debug', 'Updating RECORDER_STATES and then call checkFeedbacks(recorderRecording)') diff --git a/package.json b/package.json index 23b8ad5..9b8fedb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epiphan-pearl", - "version": "2.0.0", + "version": "2.1.0", "homepage": "https://github.com/bitfocus/companion-module-epiphan-pearl#readme", "main": "index.js", "scripts": { diff --git a/presets.js b/presets.js index 0aa6009..63197d9 100644 --- a/presets.js +++ b/presets.js @@ -11,13 +11,13 @@ module.exports = { getPresets() { let presets = {} - for (const layout of this.CHOICES_CHANNELS_LAYOUTS) { + for (const layout of this.choicesChannelLayout()) { presets[`layout_${layout.label}`] = { type: 'button', category: 'Channels', name: layout.label, style: { - text: layout.channelLabel + '\\n' + layout.layoutLabel, + text: layout.label.replace(' - ', '\\n'), size: 7, color: combineRgb(255, 255, 255), bgcolor: combineRgb(0, 0, 0), @@ -42,7 +42,7 @@ module.exports = { channelIdlayoutId: layout.id, }, style: { - color: combineRgb(255, 255, 255), + color: combineRgb(0, 0, 0), bgcolor: combineRgb(255, 0, 0), }, }, @@ -50,13 +50,13 @@ module.exports = { } } - for (const publisher of this.CHOICES_CHANNELS_PUBLISHERS) { + for (const publisher of this.choicesChannelPublishers()) { presets[`publisher_${publisher.label}`] = { type: 'button', category: 'Publishers', label: publisher.label, style: { - text: publisher.channelLabel + '\\n' + publisher.publisherLabel, + text: publisher.label.replace(' - ', '\\n'), size: 7, color: combineRgb(255, 255, 255), bgcolor: combineRgb(0, 51, 153), @@ -68,19 +68,7 @@ module.exports = { actionId: 'channelStreaming', options: { channelIdpublisherId: publisher.id, - startStopAction: 1, // START - }, - }, - ], - up: [], - }, - { - down: [ - { - actionId: 'channelStreaming', - options: { - channelIdpublisherId: publisher.id, - startStopAction: 0, // STOP + startStopAction: 3, // toggle }, }, ], @@ -94,7 +82,7 @@ module.exports = { channelIdpublisherId: publisher.id, }, style: { - color: combineRgb(255, 255, 255), + color: combineRgb(0, 0, 0), bgcolor: combineRgb(0, 255, 0), }, }, @@ -102,14 +90,14 @@ module.exports = { } } - for (const recorder of this.CHOICES_RECORDERS) { + for (const recorder of this.choicesRecorders()) { presets[`recorder_${recorder.label}`] = { type: 'button', category: 'Recorders', label: recorder.label, style: { - text: recorder.label, - size: 7, + text: recorder.label + '\\n▶️/⏹', + size: 14, color: combineRgb(255, 255, 255), bgcolor: combineRgb(0, 102, 0), }, @@ -120,19 +108,44 @@ module.exports = { actionId: 'recorderRecording', options: { recorderId: recorder.id, - startStopAction: 1, // START + startStopAction: 3, // Toggle }, }, ], up: [], }, + ], + feedbacks: [ + { + feedbackId: 'recorderRecording', + options: { + recorderId: recorder.id, + }, + style: { + color: combineRgb(0, 0, 0), + bgcolor: combineRgb(255, 0, 0), + }, + }, + ], + } + presets[`recorder_${recorder.label}_reset`] = { + type: 'button', + category: 'Recorders', + label: recorder.label, + style: { + text: recorder.label + '\\n🔁', + size: 14, + color: combineRgb(255, 255, 255), + bgcolor: combineRgb(0, 102, 0), + }, + steps: [ { down: [ { actionId: 'recorderRecording', options: { recorderId: recorder.id, - startStopAction: 0, // STOP + startStopAction: 2, // Reset }, }, ], @@ -146,7 +159,7 @@ module.exports = { recorderId: recorder.id, }, style: { - color: combineRgb(255, 255, 255), + color: combineRgb(0, 0, 0), bgcolor: combineRgb(255, 0, 0), }, },