Автор материала, перевод которого мы сегодня публикуем, говорит, что работает над приложением, которое позволяет организовывать потоковое вещание (стриминг) того, что происходит на рабочем столе пользователя.
Приложение принимает от стримера поток в формате RTMP и преобразует его в HLS-поток, который может быть воспроизведён в браузерах зрителей. В этой статье будет рассказано о том, как можно создать собственное стриминговое приложение с использованием Node.js и React. Если вы привыкли, увидев заинтересовавшую вас идею, сразу же погружаться в код, можете прямо сейчас заглянуть в этот репозиторий.
Разработка веб-сервера с базовой системой аутентификации
Давайте создадим простой веб-сервер, основанный на Node.js, в котором, средствами библиотеки passport.js, реализована локальная стратегия аутентификации пользователей. В роли постоянного хранилища информации будем использовать MongoDB. Работать с базой данных будем с помощью ODM-библиотеки Mongoose.
Инициализируем новый проект:
$ npm init
Установим зависимости:
$ npm install axios bcrypt-nodejs body-parser bootstrap config connect-ensure-login connect-flash cookie-parser ejs express express-session mongoose passport passport-local request session-file-store --save-dev
В директории проекта создадим две папки — client
и server
. Код фронтенда, основанный на React, попадёт в папку client
, а бэкенд-код будет храниться в папке server
. Сейчас мы работаем в папке server
. А именно, для создания системы аутентификации будем использовать passport.js. Мы уже установили модули passport и passport-local. Прежде чем мы опишем локальную стратегию аутентификации пользователей — создадим файл app.js
и добавим в него код, который нужен для запуска простого сервера. Если вы будете запускать этот код у себя — позаботьтесь о том, чтобы у вас была бы установлена СУБД MongoDB, и чтобы она была бы запущена в виде сервиса.
Вот код файла, который находится в проекте по адресу server/app.js
:
const express = require('express'),
Session = require('express-session'),
bodyParse = require('body-parser'),
mongoose = require('mongoose'),
middleware = require('connect-ensure-login'),
FileStore = require('session-file-store')(Session),
config = require('./config/default'),
flash = require('connect-flash'),
port = 3333,
app = express();
mongoose.connect('mongodb://127.0.0.1/nodeStream' , { useNewUrlParser: true });
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, './views'));
app.use(express.static('public'));
app.use(flash());
app.use(require('cookie-parser')());
app.use(bodyParse.urlencoded({extended: true}));
app.use(bodyParse.json({extended: true}));
app.use(Session({
store: new FileStore({
path : './server/sessions'
}),
secret: config.server.secret,
maxAge : Date().now + (60 * 1000 * 30)
}));
app.get('*', middleware.ensureLoggedIn(), (req, res) => {
res.render('index');
});
app.listen(port, () => console.log(`App listening on ${port}!`));
Мы загрузили всё необходимое для приложения промежуточное ПО, подключились к MongoDB, настроили express-сессию на использование файлового хранилища. Хранение сессий позволит восстанавливать их после перезагрузки сервера.
Теперь опишем стратегии passport.js, предназначенные для организации регистрации и аутентификации пользователей. Создадим в папке server
папку auth
и поместим в неё файл passport.js
. Вот что должно быть в файле server/auth/passport.js
:
const passport = require('passport'),
LocalStrategy = require('passport-local').Strategy,
User = require('../database/Schema').User,
shortid = require('shortid');
passport.serializeUser( (user, cb) => {
cb(null, user);
});
passport.deserializeUser( (obj, cb) => {
cb(null, obj);
});
// Стратегия passport, описывающая регистрацию пользователя
passport.use('localRegister', new LocalStrategy({
usernameField: 'email',
passwordField: 'password',
passReqToCallback: true
},
(req, email, password, done) => {
User.findOne({$or: [{email: email}, {username: req.body.username}]}, (err, user) => {
if (err)
return done(err);
if (user) {
if (user.email === email) {
req.flash('email', 'Email is already taken');
if (user.username === req.body.username) {
req.flash('username', 'Username is already taken');
return done(null, false);
} else {
let user = new User();
user.email = email;
user.password = user.generateHash(password);
user.username = req.body.username;
user.stream_key = shortid.generate();
user.save( (err) => {
if (err)
throw err;
return done(null, user);
});
});
}));
// Стратегия passport, описывающая аутентификацию пользователя
passport.use('localLogin', new LocalStrategy({
usernameField: 'email',
passwordField: 'password',
passReqToCallback: true
},
(req, email, password, done) => {
User.findOne({'email': email}, (err, user) => {
if (err)
return done(err);
if (!user)
return done(null, false, req.flash('email', 'Email doesn't exist.'));
if (!user.validPassword(password))
return done(null, false, req.flash('password', 'Oops! Wrong password.'));
return done(null, user);
});
}));
module.exports = passport;
Кроме того, нам нужно описать схему для модели пользователя (она будет называться UserSchema
). Создадим в папке server
папку database
, а в ней — файл UserSchema.js
.
Вот код файла server/database.UserSchema.js
:
let mongoose = require('mongoose'),
bcrypt = require('bcrypt-nodejs'),
shortid = require('shortid'),
Schema = mongoose.Schema;
let UserSchema = new Schema({
username: String,
email : String,
password: String,
stream_key : String,
});
UserSchema.methods.generateHash = (password) => {
return bcrypt.hashSync(password, bcrypt.genSaltSync(8), null);
};
UserSchema.methods.validPassword = function(password){
return bcrypt.compareSync(password, this.password);
};
UserSchema.methods.generateStreamKey = () => {
return shortid.generate();
};
module.exports = UserSchema;
В UserSchema
имеется три метода. Метод generateHash
предназначен для преобразования пароля, представленного в виде обычного текста, в bcrypt-хэш. Мы используем этот метод в стратегии passport для преобразования паролей, вводимых пользователями, в хэши bcrypt. Полученные хэши паролей потом сохраняются в базе данных. Метод validPassword
принимает пароль, вводимый пользователем, и проверяет его путём сравнения его хэша с хэшем, хранящимся в базе данных. Метод generateStreamKey
генерирует уникальные строки, которые мы будем передавать пользователей в качестве их стриминговых ключей (ключей потока) для RTMP-клиентов.
Вот код файла server/database/Schema.js
:
let mongoose = require('mongoose');
exports.User = mongoose.model('User', require('./UserSchema'));
Теперь, когда мы определили стратегии passport, описали схему UserSchema
и создали на её основе модель, давайте инициализируем passport в app.js
.
Вот код, которым нужно дополнить файл server/app.js
:
// Это нужно добавить в верхнюю часть файла, рядом с командами импорта
const passport = require('./auth/passport');
app.use(passport.initialize());
app.use(passport.session());
Кроме того, в app.js
надо зарегистрировать новые маршруты. Для этого добавим в server/app.js
следующий код:
// Регистрация маршрутов приложения
app.use('/login', require('./routes/login'));
app.use('/register', require('./routes/register'));
Создадим файлы login.js
и register.js
в папке routes
, которая находится в папке server
. В этих файлах определим пару вышеупомянутых маршрутов и воспользуемся промежуточным ПО passport для организации регистрации и аутентификации пользователей.
Вот код файла server/routes/login.js
:
const express = require('express'),
router = express.Router(),
passport = require('passport');
router.get('/',
require('connect-ensure-login').ensureLoggedOut(),
(req, res) => {
res.render('login', {
user : null,
errors : {
email : req.flash('email'),
password : req.flash('password')
});
});
router.post('/', passport.authenticate('localLogin', {
successRedirect : '/',
failureRedirect : '/login',
failureFlash : true
}));
module.exports = router;
Вот код файла server/routes/register.js
:
const express = require('express'),
router = express.Router(),
passport = require('passport');
router.get('/',
require('connect-ensure-login').ensureLoggedOut(),
(req, res) => {
res.render('register', {
user : null,
errors : {
username : req.flash('username'),
email : req.flash('email')
});
});
router.post('/',
require('connect-ensure-login').ensureLoggedOut(),
passport.authenticate('localRegister', {
successRedirect : '/',
failureRedirect : '/register',
failureFlash : true
})
);
module.exports = router;
Мы используем движок шаблонизации ejs. Добавим файлы шаблонов login.ejs
и register.ejs
в папку views
, которая находится в папке server
.
Вот содержимое файла server/views/login.ejs
:
<!doctype html>
<html lang="en">
<% include header.ejs %>
<body>
<% include navbar.ejs %>
<div class="container app mt-5">
<h4>Login</h4>
<hr class="my-4">
<div class="row">
<form action="/login" method="post" class="col-xs-12 col-sm-12 col-md-8 col-lg-6">
<div class="form-group">
<label>Email address</label>
<input type="email" name="email" class="form-control" placeholder="Enter email" required>
<% if (errors.email.length) { %>
<small class="form-text text-danger"><%= errors.email %></small>
<% } %>
</div>
<div class="form-group">
<label>Password</label>
<input type="password" name="password" class="form-control" placeholder="Password" required>
<% if (errors.password.length) { %>
<small class="form-text text-danger"><%= errors.password %></small>
<% } %>
</div>
<div class="form-group">
<div class="leader">
Don't have an account? Register <a href="/register">here</a>.
</div>
</div>
<button type="submit" class="btn btn-dark btn-block">Login</button>
</form>
</div>
</div>
<% include footer.ejs %>
</body>
</html>
Вот что должно быть в файле server/views/register.ejs
:
<!doctype html>
<html lang="en">
<% include header.ejs %>
<body>
<% include navbar.ejs %>
<div class="container app mt-5">
<h4>Register</h4>
<hr class="my-4">
<div class="row">
<form action="/register"
method="post"
class="col-xs-12 col-sm-12 col-md-8 col-lg-6">
<div class="form-group">
<label>Username</label>
<input type="text" name="username" class="form-control" placeholder="Enter username" required>
<% if (errors.username.length) { %>
<small class="form-text text-danger"><%= errors.username %></small>
<% } %>
</div>
<div class="form-group">
<label>Email address</label>
<input type="email" name="email" class="form-control" placeholder="Enter email" required>
<% if (errors.email.length) { %>
<small class="form-text text-danger"><%= errors.email %></small>
<% } %>
</div>
<div class="form-group">
<label>Password</label>
<input type="password" name="password" class="form-control" placeholder="Password" required>
</div>
<div class="form-group">
<div class="leader">
Have an account? Login <a href="/login">here</a>.
</div>
</div>
<button type="submit" class="btn btn-dark btn-block">Register</button>
</form>
</div>
</div>
<% include footer.ejs %>
</body>
</html>
Мы, можно сказать, закончили работу над системой аутентификации. Теперь приступим к созданию следующей части проекта и настроим RTMP-сервер.
Настройка RTMP-сервера
RTMP (Real-Time Messaging Protocol) — это протокол, который был разработан для высокопроизводительной передачи видео, аудио и различных данных между стримером и сервером. Twitch, Facebook, YouTube и многие другие сайты, предлагающие возможность потокового вещания, принимают RTMP-потоки и перекодируют их в HTTP-потоки (формат HLS) перед передачей этих потоков на свои CDN для обеспечения их высокой доступности.
Мы используем модуль node-media-server — Node.js-реализацию медиа-сервера RTMP. Этот медиа-сервер принимает RTMP-потоки и преобразует их в HLS/DASH с использованием мультимедийного фреймворка ffmpeg. Для успешной работы проекта в вашей системе должен быть установлен ffmpeg. Если вы работаете на Linux и у вас уже установлен ffmpeg, вы можете выяснить путь к нему, выполнив следующую команду из терминала:
$ which ffmpeg
# /usr/bin/ffmpeg
Для работы с пакетом node-media-server рекомендуется ffmpeg версии 4.x. Проверить установленную версию ffmpeg можно так:
$ ffmpeg --version
# ffmpeg version 4.1.3-0york1~18.04 Copyright (c) 2000-2019 the
# FFmpeg developers built with gcc 7 (Ubuntu 7.3.0-27ubuntu1~18.04)
Если ffmpeg у вас не установлен и вы работаете в Ubuntu, установить этот фреймворк можно, выполнив следующую команду:
# Добавьте в систему PPA-репозиторий. Если провести установку без PPA, то установлен будет
# ffmpeg версии 3.x.
$ sudo add-apt-repository ppa:jonathonf/ffmpeg-4
$ sudo apt install ffmpeg
Если вы работаете в Windows — можете загрузить сборки ffmpeg для Windows.
Добавьте в проект конфигурационный файл server/config/default.js
:
const config = {
server: {
secret: 'kjVkuti2xAyF3JGCzSZTk0YWM5JhI9mgQW4rytXc'
},
rtmp_server: {
rtmp: {
port: 1935,
chunk_size: 60000,
gop_cache: true,
ping: 60,
ping_timeout: 30
},
http: {
port: 8888,
mediaroot: './server/media',
allow_origin: '*'
},
trans: {
ffmpeg: '/usr/bin/ffmpeg',
tasks: [
app: 'live',
hls: true,
hlsFlags: '[hls_time=2:hls_list_size=3:hls_flags=delete_segments]',
dash: true,
dashFlags: '[f=dash:window_size=3:extra_window_size=5]'
};
module.exports = config;
Замените значение свойства ffmpeg
на путь, по которому ffmpeg установлен в вашей системе. Если вы работаете в Windows и загрузили Windows-сборку ffmpeg по вышеприведённой ссылке — не забудьте добавить к имени файла расширение .exe
. Тогда соответствующий фрагмент вышеприведённого кода будет выглядеть так:
const config = {
....
trans: {
ffmpeg: 'D:/ffmpeg/bin/ffmpeg.exe',
...
};
Теперь установим node-media-server, выполнив следующую команду:
$ npm install node-media-server --save
Создайте в папке server
файл media_server.js
.
Вот код, который нужно поместить в server/media_server.js
:
const NodeMediaServer = require('node-media-server'),
config = require('./config/default').rtmp_server;
nms = new NodeMediaServer(config);
nms.on('prePublish', async (id, StreamPath, args) => {
let stream_key = getStreamKeyFromStreamPath(StreamPath);
console.log('[NodeEvent on prePublish]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`);
});
const getStreamKeyFromStreamPath = (path) => {
let parts = path.split('/');
return parts[parts.length - 1];
};
module.exports = nms;
Пользоваться объектом NodeMediaService
довольно просто. Он обеспечивает работу RTMP-сервера и позволяет ожидать подключений. Если стриминговый ключ недействителен — входящее подключение можно отклонить. Мы будем обрабатывать событие этого объекта prePublish
. В следующем разделе мы добавим в замыкание прослушивателя событий prePublish
дополнительный код. Он позволит отклонять входящие подключения с недействительными стриминговыми ключами. Пока же мы будем принимать все входящие подключения, поступающие на RTMP-порт по умолчанию (1935). Нам нужно лишь импортировать в файле app.js
объект node_media_server
и вызвать его метод run
.
Добавим следующий код в server/app.js
:
// Добавьте это в верхней части app.js,
// туда же, где находятся остальные команды импорта
const node_media_server = require('./media_server');
// Вызовите метод run() в конце файла,
// там, где мы запускаем веб-сервер
node_media_server.run();
Загрузите и установите у себя OBS (Open Broadcaster Software). Откройте окно настроек программы и перейдите в раздел Stream
. Выберите Custom
в поле Service
и введите rtmp://127.0.0.1:1935/live
в поле Server
. Поле Stream Key
можно оставить пустым. Если программа не даст сохранить настройки без заполнения этого поля — в него можно ввести произвольный набор символов. Нажмите на кнопку Apply
и на кнопку OK
. Щёлкните кнопку Start Streaming
для того, чтобы начать передачу своего RTMP-потока на собственный локальный сервер.
Настройка OBS
Перейдите в терминал и посмотрите на то, что выводит туда медиа-сервер. Вы увидите там сведения о входящем потоке и логи нескольких прослушивателей событий.
Данные, которые выводит в терминал медиа-сервер, основанный на Node.js
Медиа-сервер даёт доступ к API, который позволяет получить список подключённых клиентов. Для того чтобы увидеть этот список — можно перейти в браузере по адресу http://127.0.0.1:8888/api/streams
. Позже мы воспользуемся этим API в React-приложении для показа списка пользователей, ведущих трансляции. Вот что можно увидеть, обратившись к этому API:
{
"live": {
"0wBic-qV4": {
"publisher": {
"app": "live",
"stream": "0wBic-qV4",
"clientId": "WMZTQAEY",
"connectCreated": "2019-05-12T16:13:05.759Z",
"bytes": 33941836,
"ip": "::ffff:127.0.0.1",
"audio": {
"codec": "AAC",
"profile": "LC",
"samplerate": 44100,
"channels": 2
},
"video": {
"codec": "H264",
"width": 1920,
"height": 1080,
"profile": "High",
"level": 4.2,
"fps": 60
},
"subscribers": [
"app": "live",
"stream": "0wBic-qV4",
"clientId": "GNJ9JYJC",
"connectCreated": "2019-05-12T16:13:05.985Z",
"bytes": 33979083,
"ip": "::ffff:127.0.0.1",
"protocol": "rtmp"
}
Теперь бэкенд практически готов. Он представляет собой работающий стриминговый сервер, поддерживающий технологии HTTP, RTMP и HLS. Однако мы ещё не создали систему проверки входящих RTMP-подключений. Она должна позволить нам добиться того, чтобы сервер принимал бы потоки только от аутентифицированных пользователей. Добавим следующий код в обработчик события prePublish
в файле server/media_server.js
:
// Добавьте команду импорта в начало файла
const User = require('./database/Schema').User;
nms.on('prePublish', async (id, StreamPath, args) => {
let stream_key = getStreamKeyFromStreamPath(StreamPath);
console.log('[NodeEvent on prePublish]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`);
User.findOne({stream_key: stream_key}, (err, user) => {
if (!err) {
if (!user) {
let session = nms.getSession(id);
session.reject();
} else {
// что-то делаем
});
});
const getStreamKeyFromStreamPath = (path) => {
let parts = path.split('/');
return parts[parts.length - 1];
};
В замыкании мы выполняем запрос к базе данных для нахождения пользователя со стриминговым ключом. Если ключ принадлежит пользователю — мы просто позволяем пользователю подключиться к серверу и опубликовать свою трансляцию. В противном случае мы отклоняем входящее RTMP-соединение.
В следующем разделе мы создадим простую клиентскую часть приложения, основанную на React. Она нужна для того чтобы позволить зрителям просматривать потоковые трансляции, а также для того, чтобы позволить стримерам генерировать и просматривать свои стриминговые ключи.
Показ потоковых трансляций
Теперь переходим в папку clients
. Так как мы собираемся создать React-приложение, нам понадобится webpack. Нужны нам и загрузчики, которые применяются для транспиляции JSX-кода в JavaScript-код, понятный браузерам. Установим следующие модули:
$ npm install @babel/core @babel/preset-env @babel/preset-react babel-loader css-loader file-loader mini-css-extract-plugin node-sass sass-loader style-loader url-loader webpack webpack-cli react react-dom react-router-dom video.js jquery bootstrap history popper.js
Добавим в проект, в его корневую директорию, конфигурационный файл для webpack (webpack.config.js
):
const path = require('path');
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const devMode = process.env.NODE_ENV !== 'production';
const webpack = require('webpack');
module.exports = {
entry : './client/index.js',
output : {
filename : 'bundle.js',
path : path.resolve(__dirname, 'public')
},
module : {
rules : [
test: /.s?[ac]ss$/,
use: [
MiniCssExtractPlugin.loader,
{ loader: 'css-loader', options: { url: false, sourceMap: true } },
{ loader: 'sass-loader', options: { sourceMap: true } }
],
},
test: /.js$/,
exclude: /node_modules/,
use: "babel-loader"
},
test: /.woff($|?)|.woff2($|?)|.ttf($|?)|.eot($|?)|.svg($|?)/,
loader: 'url-loader'
},
test: /.(png|jpg|gif)$/,
use: [{
loader: 'file-loader',
options: {
outputPath: '/',
},
}],
},
},
devtool: 'source-map',
plugins: [
new MiniCssExtractPlugin({
filename: "style.css"
}),
new webpack.ProvidePlugin({
$: 'jquery',
jQuery: 'jquery'
})
],
mode : devMode ? 'development' : 'production',
watch : devMode,
performance: {
hints: process.env.NODE_ENV === 'production' ? "warning" : false
},
};
Добавим в проект файл client/index.js
:
import React from "react";
import ReactDOM from 'react-dom';
import {BrowserRouter} from 'react-router-dom';
import 'bootstrap';
require('./index.scss');
import Root from './components/Root.js';
if(document.getElementById('root')){
ReactDOM.render(
<BrowserRouter>
<Root/>
</BrowserRouter>,
document.getElementById('root')
);
}
Вот содержимое файла client/index.scss
:
@import '~bootstrap/dist/css/bootstrap.css';
@import '~video.js/dist/video-js.css';
@import url('https://fonts.googleapis.com/css?family=Dosis');
html,body{
font-family: 'Dosis', sans-serif;
}
Для маршрутизации используется react-router. Во фронтенде мы также используем bootstrap, и, для показа трансляций — video.js. Теперь добавим в папку client
папку components
, а в неё — файл Root.js
. Вот содержимое файла client/components/Root.js
:
import React from "react";
import {Router, Route} from 'react-router-dom';
import Navbar from './Navbar';
import LiveStreams from './LiveStreams';
import Settings from './Settings';
import VideoPlayer from './VideoPlayer';
const customHistory = require("history").createBrowserHistory();
export default class Root extends React.Component {
constructor(props){
super(props);
render(){
return (
<Router history={customHistory} >
<div>
<Navbar/>
<Route exact path="/" render={props => (
<LiveStreams {...props} />
)}/>
<Route exact path="/stream/:username" render={(props) => (
<VideoPlayer {...props}/>
)}/>
<Route exact path="/settings" render={props => (
<Settings {...props} />
)}/>
</div>
</Router>
}
Компонент <Root/>
рендерит <Router/>
React, содержащий три субкомпонента <Route/>
. Компонент <LiveStreams/>
выводит список трансляций. Компонент <VideoPlayer/>
отвечает за показ проигрывателя video.js. Компонент <Settings/>
отвечает за создание интерфейса для работы со стриминговыми ключами.
Создадим компонент client/components/LiveStreams.js
:
import React from 'react';
import axios from 'axios';
import {Link} from 'react-router-dom';
import './LiveStreams.scss';
import config from '../../server/config/default';
export default class Navbar extends React.Component {
constructor(props) {
super(props);
this.state = {
live_streams: []
componentDidMount() {
this.getLiveStreams();
getLiveStreams() {
axios.get('http://127.0.0.1:' + config.rtmp_server.http.port + '/api/streams')
.then(res => {
let streams = res.data;
if (typeof (streams['live'] !== 'undefined')) {
this.getStreamsInfo(streams['live']);
});
getStreamsInfo(live_streams) {
axios.get('/streams/info', {
params: {
streams: live_streams
}).then(res => {
this.setState({
live_streams: res.data
}, () => {
console.log(this.state);
});
});
render() {
let streams = this.state.live_streams.map((stream, index) => {
return (
<div className="stream col-xs-12 col-sm-12 col-md-3 col-lg-4" key={index}>
<span className="live-label">LIVE</span>
<Link to={'/stream/' + stream.username}>
<div className="stream-thumbnail">
<img align="center" src={'/thumbnails/' + stream.stream_key + '.png'}/>
</div>
</Link>
<span className="username">
<Link to={'/stream/' + stream.username}>
{stream.username}
</Link>
</span>
</div>
);
});
return (
<div className="container mt-5">
<h4>Live Streams</h4>
<hr className="my-4"/>
<div className="streams row">
{streams}
</div>
</div>
}
Вот как выглядит страница приложения.
Фронтенд стримингового сервиса
После монтирования компонента <LiveStreams/>
выполняется обращение к API NMS для получения списка подключённых к системе клиентов. API NMS выдаёт не особенно много сведений о пользователях. В частности, от него мы можем получить сведения о стриминговых ключах, посредством которых пользователи подключены к RTMP-серверу. Эти ключи мы будем использовать при формировании запросов к базе данных для получения сведений об учётных записях пользователей.
В методе getStreamsInfo
мы выполняем XHR-запрос к /streams/info
, но мы пока не создали то, что способно ответить на этот запрос. Создадим файл server/routes/streams.js
со следующим содержимым:
const express = require('express'),
router = express.Router(),
User = require('../database/Schema').User;
router.get('/info',
require('connect-ensure-login').ensureLoggedIn(),
(req, res) => {
if(req.query.streams){
let streams = JSON.parse(req.query.streams);
let query = {$or: []};
for (let stream in streams) {
if (!streams.hasOwnProperty(stream)) continue;
query.$or.push({stream_key : stream});
User.find(query,(err, users) => {
if (err)
return;
if (users) {
res.json(users);
});
});
module.exports = router;
Мы передаём сведения о потоках, возвращённые API NMS, бэкенду, делая это для получения информации о подключённых клиентах.
Мы выполняем запрос к базе данных для получения списка пользователей, стриминговые ключи которых совпадают с теми, что мы получили от API NMS. Полученный список мы возвращаем в формате JSON. Зарегистрируем маршрут в файле server/app.js
:
app.use('/streams', require('./routes/streams'));
В итоге мы выводим список активных трансляций. В этом списке присутствует имя пользователя и миниатюра. О том, как создавать миниатюры для трансляций, мы поговорим в конце материала. Миниатюры привязаны к конкретным страницам, на которых, с помощью video.js, проигрываются HLS-потоки.
Создадим компонент client/components/VideoPlayer.js
:
import React from 'react';
import videojs from 'video.js'
import axios from 'axios';
import config from '../../server/config/default';
export default class VideoPlayer extends React.Component {
constructor(props) {
super(props);
this.state = {
stream: false,
videoJsOptions: null
componentDidMount() {
axios.get('/user', {
params: {
username: this.props.match.params.username
}).then(res => {
this.setState({
stream: true,
videoJsOptions: {
autoplay: false,
controls: true,
sources: [{
src: 'http://127.0.0.1:' + config.rtmp_server.http.port + '/live/' + res.data.stream_key + '/index.m3u8',
type: 'application/x-mpegURL'
}],
fluid: true,
}, () => {
this.player = videojs(this.videoNode, this.state.videoJsOptions, function onPlayerReady() {
console.log('onPlayerReady', this)
});
});
})
componentWillUnmount() {
if (this.player) {
this.player.dispose()
render() {
return (
<div className="row">
<div className="col-xs-12 col-sm-12 col-md-10 col-lg-8 mx-auto mt-5">
{this.state.stream ? (
<div data-vjs-player>
<video ref={node => this.videoNode = node} className="video-js vjs-big-play-centered"/>
</div>
) : ' Loading ... '}
</div>
</div>
}
При монтировании компонента мы получаем стриминговый ключ пользователя для инициализации HLS-потока в проигрывателе video.js.
Проигрыватель
Выдача стриминговых ключей тем, кто собирается заниматься потоковой трансляцией
Создадим файл компонента client/components/Settings.js
:
import React from 'react';
import axios from 'axios';
export default class Navbar extends React.Component {
constructor(props){
super(props);
this.state = {
stream_key : ''
};
this.generateStreamKey = this.generateStreamKey.bind(this);
componentDidMount() {
this.getStreamKey();
generateStreamKey(e){
axios.post('/settings/stream_key')
.then(res => {
this.setState({
stream_key : res.data.stream_key
});
})
getStreamKey(){
axios.get('/settings/stream_key')
.then(res => {
this.setState({
stream_key : res.data.stream_key
});
})
render() {
return (
<React.Fragment>
<div className="container mt-5">
<h4>Streaming Key</h4>
<hr className="my-4"/>
<div className="col-xs-12 col-sm-12 col-md-8 col-lg-6">
<div className="row">
<h5>{this.state.stream_key}</h5>
</div>
<div className="row">
<button
className="btn btn-dark mt-2"
onClick={this.generateStreamKey}>
Generate a new key
</button>
</div>
</div>
</div>
<div className="container mt-5">
<h4>How to Stream</h4>
<hr className="my-4"/>
<div className="col-12">
<div className="row">
<p>
You can use <a target="_blank" href="https://obsproject.com/">OBS</a> or
<a target="_blank" href="https://www.xsplit.com/">XSplit</a> to Live stream. If you're
using OBS, go to Settings > Stream and select Custom from service dropdown. Enter
<b>rtmp://127.0.0.1:1935/live</b> in server input field. Also, add your stream key.
Click apply to save.
</p>
</div>
</div>
</div>
</React.Fragment>
}
В соответствии с локальной стратегией passport.js, мы, если пользователь успешно зарегистрировался, создаём для него новую учётную запись с уникальным стриминговым ключом. Если пользователь посетит маршрут /settings
— он сможет увидеть свой ключ. При монтировании компонента мы выполняем XHR-запрос к бэкенду для выяснения существующего стримингового ключа пользователя и выводим его в компоненте <Settings/>
.
Пользователь может сгенерировать новый ключ. Для этого нужно нажать на кнопку Generate a new key
. Это действие вызывает выполнение XHR-запроса к серверу на создание нового ключа. Ключ создаётся, сохраняется и возвращается. Это позволяет показать новый ключ пользователю. Для того чтобы данный механизм заработал — нам нужно определить маршруты GET
и POST
для /settings/stream_key
. Создадим файл server/routes/settings.js
со следующим кодом:
const express = require('express'),
router = express.Router(),
User = require('../database/Schema').User,
shortid = require('shortid');
router.get('/stream_key',
require('connect-ensure-login').ensureLoggedIn(),
(req, res) => {
User.findOne({email: req.user.email}, (err, user) => {
if (!err) {
res.json({
stream_key: user.stream_key
})
});
});
router.post('/stream_key',
require('connect-ensure-login').ensureLoggedIn(),
(req, res) => {
User.findOneAndUpdate({
email: req.user.email
}, {
stream_key: shortid.generate()
}, {
upsert: true,
new: true,
}, (err, user) => {
if (!err) {
res.json({
stream_key: user.stream_key
})
});
});
module.exports = router;
Для генерирования уникальных строк мы используем модуль shortid.
Зарегистрируем новые маршруты в server/app.js
:
app.use('/settings', require('./routes/settings'));
Страница, которая позволяет стримерам работать со своими ключами
Генерирование миниатюр для видеопотоков
В компоненте <LiveStreams/>
(client/components/LiveStreams.js
) мы выводим миниатюры для транслируемых стримерами видеопотоков:
render() {
let streams = this.state.live_streams.map((stream, index) => {
return (
<div className="stream col-xs-12 col-sm-12 col-md-3 col-lg-4" key={index}>
<span className="live-label">LIVE</span>
<Link to={'/stream/' + stream.username}>
<div className="stream-thumbnail">
<img align="center" src={'/thumbnails/' + stream.stream_key + '.png'}/>
</div>
</Link>
<span className="username">
<Link to={'/stream/' + stream.username}>
{stream.username}
</Link>
</span>
</div>
);
});
return (
<div className="container mt-5">
<h4>Live Streams</h4>
<hr className="my-4"/>
<div className="streams row">
{streams}
</div>
</div>
}
Миниатюры будем генерировать при подключении потока к серверу. Воспользуемся заданием cron, которое, каждые 5 секунд, создаёт новые миниатюры для транслируемых потоков.
Добавим следующий вспомогательный метод в server/helpers/helpers.js
:
const spawn = require('child_process').spawn,
config = require('../config/default'),
cmd = config.rtmp_server.trans.ffmpeg;
const generateStreamThumbnail = (stream_key) => {
const args = [
'-y',
'-i', 'http://127.0.0.1:8888/live/'+stream_key+'/index.m3u8',
'-ss', '00:00:01',
'-vframes', '1',
'-vf', 'scale=-2:300',
'server/thumbnails/'+stream_key+'.png',
];
spawn(cmd, args, {
detached: true,
stdio: 'ignore'
}).unref();
};
module.exports = {
generateStreamThumbnail : generateStreamThumbnail
};
Мы передаём стриминговый ключ методу generateStreamThumbnail
.
Он запускает отдельный ffmpeg-процесс, который создаёт изображение на основе HLS-потока. Этот вспомогательный метод будем вызывать в замыкании prePublish
после проверки стримингового ключа (server/media_server.js
):
nms.on('prePublish', async (id, StreamPath, args) => {
let stream_key = getStreamKeyFromStreamPath(StreamPath);
console.log('[NodeEvent on prePublish]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`);
User.findOne({stream_key: stream_key}, (err, user) => {
if (!err) {
if (!user) {
let session = nms.getSession(id);
session.reject();
} else {
helpers.generateStreamThumbnail(stream_key);
});
});
Для того чтобы сгенерировать свежие миниатюры, мы запускаем задание cron и вызываем из него вышеописанный вспомогательный метод (server/cron/thumbnails.js
):
const CronJob = require('cron').CronJob,
request = require('request'),
helpers = require('../helpers/helpers'),
config = require('../config/default'),
port = config.rtmp_server.http.port;
const job = new CronJob('*/5 * * * * *', function () {
request
.get('http://127.0.0.1:' + port + '/api/streams', function (error, response, body) {
let streams = JSON.parse(body);
if (typeof (streams['live'] !== undefined)) {
let live_streams = streams['live'];
for (let stream in live_streams) {
if (!live_streams.hasOwnProperty(stream)) continue;
helpers.generateStreamThumbnail(stream);
});
}, null, true);
module.exports = job;
Это задание будет выполняться каждые 5 секунд. Оно будет получать список активных потоков из API NMS и генерировать для каждого потока миниатюры с использованием стримингового ключа. Задание нужно импортировать в server/app.js
и вызвать:
// Добавьте это в верхней части app.js,
const thumbnail_generator = require('./cron/thumbnails');
// Вызовите метод start() в конце файла
thumbnail_generator.start();
Итоги
Только что мы завершили рассказ о разработке приложения, позволяющего организовывать потоковое вещание. Возможно, в ходе разбора кода некоторые фрагменты системы были незаслуженно забыты. Если вы столкнулись с чем-то непонятным — взгляните на этот репозиторий. Если вы найдёте в коде какую-нибудь ошибку — автор материала просит вас ему об этом сообщить.
Вот демонстрация работы приложения.
Уважаемые читатели! Как вы подошли бы к разработке проекта, подобного тому, о котором шла речь в этом материале?
Автор: ru_vds