Update openvidu-recording-basic-node

This commit is contained in:
pabloFuente 2025-02-11 13:33:30 +01:00
parent 5e2a891250
commit 48e1fb79f7
6 changed files with 1703 additions and 1555 deletions

File diff suppressed because it is too large Load Diff

View File

@ -8,10 +8,10 @@
"start": "node src/index.js" "start": "node src/index.js"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "3.654.0", "@aws-sdk/client-s3": "3.744.0",
"cors": "2.8.5", "cors": "2.8.5",
"dotenv": "16.4.5", "dotenv": "16.4.7",
"express": "4.21.0", "express": "5.0.1",
"livekit-server-sdk": "^2.7.2" "livekit-server-sdk": "^2.9.7"
} }
} }

View File

@ -7,171 +7,190 @@ const LivekitClient = window.LivekitClient;
var room; var room;
function configureLiveKitUrl() { function configureLiveKitUrl() {
// If LIVEKIT_URL is not configured, use default value from OpenVidu Local deployment // If LIVEKIT_URL is not configured, use default value from OpenVidu Local deployment
if (!LIVEKIT_URL) { if (!LIVEKIT_URL) {
if (window.location.hostname === "localhost") { if (window.location.hostname === "localhost") {
LIVEKIT_URL = "ws://localhost:7880/"; LIVEKIT_URL = "ws://localhost:7880/";
} else { } else {
LIVEKIT_URL = "wss://" + window.location.hostname + ":7443/"; LIVEKIT_URL = "wss://" + window.location.hostname + ":7443/";
}
} }
}
} }
async function joinRoom() { async function joinRoom() {
// Disable 'Join' button // Disable 'Join' button
document.getElementById("join-button").disabled = true; document.getElementById("join-button").disabled = true;
document.getElementById("join-button").innerText = "Joining..."; document.getElementById("join-button").innerText = "Joining...";
// Initialize a new Room object // Initialize a new Room object
room = new LivekitClient.Room(); room = new LivekitClient.Room();
// Specify the actions when events take place in the room // Specify the actions when events take place in the room
// On every new Track received... // On every new Track received...
room.on(LivekitClient.RoomEvent.TrackSubscribed, (track, _publication, participant) => { room.on(
addTrack(track, participant.identity); 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();
} }
);
// 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) { function addTrack(track, participantIdentity, local = false) {
const element = track.attach(); const element = track.attach();
element.id = track.sid; element.id = track.sid;
/* If the track is a video track, we create a container and append the video element to it /* If the track is a video track, we create a container and append the video element to it
with the participant's identity */ with the participant's identity */
if (track.kind === "video") { if (track.kind === "video") {
const videoContainer = createVideoContainer(participantIdentity, local); const videoContainer = createVideoContainer(participantIdentity, local);
videoContainer.append(element); videoContainer.append(element);
appendParticipantData(videoContainer, participantIdentity + (local ? " (You)" : "")); appendParticipantData(
} else { videoContainer,
document.getElementById("layout-container").append(element); participantIdentity + (local ? " (You)" : "")
} );
} else {
document.getElementById("layout-container").append(element);
}
} }
async function leaveRoom() { async function leaveRoom() {
// Leave the room by calling 'disconnect' method over the Room object // Leave the room by calling 'disconnect' method over the Room object
await room.disconnect(); await room.disconnect();
// Remove all HTML elements inside the layout container // Remove all HTML elements inside the layout container
removeAllLayoutElements(); removeAllLayoutElements();
// Remove all recordings from the list // Remove all recordings from the list
removeAllRecordings(); removeAllRecordings();
// Reset recording state // Reset recording state
document.getElementById("recording-button").disabled = false; document.getElementById("recording-button").disabled = false;
document.getElementById("recording-button").innerText = "Start Recording"; document.getElementById("recording-button").innerText = "Start Recording";
document.getElementById("recording-button").className = "btn btn-primary"; document.getElementById("recording-button").className = "btn btn-primary";
document.getElementById("recording-text").hidden = true; document.getElementById("recording-text").hidden = true;
// Back to 'Join room' page // Back to 'Join room' page
document.getElementById("join").hidden = false; document.getElementById("join").hidden = false;
document.getElementById("room").hidden = true; document.getElementById("room").hidden = true;
// Enable 'Join' button // Enable 'Join' button
document.getElementById("join-button").disabled = false; document.getElementById("join-button").disabled = false;
document.getElementById("join-button").innerText = "Join!"; document.getElementById("join-button").innerText = "Join!";
} }
window.onbeforeunload = () => { window.onbeforeunload = () => {
room?.disconnect(); room?.disconnect();
}; };
document.addEventListener("DOMContentLoaded", async function () { document.addEventListener("DOMContentLoaded", async function () {
var currentPage = window.location.pathname; var currentPage = window.location.pathname;
if (currentPage === "/recordings.html") { if (currentPage === "/recordings.html") {
await listRecordings(); await listRecordings();
} else { } else {
generateFormValues(); generateFormValues();
} }
// Remove recording video when the dialog is closed // Remove recording video when the dialog is closed
document.getElementById("recording-video-dialog").addEventListener("close", () => { document
const recordingVideo = document.getElementById("recording-video"); .getElementById("recording-video-dialog")
recordingVideo.src = ""; .addEventListener("close", () => {
const recordingVideo = document.getElementById("recording-video");
recordingVideo.src = "";
}); });
}); });
function generateFormValues() { function generateFormValues() {
document.getElementById("room-name").value = "Test Room"; document.getElementById("room-name").value = "Test Room";
document.getElementById("participant-name").value = "Participant" + Math.floor(Math.random() * 100); document.getElementById("participant-name").value =
"Participant" + Math.floor(Math.random() * 100);
} }
function createVideoContainer(participantIdentity, local = false) { function createVideoContainer(participantIdentity, local = false) {
const videoContainer = document.createElement("div"); const videoContainer = document.createElement("div");
videoContainer.id = `camera-${participantIdentity}`; videoContainer.id = `camera-${participantIdentity}`;
videoContainer.className = "video-container"; videoContainer.className = "video-container";
const layoutContainer = document.getElementById("layout-container"); const layoutContainer = document.getElementById("layout-container");
if (local) { if (local) {
layoutContainer.prepend(videoContainer); layoutContainer.prepend(videoContainer);
} else { } else {
layoutContainer.append(videoContainer); layoutContainer.append(videoContainer);
} }
return videoContainer; return videoContainer;
} }
function appendParticipantData(videoContainer, participantIdentity) { function appendParticipantData(videoContainer, participantIdentity) {
const dataElement = document.createElement("div"); const dataElement = document.createElement("div");
dataElement.className = "participant-data"; dataElement.className = "participant-data";
dataElement.innerHTML = `<p>${participantIdentity}</p>`; dataElement.innerHTML = `<p>${participantIdentity}</p>`;
videoContainer.prepend(dataElement); videoContainer.prepend(dataElement);
} }
function removeVideoContainer(participantIdentity) { function removeVideoContainer(participantIdentity) {
const videoContainer = document.getElementById(`camera-${participantIdentity}`); const videoContainer = document.getElementById(
videoContainer?.remove(); `camera-${participantIdentity}`
);
videoContainer?.remove();
} }
function removeAllLayoutElements() { function removeAllLayoutElements() {
const layoutElements = document.getElementById("layout-container").children; const layoutElements = document.getElementById("layout-container").children;
Array.from(layoutElements).forEach((element) => { Array.from(layoutElements).forEach((element) => {
element.remove(); element.remove();
}); });
} }
/** /**
@ -188,156 +207,153 @@ function removeAllLayoutElements() {
* access to the endpoints. * access to the endpoints.
*/ */
async function getToken(roomName, participantName) { async function getToken(roomName, participantName) {
const [error, body] = await httpRequest("POST", "/token", { const [error, body] = await httpRequest("POST", "/token", {
roomName, roomName,
participantName participantName,
}); });
if (error) { if (error) {
throw new Error(`Failed to get token: ${error.message}`); throw new Error(`Failed to get token: ${error.message}`);
} }
return body.token; return body.token;
} }
async function updateRecordingInfo(isRecording) { async function updateRecordingInfo(isRecording) {
const recordingButton = document.getElementById("recording-button"); const recordingButton = document.getElementById("recording-button");
const recordingText = document.getElementById("recording-text"); const recordingText = document.getElementById("recording-text");
if (isRecording) { if (isRecording) {
recordingButton.disabled = false; recordingButton.disabled = false;
recordingButton.innerText = "Stop Recording"; recordingButton.innerText = "Stop Recording";
recordingButton.className = "btn btn-danger"; recordingButton.className = "btn btn-danger";
recordingText.hidden = false; recordingText.hidden = false;
} else { } else {
recordingButton.disabled = false; recordingButton.disabled = false;
recordingButton.innerText = "Start Recording"; recordingButton.innerText = "Start Recording";
recordingButton.className = "btn btn-primary"; recordingButton.className = "btn btn-primary";
recordingText.hidden = true; recordingText.hidden = true;
} }
const roomId = await room.getSid(); const roomId = await room.getSid();
await listRecordings(room.name, roomId); await listRecordings(roomId);
} }
async function manageRecording() { async function manageRecording() {
const recordingButton = document.getElementById("recording-button"); const recordingButton = document.getElementById("recording-button");
if (recordingButton.innerText === "Start Recording") { if (recordingButton.innerText === "Start Recording") {
recordingButton.disabled = true; recordingButton.disabled = true;
recordingButton.innerText = "Starting..."; recordingButton.innerText = "Starting...";
const [error, _] = await startRecording(); const [error, _] = await startRecording();
if (error && error.status !== 409) { if (error && error.status !== 409) {
recordingButton.disabled = false; recordingButton.disabled = false;
recordingButton.innerText = "Start Recording"; 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";
}
} }
} 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() { async function startRecording() {
return httpRequest("POST", "/recordings/start", { return httpRequest("POST", "/recordings/start", {
roomName: room.name roomName: room.name,
}); });
} }
async function stopRecording() { async function stopRecording() {
return httpRequest("POST", "/recordings/stop", { return httpRequest("POST", "/recordings/stop", {
roomName: room.name roomName: room.name,
}); });
} }
async function deleteRecording(recordingName) { async function deleteRecording(recordingName) {
const [error, _] = await httpRequest("DELETE", `/recordings/${recordingName}`); const [error, _] = await httpRequest(
"DELETE",
`/recordings/${recordingName}`
);
if (!error || error.status === 404) { if (!error || error.status === 404) {
const roomId = await room?.getSid(); const roomId = await room?.getSid();
await listRecordings(room?.name, roomId); await listRecordings(roomId);
} }
} }
async function listRecordings(roomName, roomId) { async function listRecordings(roomId) {
const url = "/recordings" + (roomName ? `?roomName=${roomName}` + (roomId ? `&roomId=${roomId}` : "") : ""); let url = "/recordings";
const [error, body] = await httpRequest("GET", url); if (roomId) {
url += `?roomId=${roomId}`;
}
const [error, body] = await httpRequest("GET", url);
if (!error) { if (!error) {
const recordings = body.recordings; const recordings = body.recordings;
showRecordingList(recordings); showRecordingList(recordings);
} }
}
async function listRecordingsByRoom() {
const roomName = document.getElementById("room-name").value;
await listRecordings(roomName);
} }
async function httpRequest(method, url, body) { async function httpRequest(method, url, body) {
try { try {
const response = await fetch(url, { const response = await fetch(url, {
method, method,
headers: { headers: {
"Content-Type": "application/json" "Content-Type": "application/json",
}, },
body: method !== "GET" ? JSON.stringify(body) : undefined body: method !== "GET" ? JSON.stringify(body) : undefined,
}); });
const responseBody = await response.json(); const responseBody = await response.json();
if (!response.ok) { if (!response.ok) {
console.error(responseBody.errorMessage); console.error(responseBody.errorMessage);
const error = { const error = {
status: response.status, status: response.status,
message: responseBody.errorMessage message: responseBody.errorMessage,
}; };
return [error, undefined]; return [error, undefined];
}
return [undefined, responseBody];
} catch (error) {
console.error(error.message);
const errorObj = {
status: 0,
message: error.message
};
return [errorObj, undefined];
} }
return [undefined, responseBody];
} catch (error) {
console.error(error.message);
const errorObj = {
status: 0,
message: error.message,
};
return [errorObj, undefined];
}
} }
function showRecordingList(recordings) { function showRecordingList(recordings) {
const recordingsList = document.getElementById("recording-list"); const recordingsList = document.getElementById("recording-list");
if (recordings.length === 0) { if (recordings.length === 0) {
recordingsList.innerHTML = "<span>There are no recordings available</span>"; recordingsList.innerHTML = "<span>There are no recordings available</span>";
} else { } else {
recordingsList.innerHTML = ""; recordingsList.innerHTML = "";
} }
recordings.forEach((recording) => { recordings.forEach((recording) => {
const recordingName = recording.name; const recordingName = recording.name;
const recordingSize = formatBytes(recording.size ?? 0);
const recordingDate = new Date(recording.startedAt).toLocaleString();
const recordingContainer = document.createElement("div"); const recordingContainer = document.createElement("div");
recordingContainer.className = "recording-container"; recordingContainer.className = "recording-container";
recordingContainer.id = recordingName; recordingContainer.id = recordingName;
recordingContainer.innerHTML = ` recordingContainer.innerHTML = `
<i class="fa-solid fa-file-video"></i> <i class="fa-solid fa-file-video"></i>
<div class="recording-info"> <div class="recording-info">
<p class="recording-name">${recordingName}</p> <p class="recording-name">${recordingName}</p>
<p class="recording-size">${recordingSize}</p>
<p class="recording-date">${recordingDate}</p>
</div> </div>
<div class="recording-actions"> <div class="recording-actions">
<button title="Play" class="icon-button" onclick="displayRecording('${recordingName}')"> <button title="Play" class="icon-button" onclick="displayRecording('${recordingName}')">
@ -349,38 +365,29 @@ function showRecordingList(recordings) {
</div> </div>
`; `;
recordingsList.append(recordingContainer); recordingsList.append(recordingContainer);
}); });
} }
function displayRecording(recordingName) { function displayRecording(recordingName) {
const recordingVideoDialog = document.getElementById("recording-video-dialog"); const recordingVideoDialog = document.getElementById(
recordingVideoDialog.showModal(); "recording-video-dialog"
const recordingVideo = document.getElementById("recording-video"); );
recordingVideo.src = `/recordings/${recordingName}`; recordingVideoDialog.showModal();
const recordingVideo = document.getElementById("recording-video");
recordingVideo.src = `/recordings/${recordingName}`;
} }
function closeRecording() { function closeRecording() {
const recordingVideoDialog = document.getElementById("recording-video-dialog"); const recordingVideoDialog = document.getElementById(
recordingVideoDialog.close(); "recording-video-dialog"
);
recordingVideoDialog.close();
} }
function removeAllRecordings() { function removeAllRecordings() {
const recordingList = document.getElementById("recording-list").children; const recordingList = document.getElementById("recording-list").children;
Array.from(recordingList).forEach((recording) => { Array.from(recordingList).forEach((recording) => {
recording.remove(); 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];
} }

View File

@ -56,18 +56,6 @@
<main> <main>
<div id="recordings-all"> <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> <h2>Recordings</h2>
<div id="recording-list"></div> <div id="recording-list"></div>
<dialog id="recording-video-dialog"> <dialog id="recording-video-dialog">

View File

@ -3,19 +3,35 @@ import express from "express";
import cors from "cors"; import cors from "cors";
import path from "path"; import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { AccessToken, EgressClient, EncodedFileOutput, EncodedFileType, WebhookReceiver } from "livekit-server-sdk"; import {
AccessToken,
EgressClient,
EncodedFileOutput,
EncodedFileType,
WebhookReceiver,
} from "livekit-server-sdk";
import { S3Service } from "./s3.service.js"; import { S3Service } from "./s3.service.js";
// Configuration
const SERVER_PORT = process.env.SERVER_PORT || 6080; const SERVER_PORT = process.env.SERVER_PORT || 6080;
// LiveKit configuration
const LIVEKIT_URL = process.env.LIVEKIT_URL || "http://localhost:7880"; const LIVEKIT_URL = process.env.LIVEKIT_URL || "http://localhost:7880";
const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY || "devkey"; const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY || "devkey";
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET || "secret"; const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET || "secret";
const RECORDINGS_PATH = process.env.RECORDINGS_PATH ?? "recordings/"; const RECORDINGS_PATH = process.env.RECORDINGS_PATH ?? "recordings/";
const RECORDING_FILE_PORTION_SIZE = 5 * 1024 * 1024; // 5MB const RECORDING_FILE_PORTION_SIZE = 5 * 1024 * 1024; // 5MB
// Initialize services
const egressClient = new EgressClient(
LIVEKIT_URL,
LIVEKIT_API_KEY,
LIVEKIT_API_SECRET
);
const webhookReceiver = new WebhookReceiver(
LIVEKIT_API_KEY,
LIVEKIT_API_SECRET
);
const s3Service = new S3Service();
const app = express(); const app = express();
app.use(cors()); app.use(cors());
@ -27,234 +43,220 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
app.use(express.static(path.join(__dirname, "../public"))); app.use(express.static(path.join(__dirname, "../public")));
// Generate access tokens for participants to join a room
app.post("/token", async (req, res) => { app.post("/token", async (req, res) => {
const roomName = req.body.roomName; const roomName = req.body.roomName;
const participantName = req.body.participantName; const participantName = req.body.participantName;
if (!roomName || !participantName) { if (!roomName || !participantName) {
res.status(400).json({ errorMessage: "roomName and participantName are required" }); return res
return; .status(400)
} .json({ errorMessage: "roomName and participantName are required" });
}
const at = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, { const at = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, {
identity: participantName identity: participantName,
}); });
at.addGrant({ roomJoin: true, room: roomName, roomRecord: true }); at.addGrant({ roomJoin: true, room: roomName, roomRecord: true });
const token = await at.toJwt(); const token = await at.toJwt();
res.json({ token });
return res.json({ token });
}); });
const webhookReceiver = new WebhookReceiver(LIVEKIT_API_KEY, LIVEKIT_API_SECRET); // Receive webhooks from LiveKit Server
app.post("/livekit/webhook", async (req, res) => { app.post("/livekit/webhook", async (req, res) => {
try { try {
const event = await webhookReceiver.receive(req.body, req.get("Authorization")); const event = await webhookReceiver.receive(
console.log(event); req.body,
} catch (error) { req.get("Authorization")
console.error("Error validating webhook event.", error); );
} console.log(event);
res.status(200).send(); } catch (error) {
console.error("Error validating webhook event.", error);
}
return res.status(200).send();
}); });
const egressClient = new EgressClient(LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET); // Start a recording
const s3Service = new S3Service();
app.post("/recordings/start", async (req, res) => { app.post("/recordings/start", async (req, res) => {
const { roomName } = req.body; const { roomName } = req.body;
if (!roomName) { if (!roomName) {
res.status(400).json({ errorMessage: "roomName is required" }); return res.status(400).json({ errorMessage: "roomName is required" });
return; }
}
const activeRecording = await getActiveRecordingByRoom(roomName); const activeRecording = await getActiveRecordingByRoom(roomName);
// Check if there is already an active recording for this room // Check if there is already an active recording for this room
if (activeRecording) { if (activeRecording) {
res.status(409).json({ errorMessage: "Recording already started for this room" }); return res
return; .status(409)
} .json({ errorMessage: "Recording already started for this room" });
}
// Use the EncodedFileOutput to save the recording to an MP4 file // Use the EncodedFileOutput to save the recording to an MP4 file
const fileOutput = new EncodedFileOutput({ // The room name, time and room ID in the file path help to organize the recordings
fileType: EncodedFileType.MP4, const fileOutput = new EncodedFileOutput({
filepath: `${RECORDINGS_PATH}{room_name}-{room_id}-{time}` fileType: EncodedFileType.MP4,
filepath: `${RECORDINGS_PATH}/{room_name}-{time}-{room_id}`,
disableManifest: true,
});
try {
// Start a RoomCompositeEgress to record all participants in the room
const egressInfo = await egressClient.startRoomCompositeEgress(roomName, {
file: fileOutput,
}); });
const recording = {
try { name: egressInfo.fileResults[0].filename.split("/").pop(),
// Start a RoomCompositeEgress to record all participants in the room startedAt: Number(egressInfo.startedAt) / 1_000_000,
const egressInfo = await egressClient.startRoomCompositeEgress(roomName, { file: fileOutput }); };
const recording = { res.json({ message: "Recording started", recording });
name: egressInfo.fileResults[0].filename.split("/").pop(), } catch (error) {
startedAt: Number(egressInfo.startedAt) / 1_000_000 console.error("Error starting recording.", error);
}; res.status(500).json({ errorMessage: "Error starting recording" });
res.json({ message: "Recording started", recording }); }
} catch (error) {
console.error("Error starting recording.", error);
res.status(500).json({ errorMessage: "Error starting recording" });
}
}); });
// Stop a recording
app.post("/recordings/stop", async (req, res) => { app.post("/recordings/stop", async (req, res) => {
const { roomName } = req.body; const { roomName } = req.body;
if (!roomName) { if (!roomName) {
res.status(400).json({ errorMessage: "roomName is required" }); return res.status(400).json({ errorMessage: "roomName is required" });
return; }
}
const activeRecording = await getActiveRecordingByRoom(roomName); const activeRecording = await getActiveRecordingByRoom(roomName);
// Check if there is an active recording for this room // Check if there is an active recording for this room
if (!activeRecording) { if (!activeRecording) {
res.status(409).json({ errorMessage: "Recording not started for this room" }); return res
return; .status(409)
} .json({ errorMessage: "Recording not started for this room" });
}
try { try {
// Stop the egress to finish the recording // Stop the egress to finish the recording
const egressInfo = await egressClient.stopEgress(activeRecording); const egressInfo = await egressClient.stopEgress(activeRecording);
const file = egressInfo.fileResults[0]; const file = egressInfo.fileResults[0];
const recording = { const recording = {
name: file.filename.split("/").pop(), name: file.filename.split("/").pop(),
startedAt: Number(egressInfo.startedAt) / 1_000_000, };
size: Number(file.size) return res.json({ message: "Recording stopped", recording });
}; } catch (error) {
res.json({ message: "Recording stopped", recording }); console.error("Error stopping recording.", error);
} catch (error) { return res.status(500).json({ errorMessage: "Error stopping recording" });
console.error("Error stopping recording.", error); }
res.status(500).json({ errorMessage: "Error stopping recording" });
}
}); });
// List recordings
app.get("/recordings", async (req, res) => {
const roomId = req.query.roomId?.toString();
try {
const awsResponse = await s3Service.listObjects(RECORDINGS_PATH);
let recordings = [];
if (awsResponse.Contents) {
recordings = awsResponse.Contents.map((recording) => {
return {
name: recording.Key.split("/").pop(),
};
});
}
// Filter recordings by room ID
recordings = recordings.filter((recording) =>
roomId ? recording.name.includes(roomId) : true
);
return res.json({ recordings });
} catch (error) {
console.error("Error listing recordings.", error);
return res.status(500).json({ errorMessage: "Error listing recordings" });
}
});
// Play a recording
app.get("/recordings/:recordingName", async (req, res) => {
const { recordingName } = req.params;
const { range } = req.headers;
const key = RECORDINGS_PATH + recordingName;
const exists = await s3Service.exists(key);
if (!exists) {
return res.status(404).json({ errorMessage: "Recording not found" });
}
try {
// Get the recording file from S3
const { stream, size, start, end } = await 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);
return res.status(500).json({ errorMessage: "Error getting recording" });
}
});
// Delete a recording
app.delete("/recordings/:recordingName", async (req, res) => {
const { recordingName } = req.params;
const key = RECORDINGS_PATH + recordingName;
const exists = await s3Service.exists(key);
if (!exists) {
return res.status(404).json({ errorMessage: "Recording not found" });
}
try {
// Delete the recording file and metadata file from S3
await Promise.all([s3Service.deleteObject(key)]);
res.json({ message: "Recording deleted" });
} catch (error) {
console.error("Error deleting recording.", error);
res.status(500).json({ errorMessage: "Error deleting recording" });
}
});
// Start the server
app.listen(SERVER_PORT, () => {
console.log("Server started on port:", SERVER_PORT);
});
// Helper functions
const getActiveRecordingByRoom = async (roomName) => { const getActiveRecordingByRoom = async (roomName) => {
try { try {
// List all active egresses for the room // List all active egresses for the room
const egresses = await egressClient.listEgress({ roomName, active: true }); const egresses = await egressClient.listEgress({ roomName, active: true });
return egresses.length > 0 ? egresses[0].egressId : null; return egresses.length > 0 ? egresses[0].egressId : null;
} catch (error) { } catch (error) {
console.error("Error listing egresses.", error); console.error("Error listing egresses.", error);
return null; return null;
} }
}; };
app.get("/recordings", 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)));
const sortedRecordings = filterAndSortRecordings(recordings, roomName, roomId);
res.json({ recordings: sortedRecordings });
} catch (error) {
console.error("Error listing recordings.", error);
res.status(500).json({ errorMessage: "Error listing recordings" });
}
});
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 = {
id: data.egress_id,
name: recordingName,
roomName: data.room_name,
roomId: data.room_id,
startedAt: Number(data.started_at) / 1000000,
size: size
};
return recording;
};
const 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);
};
app.get("/recordings/:recordingName", async (req, res) => {
const { recordingName } = req.params;
const { range } = req.headers;
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 { stream, size, start, end } = await 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" });
}
});
const getRecordingStream = async (recordingName, range) => { const getRecordingStream = async (recordingName, range) => {
const key = RECORDINGS_PATH + recordingName; const key = RECORDINGS_PATH + recordingName;
const size = await s3Service.getObjectSize(key); const size = await s3Service.getObjectSize(key);
// Get the requested range // Get the requested range
const parts = range?.replace(/bytes=/, "").split("-"); const parts = range?.replace(/bytes=/, "").split("-");
const start = range ? parseInt(parts[0], 10) : 0; const start = range ? parseInt(parts[0], 10) : 0;
const endRange = parts[1] ? parseInt(parts[1], 10) : start + RECORDING_FILE_PORTION_SIZE; const endRange = parts[1]
const end = Math.min(endRange, size - 1); ? parseInt(parts[1], 10)
: start + RECORDING_FILE_PORTION_SIZE;
const end = Math.min(endRange, size - 1);
const stream = await s3Service.getObject(key, { start, end }); const stream = await s3Service.getObject(key, { start, end });
return { stream, size, start, end }; return { stream, size, start, end };
}; };
app.delete("/recordings/: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" });
}
});
app.listen(SERVER_PORT, () => {
console.log("Server started on port:", SERVER_PORT);
});

View File

@ -1,9 +1,9 @@
import { import {
S3Client, S3Client,
GetObjectCommand, GetObjectCommand,
DeleteObjectCommand, DeleteObjectCommand,
ListObjectsV2Command, ListObjectsV2Command,
HeadObjectCommand HeadObjectCommand,
} from "@aws-sdk/client-s3"; } from "@aws-sdk/client-s3";
// S3 configuration // S3 configuration
@ -14,89 +14,80 @@ const AWS_REGION = process.env.AWS_REGION || "us-east-1";
const S3_BUCKET = process.env.S3_BUCKET || "openvidu"; const S3_BUCKET = process.env.S3_BUCKET || "openvidu";
export class S3Service { export class S3Service {
static instance; static instance;
constructor() { constructor() {
if (S3Service.instance) { if (S3Service.instance) {
return 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) { this.s3Client = new S3Client({
try { endpoint: S3_ENDPOINT,
await this.headObject(key); credentials: {
return true; accessKeyId: S3_ACCESS_KEY,
} catch (error) { secretAccessKey: S3_SECRET_KEY,
return false; },
} region: AWS_REGION,
} forcePathStyle: true,
});
async headObject(key) { S3Service.instance = this;
const params = { return this;
Bucket: S3_BUCKET, }
Key: key
};
const command = new HeadObjectCommand(params);
return this.run(command);
}
async getObjectSize(key) { async exists(key) {
const { ContentLength: size } = await this.headObject(key); try {
return size; await this.headObject(key);
return true;
} catch (error) {
return false;
} }
}
async getObject(key, range) { async headObject(key) {
const params = { const params = {
Bucket: S3_BUCKET, Bucket: S3_BUCKET,
Key: key, Key: key,
Range: range ? `bytes=${range.start}-${range.end}` : undefined };
}; const command = new HeadObjectCommand(params);
const command = new GetObjectCommand(params); return this.run(command);
const { Body: body } = await this.run(command); }
return body;
}
async getObjectAsJson(key) { async getObjectSize(key) {
const body = await this.getObject(key); const { ContentLength: size } = await this.headObject(key);
const stringifiedData = await body.transformToString(); return size;
return JSON.parse(stringifiedData); }
}
async listObjects(prefix, regex) { async getObject(key, range) {
const params = { const params = {
Bucket: S3_BUCKET, Bucket: S3_BUCKET,
Prefix: prefix Key: key,
}; Range: range ? `bytes=${range.start}-${range.end}` : undefined,
const command = new ListObjectsV2Command(params); };
const { Contents: objects } = await this.run(command); const command = new GetObjectCommand(params);
const { Body: body } = await this.run(command);
return body;
}
// Filter objects by regex and return the keys async listObjects(prefix) {
return objects?.filter((object) => regex.test(object.Key)).map((payload) => payload.Key) ?? []; const params = {
} Bucket: S3_BUCKET,
Prefix: prefix,
};
const command = new ListObjectsV2Command(params);
return await this.run(command);
}
async deleteObject(key) { async deleteObject(key) {
const params = { const params = {
Bucket: S3_BUCKET, Bucket: S3_BUCKET,
Key: key Key: key,
}; };
const command = new DeleteObjectCommand(params); const command = new DeleteObjectCommand(params);
return this.run(command); return this.run(command);
} }
async run(command) { async run(command) {
return this.s3Client.send(command); return this.s3Client.send(command);
} }
} }