advanced-features: azure-advanced-tutorial - added azure advanced recording tutorial

This commit is contained in:
Piwccle 2025-06-03 13:26:11 +02:00
parent d3180c1f9f
commit 1c57c6e85d
19 changed files with 3277 additions and 0 deletions

View File

@ -0,0 +1,15 @@
SERVER_PORT=6080
# LiveKit configuration
LIVEKIT_URL=http://localhost:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret
# Recording configuration
RECORDINGS_PATH=recordings/
RECORDING_PLAYBACK_STRATEGY=S3
# Azure Blob Storage configuration
AZURE_ACCOUNT_NAME=yourstorageaccountname
AZURE_ACCOUNT_KEY=youraccountkey
AZURE_CONTAINER_NAME=openvidu-appdata

View 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.*

View File

@ -0,0 +1,30 @@
# OpenVidu Recording Advanced Node
Simple video-call application with recording capabilities (advanced 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-features/recording-advanced/).
## 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-advanced-node
```
2. Install dependencies
```bash
npm install
```
3. Run the application
```bash
npm start
```

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,17 @@
{
"name": "openvidu-recording-advanced-node",
"version": "1.0.0",
"description": "Simple video-call application with recording capabilities (advanced version)",
"main": "src/index.js",
"type": "module",
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"@azure/storage-blob": "12.27.0",
"cors": "2.8.5",
"dotenv": "16.4.5",
"express": "4.21.0",
"livekit-server-sdk": "^2.7.2"
}
}

View File

@ -0,0 +1,432 @@
// 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.RoomMetadataChanged, async (metadata) => {
const { recordingStatus } = JSON.parse(metadata);
await updateRecordingInfo(recordingStatus);
});
// When a message is received...
room.on(LivekitClient.RoomEvent.DataReceived, async (payload, _participant, _kind, topic) => {
// If the message is a recording deletion notification, remove the recording from the list
if (topic === "RECORDING_DELETED") {
const { recordingName } = JSON.parse(new TextDecoder().decode(payload));
deleteRecordingContainer(recordingName);
}
});
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
const { recordingStatus } = JSON.parse(room.metadata);
await updateRecordingInfo(recordingStatus);
if (recordingStatus !== "STOPPED" && recordingStatus !== "FAILED") {
const roomId = await room.getSid();
await listRecordings(room.name, roomId);
}
} 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(recordingStatus) {
const recordingButton = document.getElementById("recording-button");
const recordingText = document.getElementById("recording-text");
switch (recordingStatus) {
case "STARTING":
recordingButton.disabled = true;
recordingButton.innerText = "Starting...";
break;
case "STARTED":
recordingButton.disabled = false;
recordingButton.innerText = "Stop Recording";
recordingButton.className = "btn btn-danger";
recordingText.hidden = false;
break;
case "STOPPING":
recordingButton.disabled = true;
recordingButton.innerText = "Stopping...";
recordingButton.className = "btn btn-danger";
recordingText.hidden = false;
break;
case "STOPPED":
case "FAILED":
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);
break;
}
}
async function manageRecording() {
const recordingButton = document.getElementById("recording-button");
if (recordingButton.innerText === "Start Recording") {
await startRecording();
} else {
await stopRecording();
}
}
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) {
deleteRecordingContainer(recordingName);
}
}
function deleteRecordingContainer(recordingName) {
const recordingContainer = document.getElementById(recordingName);
if (recordingContainer) {
recordingContainer.remove();
const recordingsList = document.getElementById("recording-list");
if (recordingsList.children.length === 0) {
recordingsList.innerHTML = "<span>There are no recordings available</span>";
}
}
}
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 getRecordingUrl(recordingName) {
const [_, body] = await httpRequest("GET", `/recordings/${recordingName}/url`);
return body?.recordingUrl;
}
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 recordingDuration = secondsToHms(recording.duration ?? 0);
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">${recordingDuration} | ${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);
});
}
async function displayRecording(recordingName) {
const recordingVideoDialog = document.getElementById("recording-video-dialog");
recordingVideoDialog.showModal();
const recordingVideo = document.getElementById("recording-video");
const recordingUrl = await getRecordingUrl(recordingName);
recordingVideo.src = recordingUrl;
}
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 secondsToHms(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor((seconds % 3600) % 60);
const hDisplay = h > 0 ? h + "h " : "";
const mDisplay = m > 0 ? m + "m " : "";
const sDisplay = s + "s";
return hDisplay + mDisplay + sDisplay;
}
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

View File

@ -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.9/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>

View File

@ -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.9/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>

View File

@ -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;
}

View File

@ -0,0 +1,25 @@
export const SERVER_PORT = process.env.SERVER_PORT || 6080;
export const APP_NAME = "openvidu-recording-advanced-node";
// 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";
// Azure Blob Storage configuration
export const AZURE_ACCOUNT_NAME = process.env.AZURE_ACCOUNT_NAME || "your_account_name";
export const AZURE_ACCOUNT_KEY = process.env.AZURE_ACCOUNT_KEY || "your_account_key";
export const AZURE_CONTAINER_NAME = process.env.AZURE_CONTAINER_NAME || "openvidu-appdata";
export const AZURE_ENDPOINT = process.env.AZURE_ENDPOINT || `https://${AZURE_ACCOUNT_NAME}.blob.core.windows.net`;
export const RECORDINGS_PATH = process.env.RECORDINGS_PATH ?? "recordings/";
export const RECORDINGS_METADATA_PATH = ".metadata/";
export const RECORDING_PLAYBACK_STRATEGY = process.env.RECORDING_PLAYBACK_STRATEGY || "S3"; // PROXY or S3
export const RECORDING_FILE_PORTION_SIZE = 5 * 1024 * 1024; // 5MB

View File

@ -0,0 +1,154 @@
import { Router } from "express";
import { RecordingService } from "../services/recording.service.js";
import { RoomService } from "../services/room.service.js";
import { RECORDING_PLAYBACK_STRATEGY } from "../config.js";
const recordingService = new RecordingService();
const roomService = new RoomService();
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 activeRecording = await recordingService.getActiveRecordingByRoom(roomName);
// Check if there is already an active recording for this room
if (activeRecording) {
res.status(409).json({ errorMessage: "Recording already started for this room" });
return;
}
try {
const recording = recordingService.startRecording(roomName);
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 activeRecording = await recordingService.getActiveRecordingByRoom(roomName);
// Check if there is an active recording for this room
if (!activeRecording) {
res.status(409).json({ errorMessage: "Recording not started for this room" });
return;
}
try {
const recording = await recordingService.stopRecording(activeRecording);
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 recordings = await recordingService.listRecordings(roomName, roomId);
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 { range } = req.headers;
const exists = await recordingService.existsRecording(recordingName);
if (!exists) {
res.status(404).json({ errorMessage: "Recording not found" });
return;
}
try {
// Get the recording file from Azure Blob Storage
const { stream, size, start, end } = await recordingService.getRecordingStream(recordingName, range);
// Set response headers
res.status(206);
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Content-Type", "video/mp4");
res.setHeader("Accept-Ranges", "bytes");
res.setHeader("Content-Range", `bytes ${start}-${end}/${size}`);
res.setHeader("Content-Length", end - start + 1);
// Pipe the recording file to the response
stream.pipe(res).on("finish", () => res.end());
} catch (error) {
console.error("Error getting recording.", error);
res.status(500).json({ errorMessage: "Error getting recording" });
}
});
recordingController.get("/:recordingName/url", async (req, res) => {
const { recordingName } = req.params;
const exists = await recordingService.existsRecording(recordingName);
if (!exists) {
res.status(404).json({ errorMessage: "Recording not found" });
return;
}
// If the recording playback strategy is "PROXY", return the endpoint URL
if (RECORDING_PLAYBACK_STRATEGY === "PROXY") {
res.json({ recordingUrl: `/recordings/${recordingName}` });
return;
}
try {
// If the recording playback strategy is "Azure Blob Storage", return a signed URL to access the recording directly from Azure Blob Storage
const recordingUrl = await recordingService.getRecordingUrl(recordingName);
res.json({ recordingUrl });
} catch (error) {
console.error("Error getting recording URL.", error);
res.status(500).json({ errorMessage: "Error getting recording URL" });
}
});
recordingController.delete("/:recordingName", async (req, res) => {
const { recordingName } = req.params;
const exists = await recordingService.existsRecording(recordingName);
if (!exists) {
res.status(404).json({ errorMessage: "Recording not found" });
return;
}
try {
const { roomName } = await recordingService.getRecordingMetadata(recordingName);
await recordingService.deleteRecording(recordingName);
// Notify to all participants that the recording was deleted
const existsRoom = await roomService.exists(roomName);
if (existsRoom) {
await roomService.sendDataToRoom(roomName, { recordingName });
}
res.json({ message: "Recording deleted" });
} catch (error) {
console.error("Error deleting recording.", error);
res.status(500).json({ errorMessage: "Error deleting recording" });
}
});

View File

@ -0,0 +1,38 @@
import { Router } from "express";
import { AccessToken } from "livekit-server-sdk";
import { LIVEKIT_API_KEY, LIVEKIT_API_SECRET } from "../config.js";
import { RoomService } from "../services/room.service.js";
const roomService = new RoomService();
export const roomController = Router();
roomController.post("/", 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({ room: roomName, roomJoin: true, roomRecord: true });
const token = await at.toJwt();
try {
// Create room if it doesn't exist
const exists = await roomService.exists(roomName);
if (!exists) {
await roomService.createRoom(roomName);
}
res.json({ token });
} catch (error) {
console.error("Error creating room.", error);
res.status(500).json({ errorMessage: "Error creating room" });
}
});

View File

@ -0,0 +1,76 @@
import express, { Router } from "express";
import { WebhookReceiver } from "livekit-server-sdk";
import { LIVEKIT_API_KEY, LIVEKIT_API_SECRET, APP_NAME } from "../config.js";
import { RoomService } from "../services/room.service.js";
import { RecordingService } from "../services/recording.service.js";
const webhookReceiver = new WebhookReceiver(LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
const roomService = new RoomService();
const recordingService = new RecordingService();
export const webhookController = Router();
webhookController.use(express.raw({ type: "application/webhook+json" }));
webhookController.post("/", async (req, res) => {
try {
const webhookEvent = await webhookReceiver.receive(req.body, req.get("Authorization"));
const isWebhookRelatedToMe = await checkWebhookRelatedToMe(webhookEvent);
if (isWebhookRelatedToMe) {
console.log(webhookEvent);
const { event: eventType, egressInfo } = webhookEvent;
switch (eventType) {
case "egress_started":
case "egress_updated":
await notifyRecordingStatusUpdate(egressInfo);
break;
case "egress_ended":
await handleEgressEnded(egressInfo);
break;
}
}
} catch (error) {
console.error("Error validating webhook event.", error);
}
res.status(200).send();
});
const checkWebhookRelatedToMe = async (webhookEvent) => {
const { room, egressInfo, ingressInfo } = webhookEvent;
let roomInfo = room;
if (!room || !room.metadata) {
const roomName = room?.name ?? egressInfo?.roomName ?? ingressInfo?.roomName;
roomInfo = await roomService.getRoom(roomName);
if (!roomInfo) {
return false;
}
}
const metadata = roomInfo.metadata ? JSON.parse(roomInfo.metadata) : null;
return metadata?.createdBy === APP_NAME;
};
const handleEgressEnded = async (egressInfo) => {
try {
await recordingService.saveRecordingMetadata(egressInfo);
} catch (error) {
console.error("Error saving recording metadata.", error);
}
await notifyRecordingStatusUpdate(egressInfo);
};
const notifyRecordingStatusUpdate = async (egressInfo) => {
const roomName = egressInfo.roomName;
const recordingStatus = recordingService.getRecordingStatus(egressInfo.status);
try {
await roomService.updateRoomMetadata(roomName, recordingStatus);
} catch (error) {
console.error("Error updating room metadata.", error);
}
};

View File

@ -0,0 +1,27 @@
import "dotenv/config.js";
import express from "express";
import cors from "cors";
import path from "path";
import { fileURLToPath } from "url";
import { SERVER_PORT } from "./config.js";
import { roomController } from "./controllers/room.controller.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("/token", roomController);
app.use("/recordings", recordingController);
app.use("/livekit/webhook", webhookController);
app.listen(SERVER_PORT, () => {
console.log("Server started on port:", SERVER_PORT);
});

View File

@ -0,0 +1,147 @@
import {
BlobServiceClient,
StorageSharedKeyCredential,
generateBlobSASQueryParameters,
BlobSASPermissions,
SASProtocol
} from "@azure/storage-blob";
import {
AZURE_CONTAINER_NAME,
AZURE_ACCOUNT_NAME,
AZURE_ACCOUNT_KEY,
AZURE_ENDPOINT
} from "../config.js";
export class AzureBlobService {
static instance;
constructor() {
if (AzureBlobService.instance) {
return AzureBlobService.instance;
}
const sharedKeyCredential = new StorageSharedKeyCredential(
AZURE_ACCOUNT_NAME,
AZURE_ACCOUNT_KEY
);
this.blobServiceClient = new BlobServiceClient(
AZURE_ENDPOINT,
sharedKeyCredential
);
this.containerClient = this.blobServiceClient.getContainerClient(
AZURE_CONTAINER_NAME
);
AzureBlobService.instance = this;
return this;
}
static getInstance() {
if (!AzureBlobService.instance) {
AzureBlobService.instance = new AzureBlobService();
}
return AzureBlobService.instance;
}
// Sube un objeto (JSON) al contenedor
async uploadObject(key, body) {
const blockBlobClient = this.containerClient.getBlockBlobClient(key);
const data = JSON.stringify(body);
await blockBlobClient.upload(data, Buffer.byteLength(data));
}
// Comprueba si existe un blob
async exists(key) {
const blobClient = this.containerClient.getBlobClient(key);
return await blobClient.exists();
}
// Obtiene las propiedades del blob (equivalente a headObject)
async headObject(key) {
const blobClient = this.containerClient.getBlobClient(key);
return await blobClient.getProperties();
}
// Devuelve el tamaño del blob en bytes
async getObjectSize(key) {
const props = await this.headObject(key);
return props.contentLength || 0;
}
// Descarga el blob completo o un rango, devolviendo el stream
async getObject(key, range) {
const blobClient = this.containerClient.getBlobClient(key);
let downloadResponse;
if (range) {
// range = { start: number, end: number }
const count = range.end - range.start + 1;
downloadResponse = await blobClient.download(range.start, count);
} else {
downloadResponse = await blobClient.download();
}
if (!downloadResponse.readableStreamBody) {
throw new Error("No se pudo obtener el stream del blob");
}
return downloadResponse.readableStreamBody;
}
// Genera un URL SAS válido 24 horas
async getObjectUrl(key) {
if (!AZURE_ACCOUNT_NAME || !AZURE_ACCOUNT_KEY) {
throw new Error("Credenciales de cuenta de Azure no están definidas para generar SAS");
}
const blobClient = this.containerClient.getBlobClient(key);
const expiresOn = new Date(new Date().valueOf() + 24 * 60 * 60 * 1000); // 24 horas
const sasPermissions = BlobSASPermissions.parse("r");
const sasToken = generateBlobSASQueryParameters(
{
containerName: AZURE_CONTAINER_NAME,
blobName: key,
expiresOn,
permissions: sasPermissions,
protocol: SASProtocol.Https
},
new StorageSharedKeyCredential(AZURE_ACCOUNT_NAME, AZURE_ACCOUNT_KEY)
).toString();
return `${blobClient.url}?${sasToken}`;
}
// Obtiene el contenido JSON del blob
async getObjectAsJson(key) {
const exists = await this.exists(key);
if (!exists) {
return undefined;
}
const stream = await this.getObject(key);
return new Promise((resolve, reject) => {
const chunks = [];
stream.on("data", (data) => {
chunks.push(Buffer.isBuffer(data) ? data : Buffer.from(data));
});
stream.on("end", () => {
const content = Buffer.concat(chunks).toString("utf-8");
resolve(JSON.parse(content));
});
stream.on("error", reject);
});
}
// Lista blobs bajo un prefijo y filtra por expresión regular
async listObjects(prefix, regex) {
const results = [];
for await (const blob of this.containerClient.listBlobsFlat({ prefix })) {
if (blob.name && regex.test(blob.name)) {
results.push(blob.name);
}
}
return results;
}
// Elimina un blob
async deleteObject(key) {
const blobClient = this.containerClient.getBlobClient(key);
await blobClient.delete();
}
}

View File

@ -0,0 +1,157 @@
import { EgressClient, EgressStatus, EncodedFileOutput, EncodedFileType } from "livekit-server-sdk";
import {
LIVEKIT_URL,
LIVEKIT_API_KEY,
LIVEKIT_API_SECRET,
RECORDINGS_PATH,
RECORDINGS_METADATA_PATH,
RECORDING_FILE_PORTION_SIZE
} from "../config.js";
import { AzureBlobService } from "./azure.blobstorage.service.js";
const azureBlobService = new AzureBlobService();
export class RecordingService {
static instance;
constructor() {
if (RecordingService.instance) {
return RecordingService.instance;
}
this.egressClient = new EgressClient(LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
RecordingService.instance = this;
return this;
}
async startRecording(roomName) {
// 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}`,
disableManifest: true
});
// Start a RoomCompositeEgress to record all participants in the room
const egressInfo = await this.egressClient.startRoomCompositeEgress(roomName, { file: fileOutput });
return this.convertToRecordingInfo(egressInfo);
}
async stopRecording(recordingId) {
// Stop the egress to finish the recording
const egressInfo = await this.egressClient.stopEgress(recordingId);
return this.convertToRecordingInfo(egressInfo);
}
async listRecordings(roomName, roomId) {
const keyStart =
RECORDINGS_PATH + RECORDINGS_METADATA_PATH + (roomName ? `${roomName}-` + (roomId ? roomId : "") : "");
const keyEnd = ".json";
const regex = new RegExp(`^${keyStart}.*${keyEnd}$`);
// List all egress metadata files in the recordings path that match the regex
const metadataKeys = await azureBlobService.listObjects(RECORDINGS_PATH + RECORDINGS_METADATA_PATH, regex);
const recordings = await Promise.all(metadataKeys.map((metadataKey) => azureBlobService.getObjectAsJson(metadataKey)));
return this.filterAndSortRecordings(recordings, roomName, roomId);
}
filterAndSortRecordings(recordings, roomName, roomId) {
let filteredRecordings = recordings;
if (roomName || roomId) {
filteredRecordings = recordings.filter((recording) => {
return (!roomName || recording.roomName === roomName) && (!roomId || recording.roomId === roomId);
});
}
return filteredRecordings.sort((a, b) => b.startedAt - a.startedAt);
}
async getActiveRecordingByRoom(roomName) {
try {
// List all active egresses for the room
const egresses = await this.egressClient.listEgress({ roomName, active: true });
return egresses.length > 0 ? egresses[0].egressId : null;
} catch (error) {
console.error("Error listing egresses.", error);
return null;
}
}
async getRecordingMetadata(recordingName) {
const key = this.getMetadataKey(recordingName);
return azureBlobService.getObjectAsJson(key);
}
async getRecordingStream(recordingName, range) {
const key = this.getRecordingKey(recordingName);
const size = await azureBlobService.getObjectSize(key);
// Get the requested range
const parts = range?.replace(/bytes=/, "").split("-");
const start = range ? parseInt(parts[0], 10) : 0;
const endRange = parts[1] ? parseInt(parts[1], 10) : start + RECORDING_FILE_PORTION_SIZE;
const end = Math.min(endRange, size - 1);
const stream = await azureBlobService.getObject(key, { start, end });
return { stream, size, start, end };
}
async getRecordingUrl(recordingName) {
const key = this.getRecordingKey(recordingName);
return azureBlobService.getObjectUrl(key);
}
async existsRecording(recordingName) {
const key = this.getRecordingKey(recordingName);
return azureBlobService.exists(key);
}
async deleteRecording(recordingName) {
const recordingKey = this.getRecordingKey(recordingName);
const metadataKey = this.getMetadataKey(recordingName);
// Delete the recording file and metadata file from Azure
await Promise.all([azureBlobService.deleteObject(recordingKey), azureBlobService.deleteObject(metadataKey)]);
}
async saveRecordingMetadata(egressInfo) {
const recordingInfo = this.convertToRecordingInfo(egressInfo);
const key = this.getMetadataKey(recordingInfo.name);
await azureBlobService.uploadObject(key, recordingInfo);
}
convertToRecordingInfo(egressInfo) {
const file = egressInfo.fileResults[0];
return {
id: egressInfo.egressId,
name: file.filename.split("/").pop(),
roomName: egressInfo.roomName,
roomId: egressInfo.roomId,
startedAt: Number(egressInfo.startedAt) / 1_000_000,
duration: Number(file.duration) / 1_000_000_000,
size: Number(file.size)
};
}
getRecordingStatus(egressStatus) {
switch (egressStatus) {
case EgressStatus.EGRESS_STARTING:
return "STARTING";
case EgressStatus.EGRESS_ACTIVE:
return "STARTED";
case EgressStatus.EGRESS_ENDING:
return "STOPPING";
case EgressStatus.EGRESS_COMPLETE:
return "STOPPED";
default:
return "FAILED";
}
}
getRecordingKey(recordingName) {
return RECORDINGS_PATH + recordingName;
}
getMetadataKey(recordingName) {
return RECORDINGS_PATH + RECORDINGS_METADATA_PATH + recordingName.replace(".mp4", ".json");
}
}

View File

@ -0,0 +1,61 @@
import { DataPacket_Kind, RoomServiceClient } from "livekit-server-sdk";
import { LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, APP_NAME } from "../config.js";
const encoder = new TextEncoder();
export class RoomService {
static instance;
constructor() {
if (RoomService.instance) {
return RoomService.instance;
}
this.roomClient = new RoomServiceClient(LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
RoomService.instance = this;
return this;
}
async createRoom(roomName) {
const roomOptions = {
name: roomName,
metadata: JSON.stringify({
createdBy: APP_NAME,
recordingStatus: "STOPPED"
})
};
return this.roomClient.createRoom(roomOptions);
}
async getRoom(roomName) {
const rooms = await this.roomClient.listRooms([roomName]);
return rooms.length > 0 ? rooms[0] : null;
}
async exists(roomName) {
const room = await this.getRoom(roomName);
return room !== null;
}
async updateRoomMetadata(roomName, recordingStatus) {
const metadata = {
createdBy: APP_NAME,
recordingStatus
};
return this.roomClient.updateRoomMetadata(roomName, JSON.stringify(metadata));
}
async sendDataToRoom(roomName, rawData) {
const data = encoder.encode(JSON.stringify(rawData));
const options = {
topic: "RECORDING_DELETED",
destinationSids: []
};
try {
await this.roomClient.sendData(roomName, data, DataPacket_Kind.RELIABLE, options);
} catch (error) {
console.error("Error sending data to room", error);
}
}
}