الملفات
codepill-sfu/app/src/Room.js
2024-12-26 21:49:13 +01:00

845 أسطر
27 KiB
JavaScript

'use strict';
const config = require('./config');
const crypto = require('crypto-js');
const RtmpFile = require('./RtmpFile');
const RtmpUrl = require('./RtmpUrl');
const fs = require('fs');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const Logger = require('./Logger');
const log = new Logger('Room');
const { audioLevelObserverEnabled, activeSpeakerObserverEnabled } = config.mediasoup.router;
module.exports = class Room {
constructor(room_id, worker, io) {
this.id = room_id;
this.worker = worker;
this.webRtcServer = worker.appData.webRtcServer;
this.webRtcServerActive = config.mediasoup.webRtcServerActive;
this.io = io;
this.audioLevelObserver = null;
this.audioLevelObserverEnabled = audioLevelObserverEnabled !== undefined ? audioLevelObserverEnabled : true;
this.audioLastUpdateTime = 0;
this.activeSpeakerObserverEnabled =
activeSpeakerObserverEnabled !== undefined ? activeSpeakerObserverEnabled : false;
this.activeSpeakerObserver = null;
// ##########################
this._isBroadcasting = false;
// ##########################
this._isLocked = false;
this._isLobbyEnabled = false;
this._roomPassword = null;
this._hostOnlyRecording = false;
// ##########################
this.recording = {
recSyncServerRecording: config?.server?.recording?.enabled || false,
recSyncServerEndpoint: config?.server?.recording?.endpoint || '',
};
// ##########################
this._moderator = {
video_start_privacy: false,
audio_start_muted: false,
video_start_hidden: false,
audio_cant_unmute: false,
video_cant_unhide: false,
screen_cant_share: false,
chat_cant_privately: false,
chat_cant_chatgpt: false,
media_cant_sharing: false,
};
this.survey = config.survey;
this.redirect = config.redirect;
this.videoAIEnabled = config?.videoAI?.enabled || false;
this.peers = new Map();
this.bannedPeers = [];
this.webRtcTransport = config.mediasoup.webRtcTransport;
this.router = null;
this.routerSettings = config.mediasoup.router;
this.createTheRouter();
// RTMP configuration
this.rtmpFileStreamer = null;
this.rtmpUrlStreamer = null;
this.rtmp = config.server.rtmp || false;
// Polls
this.polls = [];
this.isHostProtected = config.host.protected;
// Share Media
this.shareMediaData = {};
}
// ####################################################
// ROOM INFO
// ####################################################
toJson() {
return {
id: this.id,
broadcasting: this._isBroadcasting,
recording: this.recording,
config: {
isLocked: this._isLocked,
isLobbyEnabled: this._isLobbyEnabled,
hostOnlyRecording: this._hostOnlyRecording,
},
rtmp: {
enabled: this.rtmp && this.rtmp.enabled,
fromFile: this.rtmp && this.rtmp.fromFile,
fromUrl: this.rtmp && this.rtmp.fromUrl,
fromStream: this.rtmp && this.rtmp.fromStream,
},
hostProtected: this.isHostProtected,
moderator: this._moderator,
survey: this.survey,
redirect: this.redirect,
videoAIEnabled: this.videoAIEnabled,
thereIsPolls: this.thereIsPolls(),
shareMediaData: this.shareMediaData,
peers: JSON.stringify([...this.peers]),
};
}
// ##############################################
// SHARE MEDIA
// ##############################################
updateShareMedia(data) {
this.shareMediaData = data;
}
// ##############################################
// POLLS
// ##############################################
thereIsPolls() {
return this.polls.length > 0;
}
getPolls() {
return this.polls;
}
convertPolls(polls) {
return polls.map((poll) => {
const voters = poll.voters ? Object.fromEntries(poll.voters.entries()) : {};
return { ...poll, voters };
});
}
// ##############################################
// RTMP from FILE
// ##############################################
isRtmpFileStreamerActive() {
return this.rtmpFileStreamer;
}
async getRTMP(dir = 'rtmp') {
//
const folderPath = path.join(__dirname, '../', dir);
log.debug('[getRTMP] Files from dir', folderPath);
try {
// Create dir if not exists
if (!fs.existsSync(folderPath)) {
log.debug('[getRTMP] Dir not exists going to create', folderPath);
fs.mkdirSync(folderPath, { recursive: true });
}
const files = fs.readdirSync(folderPath);
log.info('[getRTMP] Files', files);
return files;
} catch (error) {
log.error(`[getRTMP] Error reading directory: ${error.message}`);
return [];
}
}
async startRTMP(socket_id, room, host = 'localhost', port = 1935, file = '../rtmp/BigBuckBunny.mp4') {
if (!this.rtmp || !this.rtmp.enabled) {
log.debug('[startRTMP] Server is not enabled or missing the config');
return false;
}
if (this.rtmpFileStreamer) {
log.debug('[startRTMP] Already in progress');
return false;
}
const inputFilePath = path.join(__dirname, file);
if (!fs.existsSync(inputFilePath)) {
log.error(`[startRTMP] File not found: ${inputFilePath}`);
return false;
}
log.debug('[startRTMP] Read all stream from file', inputFilePath);
this.rtmpFileStreamer = new RtmpFile(socket_id, room);
const inputStream = fs.createReadStream(inputFilePath);
const rtmpUrl = this.getRTMPUrl(host, port);
const rtmpRun = await this.rtmpFileStreamer.start(inputStream, rtmpUrl);
if (!rtmpRun) {
this.rtmpFileStreamer = false;
return this.rtmpFileStreamer;
}
return rtmpUrl;
}
stopRTMP() {
if (!this.rtmp || !this.rtmp.enabled) {
log.debug('[stopRTMP] Server is not enabled or missing the config');
return false;
}
if (this.rtmpFileStreamer) {
this.rtmpFileStreamer.stop();
this.rtmpFileStreamer = null;
log.debug('[stopRTMP] Streamer Stopped successfully!');
return true;
} else {
log.debug('[stopRTMP] No process to stop');
return false;
}
}
// ####################################################
// RTMP from URL
// ####################################################
isRtmpUrlStreamerActive() {
return this.rtmpUrlStreamer;
}
async startRTMPfromURL(
socket_id,
room,
host = 'localhost',
port = 1935,
inputVideoURL = 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
) {
if (!this.rtmp || !this.rtmp.enabled) {
log.debug('[startRTMPfromURL] Server is not enabled or missing the config');
return false;
}
if (this.rtmpUrlStreamer) {
log.debug('[startRTMPfromURL] Already in progress');
return false;
}
log.debug('[startRTMPfromURL] Input video URL', inputVideoURL);
this.rtmpUrlStreamer = new RtmpUrl(socket_id, room);
const rtmpUrl = this.getRTMPUrl(host, port);
const rtmpRun = await this.rtmpUrlStreamer.start(inputVideoURL, rtmpUrl);
if (!rtmpRun) {
this.rtmpUrlStreamer = false;
return this.rtmpUrlStreamer;
}
return rtmpUrl;
}
stopRTMPfromURL() {
if (!this.rtmp || !this.rtmp.enabled) {
log.debug('[stopRTMPfromURL] Server is not enabled or missing the config');
return false;
}
if (this.rtmpUrlStreamer) {
this.rtmpUrlStreamer.stop();
this.rtmpUrlStreamer = null;
log.debug('[stopRTMPfromURL] Streamer Stopped successfully!');
return true;
} else {
log.debug('[stopRTMPfromURL] No process to stop');
return false;
}
}
// ####################################################
// RTMP COMMON
// ####################################################
getRTMPUrl(host, port) {
const rtmpServer = this.rtmp.server != '' ? this.rtmp.server : false;
const rtmpAppName = this.rtmp.appName != '' ? this.rtmp.appName : 'live';
const rtmpStreamKey = this.rtmp.streamKey != '' ? this.rtmp.streamKey : uuidv4();
const rtmpServerSecret = this.rtmp.secret != '' ? this.rtmp.secret : false;
const expirationHours = this.rtmp.expirationHours || 4;
const rtmpServerURL = rtmpServer ? rtmpServer : `rtmp://${host}:${port}`;
const rtmpServerPath = '/' + rtmpAppName + '/' + rtmpStreamKey;
const rtmpUrl = rtmpServerSecret
? this.generateRTMPUrl(rtmpServerURL, rtmpServerPath, rtmpServerSecret, expirationHours)
: rtmpServerURL + rtmpServerPath;
log.info('RTMP Url generated', rtmpUrl);
return rtmpUrl;
}
generateRTMPUrl(baseURL, streamPath, secretKey, expirationHours = 8) {
const currentTime = Math.floor(Date.now() / 1000);
const expirationTime = currentTime + expirationHours * 3600;
const hashValue = crypto.MD5(`${streamPath}-${expirationTime}-${secretKey}`).toString();
const rtmpUrl = `${baseURL}${streamPath}?sign=${expirationTime}-${hashValue}`;
return rtmpUrl;
}
// ####################################################
// ROUTER
// ####################################################
createTheRouter() {
const { mediaCodecs } = this.routerSettings;
this.worker
.createRouter({
mediaCodecs,
})
.then((router) => {
this.router = router;
if (this.audioLevelObserverEnabled) {
this.startAudioLevelObservation();
}
if (this.activeSpeakerObserverEnabled) {
this.startActiveSpeakerObserver();
}
this.router.observer.on('close', () => {
log.info('---------------> Router is now closed as the last peer has left the room', {
room: this.id,
});
});
});
}
getRtpCapabilities() {
return this.router.rtpCapabilities;
}
closeRouter() {
this.router.close();
log.debug('Close Room router', {
router_id: this.router.id,
router_closed: this.router.closed,
});
}
// ####################################################
// PRODUCER AUDIO LEVEL OBSERVER
// ####################################################
async startAudioLevelObservation() {
log.debug('Start audioLevelObserver for signaling active speaker...');
this.audioLevelObserver = await this.router.createAudioLevelObserver({
maxEntries: 1,
threshold: -70,
interval: 100,
});
this.audioLevelObserver.on('volumes', (volumes) => {
this.sendActiveSpeakerVolume(volumes);
});
this.audioLevelObserver.on('silence', () => {
//log.debug('audioLevelObserver', { volume: 'silence' });
});
}
sendActiveSpeakerVolume(volumes) {
try {
if (!Array.isArray(volumes) || volumes.length === 0) {
throw new Error('Invalid volumes array');
}
if (Date.now() > this.audioLastUpdateTime + 100) {
this.audioLastUpdateTime = Date.now();
const { producer, volume } = volumes[0];
const audioVolume = Math.round(Math.pow(10, volume / 70) * 10); // Scale volume to 1-10
if (audioVolume > 1) {
this.peers.forEach((peer) => {
const { id, peer_audio, peer_name } = peer;
peer.producers.forEach((peerProducer) => {
if (peerProducer.id === producer.id && peerProducer.kind === 'audio' && peer_audio) {
const data = {
peer_id: id,
peer_name: peer_name,
audioVolume: audioVolume,
};
// Uncomment the following line for debugging
// log.debug('Sending audio volume', data);
this.sendToAll('audioVolume', data);
}
});
});
}
}
} catch (error) {
log.error('Error sending active speaker volume', error.message);
}
}
addProducerToAudioLevelObserver(producer) {
if (this.audioLevelObserverEnabled) {
this.audioLevelObserver.addProducer(producer);
}
}
// ####################################################
// PRODUCER DOMINANT ACTIVE SPEAKER
// ####################################################
async startActiveSpeakerObserver() {
this.activeSpeakerObserver = await this.router.createActiveSpeakerObserver();
this.activeSpeakerObserver.on('dominantspeaker', (dominantSpeaker) => {
log.debug('activeSpeakerObserver "dominantspeaker" event', dominantSpeaker.producer.id);
this.peers.forEach((peer) => {
const { id, peer_audio, peer_name } = peer;
peer.producers.forEach((peerProducer) => {
if (
peerProducer.id === dominantSpeaker.producer.id &&
peerProducer.kind === 'audio' &&
peer_audio
) {
const data = {
peer_id: id,
peer_name: peer_name,
};
// log.debug('Sending dominant speaker', data);
this.sendToAll('dominantSpeaker', data);
}
});
});
});
}
addProducerToActiveSpeakerObserver(producer) {
if (this.activeSpeakerObserverEnabled) {
this.activeSpeakerObserver.addProducer(producer);
}
}
// ####################################################
// ROOM MODERATOR
// ####################################################
updateRoomModeratorALL(data) {
this._moderator = data;
log.debug('Update room moderator all data', this._moderator);
}
updateRoomModerator(data) {
log.debug('Update room moderator', data);
switch (data.type) {
case 'video_start_privacy':
this._moderator.video_start_privacy = data.status;
break;
case 'audio_start_muted':
this._moderator.audio_start_muted = data.status;
break;
case 'video_start_hidden':
this._moderator.video_start_hidden = data.status;
break;
case 'audio_cant_unmute':
this._moderator.audio_cant_unmute = data.status;
break;
case 'video_cant_unhide':
this._moderator.video_cant_unhide = data.status;
break;
case 'screen_cant_share':
this._moderator.screen_cant_share = data.status;
break;
case 'chat_cant_privately':
this._moderator.chat_cant_privately = data.status;
break;
case 'chat_cant_chatgpt':
this._moderator.chat_cant_chatgpt = data.status;
break;
case 'media_cant_sharing':
this._moderator.media_cant_sharing = data.status;
break;
default:
break;
}
}
// ####################################################
// PEERS
// ####################################################
addPeer(peer) {
this.peers.set(peer.id, peer);
}
getPeer(socket_id) {
if (!this.peers.has(socket_id)) return;
const peer = this.peers.get(socket_id);
return peer;
}
getPeers() {
return this.peers;
}
getPeersCount() {
return this.peers.size;
}
getProducerListForPeer() {
const producerList = [];
this.peers.forEach((peer) => {
const { peer_name, peer_info } = peer;
peer.producers.forEach((producer) => {
producerList.push({
producer_id: producer.id,
peer_name: peer_name,
peer_info: peer_info,
type: producer.appData.mediaType,
});
});
});
return producerList;
}
async removePeer(socket_id) {
if (!this.peers.has(socket_id)) return;
const peer = this.getPeer(socket_id);
peer.close();
this.peers.delete(socket_id);
if (this.getPeers().size === 0) {
this.closeRouter();
}
}
// ####################################################
// WebRTC TRANSPORT
// ####################################################
async createWebRtcTransport(socket_id) {
if (!this.peers.has(socket_id)) {
throw new Error(`Peer with socket ID ${socket_id} not found in the room`);
}
const { maxIncomingBitrate, initialAvailableOutgoingBitrate, listenInfos } = this.webRtcTransport;
const webRtcTransportOptions = {
...(this.webRtcServerActive ? { webRtcServer: this.webRtcServer } : { listenInfos: listenInfos }),
enableUdp: true,
enableTcp: true,
preferUdp: true,
iceConsentTimeout: 20,
initialAvailableOutgoingBitrate,
};
log.debug('webRtcTransportOptions ----->', webRtcTransportOptions);
const transport = await this.router.createWebRtcTransport(webRtcTransportOptions);
if (!transport) {
throw new Error('Failed to create WebRtc Transport');
}
const { id, type, iceParameters, iceCandidates, dtlsParameters } = transport;
if (maxIncomingBitrate) {
try {
await transport.setMaxIncomingBitrate(maxIncomingBitrate);
} catch (error) {
log.error('Failed to set max incoming bitrate', error);
throw new Error(`Failed to set max incoming bitrate for transport ${id}`);
}
}
const peer = this.getPeer(socket_id);
try {
peer.addTransport(transport);
} catch (error) {
log.error('Failed to add peer transport', error);
throw new Error(`Failed to add peer transport ${id}`);
}
log.debug('Transport created', { transportId: id, transportType: type });
const { peer_name } = peer;
transport.observer.on('newproducer', (producer) => {
log.debug('---> new producer created [id:%s]', producer.id);
});
transport.observer.on('newconsumer', (consumer) => {
log.debug('---> new consumer created [id:%s]', consumer.id);
});
transport.observer.on('close', () => {
log.debug('---> transport close [id:%s]', transport.id);
});
transport.on('icestatechange', (iceState) => {
if (iceState === 'disconnected' || iceState === 'closed') {
log.debug('Transport closed "icestatechange" event', {
peer_name: peer_name,
transport_id: id,
iceState: iceState,
});
transport.close();
}
});
transport.on('sctpstatechange', (sctpState) => {
log.debug('Transport "sctpstatechange" event', {
peer_name: peer_name,
transport_id: id,
sctpState: sctpState,
});
});
transport.on('dtlsstatechange', (dtlsState) => {
if (dtlsState === 'failed' || dtlsState === 'closed') {
log.debug('Transport closed "dtlsstatechange" event', {
peer_name: peer_name,
transport_id: id,
dtlsState: dtlsState,
});
transport.close();
}
});
transport.on('close', () => {
log.debug('Transport closed', {
peer_name: peer_name,
transport_id: transport.id,
});
});
return {
id: id,
iceParameters: iceParameters,
iceCandidates: iceCandidates,
dtlsParameters: dtlsParameters,
};
}
async connectPeerTransport(socket_id, transport_id, dtlsParameters) {
if (!this.peers.has(socket_id)) {
throw new Error(`Peer with socket ID ${socket_id} not found in the room`);
}
const peer = this.getPeer(socket_id);
try {
await peer.connectTransport(transport_id, dtlsParameters);
} catch (error) {
log.error(`Failed to connect peer transport for socket ID ${socket_id}`, error);
throw new Error(`Failed to connect transport for peer with socket ID ${socket_id}`);
}
return '[Room|connectPeerTransport] done';
}
// ####################################################
// PRODUCE
// ####################################################
async produce(socket_id, producerTransportId, rtpParameters, kind, type) {
if (!this.peers.has(socket_id)) {
throw new Error(`Peer with socket ID ${socket_id} not found in the room`);
}
const peer = this.getPeer(socket_id);
let peerProducer;
try {
peerProducer = await peer.createProducer(producerTransportId, rtpParameters, kind, type);
} catch (error) {
log.error(`Error creating producer for peer with socket ID ${socket_id}`, error);
throw new Error(
`Error creating producer with transport ID ${producerTransportId} type ${type} for peer ${socket_id}`,
);
}
if (!peerProducer) {
throw new Error(`Failed to create producer with ID ${producerTransportId} for peer ${socket_id}`);
}
const { id } = peerProducer;
const { peer_name, peer_info } = peer;
this.broadCast(socket_id, 'newProducers', [
{
producer_id: id,
producer_socket_id: socket_id,
peer_name: peer_name,
peer_info: peer_info,
type: type,
},
]);
return id;
}
closeProducer(socket_id, producer_id) {
if (!this.peers.has(socket_id)) {
throw new Error(`Peer with socket ID ${socket_id} not found in the room`);
}
const peer = this.getPeer(socket_id);
try {
peer.closeProducer(producer_id);
} catch (error) {
log.error(`Error closing producer for peer ${socket_id}`, error);
throw new Error(`Error closing producer with ID ${producer_id} for peer ${socket_id}`);
}
}
// ####################################################
// CONSUME
// ####################################################
async consume(socket_id, consumer_transport_id, producerId, rtpCapabilities, type) {
if (!this.peers.has(socket_id)) {
throw new Error(`Peer with socket ID ${socket_id} not found in the room`);
}
if (!this.router.canConsume({ producerId, rtpCapabilities })) {
throw new Error(`Cannot consume producer with ID ${producerId} type ${type}, router validation failed`);
}
const peer = this.getPeer(socket_id);
let peerConsumer;
try {
peerConsumer = await peer.createConsumer(consumer_transport_id, producerId, rtpCapabilities);
} catch (error) {
log.error(`Error creating consumer for peer with socket ID ${socket_id}`, error);
throw new Error(
`Failed to create consumer with transport ID ${consumer_transport_id} and producer ID ${producerId} type ${type} for peer ${socket_id}`,
);
}
if (!peerConsumer) {
throw new Error(
`Consumer creation failed for transport ID ${consumer_transport_id} and producer ID ${producerId}`,
);
}
const { consumer, params } = peerConsumer;
const { id, kind } = consumer;
consumer.on('producerclose', () => {
log.debug('Consumer closed due to "producerclose" event');
peer.removeConsumer(id);
// Notify the client that the consumer is closed
this.send(socket_id, 'consumerClosed', {
consumer_id: id,
consumer_kind: kind,
});
});
return params;
}
// ####################################################
// HANDLE BANNED PEERS
// ####################################################
addBannedPeer(uuid) {
if (!this.bannedPeers.includes(uuid)) {
this.bannedPeers.push(uuid);
log.debug('Added to the banned list', {
uuid: uuid,
banned: this.bannedPeers,
});
}
}
isBanned(uuid) {
return this.bannedPeers.includes(uuid);
}
// ####################################################
// ROOM STATUS
// ####################################################
// GET
isBroadcasting() {
return this._isBroadcasting;
}
getPassword() {
return this._roomPassword;
}
// BOOL
isLocked() {
return this._isLocked;
}
isLobbyEnabled() {
return this._isLobbyEnabled;
}
isHostOnlyRecording() {
return this._hostOnlyRecording;
}
// SET
setIsBroadcasting(status) {
this._isBroadcasting = status;
}
setLocked(status, password) {
this._isLocked = status;
this._roomPassword = password;
}
setLobbyEnabled(status) {
this._isLobbyEnabled = status;
}
setHostOnlyRecording(status) {
this._hostOnlyRecording = status;
}
// ####################################################
// SENDER
// ####################################################
broadCast(socket_id, action, data) {
for (let otherID of Array.from(this.peers.keys()).filter((id) => id !== socket_id)) {
this.send(otherID, action, data);
}
}
sendTo(socket_id, action, data) {
for (let peer_id of Array.from(this.peers.keys()).filter((id) => id === socket_id)) {
this.send(peer_id, action, data);
}
}
sendToAll(action, data) {
for (let peer_id of Array.from(this.peers.keys())) {
this.send(peer_id, action, data);
}
}
send(socket_id, action, data) {
this.io.to(socket_id).emit(action, data);
}
};