openvidu-recording-improved: Improve seeking when proxying video in backend and add option to create presigned url to access video directly from s3

This commit is contained in:
juancarmore 2024-09-20 15:59:22 +02:00
parent e360ae456e
commit d89aafa991
8 changed files with 766 additions and 645 deletions

View File

@ -12,3 +12,4 @@ S3_SECRET_KEY=minioadmin
AWS_REGION=us-east-1
S3_BUCKET=openvidu
RECORDINGS_PATH=recordings/
RECORDING_PLAYBACK_STRATEGY=URL

File diff suppressed because it is too large Load Diff

View File

@ -1,17 +1,18 @@
{
"name": "openvidu-recording",
"name": "openvidu-recording-advanced-node",
"version": "1.0.0",
"description": "Simple video-call application with recording capabilities (improved version)",
"description": "Simple video-call application with recording capabilities (advanced version)",
"main": "src/index.js",
"type": "module",
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"@aws-sdk/client-s3": "3.635.0",
"@aws-sdk/client-s3": "3.654.0",
"@aws-sdk/s3-request-presigner": "3.654.0",
"cors": "2.8.5",
"dotenv": "16.4.5",
"express": "4.19.2",
"livekit-server-sdk": "2.6.1"
"express": "4.21.0",
"livekit-server-sdk": "2.6.2"
}
}

View File

@ -309,6 +309,11 @@ async function listRecordingsByRoom() {
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, {
@ -381,11 +386,13 @@ function showRecordingList(recordings) {
});
}
function displayRecording(recordingName) {
async function displayRecording(recordingName) {
const recordingVideoDialog = document.getElementById("recording-video-dialog");
recordingVideoDialog.showModal();
const recordingVideo = document.getElementById("recording-video");
recordingVideo.src = `/recordings/${recordingName}`;
const recordingUrl = await getRecordingUrl(recordingName);
recordingVideo.src = recordingUrl;
}
function closeRecording() {

View File

@ -1,5 +1,5 @@
export const SERVER_PORT = process.env.SERVER_PORT || 6080;
export const APP_NAME = "openvidu-recording-improved-tutorial";
export const APP_NAME = "openvidu-recording-advanced-node";
// LiveKit configuration
export const LIVEKIT_URL = process.env.LIVEKIT_URL || "http://localhost:7880";
@ -15,3 +15,5 @@ export const S3_BUCKET = process.env.S3_BUCKET || "openvidu";
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 || "URL"; // PROXY or URL
export const RECORDING_FILE_PORTION_SIZE = 5 * 1024 * 1024; // 5MB

View File

@ -1,6 +1,7 @@
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();
@ -17,7 +18,7 @@ recordingController.post("/start", async (req, res) => {
const activeRecording = await recordingService.getActiveRecordingByRoom(roomName);
// Check if there is already an active egress for this room
// 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;
@ -42,7 +43,7 @@ recordingController.post("/stop", async (req, res) => {
const activeRecording = await recordingService.getActiveRecordingByRoom(roomName);
// Check if there is an active egress for this room
// Check if there is an active recording for this room
if (!activeRecording) {
res.status(409).json({ errorMessage: "Recording not started for this room" });
return;
@ -72,6 +73,7 @@ recordingController.get("/", async (req, res) => {
recordingController.get("/:recordingName", async (req, res) => {
const { recordingName } = req.params;
const { range } = req.headers;
const exists = await recordingService.existsRecording(recordingName);
if (!exists) {
@ -81,21 +83,49 @@ recordingController.get("/:recordingName", async (req, res) => {
try {
// Get the recording file from S3
const { body, size } = await recordingService.getRecordingStream(recordingName);
const { stream, size, start, end } = await recordingService.getRecordingStream(recordingName, range);
// Set the response headers
// Set response headers
res.status(206);
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Content-Type", "video/mp4");
res.setHeader("Content-Length", size);
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
body.pipe(res).on("finish", () => res.end());
stream.pipe(res).on("finish", () => res.end());
} catch (error) {
console.error("Error getting recording.", error);
res.status(500).json({ errorMessage: "Error getting recording" });
}
});
recordingController.get("/:recordingName/url", async (req, res) => {
const { recordingName } = req.params;
const exists = await recordingService.existsRecording(recordingName);
if (!exists) {
res.status(404).json({ errorMessage: "Recording not found" });
return;
}
// If the recording playback strategy is "PROXY", return the endpoint URL
if (RECORDING_PLAYBACK_STRATEGY === "PROXY") {
res.json({ recordingUrl: `/recordings/${recordingName}` });
return;
}
try {
// If the recording playback strategy is "URL", return a signed URL to access the recording directly from S3
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);

View File

@ -4,7 +4,8 @@ import {
LIVEKIT_API_KEY,
LIVEKIT_API_SECRET,
RECORDINGS_PATH,
RECORDINGS_METADATA_PATH
RECORDINGS_METADATA_PATH,
RECORDING_FILE_PORTION_SIZE
} from "../config.js";
import { S3Service } from "./s3.service.js";
@ -36,7 +37,7 @@ export class RecordingService {
}
async stopRecording(recordingId) {
// Stop the Egress to finish the recording
// Stop the egress to finish the recording
const egressInfo = await this.egressClient.stopEgress(recordingId);
return this.convertToRecordingInfo(egressInfo);
}
@ -47,7 +48,7 @@ export class RecordingService {
const keyEnd = ".json";
const regex = new RegExp(`^${keyStart}.*${keyEnd}$`);
// List all Egress metadata files in the recordings path that match the regex
// List all egress metadata files in the recordings path that match the regex
const metadataKeys = await s3Service.listObjects(RECORDINGS_PATH + RECORDINGS_METADATA_PATH, regex);
const recordings = await Promise.all(metadataKeys.map((metadataKey) => s3Service.getObjectAsJson(metadataKey)));
return recordings;
@ -69,9 +70,23 @@ export class RecordingService {
return s3Service.getObjectAsJson(key);
}
async getRecordingStream(recordingName) {
async getRecordingStream(recordingName, range) {
const key = this.getRecordingKey(recordingName);
return s3Service.getObject(key);
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);
const stream = await s3Service.getObject(key, { start, end });
return { stream, size, start, end };
}
async getRecordingUrl(recordingName) {
const key = this.getRecordingKey(recordingName);
return s3Service.getObjectUrl(key);
}
async existsRecording(recordingName) {

View File

@ -6,6 +6,7 @@ import {
HeadObjectCommand,
PutObjectCommand
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { AWS_REGION, S3_ACCESS_KEY, S3_BUCKET, S3_ENDPOINT, S3_SECRET_KEY } from "../config.js";
export class S3Service {
@ -58,18 +59,33 @@ export class S3Service {
return this.run(command);
}
async getObject(key) {
async getObjectSize(key) {
const { ContentLength: size } = await this.headObject(key);
return size;
}
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 getObjectUrl(key) {
const params = {
Bucket: S3_BUCKET,
Key: key
};
const command = new GetObjectCommand(params);
const { Body: body, ContentLength: size } = await this.run(command);
return { body, size };
return getSignedUrl(this.s3Client, command, { expiresIn: 86400 }); // 24 hours
}
async getObjectAsJson(key) {
const { body } = await this.getObject(key);
const body = await this.getObject(key);
const stringifiedData = await body.transformToString();
return JSON.parse(stringifiedData);
}