Migrated openvidu-recording-node to Livekit

This commit is contained in:
Carlos Santos 2023-11-17 11:57:22 +01:00
parent 0f1fea4a0d
commit 8d46598f48
9 changed files with 1053 additions and 15069 deletions

View File

@ -58,3 +58,7 @@ typings/
.env
.vscode/
*.mp4
*.mov
*.ogg

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@
"description": "",
"main": "server.js",
"scripts": {
"start": "node server.js",
"test": "ng test"
},
"repository": {
@ -17,8 +18,10 @@
},
"homepage": "https://github.com/OpenVidu/openvidu-tutorials#readme",
"dependencies": {
"body-parser": "1.19.0",
"express": "4.17.1",
"openvidu-node-client": "2.27.0"
"body-parser": "1.20.0",
"cors": "2.8.5",
"dotenv": "16.0.1",
"express": "4.18.1",
"livekit-server-sdk": "1.2.2"
}
}

View File

@ -1,415 +1,289 @@
var OV;
var session;
var sessionName;
var LivekitClient = window.LivekitClient;
var room;
var myRoomName;
var token;
var nickname;
var numVideos = 0;
var localVideoPublication;
var localAudioPublication;
/* OPENVIDU METHODS */
function joinSession() {
function joinRoom() {
// --- 0) Change the button ---
document.getElementById("join-btn").disabled = true;
document.getElementById("join-btn").innerHTML = "Joining...";
getToken(function () {
document.getElementById('join-btn').disabled = true;
document.getElementById('join-btn').innerHTML = 'Joining...';
const myParticipantName = `Participant${Math.floor(Math.random() * 100)}`;
const myRoomName = $('#roomName').val();
// --- 1) Get an OpenVidu object ---
room = new LivekitClient.Room();
OV = new OpenVidu();
// --- 2) Init a session ---
session = OV.initSession();
// --- 3) Specify the actions when events take place in the session ---
session.on('connectionCreated', event => {
pushEvent(event);
});
session.on('connectionDestroyed', event => {
pushEvent(event);
});
// On every new Stream received...
session.on('streamCreated', event => {
pushEvent(event);
// Subscribe to the Stream to receive it
// HTML video will be appended to element with 'video-container' id
var subscriber = session.subscribe(event.stream, 'video-container');
// When the HTML video has been appended to DOM...
subscriber.on('videoElementCreated', event => {
pushEvent(event);
// Add a new HTML element for the user's name and nickname over its video
room.on(
LivekitClient.RoomEvent.TrackSubscribed,
(track, publication, participant) => {
const element = track.attach();
element.id = track.sid;
document.getElementById('video-container').appendChild(element);
if (track.kind === 'video') {
var audioTrackId;
var videoTrackId;
participant.getTracks().forEach((track) => {
if (track.kind === 'audio') {
audioTrackId = track.trackInfo.sid;
} else if (track.kind === 'video') {
videoTrackId = track.trackInfo.sid;
}
});
addIndividualRecordingButton(element.id, videoTrackId, audioTrackId);
updateNumVideos(1);
});
}
}
);
// When the HTML video has been appended to DOM...
subscriber.on('videoElementDestroyed', event => {
pushEvent(event);
// Add a new HTML element for the user's name and nickname over its video
// On every new Track destroyed...
room.on(
LivekitClient.RoomEvent.TrackUnsubscribed,
(track, publication, participant) => {
track.detach();
document.getElementById(track.sid)?.remove();
if (track.kind === 'video') {
// removeUserData(participant);
updateNumVideos(-1);
});
// When the subscriber stream has started playing media...
subscriber.on('streamPlaying', event => {
pushEvent(event);
});
});
session.on('streamDestroyed', event => {
pushEvent(event);
});
session.on('sessionDisconnected', event => {
pushEvent(event);
if (event.reason !== 'disconnect') {
removeUser();
}
if (event.reason !== 'sessionClosedByServer') {
session = null;
numVideos = 0;
$('#join').show();
$('#session').hide();
}
});
}
);
session.on('recordingStarted', event => {
pushEvent(event);
});
room.on(LivekitClient.RoomEvent.RecordingStatusChanged, (isRecording) => {
console.log('Recording status changed: ' + status);
if (!isRecording) {
listRecordings();
}
});
session.on('recordingStopped', event => {
pushEvent(event);
});
getToken(myRoomName, myParticipantName).then(async (token) => {
const livekitUrl = getLivekitUrlFromMetadata(token);
// On every asynchronous exception...
session.on('exception', (exception) => {
console.warn(exception);
});
try {
await room.connect(livekitUrl, token);
// --- 4) Connect to the session passing the retrieved token and some more data from
// the client (in this case a JSON with the nickname chosen by the user) ---
var participantName = $('#user').val();
$('#room-title').text(myRoomName);
$('#join').hide();
$('#room').show();
session.connect(token)
.then(() => {
const [audioPublication, videoPublication] = await Promise.all([
room.localParticipant.setMicrophoneEnabled(true),
room.localParticipant.setCameraEnabled(true),
]);
localVideoPublication = videoPublication;
localAudioPublication = audioPublication;
// --- 5) Set page layout for active call ---
$('#session-title').text(sessionName);
$('#join').hide();
$('#session').show();
// --- 6) Get your own camera stream ---
var publisher = OV.initPublisher('video-container', {
audioSource: undefined, // The source of audio. If undefined default microphone
videoSource: undefined, // The source of video. If undefined default webcam
publishAudio: true, // Whether you want to start publishing with your audio unmuted or not
publishVideo: true, // Whether you want to start publishing with your video enabled or not
resolution: '640x480', // The resolution of your video
frameRate: 30, // The frame rate of your video
insertMode: 'APPEND', // How the video is inserted in the target element 'video-container'
mirror: false // Whether to mirror your local video or not
});
// --- 7) Specify the actions when events take place in our publisher ---
// When the publisher stream has started playing media...
publisher.on('accessAllowed', event => {
pushEvent({
type: 'accessAllowed'
});
});
publisher.on('accessDenied', event => {
pushEvent(event);
});
publisher.on('accessDialogOpened', event => {
pushEvent({
type: 'accessDialogOpened'
});
});
publisher.on('accessDialogClosed', event => {
pushEvent({
type: 'accessDialogClosed'
});
});
// When the publisher stream has started playing media...
publisher.on('streamCreated', event => {
pushEvent(event);
});
// When our HTML video has been added to DOM...
publisher.on('videoElementCreated', event => {
pushEvent(event);
updateNumVideos(1);
$(event.element).prop('muted', true); // Mute local video
});
// When the HTML video has been appended to DOM...
publisher.on('videoElementDestroyed', event => {
pushEvent(event);
// Add a new HTML element for the user's name and nickname over its video
updateNumVideos(-1);
});
// When the publisher stream has started playing media...
publisher.on('streamPlaying', event => {
pushEvent(event);
});
// --- 8) Publish your stream ---
session.publish(publisher);
})
.catch(error => {
console.warn('There was an error connecting to the session:', error.code, error.message);
enableBtn();
});
console.log('Connected to room ' + myRoomName);
const element = videoPublication.track.attach();
element.id = videoPublication.track.sid;
document.getElementById('video-container').appendChild(element);
addIndividualRecordingButton(
element.id,
videoPublication.track.sid,
audioPublication.track.sid
);
updateNumVideos(1);
} catch (error) {
console.warn(
'There was an error connecting to the room:',
error.code,
error.message
);
enableBtn();
}
return false;
});
}
function leaveSession() {
function leaveRoom() {
room.disconnect();
room = null;
$('#video-container').empty();
numVideos = 0;
$('#join').show();
$('#room').hide();
// --- 9) Leave the session by calling 'disconnect' method over the Session object ---
session.disconnect();
enableBtn();
}
/* OPENVIDU METHODS */
function enableBtn (){
document.getElementById("join-btn").disabled = false;
document.getElementById("join-btn").innerHTML = "Join!";
function enableBtn() {
document.getElementById('join-btn').disabled = false;
document.getElementById('join-btn').innerHTML = 'Join!';
}
/* APPLICATION REST METHODS */
function getToken(callback) {
sessionName = $("#sessionName").val(); // Video-call chosen by the user
httpRequest(
'POST',
'recording-node/api/get-token', {
sessionName: sessionName
},
'Request of TOKEN gone WRONG:',
res => {
token = res[0]; // Get token from response
console.warn('Request of TOKEN gone WELL (TOKEN:' + token + ')');
callback(token); // Continue the join operation
}
);
function getToken(roomName, participantName) {
return new Promise((resolve, reject) => {
// Video-call chosen by the user
httpRequest(
'POST',
'token',
{ roomName, participantName },
'Error generating token',
(response) => resolve(response.token)
);
});
}
function removeUser() {
httpRequest(
'POST',
'recording-node/api/remove-user', {
sessionName: sessionName,
token: token
},
'User couldn\'t be removed from session',
res => {
console.warn("You have been removed from session " + sessionName);
}
);
}
async function httpRequest(method, url, body, errorMsg, successCallback) {
try {
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
body: method === 'GET' ? undefined : JSON.stringify(body),
});
function closeSession() {
httpRequest(
'DELETE',
'recording-node/api/close-session', {
sessionName: sessionName
},
'Session couldn\'t be closed',
res => {
console.warn("Session " + sessionName + " has been closed");
}
);
}
function fetchInfo() {
httpRequest(
'POST',
'recording-node/api/fetch-info', {
sessionName: sessionName
},
'Session couldn\'t be fetched',
res => {
console.warn("Session info has been fetched");
$('#textarea-http').text(JSON.stringify(res, null, "\t"));
}
);
}
function fetchAll() {
httpRequest(
'GET',
'recording-node/api/fetch-all', {},
'All session info couldn\'t be fetched',
res => {
console.warn("All session info has been fetched");
$('#textarea-http').text(JSON.stringify(res, null, "\t"));
}
);
}
function forceDisconnect() {
httpRequest(
'DELETE',
'recording-node/api/force-disconnect', {
sessionName: sessionName,
connectionId: document.getElementById('forceValue').value
},
'Connection couldn\'t be closed',
res => {
console.warn("Connection has been closed");
}
);
}
function forceUnpublish() {
httpRequest(
'DELETE',
'recording-node/api/force-unpublish', {
sessionName: sessionName,
streamId: document.getElementById('forceValue').value
},
'Stream couldn\'t be closed',
res => {
console.warn("Stream has been closed");
}
);
}
function httpRequest(method, url, body, errorMsg, callback) {
$('#textarea-http').text('');
var http = new XMLHttpRequest();
http.open(method, url, true);
http.setRequestHeader('Content-type', 'application/json');
http.addEventListener('readystatechange', processRequest, false);
http.send(JSON.stringify(body));
function processRequest() {
if (http.readyState == 4) {
if (http.status == 200) {
try {
callback(JSON.parse(http.responseText));
} catch (e) {
callback(e);
}
} else {
console.warn(errorMsg + ' (' + http.status + ')');
console.warn(http.responseText);
$('#textarea-http').text(errorMsg + ": HTTP " + http.status + " (" + http.responseText + ")");
}
if (response.ok) {
const data = await response.json();
successCallback(data);
} else {
console.warn(errorMsg);
console.warn('Error: ' + response.statusText);
}
} catch (error) {
console.error(error);
}
}
function startRecording() {
var outputMode = $('input[name=outputMode]:checked').val();
function startComposedRecording() {
var hasAudio = $('#has-audio-checkbox').prop('checked');
var hasVideo = $('#has-video-checkbox').prop('checked');
httpRequest(
'POST',
'recording-node/api/recording/start', {
session: session.sessionId,
outputMode: outputMode,
hasAudio: hasAudio,
hasVideo: hasVideo
'recordings/start',
{
roomName: room.roomInfo.name,
outputMode: 'COMPOSED',
videoOnly: hasVideo && !hasAudio,
audioOnly: hasAudio && !hasVideo,
},
'Start recording WRONG',
res => {
(res) => {
console.log(res);
document.getElementById('forceRecordingId').value = res.id;
checkBtnsRecordings();
$('#textarea-http').text(JSON.stringify(res, null, "\t"));
$('#textarea-http').text(JSON.stringify(res, null, '\t'));
}
);
}
function stopRecording() {
var forceRecordingId = document.getElementById('forceRecordingId').value;
function startIndividualRecording(videoTrackId, audioTrackId) {
return new Promise((resolve, reject) => {
httpRequest(
'POST',
'recordings/start',
{
roomName: room.roomInfo.name,
outputMode: 'INDIVIDUAL',
audioTrackId,
videoTrackId,
},
'Start recording WRONG',
(res) => {
console.log(res);
$('#textarea-http').text(JSON.stringify(res.info, null, '\t'));
resolve(res);
}
);
});
}
function stopRecording(id) {
var forceRecordingId = id ? id : $('#forceRecordingId').val();
httpRequest(
'POST',
'recording-node/api/recording/stop', {
recording: forceRecordingId
'recordings/stop',
{
recordingId: forceRecordingId,
},
'Stop recording WRONG',
res => {
(res) => {
console.log(res);
$('#textarea-http').text(JSON.stringify(res, null, "\t"));
}
);
}
function deleteRecording() {
var forceRecordingId = document.getElementById('forceRecordingId').value;
httpRequest(
'DELETE',
'recording-node/api/recording/delete', {
recording: forceRecordingId
},
'Delete recording WRONG',
res => {
console.log("DELETE ok");
$('#textarea-http').text("DELETE ok");
}
);
}
function getRecording() {
var forceRecordingId = document.getElementById('forceRecordingId').value;
httpRequest(
'GET',
'recording-node/api/recording/get/' + forceRecordingId, {},
'Get recording WRONG',
res => {
console.log(res);
$('#textarea-http').text(JSON.stringify(res, null, "\t"));
$('#forceRecordingId').val('');
$('#textarea-http').text(JSON.stringify(res.info, null, '\t'));
}
);
}
function listRecordings() {
httpRequest(
'GET',
'recording-node/api/recording/list', {},
'List recordings WRONG',
res => {
console.log(res);
$('#textarea-http').text(JSON.stringify(res, null, "\t"));
httpRequest('GET', 'recordings/list', {}, 'List recordings WRONG', (res) => {
console.log(res);
$('#recording-list').empty();
if (res.recordings && res.recordings.length > 0) {
res.recordings.forEach((recording) => {
var li = document.createElement('li');
var a = document.createElement('a');
a.href = recording.path;
a.target = '_blank';
a.appendChild(document.createTextNode(recording.name));
li.appendChild(a);
$('#recording-list').append(li);
});
$('#delete-recordings-btn').prop('disabled', res.recordings.length === 0);
}
);
});
}
function deleteRecordings() {
httpRequest('DELETE', 'recordings', {}, 'Delete recordings WRONG', (res) => {
console.log(res);
$('#recording-list').empty();
$('#delete-recordings-btn').prop('disabled', true);
$('#textarea-http').text(JSON.stringify(res, null, '\t'));
});
}
/* APPLICATION REST METHODS */
/* APPLICATION BROWSER METHODS */
events = '';
window.onbeforeunload = function () { // Gracefully leave session
if (session) {
window.onbeforeunload = function () {
// Gracefully leave room
if (room) {
removeUser();
leaveSession();
leaveRoom();
}
};
function getLivekitUrlFromMetadata(token) {
if (!token) throw new Error('Trying to get metadata from an empty token');
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
window
.atob(base64)
.split('')
.map((c) => {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
})
.join('')
);
const payload = JSON.parse(jsonPayload);
if (!payload?.metadata) throw new Error('Token does not contain metadata');
const metadata = JSON.parse(payload.metadata);
return metadata.livekitUrl;
} catch (error) {
throw new Error('Error decoding and parsing token: ' + error);
}
}
@ -432,40 +306,43 @@ function updateNumVideos(i) {
}
}
function checkBtnsForce() {
if (document.getElementById("forceValue").value === "") {
document.getElementById('buttonForceUnpublish').disabled = true;
document.getElementById('buttonForceDisconnect').disabled = true;
} else {
document.getElementById('buttonForceUnpublish').disabled = false;
document.getElementById('buttonForceDisconnect').disabled = false;
}
}
function checkBtnsRecordings() {
if (document.getElementById("forceRecordingId").value === "") {
document.getElementById('buttonGetRecording').disabled = true;
if (document.getElementById('forceRecordingId').value === '') {
document.getElementById('buttonStopRecording').disabled = true;
document.getElementById('buttonDeleteRecording').disabled = true;
} else {
document.getElementById('buttonGetRecording').disabled = false;
document.getElementById('buttonStopRecording').disabled = false;
document.getElementById('buttonDeleteRecording').disabled = false;
}
}
function pushEvent(event) {
events += (!events ? '' : '\n') + event.type;
$('#textarea-events').text(events);
function addIndividualRecordingButton(elementId, videoTrackId, audioTrackId) {
const div = document.createElement('div');
var button = document.createElement('button');
// button.id = elementId + '-button';
button.className = 'recording-track-button btn btn-sm';
button.innerHTML = 'Record Track';
button.style = 'position: absolute; left: 0; z-index: 1000;';
button.onclick = async () => {
if (button.innerHTML === 'Record Track') {
button.innerHTML = 'Stop Recording';
button.className = 'recording-track-button btn btn-sm btn-danger';
var res = await startIndividualRecording(videoTrackId, audioTrackId);
button.id = res.info.egressId;
} else {
button.innerHTML = 'Record Track';
button.className = 'recording-track-button btn btn-sm';
stopRecording(button.id);
}
};
div.appendChild(button);
var element = document.getElementById(elementId);
element.parentNode.insertBefore(div, element.nextSibling);
}
function clearHttpTextarea() {
$('#textarea-http').text('');
}
function clearEventsTextarea() {
$('#textarea-events').text('');
events = '';
}
/* APPLICATION BROWSER METHODS */
/* APPLICATION BROWSER METHODS */

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

View File

@ -17,7 +17,7 @@
<!-- Bootstrap -->
<link rel="styleSheet" href="style.css" type="text/css" media="screen">
<script src="openvidu-browser-2.27.0.js"></script>
<script src="https://cdn.jsdelivr.net/npm/livekit-client/dist/livekit-client.umd.min.js"></script>
<script src="app.js"></script>
<script>
$(document).ready(function () {
@ -35,11 +35,11 @@
<div class="navbar-header">
<a class="navbar-brand" href="/">
<img class="demo-logo" src="images/openvidu_vert_white_bg_trans_cropped.png" /> Recording Node</a>
<a class="navbar-brand nav-icon" href="https://github.com/OpenVidu/openvidu-tutorials/tree/master/openvidu-recording-node"
<a class="navbar-brand nav-icon" href="https://github.com/OpenVidu/openvidu-livekit-tutorials/tree/master/openvidu-recording-node"
title="GitHub Repository" target="_blank">
<i class="fa fa-github" aria-hidden="true"></i>
</a>
<a class="navbar-brand nav-icon" href="http://www.docs.openvidu.io/en/stable/tutorials/openvidu-recording-node/" title="Documentation"
<a class="navbar-brand nav-icon" href="#" title="Documentation"
target="_blank">
<i class="fa fa-book" aria-hidden="true"></i>
</a>
@ -53,48 +53,30 @@
<img src="images/openvidu_grey_bg_transp_cropped.png" />
</div>
<div id="join-dialog" class="jumbotron">
<h1>Join a video session</h1>
<h1>Join a video room</h1>
<form class="form-group" onsubmit="return false">
<p>
<label>Session</label>
<input class="form-control" type="text" id="sessionName" value="SessionA" required>
<label>Room</label>
<input class="form-control" type="text" id="roomName" value="RoomA" required>
</p>
<p class="text-center">
<button class="btn btn-lg btn-success" id="join-btn" onclick="joinSession()">Join!</button>
<button class="btn btn-lg btn-success" id="join-btn" onclick="joinRoom()">Join!</button>
</p>
</form>
<hr>
</div>
</div>
<div id="session" style="display: none">
<div id="session-header">
<h1 id="session-title"></h1>
<input class="btn btn-sm btn-danger" type="button" id="buttonCloseSession" onmouseup="closeSession()" value="Close session">
<input class="btn btn-sm btn-danger" type="button" id="buttonLeaveSession" onmouseup="removeUser(); leaveSession()"
value="Leave session">
<div class="vertical-separator-top"></div>
<input class="form-control" id="forceValue" type="text" onkeyup="checkBtnsForce()">
<input class="btn btn-sm" type="button" id="buttonForceUnpublish" onmouseup="forceUnpublish()" value="Force unpublish"
disabled>
<input class="btn btn-sm" type="button" id="buttonForceDisconnect" onmouseup="forceDisconnect()" value="Force disconnect"
disabled>
<div class="vertical-separator-top"></div>
<input class="btn btn-sm" type="button" id="buttonFetchInfo" onmouseup="fetchInfo()" value="Fetch info">
<input class="btn btn-sm" type="button" id="buttonFetchAll" onmouseup="fetchAll()" value="Fetch all">
<div id="room" style="display: none">
<div id="room-header">
<h1 id="room-title"></h1>
<input class="btn btn-sm btn-danger" type="button" id="buttonLeaveRoom" onmouseup="leaveRoom()"
value="Leave room">
</div>
<div id="video-container" class="col-md-12"></div>
<div id="recording-btns">
<div class="btns">
<input class="btn btn-md" type="button" id="buttonStartRecording" onmouseup="startRecording()" value="Start recording">
<form>
<label class="radio-inline">
<input type="radio" name="outputMode" value="COMPOSED" id="radio-composed" checked>COMPOSED
</label>
<label class="radio-inline">
<input type="radio" name="outputMode" value="INDIVIDUAL" id="radio-individual">INDIVIDUAL
</label>
</form>
<input class="btn btn-md" type="button" id="buttonStartRecording" onmouseup="startComposedRecording()" value="Start Composed recording">
<form>
<label class="checkbox-inline">
<input type="checkbox" id="has-audio-checkbox" checked>Has audio
@ -107,11 +89,7 @@
<div class="btns">
<input class="btn btn-md" type="button" id="buttonListRecording" onmouseup="listRecordings()" value="List recordings">
<div class="vertical-separator-bottom"></div>
<input class="btn btn-md" type="button" id="buttonGetRecording" onmouseup="getRecording()" value="Get recording"
disabled>
<input class="btn btn-md" type="button" id="buttonStopRecording" onmouseup="stopRecording()" value="Stop recording"
disabled>
<input class="btn btn-md" type="button" id="buttonDeleteRecording" onmouseup="deleteRecording()" value="Delete recording"
<input class="btn btn-md btn-danger" type="button" id="buttonStopRecording" onmouseup="stopRecording()" value="Stop recording"
disabled>
<input class="form-control" id="forceRecordingId" type="text" onkeyup="checkBtnsRecordings()">
</div>
@ -120,10 +98,10 @@
<span>HTTP responses</span>
<textarea id="textarea-http" readonly="true" class="form-control" name="textarea-http"></textarea>
</div>
<div class="textarea-container" id="textarea-events-container">
<button type="button" class="btn btn-outline-secondary" id="clear-events-btn" onclick="clearEventsTextarea()">Clear</button>
<span>OpenVidu events</span>
<textarea id="textarea-events" readonly="true" class="form-control" name="textarea-events"></textarea>
<div class="textarea-container" id="recordings-list-container">
<button type="button" class="btn btn-md btn-danger" id="delete-recordings-btn" onclick="deleteRecordings()" disabled>Delete All</button>
<span>Recordings list</span>
<ul id="recording-list"></ul>
</div>
</div>
</div>
@ -131,7 +109,7 @@
<footer class="footer">
<div class="container">
<div class="text-muted">OpenVidu © 2022</div>
<div class="text-muted">OpenVidu © 2023</div>
<a href="http://www.openvidu.io/" target="_blank">
<img class="openvidu-logo" src="images/openvidu_globe_bg_transp_cropped.png" />
</a>

File diff suppressed because one or more lines are too long

View File

@ -222,27 +222,27 @@ a:hover .demo-logo {
margin-top: 100px;
}
#session-header {
#room-header {
margin-bottom: 20px;
height: 8%;
margin-top: 70px;
}
#session-header form {
#room-header form {
display: inline-block;
}
#session-header input.btn {
#room-header input.btn {
float: right;
margin-top: 20px;
margin-left: 5px;
}
#session-title {
#room-title {
display: inline-block;
}
#session-header .form-control {
#room-header .form-control {
width: initial;
float: right;
margin: 18px 0px 0px 5px;
@ -300,12 +300,12 @@ video {
object-fit: scale-down;
}
#session {
#room {
height: 100%;
padding-bottom: 80px;
}
#session img {
#room img {
width: 100%;
height: auto;
display: inline-block;
@ -313,7 +313,7 @@ video {
vertical-align: baseline;
}
#session #video-container img {
#room #video-container img {
position: relative;
float: left;
width: 50%;
@ -387,11 +387,12 @@ table i {
}
#textarea-http-container {
width: 69%;
width: 59%;
}
#textarea-events-container {
width: 29%;
#recordings-list-container {
width: 39%;
overflow: auto;
}
.textarea-container button {

View File

@ -1,373 +1,200 @@
/* CONFIGURATION */
var OpenVidu = require('openvidu-node-client').OpenVidu;
var OpenViduRole = require('openvidu-node-client').OpenViduRole;
require('dotenv').config(
!!process.env.CONFIG ? { path: process.env.CONFIG } : {}
);
// For demo purposes we ignore self-signed certificate
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
// Node imports
var express = require('express');
var fs = require('fs');
var httpServer = (useSSL) ? require('https') : require('http');
var bodyParser = require('body-parser'); // Pull information from HTML POST (express4)
var app = express(); // Create our app with express
const express = require('express');
const fs = require('fs');
var path = require('path');
const https = require('https');
const bodyParser = require('body-parser');
const AccessToken = require('livekit-server-sdk').AccessToken;
const EgressClient = require('livekit-server-sdk').EgressClient;
const cors = require('cors');
const app = express();
// Environment variable: PORT where the node server is listening
var SERVER_PORT = process.env.SERVER_PORT || 5000;
// Environment variable: URL where our OpenVidu server is listening
var OPENVIDU_URL = process.env.OPENVIDU_URL || process.argv[2] || 'http://localhost:4443';
// Environment variable: secret shared with our OpenVidu server
var OPENVIDU_SECRET = process.env.OPENVIDU_SECRET || process.argv[3] || 'MY_SECRET';
var useSSL = (process.env.USE_SSL === 'false') ? false : true
// Entrypoint to OpenVidu Node Client SDK
var OV = new OpenVidu(OPENVIDU_URL, OPENVIDU_SECRET);
// Collection to pair session names with OpenVidu Session objects
var mapSessions = {};
// Collection to pair session names with tokens
var mapSessionNamesTokens = {};
const SERVER_PORT = process.env.SERVER_PORT || 5000;
// Environment variable: api key shared with our LiveKit deployment
const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY || 'devkey';
// Environment variable: api secret shared with our LiveKit deployment
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET || 'secret';
// Environment variable: url of our LiveKit deployment
const LIVEKIT_URL = process.env.LIVEKIT_URL || 'ws://localhost:7880';
// Environment variable: path where the recordings will be stored
const RECORDINGS_PATH = process.env.RECORDINGS_PATH || '/recordings';
// Listen (start app with node server.js)
var options = (useSSL) ? {
key: fs.readFileSync('openvidukey.pem'),
cert: fs.readFileSync('openviducert.pem')
} : {}
const options = {
key: fs.readFileSync('openvidukey.pem'),
cert: fs.readFileSync('openviducert.pem'),
};
// Server configuration
app.use(express.static(__dirname + '/public')); // Set the static files location
app.use(bodyParser.urlencoded({
'extended': 'true'
})); // Parse application/x-www-form-urlencoded
app.use(bodyParser.json()); // Parse application/json
app.use(bodyParser.json({
type: 'application/vnd.api+json'
})); // Parse application/vnd.api+json as json
const livekitUrlHostname = LIVEKIT_URL.replace(/^ws:/, 'http:').replace(
/^wss:/,
'https:'
);
const egressClient = new EgressClient(
livekitUrlHostname,
LIVEKIT_API_KEY,
LIVEKIT_API_SECRET
);
// Enable CORS support
app.use(
cors({
origin: '*',
})
);
httpServer.createServer(options, app).listen(SERVER_PORT, () => {
console.log(`App listening with ${(useSSL) ? "https": "http"} on port ${SERVER_PORT}`);
console.log(`OPENVIDU_URL: ${OPENVIDU_URL}`);
console.log(`OPENVIDU_SECRET: ${OPENVIDU_SECRET}`);
// Set the static files location
app.use(express.static(__dirname + '/public'));
// Parse application/x-www-form-urlencoded
app.use(
bodyParser.urlencoded({
extended: 'true',
})
);
// Parse application/json
app.use(bodyParser.json());
// Parse application/vnd.api+json as json
app.use(
bodyParser.json({
type: 'application/vnd.api+json',
})
);
https.createServer(options, app).listen(SERVER_PORT, () => {
console.log(`App listening on port ${SERVER_PORT}`);
console.log(`LIVEKIT API KEY: ${LIVEKIT_API_KEY}`);
console.log(`LIVEKIT API SECRET: ${LIVEKIT_API_SECRET}`);
console.log(`LIVEKIT URL: ${LIVEKIT_URL}`);
console.log();
console.log('Access the app at https://localhost:' + SERVER_PORT);
});
/* Session API */
// Get token (add new user to session)
app.post('/recording-node/api/get-token', function (req, res) {
// The video-call to connect
var sessionName = req.body.sessionName;
app.post('/token', (req, res) => {
const { roomName, participantName } = req.body;
// Role associated to this user
var role = OpenViduRole.PUBLISHER;
console.log(
`Getting a token for room '${roomName}' and participant '${participantName}'`
);
console.log("Getting a token | {sessionName}={" + sessionName + "}");
if (!roomName || !participantName) {
res
.status(400)
.json({ message: 'roomName and participantName are required' });
return;
}
// Build connectionProperties object with PUBLISHER role
var connectionProperties = {
role: role
}
if (mapSessions[sessionName]) {
// Session already exists
console.log('Existing session ' + sessionName);
// Get the existing Session from the collection
var mySession = mapSessions[sessionName];
// Generate a new Connection asynchronously with the recently created connectionProperties
mySession.createConnection(connectionProperties)
.then(connection => {
// Store the new token in the collection of tokens
mapSessionNamesTokens[sessionName].push(connection.token);
// Return the token to the client
res.status(200).send({
0: connection.token
});
})
.catch(error => {
console.error(error);
if (error.message === "404") {
delete mapSessions[sessionName];
delete mapSessionNamesTokens[sessionName];
newSession(sessionName, connectionProperties, res);
}
});
} else {
newSession(sessionName, connectionProperties, res);
}
const at = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, {
identity: participantName,
// add metadata to the token, which will be available in the participant's metadata
metadata: JSON.stringify({ livekitUrl: LIVEKIT_URL }),
});
at.addGrant({
roomJoin: true,
room: roomName,
});
res.status(200).json({ token: at.toJwt() });
});
function newSession(sessionName, connectionProperties, res) {
// New session
console.log('New session ' + sessionName);
// Create a new OpenVidu Session asynchronously
OV.createSession()
.then(session => {
// Store the new Session in the collection of Sessions
mapSessions[sessionName] = session;
// Store a new empty array in the collection of tokens
mapSessionNamesTokens[sessionName] = [];
// Generate a new connection asynchronously with the recently created connectionProperties
session.createConnection(connectionProperties)
.then(connection => {
// Store the new token in the collection of tokens
mapSessionNamesTokens[sessionName].push(connection.token);
// Return the Token to the client
res.status(200).send({
0: connection.token
});
})
.catch(error => {
console.error(error);
});
})
.catch(error => {
console.error(error);
});
}
// Remove user from session
app.post('/recording-node/api/remove-user', function (req, res) {
// Retrieve params from POST body
var sessionName = req.body.sessionName;
var token = req.body.token;
console.log('Removing user | {sessionName, token}={' + sessionName + ', ' + token + '}');
// If the session exists
if (mapSessions[sessionName] && mapSessionNamesTokens[sessionName]) {
var tokens = mapSessionNamesTokens[sessionName];
var index = tokens.indexOf(token);
// If the token exists
if (index !== -1) {
// Token removed
tokens.splice(index, 1);
console.log(sessionName + ': ' + tokens.toString());
} else {
var msg = 'Problems in the app server: the TOKEN wasn\'t valid';
console.log(msg);
res.status(500).send(msg);
}
if (tokens.length == 0) {
// Last user left: session must be removed
console.log(sessionName + ' empty!');
delete mapSessions[sessionName];
}
res.status(200).send();
} else {
var msg = 'Problems in the app server: the SESSION does not exist';
console.log(msg);
res.status(500).send(msg);
}
});
// Close session
app.delete('/recording-node/api/close-session', function (req, res) {
// Retrieve params from POST body
var sessionName = req.body.sessionName;
console.log("Closing session | {sessionName}=" + sessionName);
// If the session exists
if (mapSessions[sessionName]) {
var session = mapSessions[sessionName];
session.close();
delete mapSessions[sessionName];
delete mapSessionNamesTokens[sessionName];
res.status(200).send();
} else {
var msg = 'Problems in the app server: the SESSION does not exist';
console.log(msg);
res.status(500).send(msg);
}
});
// Fetch session info
app.post('/recording-node/api/fetch-info', function (req, res) {
// Retrieve params from POST body
var sessionName = req.body.sessionName;
console.log("Fetching session info | {sessionName}=" + sessionName);
// If the session exists
if (mapSessions[sessionName]) {
mapSessions[sessionName].fetch()
.then(changed => {
console.log("Any change: " + changed);
res.status(200).send(sessionToJson(mapSessions[sessionName]));
})
.catch(error => res.status(400).send(error.message));
} else {
var msg = 'Problems in the app server: the SESSION does not exist';
console.log(msg);
res.status(500).send(msg);
}
});
// Fetch all session info
app.get('/recording-node/api/fetch-all', function (req, res) {
console.log("Fetching all session info");
OV.fetch()
.then(changed => {
var sessions = [];
OV.activeSessions.forEach(s => {
sessions.push(sessionToJson(s));
});
console.log("Any change: " + changed);
res.status(200).send(sessions);
})
.catch(error => res.status(400).send(error.message));
});
// Force disconnect
app.delete('/recording-node/api/force-disconnect', function (req, res) {
// Retrieve params from POST body
var sessionName = req.body.sessionName;
var connectionId = req.body.connectionId;
// If the session exists
if (mapSessions[sessionName]) {
mapSessions[sessionName].forceDisconnect(connectionId)
.then(() => res.status(200).send())
.catch(error => res.status(400).send(error.message));
} else {
var msg = 'Problems in the app server: the SESSION does not exist';
console.log(msg);
res.status(500).send(msg);
}
});
// Force unpublish
app.delete('/recording-node/api/force-unpublish', function (req, res) {
// Retrieve params from POST body
var sessionName = req.body.sessionName;
var streamId = req.body.streamId;
// If the session exists
if (mapSessions[sessionName]) {
mapSessions[sessionName].forceUnpublish(streamId)
.then(() => res.status(200).send())
.catch(error => res.status(400).send(error.message));
} else {
var msg = 'Problems in the app server: the SESSION does not exist';
console.log(msg);
res.status(500).send(msg);
}
});
/* Recording API */
// Start recording
app.post('/recording-node/api/recording/start', function (req, res) {
// Retrieve params from POST body
var recordingProperties = {
outputMode: req.body.outputMode,
hasAudio: req.body.hasAudio,
hasVideo: req.body.hasVideo,
}
var sessionId = req.body.session;
console.log("Starting recording | {sessionId}=" + sessionId);
OV.startRecording(sessionId, recordingProperties)
.then(recording => res.status(200).send(recording))
.catch(error => res.status(400).send(error.message));
app.post('/recordings/start', async function (req, res) {
const {
roomName,
outputMode,
videoOnly,
audioOnly,
audioTrackId,
videoTrackId,
} = req.body;
const output = {
fileType: 0, // file type chosen based on codecs
filepath: `/recordings/${roomName}-${new Date().getTime()}`,
disableManifest: true,
};
console.log('Starting recording', roomName);
try {
let egressInfo;
if (outputMode === 'COMPOSED') {
console.log('Starting COMPOSED recording', roomName);
egressInfo = await egressClient.startRoomCompositeEgress(
roomName,
output,
{
layout: 'grid',
audioOnly,
videoOnly,
}
);
} else if (outputMode === 'INDIVIDUAL') {
console.log('Starting INDIVIDUAL recording', roomName);
egressInfo = await egressClient.startTrackCompositeEgress(
roomName,
output,
{
audioTrackId,
videoTrackId,
}
);
} else {
res.status(400).json({ message: 'outputMode is required' });
return;
}
res.status(200).json({ message: 'recording started', info: egressInfo });
} catch (error) {
console.log('Error starting recording', error);
res.status(200).json({ message: 'error starting recording' });
}
});
// Stop recording
app.post('/recording-node/api/recording/stop', function (req, res) {
// Retrieve params from POST body
var recordingId = req.body.recording;
console.log("Stopping recording | {recordingId}=" + recordingId);
app.post('/recordings/stop', async function (req, res) {
const recordingId = req.body.recordingId;
try {
if (!recordingId) {
res.status(400).json({ message: 'recordingId is required' });
return;
}
OV.stopRecording(recordingId)
.then(recording => res.status(200).send(recording))
.catch(error => res.status(400).send(error.message));
});
// Delete recording
app.delete('/recording-node/api/recording/delete', function (req, res) {
// Retrieve params from DELETE body
var recordingId = req.body.recording;
console.log("Deleting recording | {recordingId}=" + recordingId);
OV.deleteRecording(recordingId)
.then(() => res.status(200).send())
.catch(error => res.status(400).send(error.message));
});
// Get recording
app.get('/recording-node/api/recording/get/:recordingId', function (req, res) {
// Retrieve params from GET url
var recordingId = req.params.recordingId;
console.log("Getting recording | {recordingId}=" + recordingId);
OV.getRecording(recordingId)
.then(recording => res.status(200).send(recording))
.catch(error => res.status(400).send(error.message));
console.log(`Stopping recording ${recordingId}`);
const egressInfo = await egressClient.stopEgress(recordingId);
res.status(200).json({ message: 'recording stopped', info: egressInfo });
} catch (error) {
console.log('Error stopping recording', error);
res.status(200).json({ message: 'error stopping recording' });
}
});
// List all recordings
app.get('/recording-node/api/recording/list', function (req, res) {
console.log("Listing recordings");
OV.listRecordings()
.then(recordings => res.status(200).send(recordings))
.catch(error => res.status(400).send(error.message));
app.get('/recordings/list', function (req, res) {
const recordings = [];
fs.readdirSync(RECORDINGS_PATH, { recursive: true }).forEach((value) => {
// copy file to public folder for development purposes
fs.copyFileSync(`${RECORDINGS_PATH}/${value}`, `public/${value}`);
const newRec = { name: value, path: `/${value}` };
recordings.push(newRec);
});
console.log(recordings);
res.status(200).json({ recordings });
});
function sessionToJson(session) {
var json = {};
json.sessionId = session.sessionId;
json.createdAt = session.createdAt;
json.customSessionId = !!session.properties.customSessionId ? session.properties.customSessionId : "";
json.recording = session.recording;
json.mediaMode = session.properties.mediaMode;
json.recordingMode = session.properties.recordingMode;
json.defaultRecordingProperties = session.properties.defaultRecordingProperties;
var connections = {};
connections.numberOfElements = session.activeConnections.length;
var jsonArrayConnections = [];
session.activeConnections.forEach(con => {
var c = {};
c.connectionId = con.connectionId;
c.createdAt = con.createdAt;
c.role = con.role;
c.serverData = con.serverData;
c.record = con.record;
c.token = con.token;
c.clientData = con.clientData;
var pubs = [];
con.publishers.forEach(p => {
jsonP = {};
jsonP.streamId = p.streamId;
jsonP.createdAt = p.createdAt
jsonP.hasAudio = p.hasAudio;
jsonP.hasVideo = p.hasVideo;
jsonP.audioActive = p.audioActive;
jsonP.videoActive = p.videoActive;
jsonP.frameRate = p.frameRate;
jsonP.typeOfVideo = p.typeOfVideo;
jsonP.videoDimensions = p.videoDimensions;
pubs.push(jsonP);
});
var subs = [];
con.subscribers.forEach(s => {
subs.push(s);
});
c.publishers = pubs;
c.subscribers = subs;
jsonArrayConnections.push(c);
});
connections.content = jsonArrayConnections;
json.connections = connections;
return json;
}
// Delete all recordings
app.delete('/recordings', function (req, res) {
fs.readdirSync(RECORDINGS_PATH, { recursive: true }).forEach((value) => {
fs.unlinkSync(`${RECORDINGS_PATH}/${value}`);
if (fs.existsSync(`public/${value}`)) {
fs.unlinkSync(`public/${value}`);
}
});
res.status(200).json({ message: 'All recordings deleted' });
});