- PVSM.RU - https://www.pvsm.ru -

Разработка приложения для потокового вещания с помощью Node.js и React

Автор материала, перевод которого мы сегодня публикуем, говорит, что работает над приложением, которое позволяет организовывать потоковое вещание (стриминг) того, что происходит на рабочем столе пользователя.

image [1]

Приложение принимает от стримера поток в формате RTMP и преобразует его в HLS-поток, который может быть воспроизведён в браузерах зрителей. В этой статье будет рассказано о том, как можно создать собственное стриминговое приложение с использованием Node.js и React. Если вы привыкли, увидев заинтересовавшую вас идею, сразу же погружаться в код, можете прямо сейчас заглянуть в этот [2] репозиторий.

Разработка веб-сервера с базовой системой аутентификации

Давайте создадим простой веб-сервер, основанный на 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 — можете загрузить сборки [3] 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 [4] (Open Broadcaster Software). Откройте окно настроек программы и перейдите в раздел Stream. Выберите Custom в поле Service и введите rtmp://127.0.0.1:1935/live в поле Server. Поле Stream Key можно оставить пустым. Если программа не даст сохранить настройки без заполнения этого поля — в него можно ввести произвольный набор символов. Нажмите на кнопку Apply и на кнопку OK. Щёлкните кнопку Start Streaming для того, чтобы начать передачу своего RTMP-потока на собственный локальный сервер.

Разработка приложения для потокового вещания с помощью Node.js и React - 2

Настройка OBS

Перейдите в терминал и посмотрите на то, что выводит туда медиа-сервер. Вы увидите там сведения о входящем потоке и логи нескольких прослушивателей событий.

Разработка приложения для потокового вещания с помощью Node.js и React - 3

Данные, которые выводит в терминал медиа-сервер, основанный на 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>
        
}

Вот как выглядит страница приложения.

Разработка приложения для потокового вещания с помощью Node.js и React - 4

Фронтенд стримингового сервиса

После монтирования компонента <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.

Разработка приложения для потокового вещания с помощью Node.js и React - 5

Проигрыватель

Выдача стриминговых ключей тем, кто собирается заниматься потоковой трансляцией

Создадим файл компонента 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'));

Разработка приложения для потокового вещания с помощью Node.js и React - 6

Страница, которая позволяет стримерам работать со своими ключами

Генерирование миниатюр для видеопотоков

В компоненте <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();

Итоги

Только что мы завершили рассказ о разработке приложения, позволяющего организовывать потоковое вещание. Возможно, в ходе разбора кода некоторые фрагменты системы были незаслуженно забыты. Если вы столкнулись с чем-то непонятным — взгляните на этот [2] репозиторий. Если вы найдёте в коде какую-нибудь ошибку — автор материала просит вас ему об этом сообщить.

Автор: ru_vds

Источник [5]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/javascript/322160

Ссылки в тексте:

[1] Image: https://habr.com/ru/company/ruvds/blog/457860/

[2] этот: https://github.com/waleedahmad/node-stream

[3] сборки: https://ffmpeg.zeranoe.com/builds/

[4] OBS: https://obsproject.com/

[5] Источник: https://habr.com/ru/post/457860/?utm_source=habrahabr&utm_medium=rss&utm_campaign=457860