[mirotalksfu] - add RTMP server and multi-source streaming!, update dep
هذا الالتزام موجود في:
212
rtmpServers/demo/client-server-socket/client/client.js
Normal file
212
rtmpServers/demo/client-server-socket/client/client.js
Normal file
@@ -0,0 +1,212 @@
|
||||
'use strict';
|
||||
|
||||
const videoElement = document.getElementById('video');
|
||||
const startCameraButton = document.getElementById('startCamera');
|
||||
const startScreenButton = document.getElementById('startScreen');
|
||||
const stopButton = document.getElementById('stop');
|
||||
const apiSecretInput = document.getElementById('apiSecret');
|
||||
const rtmpInput = document.getElementById('rtmp');
|
||||
const copyButton = document.getElementById('copy');
|
||||
const popup = document.getElementById('popup');
|
||||
const popupMessage = document.getElementById('popupMessage');
|
||||
const closePopup = document.getElementById('closePopup');
|
||||
|
||||
/*
|
||||
Low Latency: 1-2 seconds
|
||||
Standard Use Case: 5 seconds
|
||||
High Bandwidth/Stability: 10 seconds
|
||||
*/
|
||||
const chunkDuration = 4000; // ms
|
||||
|
||||
let mediaRecorder = null;
|
||||
let rtmpKey = null;
|
||||
let socket = null;
|
||||
|
||||
function toggleButtons(disabled = true) {
|
||||
startCameraButton.disabled = disabled;
|
||||
startScreenButton.disabled = disabled;
|
||||
stopButton.disabled = disabled;
|
||||
}
|
||||
|
||||
function showPopup(message, type) {
|
||||
popup.classList.remove('success', 'error', 'warning', 'info');
|
||||
popup.classList.add(type);
|
||||
popupMessage.textContent = message;
|
||||
popup.classList.remove('hidden');
|
||||
setTimeout(() => {
|
||||
hidePopup();
|
||||
}, 5000); // Hide after 5 seconds
|
||||
}
|
||||
|
||||
function hidePopup() {
|
||||
popup.classList.add('hidden');
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
showPopup(message, 'error');
|
||||
}
|
||||
|
||||
function checkBrowserSupport() {
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
if (userAgent.includes('chrome') && !userAgent.includes('edge') && !userAgent.includes('opr')) {
|
||||
console.log('Browser is Chrome-based. Proceed with functionality.');
|
||||
} else {
|
||||
showError(
|
||||
'This application requires a Chrome-based browser (Chrome, Edge Chromium, etc.). Please switch to a supported browser.',
|
||||
);
|
||||
toggleButtons(true);
|
||||
}
|
||||
}
|
||||
|
||||
window.onload = checkBrowserSupport;
|
||||
|
||||
async function startCapture(constraints) {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
videoElement.srcObject = stream;
|
||||
return stream;
|
||||
} catch (err) {
|
||||
console.error('Error accessing media devices.', err);
|
||||
showError('Error accessing media devices. Please check your camera and microphone permissions.');
|
||||
}
|
||||
}
|
||||
|
||||
async function startScreenCapture(constraints) {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia(constraints);
|
||||
videoElement.srcObject = stream;
|
||||
return stream;
|
||||
} catch (err) {
|
||||
console.error('Error accessing screen media.', err);
|
||||
showError('Error accessing screen sharing. Please try again or check your screen sharing permissions.');
|
||||
}
|
||||
}
|
||||
|
||||
async function initRTMP() {
|
||||
const apiSecret = apiSecretInput.value;
|
||||
socket.emit('initRTMP', { apiSecret });
|
||||
}
|
||||
|
||||
async function streamRTMP(data) {
|
||||
const apiSecret = apiSecretInput.value;
|
||||
|
||||
const arrayBuffer = await data.arrayBuffer();
|
||||
const chunkSize = 1000000; // 1mb
|
||||
const totalChunks = Math.ceil(arrayBuffer.byteLength / chunkSize);
|
||||
|
||||
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
||||
const chunk = arrayBuffer.slice(chunkIndex * chunkSize, (chunkIndex + 1) * chunkSize);
|
||||
socket.emit('streamRTMP', { apiSecret: apiSecret, rtmpStreamKey: rtmpKey, data: chunk });
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRTMP() {
|
||||
if (mediaRecorder) {
|
||||
const apiSecret = apiSecretInput.value;
|
||||
mediaRecorder.stop();
|
||||
socket.emit('stopRTMP', { apiSecret: apiSecret, rtmpStreamKey: rtmpKey });
|
||||
}
|
||||
videoElement.srcObject = null;
|
||||
rtmpInput.value = '';
|
||||
toggleButtons(false);
|
||||
stopButton.disabled = true;
|
||||
}
|
||||
|
||||
async function startStreaming(stream) {
|
||||
if (!stream) return;
|
||||
|
||||
try {
|
||||
socket = io({ transports: ['websocket'] });
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('Connected to server');
|
||||
initRTMP();
|
||||
});
|
||||
|
||||
socket.on('initRTMP', (data) => {
|
||||
console.log('initRTMP', data);
|
||||
const { rtmp, rtmpStreamKey } = data;
|
||||
rtmpInput.value = rtmp;
|
||||
rtmpKey = rtmpStreamKey;
|
||||
toggleButtons(true);
|
||||
stopButton.disabled = false;
|
||||
startMediaRecorder(stream);
|
||||
});
|
||||
|
||||
socket.on('stopRTMP', () => {
|
||||
console.log('RTMP stopped successfully!');
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Error:', error);
|
||||
showError(error);
|
||||
stopRTMP();
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('Disconnected from server');
|
||||
stopRTMP();
|
||||
});
|
||||
} catch (error) {
|
||||
showPopup('Error start Streaming: ' + error.message, 'error');
|
||||
console.error('Error start Streaming', error);
|
||||
stopRTMP();
|
||||
}
|
||||
}
|
||||
|
||||
async function startMediaRecorder(stream) {
|
||||
if (!stream) return;
|
||||
|
||||
mediaRecorder = new MediaRecorder(stream, { mimeType: 'video/webm; codecs=vp8,opus' });
|
||||
|
||||
mediaRecorder.ondataavailable = async (event) => {
|
||||
if (event.data.size > 0) {
|
||||
await streamRTMP(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = (event) => {
|
||||
console.log('Media recorder stopped');
|
||||
stopTracks(stream);
|
||||
};
|
||||
|
||||
mediaRecorder.start(chunkDuration); // Record in chunks of the specified duration
|
||||
}
|
||||
|
||||
function stopTracks(stream) {
|
||||
stream.getTracks().forEach((track) => {
|
||||
track.stop();
|
||||
});
|
||||
}
|
||||
|
||||
async function startCameraStreaming() {
|
||||
const stream = await startCapture({ video: true, audio: true });
|
||||
await startStreaming(stream);
|
||||
}
|
||||
|
||||
async function startScreenStreaming() {
|
||||
const stream = await startScreenCapture({ video: true, audio: true });
|
||||
await startStreaming(stream);
|
||||
}
|
||||
|
||||
function copyRTMP() {
|
||||
const rtmpInput = document.getElementById('rtmp');
|
||||
if (!rtmpInput.value) {
|
||||
return showPopup('No RTMP URL detected', 'info');
|
||||
}
|
||||
rtmpInput.select();
|
||||
document.execCommand('copy');
|
||||
showPopup('Copied: ' + rtmpInput.value, 'success');
|
||||
}
|
||||
|
||||
startCameraButton.addEventListener('click', startCameraStreaming);
|
||||
startScreenButton.addEventListener('click', startScreenStreaming);
|
||||
stopButton.addEventListener('click', stopRTMP);
|
||||
copyButton.addEventListener('click', copyRTMP);
|
||||
closePopup.addEventListener('click', hidePopup);
|
||||
|
||||
window.addEventListener('beforeunload', async () => {
|
||||
if (mediaRecorder) {
|
||||
await stopRTMP();
|
||||
}
|
||||
});
|
||||
52
rtmpServers/demo/client-server-socket/client/index.html
Normal file
52
rtmpServers/demo/client-server-socket/client/index.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MiroTalk RTMP Streamer</title>
|
||||
<link id="icon" rel="shortcut icon" href="./logo.svg" />
|
||||
<link id="appleTouchIcon" rel="apple-touch-icon" href="./logo.svg" />
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script defer src="/socket.io/socket.io.js"></script>
|
||||
<script defer src="./client.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="popup" class="popup hidden">
|
||||
<span id="popupMessage"></span>
|
||||
<button id="closePopup">X</button>
|
||||
</div>
|
||||
<div class="container">
|
||||
<h1>MiroTalk RTMP Streamer</h1>
|
||||
<div class="input-group-inline">
|
||||
<input
|
||||
id="apiSecret"
|
||||
type="password"
|
||||
value="mirotalkRtmpApiSecret"
|
||||
placeholder="API Secret"
|
||||
title="Enter the API secret here"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group-inline">
|
||||
<input
|
||||
id="rtmp"
|
||||
type="text"
|
||||
value=""
|
||||
placeholder="rtmp://server:port/app/streamKey"
|
||||
readonly
|
||||
title="This is your RTMP live URL. It cannot be edited."
|
||||
/>
|
||||
<button id="copy" title="Click to copy the RTMP URL">Copy</button>
|
||||
</div>
|
||||
<video id="video" width="640" height="480" autoplay></video>
|
||||
<div class="button-group">
|
||||
<button id="startCamera" title="Click to start camera streaming">Start Camera Streaming</button>
|
||||
<button id="startScreen" title="Click to start screen streaming">Start Screen Streaming</button>
|
||||
<button id="stop" disabled title="Click to stop streaming">Stop Streaming</button>
|
||||
</div>
|
||||
</div>
|
||||
<footer>
|
||||
<p>© 2024 MiroTalk SFU, all rights reserved</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
1
rtmpServers/demo/client-server-socket/client/logo.svg
Normal file
1
rtmpServers/demo/client-server-socket/client/logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="28" height="32" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="a"><stop stop-color="#00BFFB" offset="0%"/><stop stop-color="#0270D7" offset="100%"/></linearGradient><linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="b"><stop stop-color="#1F232A" stop-opacity=".48" offset="0%"/><stop stop-color="#1F2329" stop-opacity="0" offset="100%"/></linearGradient><linearGradient x1="87.665%" y1="103.739%" x2="-3.169%" y2="38.807%" id="c"><stop stop-color="#FFF" stop-opacity="0" offset="0%"/><stop stop-color="#FFF" stop-opacity=".64" offset="100%"/></linearGradient><linearGradient x1="-14.104%" y1="111.262%" x2="109.871%" y2="26.355%" id="d"><stop stop-color="#0270D7" offset="0%"/><stop stop-color="#0270D7" stop-opacity="0" offset="100%"/></linearGradient></defs><g fill="none" fill-rule="evenodd"><path fill="url(#a)" transform="rotate(90 14 16)" d="M6 2l-8 13.999L6 30h16l8-14.001L22 2z"/><path fill="url(#b)" d="M14 0v32L0 24V8z"/><path fill="url(#c)" d="M28 24L0 8l14.001-8L28 8z"/><path fill-opacity=".48" fill="url(#d)" style="mix-blend-mode:multiply" d="M28 8L0 23.978V8l14.001-8L28 8z"/></g></svg>
|
||||
|
بعد العرض: | الارتفاع: | الحجم: 1.1 KiB |
205
rtmpServers/demo/client-server-socket/client/style.css
Normal file
205
rtmpServers/demo/client-server-socket/client/style.css
Normal file
@@ -0,0 +1,205 @@
|
||||
@import url('https://fonts.googleapis.com/css?family=Comfortaa:wght@500&display=swap');
|
||||
|
||||
body {
|
||||
font-family: 'Comfortaa'; /*, Arial, sans-serif;*/
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background: radial-gradient(#393939, #000000);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
background: radial-gradient(#393939, #000000);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 10px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
video {
|
||||
border: 0.1px solid #ccc;
|
||||
margin: 10px 0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.input-group-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#apiSecret {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#rtmp {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#copyButton {
|
||||
flex: 1;
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
.input-group-inline > * {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
input,
|
||||
button {
|
||||
padding: 10px;
|
||||
font-size: 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='password'] {
|
||||
flex: 1;
|
||||
background: #2c2c2c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
input[type='text'][readonly] {
|
||||
background: #2c2c2c;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
background-color: #007bff;
|
||||
color: #fff;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: #2c2c2c;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.button-group button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.popup {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: indianred;
|
||||
color: white;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-width: 300px;
|
||||
max-width: 600px;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.popup.success {
|
||||
background-color: mediumseagreen;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.popup.error {
|
||||
background-color: indianred;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.popup.warning {
|
||||
background-color: gold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.popup.info {
|
||||
background-color: cornflowerblue;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.popup.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#closePopup {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
footer {
|
||||
color: grey;
|
||||
}
|
||||
|
||||
/* Media Queries for Responsiveness */
|
||||
@media (max-width: 1024px) {
|
||||
.container {
|
||||
padding: 15px;
|
||||
}
|
||||
input,
|
||||
button {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.input-group-inline {
|
||||
flex-direction: column;
|
||||
}
|
||||
input,
|
||||
button {
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
video {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
#copyButton {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.container {
|
||||
padding: 10px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
input,
|
||||
button {
|
||||
font-size: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
68
rtmpServers/demo/client-server-socket/server/RtmpStreamer.js
Normal file
68
rtmpServers/demo/client-server-socket/server/RtmpStreamer.js
Normal file
@@ -0,0 +1,68 @@
|
||||
'use strict';
|
||||
|
||||
const { PassThrough } = require('stream');
|
||||
const ffmpeg = require('fluent-ffmpeg');
|
||||
const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg');
|
||||
ffmpeg.setFfmpegPath(ffmpegInstaller.path);
|
||||
|
||||
class RtmpStreamer {
|
||||
constructor(rtmpUrl, rtmpKey, socket) {
|
||||
(this.socket = socket), (this.rtmpUrl = rtmpUrl);
|
||||
this.rtmpKey = rtmpKey;
|
||||
this.stream = new PassThrough();
|
||||
this.ffmpegStream = null;
|
||||
this.initFFmpeg();
|
||||
this.run = true;
|
||||
}
|
||||
|
||||
initFFmpeg() {
|
||||
this.ffmpegStream = ffmpeg()
|
||||
.input(this.stream)
|
||||
.inputOptions('-re')
|
||||
.inputFormat('webm')
|
||||
.videoCodec('libx264')
|
||||
.videoBitrate('3000k')
|
||||
.size('1280x720')
|
||||
.audioCodec('aac')
|
||||
.audioBitrate('128k')
|
||||
.outputOptions(['-f flv'])
|
||||
.output(this.rtmpUrl)
|
||||
.on('start', (commandLine) => console.info('ffmpeg command', { id: this.rtmpKey, cmd: commandLine }))
|
||||
.on('error', (err, stdout, stderr) => {
|
||||
if (!err.message.includes('Exiting normally')) {
|
||||
console.error('FFmpeg error:', { id: this.rtmpKey, error: err.message });
|
||||
this.socket.emit('error', err.message);
|
||||
}
|
||||
this.end();
|
||||
})
|
||||
.on('end', () => {
|
||||
console.info('FFmpeg process ended', this.rtmpKey);
|
||||
this.end();
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
write(data) {
|
||||
if (this.stream) this.stream.write(data);
|
||||
}
|
||||
|
||||
isRunning() {
|
||||
return this.run;
|
||||
}
|
||||
|
||||
end() {
|
||||
if (this.stream) {
|
||||
this.stream.end();
|
||||
this.stream = null;
|
||||
console.info('RTMP streaming stopped', this.rtmpKey);
|
||||
}
|
||||
if (this.ffmpegStream && !this.ffmpegStream.killed) {
|
||||
this.ffmpegStream.kill('SIGTERM');
|
||||
this.ffmpegStream = null;
|
||||
console.info('FFMPEG closed successfully', this.rtmpKey);
|
||||
}
|
||||
this.run = false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RtmpStreamer;
|
||||
30
rtmpServers/demo/client-server-socket/server/package.json
Normal file
30
rtmpServers/demo/client-server-socket/server/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "mirotalk-rtmp-server-streamer",
|
||||
"version": "1.0.0",
|
||||
"description": "MiroTalk RTMP Server Streamer",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"start-dev": "nodemon server.js",
|
||||
"nms-start": "docker-compose -f ../../../node-media-server/docker-compose.yml up -d",
|
||||
"nms-stop": "docker-compose -f ../../../node-media-server/docker-compose.yml down",
|
||||
"nms-restart": "docker-compose -f ../../../node-media-server/docker-compose.yml down && docker-compose -f ../../node-media-server/docker-compose.yml up -d",
|
||||
"nms-logs": "docker logs -f mirotalk-nms"
|
||||
},
|
||||
"keywords": [
|
||||
"rtmp",
|
||||
"server",
|
||||
"streaming"
|
||||
],
|
||||
"author": "Miroslav Pejic",
|
||||
"license": "AGPLv3",
|
||||
"dependencies": {
|
||||
"body-parser": "1.20.2",
|
||||
"cors": "2.8.5",
|
||||
"socket.io": "4.7.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"uuid": "10.0.0",
|
||||
"nodemon": "^3.1.3"
|
||||
}
|
||||
}
|
||||
179
rtmpServers/demo/client-server-socket/server/server.js
Normal file
179
rtmpServers/demo/client-server-socket/server/server.js
Normal file
@@ -0,0 +1,179 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const crypto = require('crypto-js');
|
||||
const RtmpStreamer = require('./RtmpStreamer.js');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
const socketIo = require('socket.io');
|
||||
|
||||
const port = 9999;
|
||||
|
||||
const rtmpCfg = {
|
||||
enabled: true,
|
||||
maxStreams: 1,
|
||||
server: 'rtmp://localhost:1935',
|
||||
appName: 'mirotalk',
|
||||
streamKey: '',
|
||||
secret: 'mirotalkRtmpSecret', // Must match the key in node-media-server/src/config.js if play and publish are set to true, otherwise leave it ''
|
||||
apiSecret: 'mirotalkRtmpApiSecret', // Must match the apiSecret specified in the Client side.
|
||||
expirationHours: 4,
|
||||
};
|
||||
|
||||
const dir = {
|
||||
client: path.join(__dirname, '../', 'client'),
|
||||
index: path.join(__dirname, '../', 'client/index.html'),
|
||||
};
|
||||
|
||||
const streams = {}; // Collect all rtmp streams
|
||||
const corsOptions = { origin: '*', methods: ['GET'] };
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const io = socketIo(server, {
|
||||
transports: ['websocket'],
|
||||
cors: corsOptions,
|
||||
});
|
||||
|
||||
app.use(express.static(dir.client));
|
||||
app.use(cors(corsOptions));
|
||||
|
||||
// Logs requests
|
||||
app.use((req, res, next) => {
|
||||
console.debug('New request:', {
|
||||
method: req.method,
|
||||
path: req.originalUrl,
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
function checkRTMPApiSecret(apiSecret) {
|
||||
return apiSecret && apiSecret === rtmpCfg.apiSecret;
|
||||
}
|
||||
|
||||
function checkMaxStreams() {
|
||||
return Object.keys(streams).length < rtmpCfg.maxStreams;
|
||||
}
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
res.sendFile(dir.index);
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
console.log(`Socket connected: ${socket.id}`);
|
||||
|
||||
socket.on('initRTMP', ({ apiSecret }) => {
|
||||
//
|
||||
if (!checkRTMPApiSecret(apiSecret)) {
|
||||
return socket.emit('error', 'Unauthorized');
|
||||
}
|
||||
|
||||
if (!checkMaxStreams()) {
|
||||
return socket.emit('error', 'Maximum number of streams reached, please try later!');
|
||||
}
|
||||
|
||||
const hostHeader = socket.handshake.headers.host;
|
||||
const domainName = hostHeader.split(':')[0]; // Extract domain name
|
||||
|
||||
const rtmpServer = rtmpCfg.server !== '' ? rtmpCfg.server : false;
|
||||
const rtmpServerAppName = rtmpCfg.appName !== '' ? rtmpCfg.appName : 'live';
|
||||
const rtmpStreamKey = rtmpCfg.streamKey !== '' ? rtmpCfg.streamKey : uuidv4();
|
||||
const rtmpServerSecret = rtmpCfg.secret !== '' ? rtmpCfg.secret : false;
|
||||
const expirationHours = rtmpCfg.expirationHours || 4;
|
||||
const rtmpServerURL = rtmpServer ? rtmpServer : `rtmp://${domainName}:1935`;
|
||||
const rtmpServerPath = '/' + rtmpServerAppName + '/' + rtmpStreamKey;
|
||||
|
||||
const rtmp = rtmpServerSecret
|
||||
? generateRTMPUrl(rtmpServerURL, rtmpServerPath, rtmpServerSecret, expirationHours)
|
||||
: rtmpServerURL + rtmpServerPath;
|
||||
|
||||
console.info('initRTMP', {
|
||||
rtmpServer,
|
||||
rtmpServerSecret,
|
||||
rtmpServerURL,
|
||||
rtmpServerPath,
|
||||
expirationHours,
|
||||
rtmpStreamKey,
|
||||
rtmp,
|
||||
});
|
||||
|
||||
const stream = new RtmpStreamer(rtmp, rtmpStreamKey, socket);
|
||||
streams[rtmpStreamKey] = stream;
|
||||
|
||||
console.log('Active RTMP Streams', Object.keys(streams).length);
|
||||
|
||||
return socket.emit('initRTMP', { rtmp, rtmpStreamKey });
|
||||
});
|
||||
|
||||
socket.on('streamRTMP', async ({ apiSecret, rtmpStreamKey, data }) => {
|
||||
if (!checkRTMPApiSecret(apiSecret)) {
|
||||
return socket.emit('error', 'Unauthorized');
|
||||
}
|
||||
|
||||
const stream = streams[rtmpStreamKey];
|
||||
|
||||
if (!stream || !stream.isRunning()) {
|
||||
delete streams[rtmpStreamKey];
|
||||
console.debug('Stream not found', { rtmpStreamKey, streams: Object.keys(streams).length });
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug('Received video data via Socket.IO', {
|
||||
// data: data.slice(0, 20).toString('hex'),
|
||||
key: rtmpStreamKey,
|
||||
size: bytesToSize(data.length),
|
||||
});
|
||||
|
||||
stream.write(Buffer.from(data));
|
||||
socket.emit('ack');
|
||||
});
|
||||
|
||||
socket.on('stopRTMP', ({ apiSecret, rtmpStreamKey }) => {
|
||||
if (!checkRTMPApiSecret(apiSecret)) {
|
||||
return socket.emit('error', 'Unauthorized');
|
||||
}
|
||||
|
||||
const stream = streams[rtmpStreamKey];
|
||||
|
||||
if (stream) {
|
||||
stream.end();
|
||||
delete streams[rtmpStreamKey];
|
||||
console.debug('Streams', Object.keys(streams).length);
|
||||
}
|
||||
|
||||
socket.emit('stopRTMP');
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log(`Socket disconnected: ${socket.id}`);
|
||||
});
|
||||
});
|
||||
|
||||
function generateRTMPUrl(baseURL, streamPath, secretKey, expirationHours = 4) {
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
const expirationTime = currentTime + expirationHours * 3600;
|
||||
const hashValue = crypto.MD5(`${streamPath}-${expirationTime}-${secretKey}`).toString();
|
||||
const rtmpUrl = `${baseURL}${streamPath}?sign=${expirationTime}-${hashValue}`;
|
||||
|
||||
console.debug('generateRTMPUrl', {
|
||||
currentTime,
|
||||
expirationTime,
|
||||
hashValue,
|
||||
rtmpUrl,
|
||||
});
|
||||
|
||||
return rtmpUrl;
|
||||
}
|
||||
|
||||
function bytesToSize(bytes) {
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
if (bytes === 0) return '0 Byte';
|
||||
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)));
|
||||
return Math.round(bytes / Math.pow(1024, i), 2) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`Server is running on http://localhost:${port}`, rtmpCfg);
|
||||
});
|
||||
المرجع في مشكلة جديدة
حظر مستخدم