openvidu-recording-improved: Add initial structure
This commit is contained in:
parent
99bb02ce6a
commit
5b7baf2946
14
advanced-features/openvidu-recording-improved-node/.env
Normal file
14
advanced-features/openvidu-recording-improved-node/.env
Normal file
@ -0,0 +1,14 @@
|
||||
SERVER_PORT=6080
|
||||
|
||||
# LiveKit configuration
|
||||
LIVEKIT_URL=http://localhost:7880
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_SECRET=secret
|
||||
|
||||
# S3 configuration
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_ACCESS_KEY=minioadmin
|
||||
S3_SECRET_KEY=minioadmin
|
||||
AWS_REGION=us-east-1
|
||||
S3_BUCKET=openvidu
|
||||
RECORDINGS_PATH=recordings/
|
||||
129
advanced-features/openvidu-recording-improved-node/.gitignore
vendored
Normal file
129
advanced-features/openvidu-recording-improved-node/.gitignore
vendored
Normal file
@ -0,0 +1,129 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
30
advanced-features/openvidu-recording-improved-node/README.md
Normal file
30
advanced-features/openvidu-recording-improved-node/README.md
Normal file
@ -0,0 +1,30 @@
|
||||
# OpenVidu Recording Improved
|
||||
|
||||
Simple video-call application with recording capabilities (improved version). It includes a backend built with Node.js with Express and a frontend built with plain HTML, CSS and JavaScript.
|
||||
|
||||
For further information, check the [tutorial documentation](https://livekit-tutorials.openvidu.io/tutorials/advanced-tutorials/openvidu-recording-improved/).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Node](https://nodejs.org/en/download)
|
||||
|
||||
## Run
|
||||
|
||||
1. Download repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/OpenVidu/openvidu-livekit-tutorials.git
|
||||
cd openvidu-livekit-tutorials/advanced-features/openvidu-recording-improved-node
|
||||
```
|
||||
|
||||
2. Install dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Run the application
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
2543
advanced-features/openvidu-recording-improved-node/package-lock.json
generated
Normal file
2543
advanced-features/openvidu-recording-improved-node/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "openvidu-recording",
|
||||
"version": "1.0.0",
|
||||
"description": "Simple video-call application with recording capabilities (improved version)",
|
||||
"main": "src/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.635.0",
|
||||
"cors": "2.8.5",
|
||||
"dotenv": "16.4.5",
|
||||
"express": "4.19.2",
|
||||
"livekit-server-sdk": "2.6.1"
|
||||
}
|
||||
}
|
||||
386
advanced-features/openvidu-recording-improved-node/public/app.js
Normal file
386
advanced-features/openvidu-recording-improved-node/public/app.js
Normal file
@ -0,0 +1,386 @@
|
||||
// When running OpenVidu locally, leave this variable empty
|
||||
// For other deployment type, configure it with correct URL depending on your deployment
|
||||
var LIVEKIT_URL = "";
|
||||
configureLiveKitUrl();
|
||||
|
||||
const LivekitClient = window.LivekitClient;
|
||||
var room;
|
||||
|
||||
function configureLiveKitUrl() {
|
||||
// If LIVEKIT_URL is not configured, use default value from OpenVidu Local deployment
|
||||
if (!LIVEKIT_URL) {
|
||||
if (window.location.hostname === "localhost") {
|
||||
LIVEKIT_URL = "ws://localhost:7880/";
|
||||
} else {
|
||||
LIVEKIT_URL = "wss://" + window.location.hostname + ":7443/";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function joinRoom() {
|
||||
// Disable 'Join' button
|
||||
document.getElementById("join-button").disabled = true;
|
||||
document.getElementById("join-button").innerText = "Joining...";
|
||||
|
||||
// Initialize a new Room object
|
||||
room = new LivekitClient.Room();
|
||||
|
||||
// Specify the actions when events take place in the room
|
||||
// On every new Track received...
|
||||
room.on(LivekitClient.RoomEvent.TrackSubscribed, (track, _publication, participant) => {
|
||||
addTrack(track, participant.identity);
|
||||
});
|
||||
|
||||
// On every new Track destroyed...
|
||||
room.on(LivekitClient.RoomEvent.TrackUnsubscribed, (track, _publication, participant) => {
|
||||
track.detach();
|
||||
document.getElementById(track.sid)?.remove();
|
||||
|
||||
if (track.kind === "video") {
|
||||
removeVideoContainer(participant.identity);
|
||||
}
|
||||
});
|
||||
|
||||
// When recording status changes...
|
||||
room.on(LivekitClient.RoomEvent.RecordingStatusChanged, async (isRecording) => {
|
||||
await updateRecordingInfo(isRecording);
|
||||
});
|
||||
|
||||
try {
|
||||
// Get the room name and participant name from the form
|
||||
const roomName = document.getElementById("room-name").value;
|
||||
const userName = document.getElementById("participant-name").value;
|
||||
|
||||
// Get a token from your application server with the room name and participant name
|
||||
const token = await getToken(roomName, userName);
|
||||
|
||||
// Connect to the room with the LiveKit URL and the token
|
||||
await room.connect(LIVEKIT_URL, token);
|
||||
|
||||
// Hide the 'Join room' page and show the 'Room' page
|
||||
document.getElementById("room-title").innerText = roomName;
|
||||
document.getElementById("join").hidden = true;
|
||||
document.getElementById("room").hidden = false;
|
||||
|
||||
// Publish your camera and microphone
|
||||
await room.localParticipant.enableCameraAndMicrophone();
|
||||
const localVideoTrack = this.room.localParticipant.videoTrackPublications.values().next().value.track;
|
||||
addTrack(localVideoTrack, userName, true);
|
||||
|
||||
// Update recording info
|
||||
await updateRecordingInfo(room.isRecording);
|
||||
} catch (error) {
|
||||
console.log("There was an error connecting to the room:", error.message);
|
||||
await leaveRoom();
|
||||
}
|
||||
}
|
||||
|
||||
function addTrack(track, participantIdentity, local = false) {
|
||||
const element = track.attach();
|
||||
element.id = track.sid;
|
||||
|
||||
/* If the track is a video track, we create a container and append the video element to it
|
||||
with the participant's identity */
|
||||
if (track.kind === "video") {
|
||||
const videoContainer = createVideoContainer(participantIdentity, local);
|
||||
videoContainer.append(element);
|
||||
appendParticipantData(videoContainer, participantIdentity + (local ? " (You)" : ""));
|
||||
} else {
|
||||
document.getElementById("layout-container").append(element);
|
||||
}
|
||||
}
|
||||
|
||||
async function leaveRoom() {
|
||||
// Leave the room by calling 'disconnect' method over the Room object
|
||||
await room.disconnect();
|
||||
|
||||
// Remove all HTML elements inside the layout container
|
||||
removeAllLayoutElements();
|
||||
|
||||
// Remove all recordings from the list
|
||||
removeAllRecordings();
|
||||
|
||||
// Reset recording state
|
||||
document.getElementById("recording-button").disabled = false;
|
||||
document.getElementById("recording-button").innerText = "Start Recording";
|
||||
document.getElementById("recording-button").className = "btn btn-primary";
|
||||
document.getElementById("recording-text").hidden = true;
|
||||
|
||||
// Back to 'Join room' page
|
||||
document.getElementById("join").hidden = false;
|
||||
document.getElementById("room").hidden = true;
|
||||
|
||||
// Enable 'Join' button
|
||||
document.getElementById("join-button").disabled = false;
|
||||
document.getElementById("join-button").innerText = "Join!";
|
||||
}
|
||||
|
||||
window.onbeforeunload = () => {
|
||||
room?.disconnect();
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async function () {
|
||||
var currentPage = window.location.pathname;
|
||||
|
||||
if (currentPage === "/recordings.html") {
|
||||
await listRecordings();
|
||||
} else {
|
||||
generateFormValues();
|
||||
}
|
||||
|
||||
// Remove recording video when the dialog is closed
|
||||
document.getElementById("recording-video-dialog").addEventListener("close", () => {
|
||||
const recordingVideo = document.getElementById("recording-video");
|
||||
recordingVideo.src = "";
|
||||
});
|
||||
});
|
||||
|
||||
function generateFormValues() {
|
||||
document.getElementById("room-name").value = "Test Room";
|
||||
document.getElementById("participant-name").value = "Participant" + Math.floor(Math.random() * 100);
|
||||
}
|
||||
|
||||
function createVideoContainer(participantIdentity, local = false) {
|
||||
const videoContainer = document.createElement("div");
|
||||
videoContainer.id = `camera-${participantIdentity}`;
|
||||
videoContainer.className = "video-container";
|
||||
const layoutContainer = document.getElementById("layout-container");
|
||||
|
||||
if (local) {
|
||||
layoutContainer.prepend(videoContainer);
|
||||
} else {
|
||||
layoutContainer.append(videoContainer);
|
||||
}
|
||||
|
||||
return videoContainer;
|
||||
}
|
||||
|
||||
function appendParticipantData(videoContainer, participantIdentity) {
|
||||
const dataElement = document.createElement("div");
|
||||
dataElement.className = "participant-data";
|
||||
dataElement.innerHTML = `<p>${participantIdentity}</p>`;
|
||||
videoContainer.prepend(dataElement);
|
||||
}
|
||||
|
||||
function removeVideoContainer(participantIdentity) {
|
||||
const videoContainer = document.getElementById(`camera-${participantIdentity}`);
|
||||
videoContainer?.remove();
|
||||
}
|
||||
|
||||
function removeAllLayoutElements() {
|
||||
const layoutElements = document.getElementById("layout-container").children;
|
||||
Array.from(layoutElements).forEach((element) => {
|
||||
element.remove();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* --------------------------------------------
|
||||
* GETTING A TOKEN FROM YOUR APPLICATION SERVER
|
||||
* --------------------------------------------
|
||||
* The method below request the creation of a token to
|
||||
* your application server. This prevents the need to expose
|
||||
* your LiveKit API key and secret to the client side.
|
||||
*
|
||||
* In this sample code, there is no user control at all. Anybody could
|
||||
* access your application server endpoints. In a real production
|
||||
* environment, your application server must identify the user to allow
|
||||
* access to the endpoints.
|
||||
*/
|
||||
async function getToken(roomName, participantName) {
|
||||
const [error, body] = await httpRequest("POST", "/token", {
|
||||
roomName,
|
||||
participantName
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to get token: ${error.message}`);
|
||||
}
|
||||
|
||||
return body.token;
|
||||
}
|
||||
|
||||
async function updateRecordingInfo(isRecording) {
|
||||
const recordingButton = document.getElementById("recording-button");
|
||||
const recordingText = document.getElementById("recording-text");
|
||||
|
||||
if (isRecording) {
|
||||
recordingButton.disabled = false;
|
||||
recordingButton.innerText = "Stop Recording";
|
||||
recordingButton.className = "btn btn-danger";
|
||||
recordingText.hidden = false;
|
||||
} else {
|
||||
recordingButton.disabled = false;
|
||||
recordingButton.innerText = "Start Recording";
|
||||
recordingButton.className = "btn btn-primary";
|
||||
recordingText.hidden = true;
|
||||
}
|
||||
|
||||
const roomId = await room.getSid();
|
||||
await listRecordings(room.name, roomId);
|
||||
}
|
||||
|
||||
async function manageRecording() {
|
||||
const recordingButton = document.getElementById("recording-button");
|
||||
|
||||
if (recordingButton.innerText === "Start Recording") {
|
||||
recordingButton.disabled = true;
|
||||
recordingButton.innerText = "Starting...";
|
||||
|
||||
const [error, _] = await startRecording();
|
||||
|
||||
if (error && error.status !== 409) {
|
||||
recordingButton.disabled = false;
|
||||
recordingButton.innerText = "Start Recording";
|
||||
}
|
||||
} else {
|
||||
recordingButton.disabled = true;
|
||||
recordingButton.innerText = "Stopping...";
|
||||
|
||||
const [error, _] = await stopRecording();
|
||||
|
||||
if (error && error.status !== 409) {
|
||||
recordingButton.disabled = false;
|
||||
recordingButton.innerText = "Stop Recording";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function startRecording() {
|
||||
return httpRequest("POST", "/recordings/start", {
|
||||
roomName: room.name
|
||||
});
|
||||
}
|
||||
|
||||
async function stopRecording() {
|
||||
return httpRequest("POST", "/recordings/stop", {
|
||||
roomName: room.name
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteRecording(recordingName) {
|
||||
const [error, _] = await httpRequest("DELETE", `/recordings/${recordingName}`);
|
||||
|
||||
if (!error || error.status === 404) {
|
||||
const roomId = await room?.getSid();
|
||||
await listRecordings(room?.name, roomId);
|
||||
}
|
||||
}
|
||||
|
||||
async function listRecordings(roomName, roomId) {
|
||||
const url = "/recordings" + (roomName ? `?roomName=${roomName}` + (roomId ? `&roomId=${roomId}` : "") : "");
|
||||
const [error, body] = await httpRequest("GET", url);
|
||||
|
||||
if (!error) {
|
||||
const recordings = body.recordings;
|
||||
showRecordingList(recordings);
|
||||
}
|
||||
}
|
||||
|
||||
async function listRecordingsByRoom() {
|
||||
const roomName = document.getElementById("room-name").value;
|
||||
await listRecordings(roomName);
|
||||
}
|
||||
|
||||
async function httpRequest(method, url, body) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: method !== "GET" ? JSON.stringify(body) : undefined
|
||||
});
|
||||
|
||||
const responseBody = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(responseBody.errorMessage);
|
||||
const error = {
|
||||
status: response.status,
|
||||
message: responseBody.errorMessage
|
||||
};
|
||||
return [error, undefined];
|
||||
}
|
||||
|
||||
return [undefined, responseBody];
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
const errorObj = {
|
||||
status: 0,
|
||||
message: error.message
|
||||
};
|
||||
return [errorObj, undefined];
|
||||
}
|
||||
}
|
||||
|
||||
function showRecordingList(recordings) {
|
||||
const recordingsList = document.getElementById("recording-list");
|
||||
|
||||
if (recordings.length === 0) {
|
||||
recordingsList.innerHTML = "<span>There are no recordings available</span>";
|
||||
} else {
|
||||
recordingsList.innerHTML = "";
|
||||
}
|
||||
|
||||
recordings.forEach((recording) => {
|
||||
const recordingName = recording.name;
|
||||
const recordingSize = formatBytes(recording.size ?? 0);
|
||||
const recordingDate = new Date(recording.startedAt).toLocaleString();
|
||||
|
||||
const recordingContainer = document.createElement("div");
|
||||
recordingContainer.className = "recording-container";
|
||||
recordingContainer.id = recordingName;
|
||||
|
||||
recordingContainer.innerHTML = `
|
||||
<i class="fa-solid fa-file-video"></i>
|
||||
<div class="recording-info">
|
||||
<p class="recording-name">${recordingName}</p>
|
||||
<p class="recording-size">${recordingSize}</p>
|
||||
<p class="recording-date">${recordingDate}</p>
|
||||
</div>
|
||||
<div class="recording-actions">
|
||||
<button title="Play" class="icon-button" onclick="displayRecording('${recordingName}')">
|
||||
<i class="fa-solid fa-play"></i>
|
||||
</button>
|
||||
<button title="Delete" class="icon-button delete-button" onclick="deleteRecording('${recordingName}')">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
recordingsList.append(recordingContainer);
|
||||
});
|
||||
}
|
||||
|
||||
function displayRecording(recordingName) {
|
||||
const recordingVideoDialog = document.getElementById("recording-video-dialog");
|
||||
recordingVideoDialog.showModal();
|
||||
const recordingVideo = document.getElementById("recording-video");
|
||||
recordingVideo.src = `/recordings/${recordingName}`;
|
||||
}
|
||||
|
||||
function closeRecording() {
|
||||
const recordingVideoDialog = document.getElementById("recording-video-dialog");
|
||||
recordingVideoDialog.close();
|
||||
}
|
||||
|
||||
function removeAllRecordings() {
|
||||
const recordingList = document.getElementById("recording-list").children;
|
||||
Array.from(recordingList).forEach((recording) => {
|
||||
recording.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) {
|
||||
return "0Bytes";
|
||||
}
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
const decimals = i < 2 ? 0 : 1;
|
||||
|
||||
return (bytes / Math.pow(k, i)).toFixed(decimals) + sizes[i];
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@ -0,0 +1,111 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>OpenVidu Recording</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="shortcut icon" href="images/favicon.ico" type="image/x-icon" />
|
||||
|
||||
<!-- Bootstrap -->
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
|
||||
integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A=="
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
|
||||
<link rel="stylesheet" href="styles.css" type="text/css" media="screen" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/livekit-client@2.5.0/dist/livekit-client.umd.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header>
|
||||
<a href="/" title="Home"><h1>OpenVidu Recording</h1></a>
|
||||
<div id="links">
|
||||
<a
|
||||
href="https://github.com/OpenVidu/openvidu-livekit-tutorials/tree/master/advanced-features/openvidu-recording"
|
||||
title="GitHub Repository"
|
||||
target="_blank"
|
||||
>
|
||||
<i class="fa-brands fa-github"></i>
|
||||
</a>
|
||||
<a
|
||||
href="https://livekit-tutorials.openvidu.io/tutorials/advanced-tutorials/openvidu-recording/"
|
||||
title="Documentation"
|
||||
target="_blank"
|
||||
>
|
||||
<i class="fa-solid fa-book"></i>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div id="join">
|
||||
<div id="join-dialog">
|
||||
<h2>Join a Video Room</h2>
|
||||
<form onsubmit="joinRoom(); return false">
|
||||
<div>
|
||||
<label for="participant-name">Participant</label>
|
||||
<input id="participant-name" class="form-control" type="text" required />
|
||||
</div>
|
||||
<div>
|
||||
<label for="room-name">Room</label>
|
||||
<input id="room-name" class="form-control" type="text" required />
|
||||
</div>
|
||||
<button id="join-button" class="btn btn-lg btn-success" type="submit">Join!</button>
|
||||
</form>
|
||||
</div>
|
||||
<a href="/recordings.html" class="btn btn-primary">View all recordings</a>
|
||||
</div>
|
||||
|
||||
<div id="room" hidden>
|
||||
<div id="recording-text" hidden><span>Room is being recorded</span></div>
|
||||
<div id="room-header">
|
||||
<h2 id="room-title"></h2>
|
||||
<button class="btn btn-primary" id="recording-button" onclick="manageRecording()">
|
||||
Start Recording
|
||||
</button>
|
||||
<button class="btn btn-danger" id="leave-room-button" onclick="leaveRoom()">Leave Room</button>
|
||||
</div>
|
||||
<div id="layout-container"></div>
|
||||
<div id="recordings">
|
||||
<div id="recording-header">
|
||||
<h3>Session Recordings</h3>
|
||||
<a href="/recordings.html" target="_blank" class="btn btn-sm btn-primary"
|
||||
>View all recordings</a
|
||||
>
|
||||
</div>
|
||||
<div id="recording-list"></div>
|
||||
<dialog id="recording-video-dialog">
|
||||
<video id="recording-video" autoplay controls></video>
|
||||
<button class="btn btn-secondary" id="close-recording-video-dialog" onclick="closeRecording()">
|
||||
Close
|
||||
</button>
|
||||
</dialog>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p class="text">Made with love by <span>OpenVidu Team</span></p>
|
||||
<a href="https://openvidu.io/" target="_blank">
|
||||
<img id="openvidu-logo" src="images/openvidu_logo.png" alt="OpenVidu logo" />
|
||||
</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,89 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>OpenVidu Recording</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="shortcut icon" href="images/favicon.ico" type="image/x-icon" />
|
||||
|
||||
<!-- Bootstrap -->
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
|
||||
integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A=="
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
|
||||
<link rel="stylesheet" href="styles.css" type="text/css" media="screen" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/livekit-client@2.5.0/dist/livekit-client.umd.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header>
|
||||
<a href="/" title="Home"><h1>OpenVidu Recording</h1></a>
|
||||
<div id="links">
|
||||
<a
|
||||
href="https://github.com/OpenVidu/openvidu-livekit-tutorials/tree/master/advanced-features/openvidu-recording"
|
||||
title="GitHub Repository"
|
||||
target="_blank"
|
||||
>
|
||||
<i class="fa-brands fa-github"></i>
|
||||
</a>
|
||||
<a
|
||||
href="https://livekit-tutorials.openvidu.io/tutorials/advanced-tutorials/openvidu-recording/"
|
||||
title="Documentation"
|
||||
target="_blank"
|
||||
>
|
||||
<i class="fa-solid fa-book"></i>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div id="recordings-all">
|
||||
<div id="actions">
|
||||
<button id="refresh-button" title="Refresh" class="icon-button" onclick="listRecordings()">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
<form onsubmit="listRecordingsByRoom(); return false">
|
||||
<input id="room-name" type="text" placeholder="Room name" />
|
||||
<button title="Search" type="submit">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<h2>Recordings</h2>
|
||||
<div id="recording-list"></div>
|
||||
<dialog id="recording-video-dialog">
|
||||
<video id="recording-video" autoplay controls></video>
|
||||
<button class="btn btn-secondary" id="close-recording-video-dialog" onclick="closeRecording()">
|
||||
Close
|
||||
</button>
|
||||
</dialog>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p class="text">Made with love by <span>OpenVidu Team</span></p>
|
||||
<a href="https://openvidu.io/" target="_blank">
|
||||
<img id="openvidu-logo" src="images/openvidu_logo.png" alt="OpenVidu logo" />
|
||||
</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,479 @@
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding-top: 50px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
header {
|
||||
height: 50px;
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 30px;
|
||||
background-color: #4d4d4d;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
header a {
|
||||
color: #ccc;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
header a:hover {
|
||||
color: #a9a9a9;
|
||||
}
|
||||
|
||||
header i {
|
||||
padding: 5px 5px;
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
#join {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#join-dialog {
|
||||
width: 70%;
|
||||
max-width: 900px;
|
||||
padding: 60px;
|
||||
border-radius: 6px;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
#join-dialog h2 {
|
||||
color: #4d4d4d;
|
||||
font-size: 60px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#join-dialog form {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#join-dialog label {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
color: #0088aa;
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
margin-bottom: 10px;
|
||||
box-sizing: border-box;
|
||||
color: #0088aa;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
color: #0088aa;
|
||||
border-color: #0088aa;
|
||||
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(0, 136, 170, 0.6);
|
||||
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(0, 136, 170, 0.6);
|
||||
}
|
||||
|
||||
#join-dialog button {
|
||||
display: block;
|
||||
margin: 20px auto 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: #06d362;
|
||||
border-color: #06d362;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background-color: #1abd61;
|
||||
border-color: #1abd61;
|
||||
}
|
||||
|
||||
#room {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#recording-text {
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
#recording-text span {
|
||||
background-color: #ffeb3b;
|
||||
color: #333;
|
||||
border-radius: 5px;
|
||||
padding: 0 5px;
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
float: right;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#room-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
padding: 0 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
#room-title {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#layout-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
position: relative;
|
||||
background: #3b3b3b;
|
||||
aspect-ratio: 16/9;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-container video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.video-container .participant-data {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.participant-data p {
|
||||
background: #f8f8f8;
|
||||
margin: 0;
|
||||
padding: 0 5px;
|
||||
color: #777777;
|
||||
font-weight: bold;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
#recordings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
#recording-header {
|
||||
margin: 15px 0;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 3fr 1fr;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#recording-header h3 {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
color: #444;
|
||||
grid-column-start: 2;
|
||||
}
|
||||
|
||||
#recording-header a {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
#recording-list {
|
||||
width: 600px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#recording-list span {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
color: #333;
|
||||
display: block;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.recording-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid #dcdcdc;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
background-color: #f4f4f4;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.fa-file-video {
|
||||
font-size: 30px;
|
||||
color: #333;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.recording-info {
|
||||
flex-grow: 1;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.recording-info p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.recording-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.recording-size,
|
||||
.recording-date {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.recording-date {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.recording-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: #333;
|
||||
margin-left: 10px;
|
||||
padding: 4px 12px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
color: #d9534f;
|
||||
}
|
||||
|
||||
#recording-video-dialog {
|
||||
width: 500px;
|
||||
max-width: 90%;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
padding: 20px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
#recording-video {
|
||||
width: 100%;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
#close-recording-video-dialog {
|
||||
margin-top: 10px;
|
||||
float: right;
|
||||
}
|
||||
|
||||
footer {
|
||||
height: 60px;
|
||||
width: 100%;
|
||||
padding: 10px 30px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: #4d4d4d;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
footer .text {
|
||||
color: #ccc;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
footer .text span {
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#openvidu-logo {
|
||||
height: 35px;
|
||||
-webkit-transition: all 0.1s ease-in-out;
|
||||
-moz-transition: all 0.1s ease-in-out;
|
||||
-o-transition: all 0.1s ease-in-out;
|
||||
transition: all 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
#openvidu-logo:hover {
|
||||
-webkit-filter: grayscale(0.5);
|
||||
filter: grayscale(0.5);
|
||||
}
|
||||
|
||||
/* Media Queries */
|
||||
@media screen and (max-width: 768px) {
|
||||
header {
|
||||
padding: 10px 15px;
|
||||
}
|
||||
|
||||
#join-dialog {
|
||||
width: 90%;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
#join-dialog h2 {
|
||||
font-size: 50px;
|
||||
}
|
||||
|
||||
#layout-container {
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
}
|
||||
|
||||
footer {
|
||||
padding: 10px 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
header {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#join-dialog {
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
#join-dialog h2 {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
#layout-container {
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
}
|
||||
|
||||
.video-container {
|
||||
aspect-ratio: 9/16;
|
||||
}
|
||||
|
||||
footer {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Recordings page styles */
|
||||
#actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
#refresh-button {
|
||||
margin: 0;
|
||||
padding: 8px 13px;
|
||||
}
|
||||
|
||||
form input {
|
||||
padding: 8px;
|
||||
font-size: 16px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px 0 0 5px;
|
||||
}
|
||||
|
||||
form button {
|
||||
padding: 8px;
|
||||
font-size: 16px;
|
||||
border: 1px solid #ccc;
|
||||
border-left: none;
|
||||
background-color: #007bff;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
border-radius: 0 5px 5px 0;
|
||||
}
|
||||
|
||||
.search-form button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
#recordings-all {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#recordings-all h2 {
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 40px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
color: #444;
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
export const SERVER_PORT = process.env.SERVER_PORT || 6080;
|
||||
|
||||
// LiveKit configuration
|
||||
export const LIVEKIT_URL = process.env.LIVEKIT_URL || "http://localhost:7880";
|
||||
export const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY || "devkey";
|
||||
export const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET || "secret";
|
||||
|
||||
// S3 configuration
|
||||
export const S3_ENDPOINT = process.env.S3_ENDPOINT || "http://localhost:9000";
|
||||
export const S3_ACCESS_KEY = process.env.S3_ACCESS_KEY || "minioadmin";
|
||||
export const S3_SECRET_KEY = process.env.S3_SECRET_KEY || "minioadmin";
|
||||
export const AWS_REGION = process.env.AWS_REGION || "us-east-1";
|
||||
export const S3_BUCKET = process.env.S3_BUCKET || "openvidu";
|
||||
|
||||
export const RECORDINGS_PATH = process.env.RECORDINGS_PATH ?? "recordings/";
|
||||
@ -0,0 +1,172 @@
|
||||
import { Router } from "express";
|
||||
import { EgressClient, EncodedFileOutput, EncodedFileType } from "livekit-server-sdk";
|
||||
import { LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, RECORDINGS_PATH } from "../config.js";
|
||||
import { S3Service } from "../services/s3.service.js";
|
||||
|
||||
const egressClient = new EgressClient(LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
|
||||
const s3Service = new S3Service();
|
||||
|
||||
export const recordingController = Router();
|
||||
|
||||
recordingController.post("/start", async (req, res) => {
|
||||
const { roomName } = req.body;
|
||||
|
||||
if (!roomName) {
|
||||
res.status(400).json({ errorMessage: "roomName is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const activeEgresses = await getActiveEgressesByRoom(roomName);
|
||||
|
||||
// Check if there is already an active egress for this room
|
||||
if (activeEgresses.length > 0) {
|
||||
res.status(409).json({ errorMessage: "Recording already started for this room" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the EncodedFileOutput to save the recording to an MP4 file
|
||||
const fileOutput = new EncodedFileOutput({
|
||||
fileType: EncodedFileType.MP4,
|
||||
filepath: `${RECORDINGS_PATH}{room_name}-{room_id}-{time}`
|
||||
});
|
||||
|
||||
try {
|
||||
// Start a RoomCompositeEgress to record all participants in the room
|
||||
const egressInfo = await egressClient.startRoomCompositeEgress(roomName, { file: fileOutput });
|
||||
const recording = {
|
||||
name: egressInfo.fileResults[0].filename.split("/").pop(),
|
||||
startedAt: Number(egressInfo.startedAt) / 1_000_000
|
||||
};
|
||||
res.json({ message: "Recording started", recording });
|
||||
} catch (error) {
|
||||
console.error("Error starting recording.", error);
|
||||
res.status(500).json({ errorMessage: "Error starting recording" });
|
||||
}
|
||||
});
|
||||
|
||||
recordingController.post("/stop", async (req, res) => {
|
||||
const { roomName } = req.body;
|
||||
|
||||
if (!roomName) {
|
||||
res.status(400).json({ errorMessage: "roomName is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const activeEgresses = await getActiveEgressesByRoom(roomName);
|
||||
|
||||
// Check if there is an active egress for this room
|
||||
if (activeEgresses.length === 0) {
|
||||
res.status(409).json({ errorMessage: "Recording not started for this room" });
|
||||
return;
|
||||
}
|
||||
|
||||
const egressId = activeEgresses[0].egressId;
|
||||
|
||||
try {
|
||||
// Stop the Egress to finish the recording
|
||||
const egressInfo = await egressClient.stopEgress(egressId);
|
||||
const file = egressInfo.fileResults[0];
|
||||
const recording = {
|
||||
name: file.filename.split("/").pop(),
|
||||
startedAt: Number(egressInfo.startedAt) / 1_000_000,
|
||||
size: Number(file.size)
|
||||
};
|
||||
res.json({ message: "Recording stopped", recording });
|
||||
} catch (error) {
|
||||
console.error("Error stopping recording.", error);
|
||||
res.status(500).json({ errorMessage: "Error stopping recording" });
|
||||
}
|
||||
});
|
||||
|
||||
recordingController.get("/", async (req, res) => {
|
||||
const roomName = req.query.roomName?.toString();
|
||||
const roomId = req.query.roomId?.toString();
|
||||
|
||||
try {
|
||||
const keyStart = RECORDINGS_PATH + (roomName ? `${roomName}` + (roomId ? `-${roomId}` : "") : "");
|
||||
const keyEnd = ".mp4.json";
|
||||
const regex = new RegExp(`^${keyStart}.*${keyEnd}$`);
|
||||
|
||||
// List all Egress metadata files in the recordings path that match the regex
|
||||
const payloadKeys = await s3Service.listObjects(RECORDINGS_PATH, regex);
|
||||
const recordings = await Promise.all(payloadKeys.map((payloadKey) => getRecordingInfo(payloadKey)));
|
||||
res.json({ recordings });
|
||||
} catch (error) {
|
||||
console.error("Error listing recordings.", error);
|
||||
res.status(500).json({ errorMessage: "Error listing recordings" });
|
||||
}
|
||||
});
|
||||
|
||||
recordingController.get("/:recordingName", async (req, res) => {
|
||||
const { recordingName } = req.params;
|
||||
const key = RECORDINGS_PATH + recordingName;
|
||||
const exists = await s3Service.exists(key);
|
||||
|
||||
if (!exists) {
|
||||
res.status(404).json({ errorMessage: "Recording not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the recording file from S3
|
||||
const { body, size } = await s3Service.getObject(key);
|
||||
|
||||
// Set the response headers
|
||||
res.setHeader("Content-Type", "video/mp4");
|
||||
res.setHeader("Content-Length", size);
|
||||
res.setHeader("Accept-Ranges", "bytes");
|
||||
|
||||
// Pipe the recording file to the response
|
||||
body.pipe(res).on("finish", () => res.end());
|
||||
} catch (error) {
|
||||
console.error("Error getting recording.", error);
|
||||
res.status(500).json({ errorMessage: "Error getting recording" });
|
||||
}
|
||||
});
|
||||
|
||||
recordingController.delete("/:recordingName", async (req, res) => {
|
||||
const { recordingName } = req.params;
|
||||
const key = RECORDINGS_PATH + recordingName;
|
||||
const exists = await s3Service.exists(key);
|
||||
|
||||
if (!exists) {
|
||||
res.status(404).json({ errorMessage: "Recording not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete the recording file and metadata file from S3
|
||||
await Promise.all([s3Service.deleteObject(key), s3Service.deleteObject(`${key}.json`)]);
|
||||
res.json({ message: "Recording deleted" });
|
||||
} catch (error) {
|
||||
console.error("Error deleting recording.", error);
|
||||
res.status(500).json({ errorMessage: "Error deleting recording" });
|
||||
}
|
||||
});
|
||||
|
||||
const getActiveEgressesByRoom = async (roomName) => {
|
||||
try {
|
||||
// List all active egresses for the room
|
||||
return await egressClient.listEgress({ roomName, active: true });
|
||||
} catch (error) {
|
||||
console.error("Error listing egresses.", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const getRecordingInfo = async (payloadKey) => {
|
||||
// Get the Egress metadata file as JSON
|
||||
const data = await s3Service.getObjectAsJson(payloadKey);
|
||||
|
||||
// Get the recording file size
|
||||
const recordingKey = payloadKey.replace(".json", "");
|
||||
const size = await s3Service.getObjectSize(recordingKey);
|
||||
|
||||
const recordingName = recordingKey.split("/").pop();
|
||||
const recording = {
|
||||
name: recordingName,
|
||||
startedAt: Number(data.started_at) / 1000000,
|
||||
size: size
|
||||
};
|
||||
return recording;
|
||||
};
|
||||
@ -0,0 +1,19 @@
|
||||
import express, { Router } from "express";
|
||||
import { WebhookReceiver } from "livekit-server-sdk";
|
||||
import { LIVEKIT_API_KEY, LIVEKIT_API_SECRET } from "../config.js";
|
||||
|
||||
const webhookReceiver = new WebhookReceiver(LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
|
||||
|
||||
export const webhookController = Router();
|
||||
webhookController.use(express.raw({ type: "application/webhook+json" }));
|
||||
|
||||
webhookController.post("/", async (req, res) => {
|
||||
try {
|
||||
const event = await webhookReceiver.receive(req.body, req.get("Authorization"));
|
||||
console.log(event);
|
||||
} catch (error) {
|
||||
console.error("Error validating webhook event.", error);
|
||||
}
|
||||
|
||||
res.status(200).send();
|
||||
});
|
||||
@ -0,0 +1,43 @@
|
||||
import "dotenv/config.js";
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { AccessToken } from "livekit-server-sdk";
|
||||
import { LIVEKIT_API_KEY, LIVEKIT_API_SECRET, SERVER_PORT } from "./config.js";
|
||||
import { recordingController } from "./controllers/recording.controller.js";
|
||||
import { webhookController } from "./controllers/webhook.controller.js";
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Set the static files location
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
app.use(express.static(path.join(__dirname, "../public")));
|
||||
|
||||
app.use("/recordings", recordingController);
|
||||
app.use("/livekit/webhook", webhookController);
|
||||
|
||||
app.post("/token", async (req, res) => {
|
||||
const roomName = req.body.roomName;
|
||||
const participantName = req.body.participantName;
|
||||
|
||||
if (!roomName || !participantName) {
|
||||
res.status(400).json({ errorMessage: "roomName and participantName are required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const at = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, {
|
||||
identity: participantName
|
||||
});
|
||||
at.addGrant({ roomJoin: true, room: roomName, roomRecord: true });
|
||||
const token = await at.toJwt();
|
||||
res.json({ token });
|
||||
});
|
||||
|
||||
app.listen(SERVER_PORT, () => {
|
||||
console.log("Server started on port:", SERVER_PORT);
|
||||
});
|
||||
@ -0,0 +1,95 @@
|
||||
import {
|
||||
S3Client,
|
||||
GetObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
HeadObjectCommand
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { AWS_REGION, S3_ACCESS_KEY, S3_BUCKET, S3_ENDPOINT, S3_SECRET_KEY } from "../config.js";
|
||||
|
||||
export class S3Service {
|
||||
static instance;
|
||||
|
||||
constructor() {
|
||||
if (S3Service.instance) {
|
||||
return S3Service.instance;
|
||||
}
|
||||
|
||||
this.s3Client = new S3Client({
|
||||
endpoint: S3_ENDPOINT,
|
||||
credentials: {
|
||||
accessKeyId: S3_ACCESS_KEY,
|
||||
secretAccessKey: S3_SECRET_KEY
|
||||
},
|
||||
region: AWS_REGION,
|
||||
forcePathStyle: true
|
||||
});
|
||||
|
||||
S3Service.instance = this;
|
||||
return this;
|
||||
}
|
||||
|
||||
async exists(key) {
|
||||
try {
|
||||
await this.headObject(key);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async headObject(key) {
|
||||
const params = {
|
||||
Bucket: S3_BUCKET,
|
||||
Key: key
|
||||
};
|
||||
const command = new HeadObjectCommand(params);
|
||||
return this.run(command);
|
||||
}
|
||||
|
||||
async getObjectSize(key) {
|
||||
const { ContentLength } = await this.headObject(key);
|
||||
return ContentLength;
|
||||
}
|
||||
|
||||
async getObject(key) {
|
||||
const params = {
|
||||
Bucket: S3_BUCKET,
|
||||
Key: key
|
||||
};
|
||||
const command = new GetObjectCommand(params);
|
||||
const { Body: body, ContentLength: size } = await this.run(command);
|
||||
return { body, size };
|
||||
}
|
||||
|
||||
async getObjectAsJson(key) {
|
||||
const { body } = await this.getObject(key);
|
||||
const stringifiedData = await body.transformToString();
|
||||
return JSON.parse(stringifiedData);
|
||||
}
|
||||
|
||||
async listObjects(prefix, regex) {
|
||||
const params = {
|
||||
Bucket: S3_BUCKET,
|
||||
Prefix: prefix
|
||||
};
|
||||
const command = new ListObjectsV2Command(params);
|
||||
const { Contents: objects } = await this.run(command);
|
||||
|
||||
// Filter objects by regex and return the keys
|
||||
return objects?.filter((object) => regex.test(object.Key)).map((payload) => payload.Key) ?? [];
|
||||
}
|
||||
|
||||
async deleteObject(key) {
|
||||
const params = {
|
||||
Bucket: S3_BUCKET,
|
||||
Key: key
|
||||
};
|
||||
const command = new DeleteObjectCommand(params);
|
||||
return this.run(command);
|
||||
}
|
||||
|
||||
async run(command) {
|
||||
return this.s3Client.send(command);
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
Simple video-call application with recording capabilities. It includes a backend built with Node.js with Express and a frontend built with plain HTML, CSS and JavaScript.
|
||||
|
||||
For further information, check the [tutorial documentation](https://livekit-tutorials.openvidu.io/tutorials/advanced-tutorials/node/).
|
||||
For further information, check the [tutorial documentation](https://livekit-tutorials.openvidu.io/tutorials/advanced-tutorials/openvidu-recording/).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@ -14,7 +14,7 @@ For further information, check the [tutorial documentation](https://livekit-tuto
|
||||
|
||||
```bash
|
||||
git clone https://github.com/OpenVidu/openvidu-livekit-tutorials.git
|
||||
cd openvidu-livekit-tutorials/advanced-features/openvidu-recording
|
||||
cd openvidu-livekit-tutorials/advanced-features/openvidu-recording-node
|
||||
```
|
||||
|
||||
2. Install dependencies
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user