Compare commits
24 Commits
3.0.0-beta
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc4f43e887 | ||
|
|
12b2c3720a | ||
|
|
af880945dc | ||
|
|
d385068049 | ||
|
|
50a7af992a | ||
|
|
f0358d0680 | ||
|
|
4e90828d80 | ||
|
|
75076678a5 | ||
|
|
b1aeb2f2f2 | ||
|
|
1c57c6e85d | ||
|
|
d3180c1f9f | ||
|
|
9cdb39a53b | ||
|
|
d5dd493a37 | ||
|
|
8841cd2aed | ||
|
|
665681174c | ||
|
|
966ba75a8a | ||
|
|
3228a50708 | ||
|
|
e10eeb0fdc | ||
|
|
5343660dfa | ||
|
|
48e1fb79f7 | ||
|
|
5e2a891250 | ||
|
|
cecc89238d | ||
|
|
508da8b035 | ||
|
|
b5bb3e2567 |
1
README.md
Normal file
1
README.md
Normal file
@ -0,0 +1 @@
|
||||
# Visit [LiveKit tutorials](https://livekit-tutorials.openvidu.io/)
|
||||
@ -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
|
||||
129
advanced-features/openvidu-recording-advanced-node-azure/.gitignore
vendored
Normal file
129
advanced-features/openvidu-recording-advanced-node-azure/.gitignore
vendored
Normal file
@ -0,0 +1,129 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
@ -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
|
||||
```
|
||||
1290
advanced-features/openvidu-recording-advanced-node-azure/package-lock.json
generated
Normal file
1290
advanced-features/openvidu-recording-advanced-node-azure/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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 |
@ -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>
|
||||
@ -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>
|
||||
@ -0,0 +1,479 @@
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding-top: 50px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
header {
|
||||
height: 50px;
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 30px;
|
||||
background-color: #4d4d4d;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
header a {
|
||||
color: #ccc;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
header a:hover {
|
||||
color: #a9a9a9;
|
||||
}
|
||||
|
||||
header i {
|
||||
padding: 5px 5px;
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
#join {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#join-dialog {
|
||||
width: 70%;
|
||||
max-width: 900px;
|
||||
padding: 60px;
|
||||
border-radius: 6px;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
#join-dialog h2 {
|
||||
color: #4d4d4d;
|
||||
font-size: 60px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#join-dialog form {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#join-dialog label {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
color: #0088aa;
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
margin-bottom: 10px;
|
||||
box-sizing: border-box;
|
||||
color: #0088aa;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
color: #0088aa;
|
||||
border-color: #0088aa;
|
||||
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(0, 136, 170, 0.6);
|
||||
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(0, 136, 170, 0.6);
|
||||
}
|
||||
|
||||
#join-dialog button {
|
||||
display: block;
|
||||
margin: 20px auto 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: #06d362;
|
||||
border-color: #06d362;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background-color: #1abd61;
|
||||
border-color: #1abd61;
|
||||
}
|
||||
|
||||
#room {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#recording-text {
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
#recording-text span {
|
||||
background-color: #ffeb3b;
|
||||
color: #333;
|
||||
border-radius: 5px;
|
||||
padding: 0 5px;
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
float: right;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#room-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
padding: 0 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
#room-title {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#layout-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
position: relative;
|
||||
background: #3b3b3b;
|
||||
aspect-ratio: 16/9;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-container video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.video-container .participant-data {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.participant-data p {
|
||||
background: #f8f8f8;
|
||||
margin: 0;
|
||||
padding: 0 5px;
|
||||
color: #777777;
|
||||
font-weight: bold;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
#recordings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
#recording-header {
|
||||
margin: 15px 0;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 3fr 1fr;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#recording-header h3 {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
color: #444;
|
||||
grid-column-start: 2;
|
||||
}
|
||||
|
||||
#recording-header a {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
#recording-list {
|
||||
width: 600px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#recording-list span {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
color: #333;
|
||||
display: block;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.recording-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid #dcdcdc;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
background-color: #f4f4f4;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.fa-file-video {
|
||||
font-size: 30px;
|
||||
color: #333;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.recording-info {
|
||||
flex-grow: 1;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.recording-info p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.recording-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.recording-size,
|
||||
.recording-date {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.recording-date {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.recording-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: #333;
|
||||
margin-left: 10px;
|
||||
padding: 4px 12px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
color: #d9534f;
|
||||
}
|
||||
|
||||
#recording-video-dialog {
|
||||
width: 500px;
|
||||
max-width: 90%;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
padding: 20px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
#recording-video {
|
||||
width: 100%;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
#close-recording-video-dialog {
|
||||
margin-top: 10px;
|
||||
float: right;
|
||||
}
|
||||
|
||||
footer {
|
||||
height: 60px;
|
||||
width: 100%;
|
||||
padding: 10px 30px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: #4d4d4d;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
footer .text {
|
||||
color: #ccc;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
footer .text span {
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#openvidu-logo {
|
||||
height: 35px;
|
||||
-webkit-transition: all 0.1s ease-in-out;
|
||||
-moz-transition: all 0.1s ease-in-out;
|
||||
-o-transition: all 0.1s ease-in-out;
|
||||
transition: all 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
#openvidu-logo:hover {
|
||||
-webkit-filter: grayscale(0.5);
|
||||
filter: grayscale(0.5);
|
||||
}
|
||||
|
||||
/* Media Queries */
|
||||
@media screen and (max-width: 768px) {
|
||||
header {
|
||||
padding: 10px 15px;
|
||||
}
|
||||
|
||||
#join-dialog {
|
||||
width: 90%;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
#join-dialog h2 {
|
||||
font-size: 50px;
|
||||
}
|
||||
|
||||
#layout-container {
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
}
|
||||
|
||||
footer {
|
||||
padding: 10px 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
header {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#join-dialog {
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
#join-dialog h2 {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
#layout-container {
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
}
|
||||
|
||||
.video-container {
|
||||
aspect-ratio: 9/16;
|
||||
}
|
||||
|
||||
footer {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Recordings page styles */
|
||||
#actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
#refresh-button {
|
||||
margin: 0;
|
||||
padding: 8px 13px;
|
||||
}
|
||||
|
||||
form input {
|
||||
padding: 8px;
|
||||
font-size: 16px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px 0 0 5px;
|
||||
}
|
||||
|
||||
form button {
|
||||
padding: 8px;
|
||||
font-size: 16px;
|
||||
border: 1px solid #ccc;
|
||||
border-left: none;
|
||||
background-color: #007bff;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
border-radius: 0 5px 5px 0;
|
||||
}
|
||||
|
||||
.search-form button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
#recordings-all {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#recordings-all h2 {
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 40px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
color: #444;
|
||||
}
|
||||
@ -0,0 +1,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
|
||||
@ -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" });
|
||||
}
|
||||
});
|
||||
@ -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" });
|
||||
}
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
@ -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);
|
||||
});
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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/";
|
||||
|
||||
11
advanced-features/openvidu-recording-basic-node-azure/.env
Normal file
11
advanced-features/openvidu-recording-basic-node-azure/.env
Normal 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
|
||||
129
advanced-features/openvidu-recording-basic-node-azure/.gitignore
vendored
Normal file
129
advanced-features/openvidu-recording-basic-node-azure/.gitignore
vendored
Normal file
@ -0,0 +1,129 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
@ -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
|
||||
```
|
||||
1340
advanced-features/openvidu-recording-basic-node-azure/package-lock.json
generated
Normal file
1340
advanced-features/openvidu-recording-basic-node-azure/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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 |
@ -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>
|
||||
@ -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>
|
||||
@ -0,0 +1,479 @@
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding-top: 50px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
header {
|
||||
height: 50px;
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 30px;
|
||||
background-color: #4d4d4d;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
header a {
|
||||
color: #ccc;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
header a:hover {
|
||||
color: #a9a9a9;
|
||||
}
|
||||
|
||||
header i {
|
||||
padding: 5px 5px;
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
#join {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#join-dialog {
|
||||
width: 70%;
|
||||
max-width: 900px;
|
||||
padding: 60px;
|
||||
border-radius: 6px;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
#join-dialog h2 {
|
||||
color: #4d4d4d;
|
||||
font-size: 60px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#join-dialog form {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#join-dialog label {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
color: #0088aa;
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
margin-bottom: 10px;
|
||||
box-sizing: border-box;
|
||||
color: #0088aa;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
color: #0088aa;
|
||||
border-color: #0088aa;
|
||||
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(0, 136, 170, 0.6);
|
||||
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(0, 136, 170, 0.6);
|
||||
}
|
||||
|
||||
#join-dialog button {
|
||||
display: block;
|
||||
margin: 20px auto 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: #06d362;
|
||||
border-color: #06d362;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background-color: #1abd61;
|
||||
border-color: #1abd61;
|
||||
}
|
||||
|
||||
#room {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#recording-text {
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
#recording-text span {
|
||||
background-color: #ffeb3b;
|
||||
color: #333;
|
||||
border-radius: 5px;
|
||||
padding: 0 5px;
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
float: right;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#room-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
padding: 0 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
#room-title {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#layout-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
position: relative;
|
||||
background: #3b3b3b;
|
||||
aspect-ratio: 16/9;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-container video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.video-container .participant-data {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.participant-data p {
|
||||
background: #f8f8f8;
|
||||
margin: 0;
|
||||
padding: 0 5px;
|
||||
color: #777777;
|
||||
font-weight: bold;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
#recordings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
#recording-header {
|
||||
margin: 15px 0;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 3fr 1fr;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#recording-header h3 {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
color: #444;
|
||||
grid-column-start: 2;
|
||||
}
|
||||
|
||||
#recording-header a {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
#recording-list {
|
||||
width: 600px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#recording-list span {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
color: #333;
|
||||
display: block;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.recording-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid #dcdcdc;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
background-color: #f4f4f4;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.fa-file-video {
|
||||
font-size: 30px;
|
||||
color: #333;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.recording-info {
|
||||
flex-grow: 1;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.recording-info p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.recording-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.recording-size,
|
||||
.recording-date {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.recording-date {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.recording-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: #333;
|
||||
margin-left: 10px;
|
||||
padding: 4px 12px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
color: #d9534f;
|
||||
}
|
||||
|
||||
#recording-video-dialog {
|
||||
width: 500px;
|
||||
max-width: 90%;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
padding: 20px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
#recording-video {
|
||||
width: 100%;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
#close-recording-video-dialog {
|
||||
margin-top: 10px;
|
||||
float: right;
|
||||
}
|
||||
|
||||
footer {
|
||||
height: 60px;
|
||||
width: 100%;
|
||||
padding: 10px 30px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: #4d4d4d;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
footer .text {
|
||||
color: #ccc;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
footer .text span {
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#openvidu-logo {
|
||||
height: 35px;
|
||||
-webkit-transition: all 0.1s ease-in-out;
|
||||
-moz-transition: all 0.1s ease-in-out;
|
||||
-o-transition: all 0.1s ease-in-out;
|
||||
transition: all 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
#openvidu-logo:hover {
|
||||
-webkit-filter: grayscale(0.5);
|
||||
filter: grayscale(0.5);
|
||||
}
|
||||
|
||||
/* Media Queries */
|
||||
@media screen and (max-width: 768px) {
|
||||
header {
|
||||
padding: 10px 15px;
|
||||
}
|
||||
|
||||
#join-dialog {
|
||||
width: 90%;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
#join-dialog h2 {
|
||||
font-size: 50px;
|
||||
}
|
||||
|
||||
#layout-container {
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
}
|
||||
|
||||
footer {
|
||||
padding: 10px 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
header {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#join-dialog {
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
#join-dialog h2 {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
#layout-container {
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
}
|
||||
|
||||
.video-container {
|
||||
aspect-ratio: 9/16;
|
||||
}
|
||||
|
||||
footer {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Recordings page styles */
|
||||
#actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
#refresh-button {
|
||||
margin: 0;
|
||||
padding: 8px 13px;
|
||||
}
|
||||
|
||||
form input {
|
||||
padding: 8px;
|
||||
font-size: 16px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px 0 0 5px;
|
||||
}
|
||||
|
||||
form button {
|
||||
padding: 8px;
|
||||
font-size: 16px;
|
||||
border: 1px solid #ccc;
|
||||
border-left: none;
|
||||
background-color: #007bff;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
border-radius: 0 5px 5px 0;
|
||||
}
|
||||
|
||||
.search-form button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
#recordings-all {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#recordings-all h2 {
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 40px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
color: #444;
|
||||
}
|
||||
@ -0,0 +1,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();
|
||||
}
|
||||
}
|
||||
@ -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 };
|
||||
};
|
||||
@ -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
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
5
ai-services/openvidu-live-captions/README.md
Normal file
5
ai-services/openvidu-live-captions/README.md
Normal 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/)
|
||||
221
ai-services/openvidu-live-captions/src/app.js
Normal file
221
ai-services/openvidu-live-captions/src/app.js
Normal 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;
|
||||
}
|
||||
94
ai-services/openvidu-live-captions/src/index.html
Normal file
94
ai-services/openvidu-live-captions/src/index.html
Normal 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 |
283
ai-services/openvidu-live-captions/src/styles.css
Normal file
283
ai-services/openvidu-live-captions/src/styles.css
Normal 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;
|
||||
}
|
||||
6361
application-client/openvidu-angular/package-lock.json
generated
6361
application-client/openvidu-angular/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
|
||||
1884
application-client/openvidu-electron/package-lock.json
generated
1884
application-client/openvidu-electron/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
4948
application-client/openvidu-ionic/package-lock.json
generated
4948
application-client/openvidu-ionic/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
1269
application-client/openvidu-react/package-lock.json
generated
1269
application-client/openvidu-react/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
},
|
||||
|
||||
1269
application-client/openvidu-vue/package-lock.json
generated
1269
application-client/openvidu-vue/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -12,7 +12,7 @@
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"livekit-client": "2.1.5",
|
||||
"livekit-client": "^2.5.9",
|
||||
"vue": "^3.4.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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=
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
579
application-server/node/package-lock.json
generated
579
application-server/node/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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": [
|
||||
{
|
||||
|
||||
162
application-server/php/composer.lock
generated
162
application-server/php/composer.lock
generated
@ -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",
|
||||
|
||||
Binary file not shown.
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user