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,
+ },
+};