diff --git a/advanced-features/openvidu-recording-node/.env b/advanced-features/openvidu-recording-node/.env index e1c38bf1..053224b1 100644 --- a/advanced-features/openvidu-recording-node/.env +++ b/advanced-features/openvidu-recording-node/.env @@ -11,4 +11,4 @@ S3_ACCESS_KEY=minioadmin S3_SECRET_KEY=minioadmin AWS_REGION=us-east-1 S3_BUCKET=openvidu -DEFAULT_RECORDINGS_PATH=recordings +RECORDINGS_PATH=recordings/ diff --git a/advanced-features/openvidu-recording-node/package.json b/advanced-features/openvidu-recording-node/package.json index 1d48a6c7..92d4cd40 100644 --- a/advanced-features/openvidu-recording-node/package.json +++ b/advanced-features/openvidu-recording-node/package.json @@ -2,10 +2,10 @@ "name": "openvidu-recording", "version": "1.0.0", "description": "Simple video-call application with recording capabilities", - "main": "index.js", + "main": "src/index.js", "type": "module", "scripts": { - "start": "node index.js" + "start": "node src/index.js" }, "dependencies": { "@aws-sdk/client-s3": "3.635.0", diff --git a/advanced-features/openvidu-recording-node/index.js b/advanced-features/openvidu-recording-node/src/index.js similarity index 65% rename from advanced-features/openvidu-recording-node/index.js rename to advanced-features/openvidu-recording-node/src/index.js index b8c2879b..2aca4f03 100644 --- a/advanced-features/openvidu-recording-node/index.js +++ b/advanced-features/openvidu-recording-node/src/index.js @@ -4,13 +4,7 @@ import cors from "cors"; import path from "path"; import { fileURLToPath } from "url"; import { AccessToken, EgressClient, EncodedFileOutput, EncodedFileType, WebhookReceiver } from "livekit-server-sdk"; -import { - S3Client, - GetObjectCommand, - DeleteObjectCommand, - ListObjectsV2Command, - HeadObjectCommand -} from "@aws-sdk/client-s3"; +import { S3Service } from "./s3.service.js"; const SERVER_PORT = process.env.SERVER_PORT || 6080; @@ -19,13 +13,7 @@ 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"; -// S3 configuration -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 DEFAULT_RECORDINGS_PATH = process.env.DEFAULT_RECORDINGS_PATH ?? "recordings"; +const RECORDINGS_PATH = process.env.RECORDINGS_PATH ?? "recordings/"; const app = express(); @@ -36,7 +24,7 @@ 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(__dirname + "/public")); +app.use(express.static(path.join(__dirname, "../public"))); app.post("/token", async (req, res) => { const roomName = req.body.roomName; @@ -68,15 +56,7 @@ app.post("/livekit/webhook", async (req, res) => { }); const egressClient = new EgressClient(LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET); -const s3Client = new S3Client({ - endpoint: S3_ENDPOINT, - credentials: { - accessKeyId: S3_ACCESS_KEY, - secretAccessKey: S3_SECRET_KEY - }, - region: AWS_REGION, - forcePathStyle: true -}); +const s3Service = new S3Service(); app.post("/recordings/start", async (req, res) => { const { roomName } = req.body; @@ -95,7 +75,7 @@ app.post("/recordings/start", async (req, res) => { const fileOutput = new EncodedFileOutput({ fileType: EncodedFileType.MP4, - filepath: `${DEFAULT_RECORDINGS_PATH}/{room_name}-{room_id}-{time}` + filepath: `${RECORDINGS_PATH}{room_name}-{room_id}-{time}` }); try { @@ -156,19 +136,11 @@ app.get("/recordings", async (req, res) => { const roomName = req.query.roomName?.toString(); const roomId = req.query.roomId?.toString(); - const command = new ListObjectsV2Command({ - Bucket: S3_BUCKET, - Prefix: DEFAULT_RECORDINGS_PATH + "/" - }); - try { - const { Contents: objects } = await s3Client.send(command); - const keyStart = DEFAULT_RECORDINGS_PATH + "/" + (roomName ? `${roomName}` + (roomId ? `-${roomId}` : "") : ""); - const payloadKeys = - objects - ?.filter((object) => object.Key.startsWith(keyStart) && object.Key.endsWith(".mp4.json")) - .map((payload) => payload.Key) ?? []; - + const keyStart = RECORDINGS_PATH + (roomName ? `${roomName}` + (roomId ? `-${roomId}` : "") : ""); + const keyEnd = ".mp4.json"; + const regex = new RegExp(`^${keyStart}.*${keyEnd}$`); + const payloadKeys = await s3Service.listObjects(RECORDINGS_PATH, regex); const recordings = await Promise.all(payloadKeys.map((payloadKey) => getRecordingInfo(payloadKey))); res.json({ recordings }); } catch (error) { @@ -178,20 +150,10 @@ app.get("/recordings", async (req, res) => { }); const getRecordingInfo = async (payloadKey) => { - const objectCommand = new GetObjectCommand({ - Bucket: S3_BUCKET, - Key: payloadKey - }); - const { Body } = await s3Client.send(objectCommand); - const stringifiedData = await Body.transformToString(); - const data = JSON.parse(stringifiedData); + const data = await s3Service.getObjectAsJson(payloadKey); const recordingKey = payloadKey.replace(".json", ""); - const headCommand = new HeadObjectCommand({ - Bucket: S3_BUCKET, - Key: recordingKey - }); - const { ContentLength: size } = await s3Client.send(headCommand); + const size = await s3Service.getObjectSize(recordingKey); const recordingName = recordingKey.split("/").pop(); const recording = { @@ -204,25 +166,20 @@ const getRecordingInfo = async (payloadKey) => { app.get("/recordings/:recordingName", async (req, res) => { const { recordingName } = req.params; - - const exists = await checkRecordingExists(recordingName); + const key = RECORDINGS_PATH + recordingName; + const exists = await s3Service.exists(key); if (!exists) { res.status(404).json({ errorMessage: "Recording not found" }); return; } - const command = new GetObjectCommand({ - Bucket: S3_BUCKET, - Key: `${DEFAULT_RECORDINGS_PATH}/${recordingName}` - }); - try { - const { Body, ContentLength: fileSize } = await s3Client.send(command); + const { body, size } = await s3Service.getObject(key); res.setHeader("Content-Type", "video/mp4"); - res.setHeader("Content-Length", fileSize); + res.setHeader("Content-Length", size); res.setHeader("Accept-Ranges", "bytes"); - Body.pipe(res).on("finish", () => res.end()); + body.pipe(res).on("finish", () => res.end()); } catch (error) { console.error("Error getting recording.", error); res.status(500).json({ errorMessage: "Error getting recording" }); @@ -231,27 +188,16 @@ app.get("/recordings/:recordingName", async (req, res) => { app.delete("/recordings/:recordingName", async (req, res) => { const { recordingName } = req.params; - - const exists = await checkRecordingExists(recordingName); + const key = RECORDINGS_PATH + recordingName; + const exists = await s3Service.exists(key); if (!exists) { res.status(404).json({ errorMessage: "Recording not found" }); return; } - const deleteRecordingCommand = new DeleteObjectCommand({ - Bucket: S3_BUCKET, - Key: `${DEFAULT_RECORDINGS_PATH}/${recordingName}` - }); - const deletePayloadCommand = new DeleteObjectCommand({ - Bucket: S3_BUCKET, - Key: `${DEFAULT_RECORDINGS_PATH}/${recordingName}.json` - }); - try { - console.log("Deleting recording:", recordingName); - await Promise.all([s3Client.send(deleteRecordingCommand), s3Client.send(deletePayloadCommand)]); - console.log("Recording deleted:", recordingName); + await Promise.all([s3Service.deleteObject(key), s3Service.deleteObject(`${key}.json`)]); res.json({ message: "Recording deleted" }); } catch (error) { console.error("Error deleting recording.", error); @@ -259,20 +205,6 @@ app.delete("/recordings/:recordingName", async (req, res) => { } }); -const checkRecordingExists = async (recordingName) => { - const headCommand = new HeadObjectCommand({ - Bucket: S3_BUCKET, - Key: `${DEFAULT_RECORDINGS_PATH}/${recordingName}` - }); - - try { - await s3Client.send(headCommand); - return true; - } catch (error) { - return false; - } -}; - app.listen(SERVER_PORT, () => { console.log("Server started on port:", SERVER_PORT); }); diff --git a/advanced-features/openvidu-recording-node/src/s3.service.js b/advanced-features/openvidu-recording-node/src/s3.service.js new file mode 100644 index 00000000..52f1f239 --- /dev/null +++ b/advanced-features/openvidu-recording-node/src/s3.service.js @@ -0,0 +1,103 @@ +import { + S3Client, + GetObjectCommand, + DeleteObjectCommand, + ListObjectsV2Command, + HeadObjectCommand +} from "@aws-sdk/client-s3"; + +// S3 configuration +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"; + +export class S3Service { + 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; + } + + async exists(key) { + try { + await this.headObject(key); + return true; + } catch (error) { + return false; + } + } + + async headObject(key) { + const params = { + Bucket: S3_BUCKET, + Key: key + }; + const command = new HeadObjectCommand(params); + return this.run(command); + } + + async getObjectSize(key) { + const { ContentLength } = await this.headObject(key); + return ContentLength; + } + + async getObject(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 }; + } + + async getObjectAsJson(key) { + const { body } = await this.getObject(key); + const stringifiedData = await body.transformToString(); + return JSON.parse(stringifiedData); + } + + async listObjects(prefix, regex) { + const params = { + Bucket: S3_BUCKET, + Prefix: prefix + }; + const command = new ListObjectsV2Command(params); + const { Contents: objects } = await this.run(command); + return ( + objects + ?.filter((object) => regex.test(object.Key)) + .map((payload) => payload.Key) ?? [] + ); + } + + 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); + } +}