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:
parent
e360ae456e
commit
d89aafa991
@ -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
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user