diff --git a/README.md b/README.md index 95d1c30..6966403 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ The supported Janus plugins are: - Streaming - VideoRoom - SIP +- Record&Play The library is available on [npm](https://www.npmjs.com/package/janode) and the source code is on [github](https://github.com/meetecho/janode). @@ -159,4 +160,4 @@ Then use the npm script: npm run build-docs ``` -Documentation in HTML format will be built under the `docs` folder. \ No newline at end of file +Documentation in HTML format will be built under the `docs` folder. diff --git a/package.json b/package.json index 202daaa..724bac2 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "./handle": "./src/handle.js", "./plugins/audiobridge": "./src/plugins/audiobridge-plugin.js", "./plugins/echotest": "./src/plugins/echotest-plugin.js", + "./plugins/recordplay": "./src/plugins/recordplay-plugin.js", "./plugins/sip": "./src/plugins/sip-plugin.js", "./plugins/streaming": "./src/plugins/streaming-plugin.js", "./plugins/videoroom": "./src/plugins/videoroom-plugin.js" diff --git a/src/plugins/recordplay-plugin.js b/src/plugins/recordplay-plugin.js new file mode 100644 index 0000000..7c97777 --- /dev/null +++ b/src/plugins/recordplay-plugin.js @@ -0,0 +1,526 @@ +'use strict'; + +/** + * This module contains the implementation of the Record&Play plugin (ref. {@link https://janus.conf.meetecho.com/docs/recordplay.html}). + * @module recordplay-plugin + */ + +import Handle from '../handle.js'; + +/* The plugin ID exported in the plugin descriptor */ +const PLUGIN_ID = 'janus.plugin.recordplay'; + +/* These are the requests defined for the Janus RecordPlay API */ +const REQUEST_LIST = 'list'; +const REQUEST_UPDATE = 'update'; +const REQUEST_RECORD = 'record'; +const REQUEST_PLAY = 'play'; +const REQUEST_START = 'start'; +const REQUEST_CONFIGURE = 'configure'; +const REQUEST_PAUSE = 'pause'; +const REQUEST_RESUME = 'resume'; +const REQUEST_STOP = 'stop'; + +/* These are the events/responses that the Janode plugin will manage */ +/* Some of them will be exported in the plugin descriptor */ +const PLUGIN_EVENT = { + RECORDINGS_LIST: 'recordplay_list', + RECORDING: 'recordplay_recording', + CONFIGURED: 'recordplay_configured', + PAUSED: 'recordplay_paused', + RESUMED: 'recordplay_resumed', + PREPARING: 'recordplay_preparing', + PLAYING: 'recordplay_playing', + STOPPED: 'recordplay_stopped', + SLOW_LINK: 'recordplay_slowlink', + DONE: 'recordplay_done', + SUCCESS: 'recordplay_success', + ERROR: 'recordplay_error', +}; + +/** + * The class implementing the Record&Play plugin (ref. {@link https://janus.conf.meetecho.com/docs/recordplay.html}).
+ * + * It extends the base Janode Handle class and overrides the base "handleMessage" method.
+ * + * Moreover it defines many methods to support RecordPlay operations. + * + * @hideconstructor + * @extends module:handle~Handle + */ +class RecordPlayHandle extends Handle { + /** + * Create a Janode RecordPlay handle. + * + * @param {module:session~Session} session - A reference to the parent session + * @param {number} id - The handle identifier + */ + constructor(session, id) { + super(session, id); + } + + /** + * The custom "handleMessage" needed for handling RecordPlay messages. + * + * @private + * @param {Object} janus_message + * @returns {Object} A falsy value for unhandled events, a truthy value for handled events + */ + handleMessage(janus_message) { + const { plugindata, transaction } = janus_message; + if (plugindata && plugindata.data && plugindata.data.recordplay) { + /** + * @type {RecordPlayData} + */ + const message_data = plugindata.data; + const { recordplay, error, error_code } = message_data; + + /* Prepare an object for the output Janode event */ + const janode_event = this._newPluginEvent(janus_message); + + /* The plugin will emit an event only if the handle does not own the transaction */ + /* That means that a transaction has already been closed or this is an async event */ + const emit = (this.ownsTransaction(transaction) === false); + + switch (recordplay) { + + /* Got a list of recordings */ + case 'list': + /* Recordings list API */ + janode_event.event = PLUGIN_EVENT.RECORDINGS_LIST; + if (typeof message_data.list !== 'undefined') + janode_event.data.list = message_data.list; + break; + + /* Update success */ + case 'ok': + /* "ok" is treated as "success" */ + janode_event.event = PLUGIN_EVENT.SUCCESS; + break; + + /* Configure success */ + case 'configure': + /* Configure API */ + janode_event.event = PLUGIN_EVENT.CONFIGURED; + if (typeof message_data.result !== 'undefined') + janode_event.data.settings = message_data.result.settings; + break; + + /* Generic event (e.g. errors) */ + case 'event': + /* RecordPlay error */ + if (error) { + janode_event.event = PLUGIN_EVENT.ERROR; + janode_event.data = new Error(`${error_code} ${error}`); + /* In case of error, close a transaction */ + this.closeTransactionWithError(transaction, janode_event.data); + break; + } + /* Update for this handle */ + if (typeof message_data.result !== 'undefined') { + if (typeof message_data.result.status !== 'undefined') { + if (typeof message_data.result.id !== 'undefined') + janode_event.data.id = message_data.result.id; + if (typeof message_data.result.is_private !== 'undefined') + janode_event.data.is_private = message_data.result.is_private; + if (typeof message_data.result.media !== 'undefined') + janode_event.data.media = message_data.result.media; + if (typeof message_data.result.uplink !== 'undefined') + janode_event.data.uplink = message_data.result.uplink; + switch (message_data.result.status) { + case 'recording': + janode_event.event = PLUGIN_EVENT.RECORDING; + break; + case 'paused': + janode_event.event = PLUGIN_EVENT.PAUSED; + break; + case 'resumed': + janode_event.event = PLUGIN_EVENT.RESUMED; + break; + case 'preparing': + janode_event.event = PLUGIN_EVENT.PREPARING; + break; + case 'playing': + janode_event.event = PLUGIN_EVENT.PLAYING; + break; + case 'stopped': + janode_event.event = PLUGIN_EVENT.STOPPED; + break; + case 'slow_link': + janode_event.event = PLUGIN_EVENT.SLOW_LINK; + break; + case 'done': + janode_event.event = PLUGIN_EVENT.DONE; + break; + } + } + break; + } + } + + /* The event has been handled */ + if (janode_event.event) { + /* Try to close the transaction */ + this.closeTransactionWithSuccess(transaction, janus_message); + /* If the transaction was not owned, emit the event */ + if (emit) this.emit(janode_event.event, janode_event.data); + return janode_event; + } + } + + /* The event has not been handled, return a falsy value */ + return null; + } + + /*----------*/ + /* USER API */ + /*----------*/ + + /* These are the APIs that users need to work with the recordplay plugin */ + + /** + * List recordings. + * + * @param {Object} params + * @param {string} [params.admin_key] - The optional admin key needed for invoking the API + * @returns {Promise} + */ + async listRecordings({ admin_key }) { + const body = { + request: REQUEST_LIST, + }; + if (typeof admin_key === 'string') body.admin_key = admin_key; + + const response = await this.message(body); + const { event, data: evtdata } = this._getPluginEvent(response); + if (event === PLUGIN_EVENT.RECORDINGS_LIST) + return evtdata; + const error = new Error(`unexpected response to ${body.request} request`); + throw (error); + } + + /** + * Re-index the list of recordings. + * + * @param {Object} params + * @param {string} [params.admin_key] - The optional admin key needed for invoking the API + * @returns {Promise} + */ + async updateRecordings({ admin_key }) { + const body = { + request: REQUEST_UPDATE, + }; + if (typeof admin_key === 'string') body.admin_key = admin_key; + + const response = await this.message(body); + const { event, data: evtdata } = this._getPluginEvent(response); + if (event === PLUGIN_EVENT.SUCCESS) + return evtdata; + const error = new Error(`unexpected response to ${body.request} request`); + throw (error); + } + + /** + * Configure a recording session. + * + * @param {Object} params + * @param {number} [params.maxBitrate] - The optional bitrate to enforce via REMB + * @param {number} [params.keyframeInterval] - The optional keyframe interval to enforce, in ms + * @returns {Promise} + */ + async configure({ maxBitrate, keyframeInterval }) { + const body = { + request: REQUEST_CONFIGURE, + }; + if (typeof maxBitrate === 'number') body['video-bitrate-max'] = maxBitrate; + if (typeof keyframeInterval === 'number') body['video-keyframe-interval'] = keyframeInterval; + + const response = await this.message(body); + const { event, data: evtdata } = this._getPluginEvent(response); + if (event === PLUGIN_EVENT.CONFIGURED) + return evtdata; + const error = new Error(`unexpected response to ${body.request} request`); + throw (error); + } + + /** + * Start a recording session. + * + * @param {Object} params + * @param {number} [params.id] - The ID to assign to the recording + * @param {string} [params.name] - The short description of the recording + * @param {boolean} [params.is_private] - Flag the recording as private + * @param {string} [params.filename] - Set the base path/filename for the recording + * @param {string} [audiocodec] - Set the audio codec to use in the recording + * @param {string} [videocodec] - Set the video codec to use in the recording + * @param {string} [videoprofile] - Set the video fmtp to use in the recording + * @param {boolean} [params.opusred] - Set whether RED should be negotiated for audio + * @param {boolean} [params.textdata] - In case data channels are negotiated, set whether it should be text (default) or binary data + * @param {boolean} [params.update] - Set to true for renegotiations + * @param {RTCSessionDescription} params.jsep - JSEP offer to be sent to Janus + * @returns {Promise} + */ + async record({ id, name, is_private, filename, audiocodec, videocodec, videoprofile, opusred, textdata, update, jsep }) { + if (!jsep || typeof jsep !== 'object' || jsep && jsep.type !== 'offer') { + const error = new Error('jsep must be an offer'); + return Promise.reject(error); + } + const body = { + request: REQUEST_RECORD, + }; + if (typeof id === 'number') body.id = id; + if (typeof name === 'string') body.name = name; + if (typeof is_private === 'boolean') body.is_private = is_private; + if (typeof filename === 'string') body.filename = filename; + if (typeof audiocodec === 'string') body.audiocodec = audiocodec; + if (typeof videocodec === 'string') body.videocodec = videocodec; + if (typeof videoprofile === 'string') body.videoprofile = videoprofile; + if (typeof opusred === 'boolean') body.opusred = opusred; + if (typeof textdata === 'boolean') body.textdata = textdata; + if (typeof update === 'boolean') body.update = update; + + const response = await this.message(body, jsep); + const { event, data: evtdata } = this._getPluginEvent(response); + if (event === PLUGIN_EVENT.RECORDING) + return evtdata; + const error = new Error(`unexpected response to ${body.request} request`); + throw (error); + } + + /** + * Play an existing recording. + * + * @param {Object} params + * @param {number} params.id - The ID of the recording to replay + * @param {boolean} [params.restart] - Set to true for triggering a renegotiation and an ICE restart + * @returns {Promise} + */ + async play({ id, restart }) { + const body = { + request: REQUEST_PLAY, + id + }; + if (typeof restart === 'boolean') body.restart = restart; + + const response = await this.message(body); + const { event, data: evtdata } = this._getPluginEvent(response); + if (event === PLUGIN_EVENT.PREPARING) + return evtdata; + const error = new Error(`unexpected response to ${body.request} request`); + throw (error); + } + + /** + * Start a playback session. + * + * @param {Object} params + * @param {RTCSessionDescription} params.jsep + * @returns {Promise} + */ + async start({ jsep }) { + if (!jsep || typeof jsep !== 'object' || jsep && jsep.type !== 'answer') { + const error = new Error('jsep must be an answer'); + return Promise.reject(error); + } + + const body = { + request: REQUEST_START, + }; + + const response = await this.message(body, jsep); + const { event, data: evtdata } = this._getPluginEvent(response);; + if (event === PLUGIN_EVENT.PLAYING) + return evtdata; + const error = new Error(`unexpected response to ${body.request} request`); + throw (error); + } + + /** + * Pauses the current recording session. + * + * @returns {Promise} + */ + async pause() { + const body = { + request: REQUEST_PAUSE, + }; + + const response = await this.message(body); + const { event, data: evtdata } = this._getPluginEvent(response);; + if (event === PLUGIN_EVENT.PAUSED) + return evtdata; + const error = new Error(`unexpected response to ${body.request} request`); + throw (error); + } + + /** + * Resumes the current recording session. + * + * @returns {Promise} + */ + async resume() { + const body = { + request: REQUEST_RESUME, + }; + + const response = await this.message(body); + const { event, data: evtdata } = this._getPluginEvent(response);; + if (event === PLUGIN_EVENT.RESUMED) + return evtdata; + const error = new Error(`unexpected response to ${body.request} request`); + throw (error); + } + + /** + * Stop the current recording or playback session. + * + * @returns {Promise} + */ + async stop() { + const body = { + request: REQUEST_STOP, + }; + + const response = await this.message(body); + const { event, data: evtdata } = this._getPluginEvent(response);; + if (event === PLUGIN_EVENT.STOPPED) + return evtdata; + const error = new Error(`unexpected response to ${body.request} request`); + throw (error); + } + +} + +/** + * The payload of the plugin message (cfr. Janus docs). + * {@link https://janus.conf.meetecho.com/docs/recordplay.html} + * + * @private + * @typedef {Object} RecordPlayData + */ + +/** + * The response event for recordplay recordings request. + * + * @typedef {Object} RECORDPLAY_EVENT_RECORDINGS_LIST + * @property {object[]} list - The list of the recordings as returned by Janus + */ + +/** + * The response event for recordplay update request. + * + * @typedef {Object} RECORDPLAY_EVENT_UPDATE_RESPONSE + */ + +/** + * The response event for record request. + * + * @typedef {Object} RECORDPLAY_EVENT_RECORDING + * @property {number} [id] - The involved recording identifier + * @property {boolean} [is_private] - True if the event mentions a private recording + * @property {RTCSessionDescription} [jsep] - Optional JSEP from Janus + */ + +/** + * The response event for configure request. + * + * @typedef {Object} RECORDPLAY_EVENT_CONFIGURED + * @property {object} [settings] - The current settings as returned by Janus + */ + +/** + * The response event for pause request. + * + * @typedef {Object} RECORDPLAY_EVENT_PAUSED + * @property {number} [id] - The involved recording identifier + */ + +/** + * The response event for resume request. + * + * @typedef {Object} RECORDPLAY_EVENT_RESUMED + * @property {number} [id] - The involved recording identifier + */ + +/** + * The response event for play request. + * + * @typedef {Object} RECORDPLAY_EVENT_PREPARING + * @property {number} [id] - The involved recording identifier + * @property {boolean} [is_private] - True if the event mentions a private recording + * @property {RTCSessionDescription} [jsep] - Optional JSEP from Janus + */ + +/** + * The response event for start request. + * + * @typedef {Object} RECORDPLAY_EVENT_PLAYING + * @property {number} [id] - The involved recording identifier + */ + +/** + * The response event for stop request. + * + * @typedef {Object} RECORDPLAY_EVENT_STOPPED + * @property {number} [id] - The involved recording identifier + * @property {boolean} [is_private] - True if the event mentions a private recording + */ + +/** + * A recordplay slow-link event. + * + * @typedef {Object} RECORDPLAY_EVENT_SLOW_LINK + * @property {string} [media] - Audio or video + * @property {number} [current-bitrate] - The current configured max video bitrate + * @property {boolean} [uplink] - Whether this is an uplink or downlink event + */ + +/** + * A recordplay done event. + * + * @typedef {Object} RECORDPLAY_EVENT_DONE + * @property {number} [id] - The involved recording identifier + * @property {boolean} [is_private] - True if the event mentions a private recording + */ + +/** + * The exported plugin descriptor. + * + * @type {Object} + * @property {string} id - The plugin identifier used when attaching to Janus + * @property {module:recordplay-plugin~RecordPlayHandle} Handle - The custom class implementing the plugin + * @property {Object} EVENT - The events emitted by the plugin + * @property {string} EVENT.RECORDPLAY_SLOW_LINK {@link module:recordplay-plugin~RecordPlayHandle#event:RECORDPLAY_STATUS RECORDPLAY_SLOW_LINK} + * @property {string} EVENT.RECORDPLAY_DONE {@link module:recordplay-plugin~RecordPlayHandle#event:RECORDPLAY_STATUS RECORDPLAY_DONE} + * @property {string} EVENT.RECORDPLAY_ERROR {@link module:recordplay-plugin~RecordPlayHandle#event:RECORDPLAY_ERROR RECORDPLAY_ERROR} + */ +export default { + id: PLUGIN_ID, + Handle: RecordPlayHandle, + + EVENT: { + /** + * Trouble on an active stream. + * + * @event module:recordplay-plugin~RecordPlayHandle#event:RECORDPLAY_SLOW_LINK + * @type {module:recordplay-plugin~RECORDPLAY_EVENT_SLOW_LINK} + */ + RECORDPLAY_SLOW_LINK: PLUGIN_EVENT.SLOW_LINK, + + /** + * A recording/playback session is over. + * + * @event module:recordplay-plugin~RecordPlayHandle#event:RECORDPLAY_DONE + * @type {module:recordplay-plugin~RECORDPLAY_EVENT_DONE} + */ + RECORDPLAY_DONE: PLUGIN_EVENT.DONE, + + /** + * Generic recordplay error. + * + * @event module:recordplay-plugin~RecordPlayHandle#event:RECORDPLAY_ERROR + * @type {Error} + */ + RECORDPLAY_ERROR: PLUGIN_EVENT.ERROR, + }, +};