Compare commits

...

24 Commits

Author SHA1 Message Date
pabloFuente
cc4f43e887 openvidu-live-captions: remove bug with participantInfo on transcriptions 2025-07-02 19:04:45 +02:00
pabloFuente
12b2c3720a Updated openvidu-live-captions 2025-06-27 17:43:53 +02:00
pabloFuente
af880945dc openvidu-live-captions: another minor beautification 2025-06-27 11:51:04 +02:00
pabloFuente
d385068049 openvidu-live-captions: minor beautification 2025-06-27 11:49:22 +02:00
pabloFuente
50a7af992a openvidu-live-captions: updated livekit-client CDN version and HTML title 2025-06-27 11:40:48 +02:00
pabloFuente
f0358d0680 openvidu-live-captions tutorial 2025-06-26 17:37:42 +02:00
Piwccle
4e90828d80 advanced-features: azure-recording-tutorials - more changes to the S3 references 2025-06-03 17:24:57 +02:00
Piwccle
75076678a5 advanced-features: azure-advanced-recording-tutorial - changed all s3 references 2025-06-03 17:04:21 +02:00
Piwccle
b1aeb2f2f2 advanced-features: azure-recording-basic-tutorial - updated package-lock.json 2025-06-03 13:26:54 +02:00
Piwccle
1c57c6e85d advanced-features: azure-advanced-tutorial - added azure advanced recording tutorial 2025-06-03 13:26:11 +02:00
Piwccle
d3180c1f9f advanced-features: recording-basic-azure - changed some comments with s3 references to azure references 2025-06-03 12:01:12 +02:00
Piwccle
9cdb39a53b advanced-features: recording-basic-azure - updated name of the service 2025-06-03 11:09:07 +02:00
Piwccle
d5dd493a37 advanced-features: recording-basic-azure - updated service name and deleted some lines of .env refered to s3 2025-06-03 11:06:54 +02:00
Piwccle
8841cd2aed advanced-features: added basic recording tutorial for azure blob storage 2025-06-02 11:47:41 +02:00
juancarmore
665681174c Update S3_BUCKET to use 'openvidu-appdata' as default value in configuration files 2025-05-29 20:30:53 +02:00
pabloFuente
966ba75a8a Update dependencies of python tutorial 2025-03-05 17:45:44 +01:00
juancarmore
3228a50708 Fix comment for S3 recording deletion 2025-02-19 11:56:17 +01:00
pabloFuente
e10eeb0fdc openvidu-recording-basic-node: remove comment 2025-02-11 17:39:15 +01:00
pabloFuente
5343660dfa openvidu-recording-basic-node: minor refactoring 2025-02-11 15:44:53 +01:00
pabloFuente
48e1fb79f7 Update openvidu-recording-basic-node 2025-02-11 13:33:30 +01:00
pabloFuente
5e2a891250 dotnet: fix token parameters types with ToString() 2024-12-13 15:17:27 +01:00
pabloFuente
cecc89238d Use Livekit.Server.Sdk.Dotnet 2024-11-27 13:13:14 +01:00
pabloFuente
508da8b035 Add README 2024-11-18 14:01:58 +01:00
pabloFuente
b5bb3e2567 Update all livekit dependencies 2024-10-22 12:00:27 +02:00
76 changed files with 18035 additions and 9085 deletions

1
README.md Normal file
View File

@ -0,0 +1 @@
# Visit [LiveKit tutorials](https://livekit-tutorials.openvidu.io/)

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=AZURE
# Azure Blob Storage configuration
AZURE_ACCOUNT_NAME=your_account_name
AZURE_ACCOUNT_KEY=your_account_key
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,18 @@
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";
// 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 || "AZURE"; // PROXY or S3
export const RECORDING_FILE_PORTION_SIZE = 5 * 1024 * 1024; // 5MB

View File

@ -0,0 +1,155 @@
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) => {
console.log("Getting recording URL...");
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;
}
// Uploads an object (JSON) to the container
async uploadObject(key, body) {
const blockBlobClient = this.containerClient.getBlockBlobClient(key);
const data = JSON.stringify(body);
await blockBlobClient.upload(data, Buffer.byteLength(data));
}
// Checks if a blob exists
async exists(key) {
const blobClient = this.containerClient.getBlobClient(key);
return await blobClient.exists();
}
// Gets the blob properties (equivalent to headObject)
async headObject(key) {
const blobClient = this.containerClient.getBlobClient(key);
return await blobClient.getProperties();
}
// Returns the blob size in bytes
async getObjectSize(key) {
const props = await this.headObject(key);
return props.contentLength || 0;
}
// Downloads the complete blob or a range, returning the 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("Could not obtain the blob stream");
}
return downloadResponse.readableStreamBody;
}
// Generates a valid SAS URL for 24 hours
async getObjectUrl(key) {
if (!AZURE_ACCOUNT_NAME || !AZURE_ACCOUNT_KEY) {
throw new Error("Azure account credentials are not defined to generate SAS");
}
const blobClient = this.containerClient.getBlobClient(key);
const expiresOn = new Date(new Date().valueOf() + 24 * 60 * 60 * 1000); // 24 hours
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}`;
}
// Gets the JSON content of the 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);
});
}
// Lists blobs under a prefix and filters by regular expression
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;
}
// Deletes a 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);
}
}
}

View File

@ -10,6 +10,6 @@ S3_ENDPOINT=http://localhost:9000
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
AWS_REGION=us-east-1
S3_BUCKET=openvidu
S3_BUCKET=openvidu-appdata
RECORDINGS_PATH=recordings/
RECORDING_PLAYBACK_STRATEGY=S3

View File

@ -13,7 +13,7 @@
"cors": "2.8.5",
"dotenv": "16.4.5",
"express": "4.21.0",
"livekit-server-sdk": "2.6.2"
"livekit-server-sdk": "^2.7.2"
}
},
"node_modules/@aws-crypto/crc32": {
@ -957,9 +957,9 @@
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@livekit/protocol": {
"version": "1.22.0",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.22.0.tgz",
"integrity": "sha512-KYOfVAz38YFRsmEzeDgzoaHZJhMZEkeZQlzr9xIjczWR9SeEaYNU6+IDcZRlrYcpWl6Almgt/OhXcQn+nkrDGw==",
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.27.0.tgz",
"integrity": "sha512-jVb4zljNaYKoLiL5MBjGiO1+QKVsxMqXT/c0dwcKUW7NCLjAZXucoQVV1Y79FCbKwVnOCOtI6wwteEntbfk/Qw==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^1.10.0"
@ -2160,12 +2160,12 @@
}
},
"node_modules/livekit-server-sdk": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/livekit-server-sdk/-/livekit-server-sdk-2.6.2.tgz",
"integrity": "sha512-3fFzHu7sAynUaUFTCKtRP9lgQCU0Qe/x7XA99GpT1ro7fTy1ZVzaWq34WcXEyUGBBMFxG19LlSIAQBcGZVStWQ==",
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/livekit-server-sdk/-/livekit-server-sdk-2.7.2.tgz",
"integrity": "sha512-qDNRXeo+WMnY5nKSug7KHJ9er9JIuKi+r7H9ZaSBbmbaOt62i0b4BrHBMFSMr8pAuWzuSxihCFa29q5QvFc5fw==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/protocol": "^1.20.0",
"@livekit/protocol": "^1.23.0",
"camelcase-keys": "^9.0.0",
"jose": "^5.1.2"
},

View File

@ -13,6 +13,6 @@
"cors": "2.8.5",
"dotenv": "16.4.5",
"express": "4.21.0",
"livekit-server-sdk": "2.6.2"
"livekit-server-sdk": "^2.7.2"
}
}

View File

@ -29,7 +29,7 @@
/>
<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="https://cdn.jsdelivr.net/npm/livekit-client@2.5.9/dist/livekit-client.umd.js"></script>
<script src="app.js"></script>
</head>

View File

@ -29,7 +29,7 @@
/>
<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="https://cdn.jsdelivr.net/npm/livekit-client@2.5.9/dist/livekit-client.umd.js"></script>
<script src="app.js"></script>
</head>

View File

@ -11,7 +11,7 @@ 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 S3_BUCKET = process.env.S3_BUCKET || "openvidu-appdata";
export const RECORDINGS_PATH = process.env.RECORDINGS_PATH ?? "recordings/";
export const RECORDINGS_METADATA_PATH = ".metadata/";

View File

@ -0,0 +1,11 @@
SERVER_PORT=6080
# LiveKit configuration
LIVEKIT_URL=http://localhost:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret
# 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 Basic Node
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-features/recording-basic/).
## 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-basic-node-azure
```
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-basic-node-azure",
"version": "1.0.0",
"description": "Simple video-call application with recording capabilities",
"main": "src/index.js",
"type": "module",
"scripts": {
"start": "NODE_TLS_REJECT_UNAUTHORIZED=0 node src/index.js"
},
"dependencies": {
"@azure/storage-blob": "12.27.0",
"cors": "2.8.5",
"dotenv": "16.4.7",
"express": "5.0.1",
"livekit-server-sdk": "^2.9.7"
}
}

View File

@ -0,0 +1,393 @@
// 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(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(roomId);
}
}
async function listRecordings(roomId) {
let url = "/recordings";
if (roomId) {
url += `?roomId=${roomId}`;
}
const [error, body] = await httpRequest("GET", url);
if (!error) {
const recordings = body.recordings;
showRecordingList(recordings);
}
}
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 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>
</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();
});
}

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,77 @@
<!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">
<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,91 @@
import {
BlobServiceClient,
StorageSharedKeyCredential,
} from "@azure/storage-blob";
// Azure configuration
const AZURE_ACCOUNT_NAME = process.env.AZURE_ACCOUNT_NAME || "devstoreaccount";
const AZURE_ACCOUNT_KEY =
process.env.AZURE_ACCOUNT_KEY || "nokey";
const AZURE_CONTAINER_NAME =
process.env.AZURE_CONTAINER_NAME || "openvidu-appdata";
const AZURE_ENDPOINT =
process.env.AZURE_ENDPOINT ||
`https://${AZURE_ACCOUNT_NAME}.blob.core.windows.net`;
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;
}
async exists(key) {
const blobClient = this.containerClient.getBlobClient(key);
return await blobClient.exists();
}
async getObjectSize(key) {
const props = await this.headObject(key);
return props.contentLength;
}
async headObject(key) {
const blobClient = this.containerClient.getBlobClient(key);
const props = await blobClient.getProperties();
return props;
}
async getObject(key, range) {
const blobClient = this.containerClient.getBlobClient(key);
const downloadOptions = range
? {
rangeGetContentMD5: false,
range: {
offset: range.start,
count: range.end - range.start + 1,
},
}
: {};
const response = await blobClient.download(
downloadOptions.range?.offset ?? 0,
downloadOptions.range?.count
);
return response.readableStreamBody;
}
async listObjects() {
const result = [];
for await (const blob of this.containerClient.listBlobsFlat()) {
result.push(blob);
}
return result;
}
async deleteObject(key) {
const blobClient = this.containerClient.getBlobClient(key);
return await blobClient.deleteIfExists();
}
}

View File

@ -0,0 +1,255 @@
import "dotenv/config";
import express from "express";
import cors from "cors";
import path from "path";
import { fileURLToPath } from "url";
import {
AccessToken,
EgressClient,
EncodedFileOutput,
EncodedFileType,
WebhookReceiver,
} from "livekit-server-sdk";
import { AzureBlobService } from "./azure.blobstorage.service.js";
// Configuration
const SERVER_PORT = process.env.SERVER_PORT || 6080;
const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY || "devkey";
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET || "secret";
const LIVEKIT_URL = process.env.LIVEKIT_URL || "http://localhost:7880";
const RECORDING_FILE_PORTION_SIZE = 5 * 1024 * 1024; // 5MB
const app = express();
app.use(cors());
app.use(express.json());
app.use(express.raw({ type: "application/webhook+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")));
const egressClient = new EgressClient(
LIVEKIT_URL,
LIVEKIT_API_KEY,
LIVEKIT_API_SECRET
);
const azureBlobService = new AzureBlobService();
const webhookReceiver = new WebhookReceiver(
LIVEKIT_API_KEY,
LIVEKIT_API_SECRET
);
// Generate access tokens for participants to join a room
app.post("/token", async (req, res) => {
const roomName = req.body.roomName;
const participantName = req.body.participantName;
if (!roomName || !participantName) {
return res
.status(400)
.json({ errorMessage: "roomName and participantName are required" });
}
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();
return res.json({ token });
});
// Receive webhooks from LiveKit Server
app.post("/livekit/webhook", 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);
}
return res.status(200).send();
});
// Start a recording
app.post("/recordings/start", async (req, res) => {
const { roomName } = req.body;
if (!roomName) {
return res.status(400).json({ errorMessage: "roomName is required" });
}
const activeRecording = await getActiveRecordingByRoom(roomName);
// Check if there is already an active recording for this room
if (activeRecording) {
return res
.status(409)
.json({ errorMessage: "Recording already started for this room" });
}
// Use the EncodedFileOutput to save the recording to an MP4 file
// The room name, time and room ID in the file path help to organize the recordings
const fileOutput = new EncodedFileOutput({
fileType: EncodedFileType.MP4,
filepath: `RoomComposite-{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 = {
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" });
}
});
// Stop a recording
app.post("/recordings/stop", async (req, res) => {
const { roomName } = req.body;
if (!roomName) {
return res.status(400).json({ errorMessage: "roomName is required" });
}
const activeRecording = await getActiveRecordingByRoom(roomName);
// Check if there is an active recording for this room
if (!activeRecording) {
return res
.status(409)
.json({ errorMessage: "Recording not started for this room" });
}
try {
// Stop the egress to finish the recording
const egressInfo = await egressClient.stopEgress(activeRecording);
const file = egressInfo.fileResults[0];
const recording = {
name: file.filename.split("/").pop(),
};
return res.json({ message: "Recording stopped", recording });
} catch (error) {
console.error("Error stopping recording.", error);
return res.status(500).json({ errorMessage: "Error stopping recording" });
}
});
// List recordings
app.get("/recordings", async (req, res) => {
const roomId = req.query.roomId?.toString();
try {
const azureResponse = await azureBlobService.listObjects();
let recordings = [];
if (azureResponse.length > 0) {
recordings = azureResponse.map((obj) => ({
name: obj.name,
}));
}
// 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 exists = await azureBlobService.exists(recordingName);
if (!exists) {
return res.status(404).json({ errorMessage: "Recording not found" });
}
try {
// Get the recording file from Azure Blob Storage
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 exists = await azureBlobService.exists(recordingName);
if (!exists) {
return res.status(404).json({ errorMessage: "Recording not found" });
}
try {
// Delete the recording file from Azure Blob Storage
await Promise.all([azureBlobService.deleteObject(recordingName)]);
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) => {
try {
// List all active egresses for the room
const egresses = await egressClient.listEgress({ roomName, active: true });
return egresses.length > 0 ? egresses[0].egressId : null;
} catch (error) {
console.error("Error listing egresses.", error);
return null;
}
};
const getRecordingStream = async (recordingName, range) => {
const size = await azureBlobService.getObjectSize(recordingName);
// 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(recordingName, { start, end });
return { stream, size, start, end };
};

View File

@ -10,5 +10,5 @@ S3_ENDPOINT=http://localhost:9000
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
AWS_REGION=us-east-1
S3_BUCKET=openvidu
S3_BUCKET=openvidu-appdata
RECORDINGS_PATH=recordings/

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -7,171 +7,190 @@ 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/";
}
// 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...";
// Disable 'Join' button
document.getElementById("join-button").disabled = true;
document.getElementById("join-button").innerText = "Joining...";
// Initialize a new Room object
room = new LivekitClient.Room();
// 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();
// 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;
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
/* 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);
}
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();
// 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 HTML elements inside the layout container
removeAllLayoutElements();
// Remove all recordings from the list
removeAllRecordings();
// 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;
// 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;
// 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!";
// Enable 'Join' button
document.getElementById("join-button").disabled = false;
document.getElementById("join-button").innerText = "Join!";
}
window.onbeforeunload = () => {
room?.disconnect();
room?.disconnect();
};
document.addEventListener("DOMContentLoaded", async function () {
var currentPage = window.location.pathname;
var currentPage = window.location.pathname;
if (currentPage === "/recordings.html") {
await listRecordings();
} else {
generateFormValues();
}
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 = "";
// 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);
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");
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);
}
if (local) {
layoutContainer.prepend(videoContainer);
} else {
layoutContainer.append(videoContainer);
}
return videoContainer;
return videoContainer;
}
function appendParticipantData(videoContainer, participantIdentity) {
const dataElement = document.createElement("div");
dataElement.className = "participant-data";
dataElement.innerHTML = `<p>${participantIdentity}</p>`;
videoContainer.prepend(dataElement);
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();
const videoContainer = document.getElementById(
`camera-${participantIdentity}`
);
videoContainer?.remove();
}
function removeAllLayoutElements() {
const layoutElements = document.getElementById("layout-container").children;
Array.from(layoutElements).forEach((element) => {
element.remove();
});
const layoutElements = document.getElementById("layout-container").children;
Array.from(layoutElements).forEach((element) => {
element.remove();
});
}
/**
@ -188,156 +207,153 @@ function removeAllLayoutElements() {
* access to the endpoints.
*/
async function getToken(roomName, participantName) {
const [error, body] = await httpRequest("POST", "/token", {
roomName,
participantName
});
const [error, body] = await httpRequest("POST", "/token", {
roomName,
participantName,
});
if (error) {
throw new Error(`Failed to get token: ${error.message}`);
}
if (error) {
throw new Error(`Failed to get token: ${error.message}`);
}
return body.token;
return body.token;
}
async function updateRecordingInfo(isRecording) {
const recordingButton = document.getElementById("recording-button");
const recordingText = document.getElementById("recording-text");
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;
}
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);
const roomId = await room.getSid();
await listRecordings(roomId);
}
async function manageRecording() {
const recordingButton = document.getElementById("recording-button");
const recordingButton = document.getElementById("recording-button");
if (recordingButton.innerText === "Start Recording") {
recordingButton.disabled = true;
recordingButton.innerText = "Starting...";
if (recordingButton.innerText === "Start Recording") {
recordingButton.disabled = true;
recordingButton.innerText = "Starting...";
const [error, _] = await startRecording();
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";
}
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
});
return httpRequest("POST", "/recordings/start", {
roomName: room.name,
});
}
async function stopRecording() {
return httpRequest("POST", "/recordings/stop", {
roomName: room.name
});
return httpRequest("POST", "/recordings/stop", {
roomName: room.name,
});
}
async function deleteRecording(recordingName) {
const [error, _] = await httpRequest("DELETE", `/recordings/${recordingName}`);
const [error, _] = await httpRequest(
"DELETE",
`/recordings/${recordingName}`
);
if (!error || error.status === 404) {
const roomId = await room?.getSid();
await listRecordings(room?.name, roomId);
}
if (!error || error.status === 404) {
const roomId = await room?.getSid();
await listRecordings(roomId);
}
}
async function listRecordings(roomName, roomId) {
const url = "/recordings" + (roomName ? `?roomName=${roomName}` + (roomId ? `&roomId=${roomId}` : "") : "");
const [error, body] = await httpRequest("GET", url);
async function listRecordings(roomId) {
let url = "/recordings";
if (roomId) {
url += `?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);
if (!error) {
const recordings = body.recordings;
showRecordingList(recordings);
}
}
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
});
try {
const response = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
},
body: method !== "GET" ? JSON.stringify(body) : undefined,
});
const responseBody = await response.json();
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];
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");
const recordingsList = document.getElementById("recording-list");
if (recordings.length === 0) {
recordingsList.innerHTML = "<span>There are no recordings available</span>";
} else {
recordingsList.innerHTML = "";
}
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();
recordings.forEach((recording) => {
const recordingName = recording.name;
const recordingContainer = document.createElement("div");
recordingContainer.className = "recording-container";
recordingContainer.id = recordingName;
const recordingContainer = document.createElement("div");
recordingContainer.className = "recording-container";
recordingContainer.id = recordingName;
recordingContainer.innerHTML = `
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}')">
@ -349,38 +365,29 @@ function showRecordingList(recordings) {
</div>
`;
recordingsList.append(recordingContainer);
});
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}`;
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();
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];
const recordingList = document.getElementById("recording-list").children;
Array.from(recordingList).forEach((recording) => {
recording.remove();
});
}

View File

@ -29,7 +29,7 @@
/>
<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="https://cdn.jsdelivr.net/npm/livekit-client@2.5.9/dist/livekit-client.umd.js"></script>
<script src="app.js"></script>
</head>

View File

@ -29,7 +29,7 @@
/>
<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="https://cdn.jsdelivr.net/npm/livekit-client@2.5.9/dist/livekit-client.umd.js"></script>
<script src="app.js"></script>
</head>
@ -56,18 +56,6 @@
<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">

View File

@ -3,16 +3,20 @@ import express from "express";
import cors from "cors";
import path from "path";
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";
// Configuration
const SERVER_PORT = process.env.SERVER_PORT || 6080;
// LiveKit configuration
const LIVEKIT_URL = process.env.LIVEKIT_URL || "http://localhost:7880";
const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY || "devkey";
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET || "secret";
const LIVEKIT_URL = process.env.LIVEKIT_URL || "http://localhost:7880";
const RECORDINGS_PATH = process.env.RECORDINGS_PATH ?? "recordings/";
const RECORDING_FILE_PORTION_SIZE = 5 * 1024 * 1024; // 5MB
@ -27,234 +31,231 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
app.use(express.static(path.join(__dirname, "../public")));
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 });
});
const webhookReceiver = new WebhookReceiver(LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
app.post("/livekit/webhook", 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();
});
const egressClient = new EgressClient(LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
const egressClient = new EgressClient(
LIVEKIT_URL,
LIVEKIT_API_KEY,
LIVEKIT_API_SECRET
);
const s3Service = new S3Service();
const webhookReceiver = new WebhookReceiver(
LIVEKIT_API_KEY,
LIVEKIT_API_SECRET
);
// Generate access tokens for participants to join a room
app.post("/token", async (req, res) => {
const roomName = req.body.roomName;
const participantName = req.body.participantName;
if (!roomName || !participantName) {
return res
.status(400)
.json({ errorMessage: "roomName and participantName are required" });
}
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();
return res.json({ token });
});
// Receive webhooks from LiveKit Server
app.post("/livekit/webhook", 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);
}
return res.status(200).send();
});
// Start a recording
app.post("/recordings/start", async (req, res) => {
const { roomName } = req.body;
const { roomName } = req.body;
if (!roomName) {
res.status(400).json({ errorMessage: "roomName is required" });
return;
}
if (!roomName) {
return res.status(400).json({ errorMessage: "roomName is required" });
}
const activeRecording = await getActiveRecordingByRoom(roomName);
const activeRecording = await 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;
}
// Check if there is already an active recording for this room
if (activeRecording) {
return res
.status(409)
.json({ errorMessage: "Recording already started for this room" });
}
// 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}`
// Use the EncodedFileOutput to save the recording to an MP4 file
// The room name, time and room ID in the file path help to organize the recordings
const fileOutput = new EncodedFileOutput({
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,
});
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" });
}
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" });
}
});
// Stop a recording
app.post("/recordings/stop", async (req, res) => {
const { roomName } = req.body;
const { roomName } = req.body;
if (!roomName) {
res.status(400).json({ errorMessage: "roomName is required" });
return;
}
if (!roomName) {
return res.status(400).json({ errorMessage: "roomName is required" });
}
const activeRecording = await getActiveRecordingByRoom(roomName);
const activeRecording = await 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;
}
// Check if there is an active recording for this room
if (!activeRecording) {
return res
.status(409)
.json({ errorMessage: "Recording not started for this room" });
}
try {
// Stop the egress to finish the recording
const egressInfo = await egressClient.stopEgress(activeRecording);
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" });
}
try {
// Stop the egress to finish the recording
const egressInfo = await egressClient.stopEgress(activeRecording);
const file = egressInfo.fileResults[0];
const recording = {
name: file.filename.split("/").pop(),
};
return res.json({ message: "Recording stopped", recording });
} catch (error) {
console.error("Error stopping recording.", error);
return 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 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) => {
try {
// List all active egresses for the room
const egresses = await egressClient.listEgress({ roomName, active: true });
return egresses.length > 0 ? egresses[0].egressId : null;
} catch (error) {
console.error("Error listing egresses.", error);
return null;
}
try {
// List all active egresses for the room
const egresses = await egressClient.listEgress({ roomName, active: true });
return egresses.length > 0 ? egresses[0].egressId : null;
} catch (error) {
console.error("Error listing egresses.", error);
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 key = RECORDINGS_PATH + recordingName;
const size = await s3Service.getObjectSize(key);
const key = RECORDINGS_PATH + recordingName;
const size = await s3Service.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);
// 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 s3Service.getObject(key, { start, end });
return { stream, size, start, end };
const stream = await s3Service.getObject(key, { 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 {
S3Client,
GetObjectCommand,
DeleteObjectCommand,
ListObjectsV2Command,
HeadObjectCommand
S3Client,
GetObjectCommand,
DeleteObjectCommand,
ListObjectsV2Command,
HeadObjectCommand,
} from "@aws-sdk/client-s3";
// S3 configuration
@ -11,92 +11,83 @@ const S3_ENDPOINT = process.env.S3_ENDPOINT || "http://localhost:9000";
const S3_ACCESS_KEY = process.env.S3_ACCESS_KEY || "minioadmin";
const S3_SECRET_KEY = process.env.S3_SECRET_KEY || "minioadmin";
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-appdata";
export class S3Service {
static instance;
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;
constructor() {
if (S3Service.instance) {
return S3Service.instance;
}
async exists(key) {
try {
await this.headObject(key);
return true;
} catch (error) {
return false;
}
}
this.s3Client = new S3Client({
endpoint: S3_ENDPOINT,
credentials: {
accessKeyId: S3_ACCESS_KEY,
secretAccessKey: S3_SECRET_KEY,
},
region: AWS_REGION,
forcePathStyle: true,
});
async headObject(key) {
const params = {
Bucket: S3_BUCKET,
Key: key
};
const command = new HeadObjectCommand(params);
return this.run(command);
}
S3Service.instance = this;
return this;
}
async getObjectSize(key) {
const { ContentLength: size } = await this.headObject(key);
return size;
async exists(key) {
try {
await this.headObject(key);
return true;
} catch (error) {
return false;
}
}
async getObject(key, range) {
const params = {
Bucket: S3_BUCKET,
Key: key,
Range: range ? `bytes=${range.start}-${range.end}` : undefined
};
const command = new GetObjectCommand(params);
const { Body: body } = await this.run(command);
return body;
}
async headObject(key) {
const params = {
Bucket: S3_BUCKET,
Key: key,
};
const command = new HeadObjectCommand(params);
return this.run(command);
}
async getObjectAsJson(key) {
const body = await this.getObject(key);
const stringifiedData = await body.transformToString();
return JSON.parse(stringifiedData);
}
async getObjectSize(key) {
const { ContentLength: size } = await this.headObject(key);
return size;
}
async listObjects(prefix, regex) {
const params = {
Bucket: S3_BUCKET,
Prefix: prefix
};
const command = new ListObjectsV2Command(params);
const { Contents: objects } = await this.run(command);
async getObject(key, range) {
const params = {
Bucket: S3_BUCKET,
Key: key,
Range: range ? `bytes=${range.start}-${range.end}` : undefined,
};
const command = new GetObjectCommand(params);
const { Body: body } = await this.run(command);
return body;
}
// Filter objects by regex and return the keys
return objects?.filter((object) => regex.test(object.Key)).map((payload) => payload.Key) ?? [];
}
async listObjects(prefix) {
const params = {
Bucket: S3_BUCKET,
Prefix: prefix,
};
const command = new ListObjectsV2Command(params);
return await this.run(command);
}
async deleteObject(key) {
const params = {
Bucket: S3_BUCKET,
Key: key
};
const command = new DeleteObjectCommand(params);
return this.run(command);
}
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);
}
async run(command) {
return this.s3Client.send(command);
}
}

View File

@ -0,0 +1,5 @@
# OpenVidu Captions
This is the basic JavaScript tutorial extended to show live captions. It uses OpenVidu AI Services to generate captions from the audio Tracks of Participants in the Rooms.
Visit [Live Captions tutorial](https://openvidu.io/latest/docs/tutorials/ai-services/openvidu-live-captions/)

View File

@ -0,0 +1,221 @@
// When running OpenVidu locally, leave these variables empty
// For other deployment type, configure them with correct URLs depending on your deployment
var APPLICATION_SERVER_URL = "";
var LIVEKIT_URL = "";
configureUrls();
const LivekitClient = window.LivekitClient;
var room;
function configureUrls() {
// If APPLICATION_SERVER_URL is not configured, use default value from OpenVidu Local deployment
if (!APPLICATION_SERVER_URL) {
if (window.location.hostname === "localhost") {
APPLICATION_SERVER_URL = "http://localhost:6080/";
} else {
APPLICATION_SERVER_URL = "https://" + window.location.hostname + ":6443/";
}
}
// 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);
}
}
);
room.registerTextStreamHandler("lk.transcription", async (reader, participantInfo) => {
const message = await reader.readAll();
const isFinal = reader.info.attributes["lk.transcription_final"] === "true";
const trackId = reader.info.attributes["lk.transcribed_track_id"];
if (isFinal) {
const speaker = participantInfo.identity == room.localParticipant.identity
? "You" : participantInfo.identity;
const timestamp = new Date().toLocaleTimeString();
const captionsTextarea = document.getElementById("captions");
captionsTextarea.value += `[${timestamp}] ${speaker}: ${message}\n`;
captionsTextarea.scrollTop = captionsTextarea.scrollHeight;
}
}
);
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);
} 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();
// Clear the captions textarea
document.getElementById("captions").value = "";
// 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();
};
window.onload = generateFormValues;
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 response = await fetch(APPLICATION_SERVER_URL + "token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
roomName,
participantName,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Failed to get token: ${error.errorMessage}`);
}
const token = await response.json();
return token.token;
}

View File

@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>OpenVidu Live Captions</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="shortcut icon" href="resources/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.14.0/dist/livekit-client.umd.js"></script>
<script src="app.js"></script>
</head>
<body>
<header>
<a href="/" title="Home"><h1>OpenVidu Live Captions</h1></a>
<div id="links">
<a
href="https://github.com/OpenVidu/openvidu-livekit-tutorials/tree/master/application-client/openvidu-js"
title="GitHub Repository"
target="_blank"
>
<i class="fa-brands fa-github"></i>
</a>
<a
href="https://livekit-tutorials.openvidu.io/tutorials/application-client/javascript/"
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>
</div>
<div id="room" hidden>
<div id="room-header">
<h2 id="room-title"></h2>
<button class="btn btn-danger" id="leave-room-button" onclick="leaveRoom()">Leave Room</button>
</div>
<div id="layout-container"></div>
<div id="captions-container">
<textarea id="captions" class="form-control" rows="3"></textarea>
</div>
</div>
</main>
<footer>
<p class="text">Made with love by <span>OpenVidu Team</span></p>
<a href="http://www.openvidu.io/" target="_blank">
<img id="openvidu-logo" src="resources/images/openvidu_logo.png" alt="OpenVidu logo" />
</a>
</footer>
</body>
</html>

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,283 @@
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;
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;
}
#room-header {
display: flex;
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;
}
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;
}
}
#captions-container {
width: 100%;
padding: 25px;
}
textarea {
width: 100%;
min-height: 100px;
padding: 10px;
}

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,7 @@
"@angular/platform-browser": "^18.0.0",
"@angular/platform-browser-dynamic": "^18.0.0",
"@angular/router": "^18.0.0",
"livekit-client": "2.1.5",
"livekit-client": "^2.5.9",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.14.3"

File diff suppressed because it is too large Load Diff

View File

@ -25,6 +25,6 @@
"license": "Apache-2.0",
"dependencies": {
"electron-squirrel-startup": "^1.0.1",
"livekit-client": "2.1.5"
"livekit-client": "^2.5.9"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -28,7 +28,7 @@
"@capacitor/status-bar": "6.0.0",
"@ionic/angular": "^8.0.0",
"ionicons": "^7.2.1",
"livekit-client": "2.2.0",
"livekit-client": "^2.5.9",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.14.2"

View File

@ -29,7 +29,7 @@
/>
<link rel="stylesheet" href="styles.css" type="text/css" media="screen" />
<script src="https://cdn.jsdelivr.net/npm/livekit-client@2.1.5/dist/livekit-client.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/livekit-client@2.5.9/dist/livekit-client.umd.js"></script>
<script src="app.js"></script>
</head>

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"livekit-client": "2.1.5",
"livekit-client": "^2.5.9",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,7 @@
"format": "prettier --write src/"
},
"dependencies": {
"livekit-client": "2.1.5",
"livekit-client": "^2.5.9",
"vue": "^3.4.21"
},
"devDependencies": {

View File

@ -1,17 +1,14 @@
using System.Text;
using System.IdentityModel.Tokens.Jwt;
using Microsoft.IdentityModel.Tokens;
using System.Text.Json;
using System.Security.Cryptography;
using System.Security.Claims;
using System.Text.Json;
using Livekit.Server.Sdk.Dotnet;
var builder = WebApplication.CreateBuilder(args);
var MyAllowSpecificOrigins = "_myAllowSpecificOrigins";
IConfiguration config = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json")
.AddEnvironmentVariables().Build();
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json")
.AddEnvironmentVariables()
.Build();
// Load env variables
var SERVER_PORT = config.GetValue<int>("SERVER_PORT");
@ -21,11 +18,13 @@ var LIVEKIT_API_SECRET = config.GetValue<string>("LIVEKIT_API_SECRET");
// Enable CORS support
builder.Services.AddCors(options =>
{
options.AddPolicy(name: MyAllowSpecificOrigins,
builder =>
{
builder.WithOrigins("*").AllowAnyHeader();
});
options.AddPolicy(
name: MyAllowSpecificOrigins,
builder =>
{
builder.WithOrigins("*").AllowAnyHeader();
}
);
});
builder.WebHost.UseKestrel(serverOptions =>
@ -36,103 +35,63 @@ builder.WebHost.UseKestrel(serverOptions =>
var app = builder.Build();
app.UseCors(MyAllowSpecificOrigins);
app.MapPost("/token", async (HttpRequest request) =>
{
var body = new StreamReader(request.Body);
string postData = await body.ReadToEndAsync();
Dictionary<string, dynamic> bodyParams = JsonSerializer.Deserialize<Dictionary<string, dynamic>>(postData) ?? new Dictionary<string, dynamic>();
if (bodyParams.TryGetValue("roomName", out var roomName) && bodyParams.TryGetValue("participantName", out var participantName))
app.MapPost(
"/token",
async (HttpRequest request) =>
{
var token = CreateLiveKitJWT(roomName.ToString(), participantName.ToString());
return Results.Json(new { token });
var body = new StreamReader(request.Body);
string postData = await body.ReadToEndAsync();
Dictionary<string, dynamic> bodyParams =
JsonSerializer.Deserialize<Dictionary<string, dynamic>>(postData)
?? new Dictionary<string, dynamic>();
if (
bodyParams.TryGetValue("roomName", out var roomName)
&& bodyParams.TryGetValue("participantName", out var participantName)
)
{
var token = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET)
.WithIdentity(participantName.ToString())
.WithName(participantName.ToString())
.WithGrants(new VideoGrants { RoomJoin = true, Room = roomName.ToString() });
var jwt = token.ToJwt();
return Results.Json(new { token = jwt });
}
else
{
return Results.BadRequest(
new { errorMessage = "roomName and participantName are required" }
);
}
}
else
);
app.MapPost(
"/livekit/webhook",
async (HttpRequest request) =>
{
return Results.BadRequest(new { errorMessage = "roomName and participantName are required" });
var webhookReceiver = new WebhookReceiver(LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
try
{
StreamReader body = new StreamReader(request.Body);
string postData = await body.ReadToEndAsync();
string authHeader =
request.Headers["Authorization"].FirstOrDefault()
?? throw new Exception("Authorization header is missing");
WebhookEvent webhookEvent = webhookReceiver.Receive(postData, authHeader);
Console.Out.WriteLine(webhookEvent);
return Results.Ok();
}
catch (Exception e)
{
Console.Error.WriteLine("Error validating webhook event: " + e.Message);
return Results.Unauthorized();
}
}
});
app.MapPost("/livekit/webhook", async (HttpRequest request) =>
{
var body = new StreamReader(request.Body);
string postData = await body.ReadToEndAsync();
var authHeader = request.Headers["Authorization"];
if (authHeader.Count == 0)
{
return Results.BadRequest("Authorization header is required");
}
try
{
VerifyWebhookEvent(authHeader.First(), postData);
}
catch (Exception e)
{
Console.Error.WriteLine("Error validating webhook event: " + e.Message);
return Results.Unauthorized();
}
Console.Out.WriteLine(postData);
return Results.Ok();
});
string CreateLiveKitJWT(string roomName, string participantName)
{
JwtHeader headers = new(new SigningCredentials(new SymmetricSecurityKey(Encoding.UTF8.GetBytes(LIVEKIT_API_SECRET)), "HS256"));
var videoGrants = new Dictionary<string, object>()
{
{ "room", roomName },
{ "roomJoin", true }
};
JwtPayload payload = new()
{
{ "exp", new DateTimeOffset(DateTime.UtcNow.AddHours(6)).ToUnixTimeSeconds() },
{ "iss", LIVEKIT_API_KEY },
{ "nbf", 0 },
{ "sub", participantName },
{ "name", participantName },
{ "video", videoGrants }
};
JwtSecurityToken token = new(headers, payload);
return new JwtSecurityTokenHandler().WriteToken(token);
}
void VerifyWebhookEvent(string authHeader, string body)
{
var utf8Encoding = new UTF8Encoding();
var tokenValidationParameters = new TokenValidationParameters()
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(utf8Encoding.GetBytes(LIVEKIT_API_SECRET)),
ValidateIssuer = true,
ValidIssuer = LIVEKIT_API_KEY,
ValidateAudience = false
};
var jwtValidator = new JwtSecurityTokenHandler();
var claimsPrincipal = jwtValidator.ValidateToken(authHeader, tokenValidationParameters, out SecurityToken validatedToken);
var sha256 = SHA256.Create();
var hashBytes = sha256.ComputeHash(utf8Encoding.GetBytes(body));
var hash = Convert.ToBase64String(hashBytes);
if (claimsPrincipal.HasClaim(c => c.Type == "sha256") && claimsPrincipal.FindFirstValue("sha256") != hash)
{
throw new ArgumentException("sha256 checksum of body does not match!");
}
}
if (LIVEKIT_API_KEY == null || LIVEKIT_API_SECRET == null)
{
Console.Error.WriteLine("\nERROR: LIVEKIT_API_KEY and LIVEKIT_API_SECRET not set\n");
Environment.Exit(1);
}
if (LIVEKIT_API_SECRET.Length < 32)
{
Console.Error.WriteLine("\nERROR: LIVEKIT_API_SECRET must be at least 32 characters long\n");
Environment.Exit(1);
}
);
app.Run();

View File

@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.5.1" />
<PackageReference Include="Livekit.Server.Sdk.Dotnet" Version="1.0.2" />
</ItemGroup>
</Project>

View File

@ -6,35 +6,73 @@ require (
github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.0
github.com/joho/godotenv v1.5.1
github.com/livekit/protocol v1.16.0
github.com/livekit/protocol v1.27.0
)
require (
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.33.0-20240401165935-b983156c5e99.1 // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/bufbuild/protovalidate-go v0.6.1 // indirect
github.com/bufbuild/protoyaml-go v0.1.9 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/frostbyte73/core v0.0.10 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/frostbyte73/core v0.0.12 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gammazero/deque v0.2.1 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.3 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/cel-go v0.20.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/jxskiss/base62 v1.1.0 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lithammer/shortuuid/v4 v4.0.0 // indirect
github.com/livekit/mageutil v0.0.0-20230125210925-54e8a70427c1 // indirect
github.com/livekit/psrpc v0.6.1-0.20240924010758-9f0a4268a3b9 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/nats-io/nats.go v1.36.0 // indirect
github.com/nats-io/nkeys v0.4.7 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pion/datachannel v1.5.5 // indirect
github.com/pion/dtls/v2 v2.2.7 // indirect
github.com/pion/ice/v2 v2.3.13 // indirect
github.com/pion/interceptor v0.1.25 // indirect
github.com/pion/logging v0.2.2 // indirect
github.com/pion/mdns v0.0.12 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.12 // indirect
github.com/pion/rtp v1.8.3 // indirect
github.com/pion/sctp v1.8.12 // indirect
github.com/pion/sdp/v3 v3.0.6 // indirect
github.com/pion/srtp/v2 v2.0.18 // indirect
github.com/pion/stun v0.6.1 // indirect
github.com/pion/transport/v2 v2.2.3 // indirect
github.com/pion/turn/v2 v2.1.3 // indirect
github.com/pion/webrtc/v3 v3.2.28 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/puzpuzpuz/xsync/v3 v3.1.0 // indirect
github.com/redis/go-redis/v9 v9.6.1 // indirect
github.com/stoewer/go-strcase v1.3.0 // indirect
github.com/stretchr/testify v1.9.0 // indirect
github.com/twitchtv/twirp v8.1.3+incompatible // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
@ -44,13 +82,15 @@ require (
go.uber.org/zap v1.27.0 // indirect
go.uber.org/zap/exp v0.2.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240506185236-b8a5c65736ae // indirect
google.golang.org/grpc v1.63.2 // indirect
google.golang.org/protobuf v1.34.1 // indirect
golang.org/x/crypto v0.25.0 // indirect
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/net v0.27.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.22.0 // indirect
golang.org/x/text v0.16.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240725223205-93522f1f2a9f // indirect
google.golang.org/grpc v1.65.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@ -1,29 +1,40 @@
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.33.0-20240401165935-b983156c5e99.1 h1:2IGhRovxlsOIQgx2ekZWo4wTPAYpck41+18ICxs37is=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.33.0-20240401165935-b983156c5e99.1/go.mod h1:Tgn5bgL220vkFOI0KPStlcClPeOJzAv4uT+V8JXGUnw=
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bufbuild/protovalidate-go v0.6.1 h1:uzW8r0CDvqApUChNj87VzZVoQSKhcVdw5UWOE605UIw=
github.com/bufbuild/protovalidate-go v0.6.1/go.mod h1:4BR3rKEJiUiTy+sqsusFn2ladOf0kYmA2Reo6BHSBgQ=
github.com/bufbuild/protoyaml-go v0.1.9 h1:anV5UtF1Mlvkkgp4NWA6U/zOnJFng8Orq4Vf3ZUQHBU=
github.com/bufbuild/protoyaml-go v0.1.9/go.mod h1:KCBItkvZOK/zwGueLdH1Wx1RLyFn5rCH7YjQrdty2Wc=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/eapache/channels v1.1.0 h1:F1taHcn7/F0i8DYqKXJnyhJcVpp2kgFcNePxXtnyu4k=
github.com/eapache/channels v1.1.0/go.mod h1:jMm2qB5Ubtg9zLd+inMZd2/NUvXgzmWXsDaLyQIGfH0=
github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/frostbyte73/core v0.0.10 h1:D4DQXdPb8ICayz0n75rs4UYTXrUSdxzUfeleuNJORsU=
github.com/frostbyte73/core v0.0.10/go.mod h1:XsOGqrqe/VEV7+8vJ+3a8qnCIXNbKsoEiu/czs7nrcU=
github.com/envoyproxy/protoc-gen-validate v1.0.4 h1:gVPz/FMfvh57HdSJQyvBtF00j8JU4zdyUgIUNhlgg0A=
github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/frostbyte73/core v0.0.12 h1:kySA8+Os6eqnPFoExD2T7cehjSAY1MRyIViL0yTy2uc=
github.com/frostbyte73/core v0.0.12/go.mod h1:XsOGqrqe/VEV7+8vJ+3a8qnCIXNbKsoEiu/czs7nrcU=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
@ -38,8 +49,8 @@ github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=
github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@ -48,33 +59,56 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/cel-go v0.20.1 h1:nDx9r8S3L4pE61eDdt8igGj8rf5kjYR3ILxWIpWNi84=
github.com/google/cel-go v0.20.1/go.mod h1:kWcIzTsPX0zmQ+H3TirHstLLf9ep5QTsZBN9u4dOYLg=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M=
github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jxskiss/base62 v1.1.0 h1:A5zbF8v8WXx2xixnAKD2w+abC+sIzYJX+nxmhA6HWFw=
github.com/jxskiss/base62 v1.1.0/go.mod h1:HhWAlUXvxKThfOlZbcuFzsqwtF5TcqS9ru3y5GfjWAc=
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
@ -83,12 +117,12 @@ github.com/lithammer/shortuuid/v4 v4.0.0 h1:QRbbVkfgNippHOS8PXDkti4NaWeyYfcBTHtw
github.com/lithammer/shortuuid/v4 v4.0.0/go.mod h1:Zs8puNcrvf2rV9rTH51ZLLcj7ZXqQI3lv67aw4KiB1Y=
github.com/livekit/mageutil v0.0.0-20230125210925-54e8a70427c1 h1:jm09419p0lqTkDaKb5iXdynYrzB84ErPPO4LbRASk58=
github.com/livekit/mageutil v0.0.0-20230125210925-54e8a70427c1/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ=
github.com/livekit/protocol v1.15.0 h1:JAatoWKYdFx3D0U4JBWg25ZlrY+NK26xHabFopS2Jhk=
github.com/livekit/protocol v1.15.0/go.mod h1:pnn0Dv+/0K0OFqKHX6J6SreYO1dZxl6tDuAZ1ns8L/w=
github.com/livekit/protocol v1.16.0 h1:TkUuirvfF1xIfpo5szXqAEEgg7QyML8d0O7+4NQpM7w=
github.com/livekit/protocol v1.16.0/go.mod h1:pnn0Dv+/0K0OFqKHX6J6SreYO1dZxl6tDuAZ1ns8L/w=
github.com/livekit/psrpc v0.5.3-0.20240228172457-3724cb4adbc4 h1:253WtQ2VGVHzIIzW9MUZj7vUDDILESU3zsEbiRdxYF0=
github.com/livekit/psrpc v0.5.3-0.20240228172457-3724cb4adbc4/go.mod h1:CQUBSPfYYAaevg1TNCc6/aYsa8DJH4jSRFdCeSZk5u0=
github.com/livekit/protocol v1.27.0 h1:qdZ8S4eH11XbBQxpG4eHh9GZC7weyydngWNvH2NTD+w=
github.com/livekit/protocol v1.27.0/go.mod h1:nxRzmQBKSYK64gqr7ABWwt78hvrgiO2wYuCojRYb7Gs=
github.com/livekit/psrpc v0.6.1-0.20240924010758-9f0a4268a3b9 h1:33oBjGpVD9tYkDXQU42tnHl8eCX9G6PVUToBVuCUyOs=
github.com/livekit/psrpc v0.6.1-0.20240924010758-9f0a4268a3b9/go.mod h1:CQUBSPfYYAaevg1TNCc6/aYsa8DJH4jSRFdCeSZk5u0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -96,12 +130,21 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nats-io/nats.go v1.31.0 h1:/WFBHEc/dOKBF6qf1TZhrdEfTmOZ5JzdJ+Y3m6Y/p7E=
github.com/nats-io/nats.go v1.31.0/go.mod h1:di3Bm5MLsoB4Bx61CBTsxuarI36WbhAwOm8QrW39+i8=
github.com/nats-io/nkeys v0.4.6 h1:IzVe95ru2CT6ta874rt9saQRkWfe2nFj1NtvYSLqMzY=
github.com/nats-io/nkeys v0.4.6/go.mod h1:4DxZNzenSVd1cYQoAa8948QY3QDjrHfcfVADymtkpts=
github.com/nats-io/nats.go v1.36.0 h1:suEUPuWzTSse/XhESwqLxXGuj8vGRuPRoG7MoRN/qyU=
github.com/nats-io/nats.go v1.36.0/go.mod h1:Ubdu4Nh9exXdSz0RVWRFBbRfrbSxOYd26oF0wkWclB8=
github.com/nats-io/nkeys v0.4.7 h1:RwNJbbIdYCoClSDNY7QVKZlyb/wfT6ugvFCiKy6vDvI=
github.com/nats-io/nkeys v0.4.7/go.mod h1:kqXRgRDPlGy7nGaEDMuYzmiJCIAAWDK0IMBtDmGD0nc=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pion/datachannel v1.5.5 h1:10ef4kwdjije+M9d7Xm9im2Y3O6A6ccQb0zcqZcJew8=
@ -118,10 +161,13 @@ github.com/pion/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8=
github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.10/go.mod h1:ztfEwXZNLGyF1oQDttz/ZKIBaeeg/oWbRYqzBM9TL1I=
github.com/pion/rtcp v1.2.12 h1:bKWiX93XKgDZENEXCijvHRU/wRifm6JV5DGcH6twtSM=
github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
github.com/pion/rtp v1.8.2/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
github.com/pion/rtp v1.8.3 h1:VEHxqzSVQxCkKDSHro5/4IUUG1ea+MFdqR2R3xSpNU8=
github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
github.com/pion/sctp v1.8.5/go.mod h1:SUFFfDpViyKejTAdwD1d/HQsCu+V/40cCs2nZIvC3s0=
github.com/pion/sctp v1.8.12 h1:2VX50pedElH+is6FI+OKyRTeN5oy4mrk2HjnGa3UCmY=
github.com/pion/sctp v1.8.12/go.mod h1:cMLT45jqw3+jiJCrtHVwfQLnfR0MGZ4rgOJwUOIqLkI=
github.com/pion/sdp/v3 v3.0.6 h1:WuDLhtuFUUVpTfus9ILC4HRyHsW6TdugjEX/QY9OiUw=
@ -130,8 +176,14 @@ github.com/pion/srtp/v2 v2.0.18 h1:vKpAXfawO9RtTRKZJbG4y0v1b11NZxQnxRl85kGuUlo=
github.com/pion/srtp/v2 v2.0.18/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA=
github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4=
github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8=
github.com/pion/transport v0.14.1 h1:XSM6olwW+o8J4SCmOBb/BpwZypkHeyM0PGFCxNQBr40=
github.com/pion/transport v0.14.1/go.mod h1:4tGmbk00NeYA3rUa9+n+dzCCoKkcy3YlYb99Jn2fNnI=
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
github.com/pion/transport/v2 v2.2.2/go.mod h1:OJg3ojoBJopjEeECq2yJdXH9YVrUJ1uQ++NjXLOUorc=
github.com/pion/transport/v2 v2.2.3 h1:XcOE3/x41HOSKbl1BfyY1TF1dERx7lVvlMCbXU7kfvA=
github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM=
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
github.com/pion/turn/v2 v2.1.3 h1:pYxTVWG2gpC97opdRc5IGsQ1lJ9O/IlNhkzj7MMrGAA=
github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
github.com/pion/webrtc/v3 v3.2.28 h1:ienStxZ6HcjtH2UlmnFpMM0loENiYjaX437uIUpQSKo=
@ -140,30 +192,26 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/puzpuzpuz/xsync/v3 v3.1.0 h1:EewKT7/LNac5SLiEblJeUu8z5eERHrmRLnMQL2d7qX4=
github.com/puzpuzpuz/xsync/v3 v3.1.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8=
github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4=
github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs=
github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
@ -173,11 +221,16 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
@ -188,64 +241,130 @@ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUu
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240506185236-b8a5c65736ae h1:c55+MER4zkBS14uJhSZMGGmya0yJx5iHV4x/fpOSNRk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240506185236-b8a5c65736ae/go.mod h1:I7Y+G38R2bu5j1aLzfFmQfTcU/WnFuqDwLZAbvKTKpM=
google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM=
google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 h1:7whR9kGa5LUwFtpLm2ArCEejtnxlGeLbAyjFY8sGNFw=
google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157/go.mod h1:99sLkeliLXfdj2J75X3Ho+rrVCaJze0uwN7zDDkjPVU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240725223205-93522f1f2a9f h1:RARaIm8pxYuxyNPbBQf5igT7XdOyCNtat1qAT2ZxjU4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240725223205-93522f1f2a9f/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -7,22 +7,14 @@ import (
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
"github.com/livekit/protocol/auth"
"github.com/livekit/protocol/webhook"
)
var (
SERVER_PORT = getEnv("SERVER_PORT", "6080")
LIVEKIT_API_KEY = getEnv("LIVEKIT_API_KEY", "devkey")
LIVEKIT_API_SECRET = getEnv("LIVEKIT_API_SECRET", "secret")
)
func getEnv(key, defaultValue string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return defaultValue
}
var SERVER_PORT string
var LIVEKIT_API_KEY string
var LIVEKIT_API_SECRET string
func createToken(context *gin.Context) {
var body struct {
@ -45,7 +37,7 @@ func createToken(context *gin.Context) {
RoomJoin: true,
Room: body.RoomName,
}
at.AddGrant(grant).SetIdentity(body.ParticipantName)
at.SetVideoGrant(grant).SetIdentity(body.ParticipantName)
token, err := at.ToJWT()
if err != nil {
@ -69,9 +61,24 @@ func receiveWebhook(context *gin.Context) {
}
func main() {
loadEnv()
router := gin.Default()
router.Use(cors.Default())
router.POST("/token", createToken)
router.POST("/livekit/webhook", receiveWebhook)
router.Run(":" + SERVER_PORT)
}
func loadEnv() {
godotenv.Load() // Load environment variables from .env file
SERVER_PORT = getEnv("SERVER_PORT", "6080")
LIVEKIT_API_KEY = getEnv("LIVEKIT_API", "devkey")
LIVEKIT_API_SECRET = getEnv("LIVEKIT_API_SECRET", "secret")
}
func getEnv(key, defaultValue string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return defaultValue
}

View File

@ -6,7 +6,7 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.6</version>
<version>3.3.4</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
@ -28,8 +28,8 @@
<dependency>
<groupId>io.livekit</groupId>
<artifactId>livekit-server</artifactId>
<version>0.5.11</version>
</dependency>
<version>0.8.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,7 @@
"dependencies": {
"cors": "2.8.5",
"dotenv": "16.4.5",
"express": "4.19.2",
"livekit-server-sdk": "2.3.0"
"express": "5.0.1",
"livekit-server-sdk": "^2.7.2"
}
}

View File

@ -4,7 +4,7 @@
"type": "project",
"require": {
"vlucas/phpdotenv": "5.6.0",
"agence104/livekit-server-sdk": "1.2.3"
"agence104/livekit-server-sdk": "1.2.4"
},
"authors": [
{

View File

@ -4,20 +4,20 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "6e44cb3fdf0c966fd5e708c217221e07",
"content-hash": "a312e390b09f5609c5cc2468f9e951e6",
"packages": [
{
"name": "agence104/livekit-server-sdk",
"version": "1.2.3",
"version": "1.2.4",
"source": {
"type": "git",
"url": "https://github.com/agence104/livekit-server-sdk-php.git",
"reference": "26d0d0e87ddfc5805ebb420f22e693b696749fa8"
"reference": "c32e0b43996320347bacb0becfac481938eb9af4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/agence104/livekit-server-sdk-php/zipball/26d0d0e87ddfc5805ebb420f22e693b696749fa8",
"reference": "26d0d0e87ddfc5805ebb420f22e693b696749fa8",
"url": "https://api.github.com/repos/agence104/livekit-server-sdk-php/zipball/c32e0b43996320347bacb0becfac481938eb9af4",
"reference": "c32e0b43996320347bacb0becfac481938eb9af4",
"shasum": ""
},
"require": {
@ -52,9 +52,9 @@
"description": "Server-side SDK for LiveKit.",
"support": {
"issues": "https://github.com/agence104/livekit-server-sdk-php/issues",
"source": "https://github.com/agence104/livekit-server-sdk-php/tree/1.2.3"
"source": "https://github.com/agence104/livekit-server-sdk-php/tree/1.2.4"
},
"time": "2024-03-09T19:50:15+00:00"
"time": "2024-09-05T01:15:55+00:00"
},
{
"name": "firebase/php-jwt",
@ -121,16 +121,16 @@
},
{
"name": "google/protobuf",
"version": "v3.25.3",
"version": "v3.25.5",
"source": {
"type": "git",
"url": "https://github.com/protocolbuffers/protobuf-php.git",
"reference": "983a87f4f8798a90ca3a25b0f300b8fda38df643"
"reference": "dd2cf3f7b577dced3851c2ea76c3daa9f8aa0ff4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/983a87f4f8798a90ca3a25b0f300b8fda38df643",
"reference": "983a87f4f8798a90ca3a25b0f300b8fda38df643",
"url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/dd2cf3f7b577dced3851c2ea76c3daa9f8aa0ff4",
"reference": "dd2cf3f7b577dced3851c2ea76c3daa9f8aa0ff4",
"shasum": ""
},
"require": {
@ -159,30 +159,30 @@
"proto"
],
"support": {
"source": "https://github.com/protocolbuffers/protobuf-php/tree/v3.25.3"
"source": "https://github.com/protocolbuffers/protobuf-php/tree/v3.25.5"
},
"time": "2024-02-15T21:11:49+00:00"
"time": "2024-09-18T22:04:15+00:00"
},
{
"name": "graham-campbell/result-type",
"version": "v1.1.2",
"version": "v1.1.3",
"source": {
"type": "git",
"url": "https://github.com/GrahamCampbell/Result-Type.git",
"reference": "fbd48bce38f73f8a4ec8583362e732e4095e5862"
"reference": "3ba905c11371512af9d9bdd27d99b782216b6945"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/fbd48bce38f73f8a4ec8583362e732e4095e5862",
"reference": "fbd48bce38f73f8a4ec8583362e732e4095e5862",
"url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/3ba905c11371512af9d9bdd27d99b782216b6945",
"reference": "3ba905c11371512af9d9bdd27d99b782216b6945",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
"phpoption/phpoption": "^1.9.2"
"phpoption/phpoption": "^1.9.3"
},
"require-dev": {
"phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2"
"phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28"
},
"type": "library",
"autoload": {
@ -211,7 +211,7 @@
],
"support": {
"issues": "https://github.com/GrahamCampbell/Result-Type/issues",
"source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.2"
"source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.3"
},
"funding": [
{
@ -223,26 +223,26 @@
"type": "tidelift"
}
],
"time": "2023-11-12T22:16:48+00:00"
"time": "2024-07-20T21:45:45+00:00"
},
{
"name": "guzzlehttp/guzzle",
"version": "7.8.1",
"version": "7.9.2",
"source": {
"type": "git",
"url": "https://github.com/guzzle/guzzle.git",
"reference": "41042bc7ab002487b876a0683fc8dce04ddce104"
"reference": "d281ed313b989f213357e3be1a179f02196ac99b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/41042bc7ab002487b876a0683fc8dce04ddce104",
"reference": "41042bc7ab002487b876a0683fc8dce04ddce104",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/d281ed313b989f213357e3be1a179f02196ac99b",
"reference": "d281ed313b989f213357e3be1a179f02196ac99b",
"shasum": ""
},
"require": {
"ext-json": "*",
"guzzlehttp/promises": "^1.5.3 || ^2.0.1",
"guzzlehttp/psr7": "^1.9.1 || ^2.5.1",
"guzzlehttp/promises": "^1.5.3 || ^2.0.3",
"guzzlehttp/psr7": "^2.7.0",
"php": "^7.2.5 || ^8.0",
"psr/http-client": "^1.0",
"symfony/deprecation-contracts": "^2.2 || ^3.0"
@ -253,9 +253,9 @@
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"ext-curl": "*",
"php-http/client-integration-tests": "dev-master#2c025848417c1135031fdf9c728ee53d0a7ceaee as 3.0.999",
"guzzle/client-integration-tests": "3.0.2",
"php-http/message-factory": "^1.1",
"phpunit/phpunit": "^8.5.36 || ^9.6.15",
"phpunit/phpunit": "^8.5.39 || ^9.6.20",
"psr/log": "^1.1 || ^2.0 || ^3.0"
},
"suggest": {
@ -333,7 +333,7 @@
],
"support": {
"issues": "https://github.com/guzzle/guzzle/issues",
"source": "https://github.com/guzzle/guzzle/tree/7.8.1"
"source": "https://github.com/guzzle/guzzle/tree/7.9.2"
},
"funding": [
{
@ -349,20 +349,20 @@
"type": "tidelift"
}
],
"time": "2023-12-03T20:35:24+00:00"
"time": "2024-07-24T11:22:20+00:00"
},
{
"name": "guzzlehttp/promises",
"version": "2.0.2",
"version": "2.0.4",
"source": {
"type": "git",
"url": "https://github.com/guzzle/promises.git",
"reference": "bbff78d96034045e58e13dedd6ad91b5d1253223"
"reference": "f9c436286ab2892c7db7be8c8da4ef61ccf7b455"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/promises/zipball/bbff78d96034045e58e13dedd6ad91b5d1253223",
"reference": "bbff78d96034045e58e13dedd6ad91b5d1253223",
"url": "https://api.github.com/repos/guzzle/promises/zipball/f9c436286ab2892c7db7be8c8da4ef61ccf7b455",
"reference": "f9c436286ab2892c7db7be8c8da4ef61ccf7b455",
"shasum": ""
},
"require": {
@ -370,7 +370,7 @@
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"phpunit/phpunit": "^8.5.36 || ^9.6.15"
"phpunit/phpunit": "^8.5.39 || ^9.6.20"
},
"type": "library",
"extra": {
@ -416,7 +416,7 @@
],
"support": {
"issues": "https://github.com/guzzle/promises/issues",
"source": "https://github.com/guzzle/promises/tree/2.0.2"
"source": "https://github.com/guzzle/promises/tree/2.0.4"
},
"funding": [
{
@ -432,20 +432,20 @@
"type": "tidelift"
}
],
"time": "2023-12-03T20:19:20+00:00"
"time": "2024-10-17T10:06:22+00:00"
},
{
"name": "guzzlehttp/psr7",
"version": "2.6.2",
"version": "2.7.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
"reference": "45b30f99ac27b5ca93cb4831afe16285f57b8221"
"reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/45b30f99ac27b5ca93cb4831afe16285f57b8221",
"reference": "45b30f99ac27b5ca93cb4831afe16285f57b8221",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/a70f5c95fb43bc83f07c9c948baa0dc1829bf201",
"reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201",
"shasum": ""
},
"require": {
@ -460,8 +460,8 @@
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"http-interop/http-factory-tests": "^0.9",
"phpunit/phpunit": "^8.5.36 || ^9.6.15"
"http-interop/http-factory-tests": "0.9.0",
"phpunit/phpunit": "^8.5.39 || ^9.6.20"
},
"suggest": {
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
@ -532,7 +532,7 @@
],
"support": {
"issues": "https://github.com/guzzle/psr7/issues",
"source": "https://github.com/guzzle/psr7/tree/2.6.2"
"source": "https://github.com/guzzle/psr7/tree/2.7.0"
},
"funding": [
{
@ -548,20 +548,20 @@
"type": "tidelift"
}
],
"time": "2023-12-03T20:05:35+00:00"
"time": "2024-07-18T11:15:46+00:00"
},
{
"name": "php-http/discovery",
"version": "1.19.4",
"version": "1.20.0",
"source": {
"type": "git",
"url": "https://github.com/php-http/discovery.git",
"reference": "0700efda8d7526335132360167315fdab3aeb599"
"reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-http/discovery/zipball/0700efda8d7526335132360167315fdab3aeb599",
"reference": "0700efda8d7526335132360167315fdab3aeb599",
"url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d",
"reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d",
"shasum": ""
},
"require": {
@ -625,22 +625,22 @@
],
"support": {
"issues": "https://github.com/php-http/discovery/issues",
"source": "https://github.com/php-http/discovery/tree/1.19.4"
"source": "https://github.com/php-http/discovery/tree/1.20.0"
},
"time": "2024-03-29T13:00:05+00:00"
"time": "2024-10-02T11:20:13+00:00"
},
{
"name": "phpoption/phpoption",
"version": "1.9.2",
"version": "1.9.3",
"source": {
"type": "git",
"url": "https://github.com/schmittjoh/php-option.git",
"reference": "80735db690fe4fc5c76dfa7f9b770634285fa820"
"reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/schmittjoh/php-option/zipball/80735db690fe4fc5c76dfa7f9b770634285fa820",
"reference": "80735db690fe4fc5c76dfa7f9b770634285fa820",
"url": "https://api.github.com/repos/schmittjoh/php-option/zipball/e3fac8b24f56113f7cb96af14958c0dd16330f54",
"reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54",
"shasum": ""
},
"require": {
@ -648,13 +648,13 @@
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2"
"phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28"
},
"type": "library",
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": true
"forward-command": false
},
"branch-alias": {
"dev-master": "1.9-dev"
@ -690,7 +690,7 @@
],
"support": {
"issues": "https://github.com/schmittjoh/php-option/issues",
"source": "https://github.com/schmittjoh/php-option/tree/1.9.2"
"source": "https://github.com/schmittjoh/php-option/tree/1.9.3"
},
"funding": [
{
@ -702,7 +702,7 @@
"type": "tidelift"
}
],
"time": "2023-11-12T21:59:55+00:00"
"time": "2024-07-20T21:41:07+00:00"
},
{
"name": "psr/http-client",
@ -1033,20 +1033,20 @@
},
{
"name": "symfony/polyfill-ctype",
"version": "v1.29.0",
"version": "v1.31.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
"reference": "ef4d7e442ca910c4764bce785146269b30cb5fc4"
"reference": "a3cc8b044a6ea513310cbd48ef7333b384945638"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/ef4d7e442ca910c4764bce785146269b30cb5fc4",
"reference": "ef4d7e442ca910c4764bce785146269b30cb5fc4",
"url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638",
"reference": "a3cc8b044a6ea513310cbd48ef7333b384945638",
"shasum": ""
},
"require": {
"php": ">=7.1"
"php": ">=7.2"
},
"provide": {
"ext-ctype": "*"
@ -1092,7 +1092,7 @@
"portable"
],
"support": {
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.29.0"
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0"
},
"funding": [
{
@ -1108,24 +1108,24 @@
"type": "tidelift"
}
],
"time": "2024-01-29T20:11:03+00:00"
"time": "2024-09-09T11:45:10+00:00"
},
{
"name": "symfony/polyfill-mbstring",
"version": "v1.29.0",
"version": "v1.31.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
"reference": "9773676c8a1bb1f8d4340a62efe641cf76eda7ec"
"reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/9773676c8a1bb1f8d4340a62efe641cf76eda7ec",
"reference": "9773676c8a1bb1f8d4340a62efe641cf76eda7ec",
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341",
"reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341",
"shasum": ""
},
"require": {
"php": ">=7.1"
"php": ">=7.2"
},
"provide": {
"ext-mbstring": "*"
@ -1172,7 +1172,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.29.0"
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0"
},
"funding": [
{
@ -1188,24 +1188,24 @@
"type": "tidelift"
}
],
"time": "2024-01-29T20:11:03+00:00"
"time": "2024-09-09T11:45:10+00:00"
},
{
"name": "symfony/polyfill-php80",
"version": "v1.29.0",
"version": "v1.31.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php80.git",
"reference": "87b68208d5c1188808dd7839ee1e6c8ec3b02f1b"
"reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/87b68208d5c1188808dd7839ee1e6c8ec3b02f1b",
"reference": "87b68208d5c1188808dd7839ee1e6c8ec3b02f1b",
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8",
"reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8",
"shasum": ""
},
"require": {
"php": ">=7.1"
"php": ">=7.2"
},
"type": "library",
"extra": {
@ -1252,7 +1252,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php80/tree/v1.29.0"
"source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0"
},
"funding": [
{
@ -1268,7 +1268,7 @@
"type": "tidelift"
}
],
"time": "2024-01-29T20:11:03+00:00"
"time": "2024-09-09T11:45:10+00:00"
},
{
"name": "twirp/twirp",

View File

@ -6,7 +6,7 @@ edition = "2021"
[dependencies]
axum = "0.7.5"
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.5.2", features = ["cors"] }
serde_json = "1.0.117"
livekit-api = "0.3.2"
tower-http = { version = "0.6.1", features = ["cors"] }
serde_json = "1.0.132"
livekit-api = { version = "0.4.0", features = ["signal-client-tokio"] }
dotenv = "0.15.0"