diff --git a/lib/statistics.js b/lib/statistics.js new file mode 100644 index 0000000..0604683 --- /dev/null +++ b/lib/statistics.js @@ -0,0 +1,511 @@ +'use strict'; + +import debug from 'debug'; +import 'regenerator-runtime/runtime'; +import { v4 as uuidv4 } from 'uuid'; +import { EventEmitter } from 'events'; + +import { map2obj, parseStats } from './utils'; + +const DEBUG = debug('sylkrtc:Statistics'); + + +const eventListeners = {}; + +class Statistics extends EventEmitter { + constructor(options={}) { + super(); + this._getStatsInterval = options.getStatsInterval || 2000; + this._rawStats = !!options.rawStats; + this._statsObject = !!options.statsObject; + this._filteredStats = !!options.filteredStats; + + if (typeof options.remote === 'boolean') { + this.remote = options.remote; + } + + this._connectionsToMonitor = {}; + this.statsToMonitor = [ + 'inbound-rtp', + 'outbound-rtp', + 'remote-inbound-rtp', + 'remote-outbound-rtp', + 'peer-connection', + 'data-channel', + 'stream', + 'track', + 'sender', + 'receiver', + 'transport', + 'candidate-pair', + 'local-candidate', + 'remote-candidate' + ]; + } + + + get numberOfMonitoredPeers () { + return Object.keys(this._connectionsToMonitor).length; + } + + async addConnection(options) { + const {pc, peerId} = options; + let {connectionId, remote} = options; + + remote = typeof remote === 'boolean' ? remote : this.remote; + + if (!pc || !(pc instanceof RTCPeerConnection)) { + throw new Error(`Missing argument 'pc' or is not of instance RTCPeerConnection`); + } + + if (!peerId) { + throw new Error('Missing argument peerId'); + } + + if (this._connectionsToMonitor[peerId]) { + if (connectionId && connectionId in this._connectionsToMonitor[peerId]) { + throw new Error(`We are already monitoring connection with id ${connectionId}.`); + } else { + for (let id of this._connectionsToMonitor[peerId]) { + const peerConnection = this._connectionsMonitor[peerId][id]; + if (peerConnection.pc === pc) { + throw new Error(`We are already monitoring peer with id ${peerId}.`); + } + + // remove an connection if it's already closed. + if(peerConnection.pc.connectionState === 'closed') { + this.removeConnection(peerId, peerConnection.pc); + } + } + } + } + const config = pc.getConfiguration(); + + // don't log credentials + if (config.iceServers) { + config.iceServers.forEach(function (server) { + delete server.credential; + }); + } + + // if the user didn't send a connectionId, we should generate one + if (!connectionId) { + connectionId = uuidv4(); + } + + DEBUG(`Adding PeerConnection with id ${peerId}.`); + this._monitorPc({ + peerId, + connectionId, + pc, + remote + }); + + return {connectionId}; + } + + removeConnection (options) { + let {peerId, connectionId, pc} = options; + + if (!peerId && !pc && !connectionId) { + throw new Error('Missing arguments. You need to either send a peerId and pc, or a connectionId.'); + } + + if ((peerId && !pc) || (pc && !peerId)) { + throw new Error('By not sending a connectionId, you need to send a peerId and a pc (RTCPeerConnection instance)'); + } + + // if the user sent a connectionId, use that + if (connectionId) { + DEBUG('Removing connection: %s',connectionId); + for (let pId in this._connectionsToMonitor) { + if (connectionId in this._connectionsToMonitor[pId]) { + peerId = pId; + + // remove listeners + this._removePeerConnectionEventListeners(peerId, connectionId, pc); + delete this._connectionsToMonitor[pId][connectionId]; + } + } + // else, if the user sent a peerId and pc + } else if (peerId && pc) { + // check if we have this peerId + if (peerId in this._connectionsToMonitor) { + // loop through all connections + for (let connectionId in this._connectionsToMonitor[peerId]) { + // until we find the one we're searching for + if (this._connectionsToMonitor[peerId][connectionId].pc === pc) { + DEBUG('Removing peerConnection: %s', peerId); + // remove listeners + this._removePeerConnectionEventListeners(peerId, connectionId, pc); + // delete it + delete this._connectionsToMonitor[peerId][connectionId]; + } + } + } + } + + if (Object.values(this._connectionsToMonitor[peerId]).length === 0) { + delete this._connectionsToMonitor[peerId]; + } + } + + removePeer (id) { + DEBUG(`Removing PeerConnection with id ${id}.`); + if (!this._connectionsToMonitor[id]) { + return; + } + + for (let connectionId in this._connectionsToMonitor[id]) { + let pc = this.peersToMonitor[id][connectionId].pc; + + this._removePeerConnectionEventListeners(id, connectionId, pc); + } + + // remove from peersToMonitor + delete this._connectionsToMonitor[id]; + } + + _monitorPc(options) { + let {peerId, connectionId, pc, remote} = options; + + if (!pc) { + return; + } + + const monitorPcObject = { + pc: pc, + connectionId, + stream: null, + stats: { + // keep a reference of the current stat + parsed: null, + raw: null + }, + options: { + remote + } + }; + + if (this._connectionsToMonitor[peerId]) { + if (connectionId in this.peersToMonitor[peerId]) { + DEBUG(`Already watching connection with ID ${connectionId}`); + return; + } + this._connectionsToMonitor[peerId][connectionId] = monitorPcObject; + } else { + // keep this in an object to avoid duplicates + this._connectionsToMonitor[peerId] = {[connectionId]: monitorPcObject}; + } + this._addPeerConnectionEventListeners(peerId, connectionId, pc); + + // start monitoring from the first peer added + if (this.numberOfMonitoredPeers === 1) { + this._startStatsMonitoring(); + // this._startConnectionStateMonitoring(); + } + } + + _startStatsMonitoring () { + if (this._monitoringSetInterval) { + return; + } + + DEBUG('Start collecting statistics'); + + this._monitoringSetInterval = window.setInterval(() => { + if (!this.numberOfMonitoredPeers) { + this._stopStatsMonitoring(); + } + + this._getStats().then((statsEvents) => { + statsEvents.forEach((statsEventObject) => { + this.emit(statsEventObject.tag, statsEventObject); + }); + }); + }, this._getStatsInterval); + } + + _stopStatsMonitoring () { + if (this._monitoringSetInterval) { + window.clearInterval(this._monitoringSetInterval); + this._monitoringSetInterval = 0; + } + } + + _checkIfConnectionIsClosed (peerId, connectionId, pc) { + const isClosed = this._isConnectionClosed(pc); + + if (isClosed) { + DEBUG('Removing %s, connection is closed', peerId); + this.removeConnection({peerId, pc}); + } + return isClosed; + } + + _isConnectionClosed (pc) { + return pc.connectionState === 'closed' || pc.iceConnectionState === 'closed'; + } + + /* + _startConnectionStateMonitoring () { + this.connectionMonitoringSetInterval = window.setInterval(() => { + if (!this.numberOfMonitoredPeers) { + this._stopConnectionStateMonitoring() + } + + for (const id in this._connectionsToMonitor) { + for (const connectionId in this._connectionsToMonitor[id]) { + const pc = this._connectionsToMonitor[id][connectionId].pc + this._checkIfConnectionIsClosed(id, connectionId, pc); + } + } + }, this._getStatsInterval) + } + + _stopConnectionStateMonitoring() { + if (this.connectionMonitoringSetInterval) { + window.clearInterval(this.connectionMonitoringSetInterval) + this.connectionMonitoringSetInterval = 0 + } + } + */ + + async _getStats (id = null) { + // DEBUG(id ? `Getting stats from peer ${id}` : `Getting stats from all peers`) + let peersToAnalyse = {}; + + // if we want the stats for a specific peer + if (id) { + peersToAnalyse[id] = this._connectionsToMonitor[id]; + if (!peersToAnalyse[id]) { + throw new Error(`Cannot get stats. Peer with id ${id} does not exist`); + } + } else { + // else, get stats for all of them + peersToAnalyse = this._connectionsToMonitor; + } + + let statsEventList = []; + + for (const id in peersToAnalyse) { + for (const connectionId in peersToAnalyse[id]) { + const peerObject = peersToAnalyse[id][connectionId]; + const pc = peerObject.pc; + + // if this connection is closed, continue + if (!pc || this._checkIfConnectionIsClosed(id, connectionId, pc)) { + continue; + } + + try { + const prom = pc.getStats(null); + if (prom) { + const res = await prom; + // create an object from the RTCStats map + const statsObject = map2obj(res); + + const parseStatsOptions = {remote: true}; + const parsedStats = parseStats(res, peerObject.stats.parsed, parseStatsOptions); + + const statsEventObject = { + event: 'stats', + tag: 'stats', + peerId: id, + connectionId: connectionId, + data: parsedStats + }; + + if (this.rawStats === true) { + statsEventObject.rawStats = res; + } + if (this.statsObject === true) { + statsEventObject.statsObject = statsObject; + } + if (this.filteredStats === true) { + statsEventObject.filteredStats = this._filteroutStats(statsObject); + } + + if (peerObject.stream) { + statsEventObject.stream = peerObject.stream; + } + statsEventList.push(statsEventObject); + + peerObject.stats.parsed = parsedStats; + } else { + DEBUG(`PeerConnection from peer ${id} did not return any stats data`); + } + } catch (e) { + DEBUG(e); + } + } + } + + return statsEventList; + } + + _filteroutStats (stats = {}) { + const fullObject = {...stats}; + for (const key in fullObject) { + let stat = fullObject[key]; + if (!this.statsToMonitor.includes(stat.type)) { + delete fullObject[key]; + } + } + + return fullObject; + } + + + get peerConnectionListeners () { + return { + /* + icecandidate: (id, pc, e) => { + DEBUG('[pc-event] icecandidate | peerId: ${peerId}', e) + + this.emitEvent({ + event: 'onicecandidate', + tag: 'connection', + peerId: id, + data: e.candidate + }) + }, + */ + track: (id, connectionId, pc, e) => { + DEBUG(`[pc-event] track | peerId: ${id}`, e); + + const track = e.track; + const stream = e.streams[0]; + + // save the remote stream + if (id in this._connectionsToMonitor && connectionId in this._connectionsToMonitor[id]) { + this._connectionsToMonitor[id][connectionId].stream = stream; + } + + // this.addTrackEventListeners(track) + // this.emitEvent({ + // event: 'ontrack', + // tag: 'track', + // peerId: id, + // data: { + // stream: stream ? this.getStreamDetails(stream) : null, + // track: track ? this.getMediaTrackDetails(track) : null, + // title: e.track.kind + ':' + e.track.id + ' ' + e.streams.map(function (stream) { + // return 'stream:' + stream.id + // }) + // } + // }) + }, + /* + signalingstatechange: (id, pc) => { + DEBUG(`[pc-event] signalingstatechange | peerId: ${id}`) + this.emitEvent({ + event: 'onsignalingstatechange', + tag: 'connection', + peerId: id, + data: { + signalingState: pc.signalingState, + localDescription: pc.localDescription, + remoteDescription: pc.remoteDescription + } + }) + }, + iceconnectionstatechange: (id, pc) => { + DEBUG(`[pc-event] iceconnectionstatechange | peerId: ${id}`) + this.emitEvent({ + event: 'oniceconnectionstatechange', + tag: 'connection', + peerId: id, + data: pc.iceConnectionState + }) + }, + icegatheringstatechange: (id, pc) => { + DEBUG(`[pc-event] icegatheringstatechange | peerId: ${id}`) + this.emitEvent({ + event: 'onicegatheringstatechange', + tag: 'connection', + peerId: id, + data: pc.iceGatheringState + }) + }, + icecandidateerror: (id, pc, ev) => { + DEBUG(`[pc-event] icecandidateerror | peerId: ${id}`) + this.emitEvent({ + event: 'onicecandidateerror', + tag: 'connection', + peerId: id, + error: { + errorCode: ev.errorCode + } + }) + }, + connectionstatechange: (id, pc) => { + DEBUG(`[pc-event] connectionstatechange | peerId: ${id}`) + this.emitEvent({ + event: 'onconnectionstatechange', + tag: 'connection', + peerId: id, + data: pc.connectionState + }) + }, + negotiationneeded: (id, pc) => { + DEBUG(`[pc-event] negotiationneeded | peerId: ${id}`) + this.emitEvent({ + event: 'onnegotiationneeded', + tag: 'connection', + peerId: id + }) + }, + datachannel: (id, pc, event) => { + DEBUG(`[pc-event] datachannel | peerId: ${id}`, event) + this.emitEvent({ + event: 'ondatachannel', + tag: 'datachannel', + peerId: id, + data: event.channel + }) + } + */ + }; + } + + _addPeerConnectionEventListeners (peerId, connectionId, pc) { + DEBUG(`Adding event listeners for peer ${peerId} and connection ${connectionId}.`); + + eventListeners[connectionId] = {}; + Object.keys(this.peerConnectionListeners).forEach(eventName => { + eventListeners[connectionId][eventName] = this.peerConnectionListeners[eventName].bind(this, peerId, connectionId, pc); + pc.addEventListener(eventName, eventListeners[connectionId][eventName], false); + }); + } + + _removePeerConnectionEventListeners(peerId, connectionId, pc) { + if (connectionId in eventListeners) { + // remove all PeerConnection listeners + Object.keys(this.peerConnectionListeners).forEach(eventName => { + pc.removeEventListener(eventName, eventListeners[connectionId][eventName], false); + }); + + // remove reference for this connection + delete eventListeners[connectionId]; + } + + // also remove track listeners + // pc.getSenders().forEach(sender => { + // if (sender.track) { + // this.removeTrackEventListeners(sender.track) + // } + // }) + // + // pc.getReceivers().forEach(receiver => { + // if (receiver.track) { + // this.removeTrackEventListeners(receiver.track) + // } + // }) + } + + +} + + +export { Statistics }; diff --git a/lib/utils.js b/lib/utils.js index 20b2f0c..f13aa7b 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,198 +1,487 @@ 'use strict'; import transform from 'sdp-transform'; import attachMediaStream from '@rifflearning/attachmediastream'; import DOMPurify from 'dompurify'; class Identity { constructor(uri, displayName=null) { this._uri = uri; this._displayName = displayName; } get uri() { return this._uri; } get displayName() { return this._displayName; } toString() { if (!this._displayName) { return this._uri; } else { return `${this._displayName} <${this._uri}>`; } } } class SharedFile { constructor(filename, filesize, uploader, session) { this._filename = filename; this._filesize = filesize; this._uploader = uploader; this._session = session; } get filename() { return this._filename; } get filesize() { return this._filesize; } get uploader() { return this._uploader; } get session() { return this._session; } } function createLocalSdp(pc, type, options) { if (type !== 'offer' && type !== 'answer') { return Promise.reject('type must be "offer" or "answer", but "' +type+ '" was given'); } let p = new Promise(function(resolve, reject) { let createFunc; if (type === 'offer' ) { createFunc = 'createOffer'; } else { createFunc = 'createAnswer'; } pc[createFunc](options) .then((desc) => { return pc.setLocalDescription(desc); }) .then(() => { resolve(mungeSdp(pc.localDescription.sdp)); }) // failure .catch((error) => { reject('Error creating local SDP or setting local description: ' + error); }); }); return p; } function mungeSdp(sdp, fixmsid=false) { let parsedSdp = transform.parse(sdp); let h264payload = null; let hasProfileLevelId = false; // try to fix H264 support for (let media of parsedSdp.media) { if (media.type === 'video') { for (let rtp of media.rtp) { if (rtp.codec === 'H264') { h264payload = rtp.payload; break; } } if (h264payload !== null) { for (let fmtp of media.fmtp) { if (fmtp.payload === h264payload && fmtp.config.indexOf('profile-level-id') !== -1) { hasProfileLevelId = true; break; } } if (!hasProfileLevelId) { media.fmtp.push({ payload: h264payload, config: 'profile-level-id=42e01f;packetization-mode=1;level-asymmetry-allowed=1' }); } break; } } } if (fixmsid === true) { const randomNumber = Math.floor(100000 + Math.random() * 900000); for (let media of parsedSdp.media) { media.msid = media.msid + '-' + randomNumber; for(let ssrc of media.ssrcs) { if (ssrc.attribute === 'msid') { ssrc.value = ssrc.value + '-' + randomNumber; } } } } // remove bogus rtcp-fb elements for (let media of parsedSdp.media) { let payloads = String(media.payloads).split(' '); if (media.rtcpFb) { media.rtcpFb = media.rtcpFb.filter((item) => { return payloads.indexOf(String(item.payload)) !== -1; }); } } return transform.write(parsedSdp); } function getMediaDirections(sdp) { const parsedSdp = transform.parse(sdp); const directions = {}; for (let media of parsedSdp.media) { directions[media.type] = (directions[media.type] || []).concat(media.direction); } return directions; } function closeMediaStream(stream) { if (!stream) { return; } // Latest spec states that MediaStream has no stop() method and instead must // call stop() on every MediaStreamTrack. if (MediaStreamTrack && MediaStreamTrack.prototype && MediaStreamTrack.prototype.stop) { if (stream.getTracks) { for (let track of stream.getTracks()) { track.stop(); } } else { for (let track of stream.getAudioTracks()) { track.stop(); } for (let track of stream.getVideoTracks()) { track.stop(); } } // Deprecated by the spec, but still in use. } else if (typeof stream.stop === 'function') { stream.stop(); } } function sanatizeHtml(html) { return DOMPurify.sanitize(html.trim()); } export function removeAllowExtmapMixed() { /* remove a=extmap-allow-mixed for Chrome < M71 */ if (!window.RTCPeerConnection) { return; } const nativeSRD = window.RTCPeerConnection.prototype.setRemoteDescription; window.RTCPeerConnection.prototype.setRemoteDescription = function(desc) { if (desc && desc.sdp && desc.sdp.indexOf('\na=extmap-allow-mixed') !== -1) { desc.sdp = desc.sdp.split('\n').filter((line) => { return line.trim() !== 'a=extmap-allow-mixed'; }).join('\n'); } return nativeSRD.apply(this, arguments); }; } -export default { Identity, SharedFile, createLocalSdp, mungeSdp, getMediaDirections, attachMediaStream, closeMediaStream, sanatizeHtml, removeAllowExtmapMixed}; +function _addAdditionalData (currentStats, previousStats) { + // we need the previousStats stats to compute thse values + if (!previousStats) { + return currentStats; + } + + // audio + // inbound + currentStats.audio.inbound.map((report) => { + let prev = previousStats.audio.inbound.find(r => r.id === report.id); + report.bitrate = _computeBitrate(report, prev, 'bytesReceived'); + report.packetRate = _computeBitrate(report, prev, 'packetsReceived'); + }); + // outbound + currentStats.audio.outbound.map((report) => { + let prev = previousStats.audio.outbound.find(r => r.id === report.id); + report.bitrate = _computeBitrate(report, prev, 'bytesSent'); + report.packetRate = _computeBitrate(report, prev, 'packetsSent'); + }); + + // video + // inbound + currentStats.video.inbound.map((report) => { + let prev = previousStats.video.inbound.find(r => r.id === report.id); + report.bitrate = _computeBitrate(report, prev, 'bytesReceived'); + report.packetRate = _computeBitrate(report, prev, 'packetsReceived'); + }); + // outbound + currentStats.video.outbound.map((report) => { + let prev = previousStats.video.outbound.find(r => r.id === report.id); + report.bitrate = _computeBitrate(report, prev, 'bytesSent'); + report.packetRate = _computeBitrate(report, prev, 'packetsSent'); + }); + + return currentStats; +} + +function _getCandidatePairInfo (candidatePair, stats) { + if (!candidatePair || !stats) { + return {}; + } + + const connection = {...candidatePair}; + + if (connection.localCandidateId) { + const localCandidate = stats.get(connection.localCandidateId); + connection.local = {...localCandidate}; + } + + if (connection.remoteCandidateId) { + const remoteCandidate = stats.get(connection.remoteCandidateId); + connection.remote = {...remoteCandidate}; + } + + return connection; +} + +// Takes two stats reports and determines the rate based on two counter readings +// and the time between them (which is in units of milliseconds). +function _computeRate (newReport, oldReport, statName) { + const newVal = newReport[statName]; + const oldVal = oldReport ? oldReport[statName] : null; + if (newVal === null || oldVal === null) { + return null; + } + return (newVal - oldVal) / (newReport.timestamp - oldReport.timestamp) * 1000; +} + +// Convert a byte rate to a bit rate. +function _computeBitrate (newReport, oldReport, statName) { + return _computeRate(newReport, oldReport, statName) * 8; +} + +export function parseStats (stats, previousStats, options= {}) { + // Create an object structure with all the needed stats and types that we care + // about. This allows to map the getStats stats to other stats names. + + if (!stats) { + return null; + } + + /** + * The starting object where we will save the details from the stats report + * @type {Object} + */ + let statsObject = { + audio: { + inbound: [], + outbound: [] + }, + video: { + inbound: [], + outbound: [] + }, + connection: { + inbound: [], + outbound: [] + } + }; + + // if we want to collect remote data also + if (options.remote) { + statsObject.remote = { + audio:{ + inbound: [], + outbound: [] + }, + video:{ + inbound: [], + outbound: [] + } + }; + } + + for (const report of stats.values()) { + switch (report.type) { + case 'outbound-rtp': { + // let outbound = {}; + const mediaType = report.mediaType || report.kind; + const codecInfo = {}; + if (!['audio', 'video'].includes(mediaType)) { + continue; + } + + if (report.codecId) { + const codec = stats.get(report.codecId); + if (codec) { + codecInfo.clockRate = codec.clockRate; + codecInfo.mimeType = codec.mimeType; + codecInfo.payloadType = codec.payloadType; + } + } + + statsObject[mediaType].outbound.push({...report, ...codecInfo}); + break; + } + case 'inbound-rtp': { + // let inbound = {}; + let mediaType = report.mediaType || report.kind; + const codecInfo = {}; + + // Safari is missing mediaType and kind for 'inbound-rtp' + if (!['audio', 'video'].includes(mediaType)) { + if (report.id.includes('Video')) { + mediaType = 'video'; + } else if (report.id.includes('Audio')) { + mediaType = 'audio'; + } else { + continue; + } + } + + if (report.codecId) { + const codec = stats.get(report.codecId); + if (codec) { + codecInfo.clockRate = codec.clockRate; + codecInfo.mimeType = codec.mimeType; + codecInfo.payloadType = codec.payloadType; + } + } + + // if we don't have connection details already saved + // and the transportId is present (most likely chrome) + // get the details from the candidate-pair + if (!statsObject.connection.id && report.transportId) { + const transport = stats.get(report.transportId); + if (transport && transport.selectedCandidatePairId) { + const candidatePair = stats.get(transport.selectedCandidatePairId); + statsObject.connection = _getCandidatePairInfo(candidatePair, stats); + } + } + + statsObject[mediaType].inbound.push({...report, ...codecInfo}); + break; + } + case 'peer-connection': { + statsObject.connection.dataChannelsClosed = report.dataChannelsClosed; + statsObject.connection.dataChannelsOpened = report.dataChannelsOpened; + break; + } + case 'remote-inbound-rtp': { + if(!options.remote) { + break; + } + // let inbound = {}; + let mediaType = report.mediaType || report.kind; + const codecInfo = {}; + + // Safari is missing mediaType and kind for 'inbound-rtp' + if (!['audio', 'video'].includes(mediaType)) { + if (report.id.includes('Video')) { + mediaType = 'video'; + } else if (report.id.includes('Audio')) { + mediaType = 'audio'; + } else { + continue; + } + } + + if (report.codecId) { + const codec = stats.get(report.codecId); + if (codec) { + codecInfo.clockRate = codec.clockRate; + codecInfo.mimeType = codec.mimeType; + codecInfo.payloadType = codec.payloadType; + } + } + + // if we don't have connection details already saved + // and the transportId is present (most likely chrome) + // get the details from the candidate-pair + if (!statsObject.connection.id && report.transportId) { + const transport = stats.get(report.transportId); + if (transport && transport.selectedCandidatePairId) { + const candidatePair = stats.get(transport.selectedCandidatePairId); + statsObject.connection = _getCandidatePairInfo(candidatePair, stats); + } + } + + statsObject.remote[mediaType].inbound.push({...report, ...codecInfo}); + break; + } + case 'remote-outbound-rtp': { + if(!options.remote) { + break; + } + // let outbound = {}; + const mediaType = report.mediaType || report.kind; + const codecInfo = {}; + if (!['audio', 'video'].includes(mediaType)) { + continue; + } + + if (report.codecId) { + const codec = stats.get(report.codecId); + if (codec) { + codecInfo.clockRate = codec.clockRate; + codecInfo.mimeType = codec.mimeType; + codecInfo.payloadType = codec.payloadType; + } + } + + statsObject.remote[mediaType].outbound.push({...report, ...codecInfo}); + break; + } + default: + } + } + + // if we didn't find a candidate-pair while going through inbound-rtp + // look for it again + if (!statsObject.connection.id) { + for (const report of stats.values()) { + // select the current active candidate-pair report + if (report.type === 'candidate-pair' && report.nominated && report.state === 'succeeded') { + statsObject.connection = _getCandidatePairInfo(report, stats); + } + } + } + + statsObject = _addAdditionalData(statsObject, previousStats); + + return statsObject; +} + +export function map2obj (stats) { + if (!stats.entries) { + return stats; + } + const o = {}; + stats.forEach(function (v, k) { + o[k] = v; + }); + return o; +} + + +export default { + Identity, + SharedFile, + createLocalSdp, + mungeSdp, + getMediaDirections, + attachMediaStream, + closeMediaStream, + sanatizeHtml, + removeAllowExtmapMixed, + map2obj, + parseStats +};