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

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

image

Приложение принимает от стримера поток в формате 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();

Итоги


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

Вот демонстрация работы приложения.

Уважаемые читатели! Как вы подошли бы к разработке проекта, подобного тому, о котором шла речь в этом материале?

  • +35
  • 11,2k
  • 6
RUVDS.com
821,62
RUVDS – хостинг VDS/VPS серверов
Поделиться публикацией

Комментарии 6

    0
    Есть вариант получше и попроще чем node-media-server github.com/arut/nginx-rtmp-module

    Мне больше интересно, как организовать систему доставки контента, например в данном случае 1 сервер для стрима, что делать, если пользователей несколько тысяч?
    Если вы знаете ответ, пожалуйста ответьте на этот вопрос toster.ru/q/640003?e=7736213#clarification_714557
      0
      Уйти за CDN не поможет? Или вещать через ютюб, твитч или что-то такое.
      Вообще по теме статьи имеется shinobi.video на том же самом node.js сделаный.
      Можно гнать через него хоть экран хоть что.
        0
        Потому что нужно строить CDN.
        Кешировать чанки или плейлист смысла нет никакого.
        Ну и 1000 зрителей это уже совсееем не гигабит канала…
        0
        Отличная статья.
        В прошлой вашей статье о реакт приложении я задавал вопрос про монорепозиторий, как вы организовываете его организовывает. Тогда без ответа вопрос остался, пришлось самому разобраться в вопросе и нашел вариант с Lerna. В этой вы как раз упомянули папки client, server и в корне конфиг вепака для сборки. А почему Lerna не стали использовать? Были проблемы при эксплуатации?
          0
          Это же перевод. По ссылке не гитхаб есть ссылка на оригинал статьи. Кто делал перевод не станет отвечать на технические подробности, т.к. просто, вероятнее всего, и не знает что ответить. Как в прочем может и React с Node.js не знать.
            0
            Спасибо за коммент. Просто я понял что это перевод, когда зашел проверить комментарий) Затем я перешел по ссылке на оригинал из прошлой статьи и задал вопрос автору. medium.com/@eliezer/its-an-okay-idea-e31a3156c658

            На что он ответил, что возможно это хорошая идея, но он в реальности не сталкивался когда сильно нужно шарить код между клиентом и серверов. Аuto type generation — правде не понял что это, возможно это статическая типизация, решает 90% проблем.
            При этом когда он юзал Лерну у него были доп.сложности. Но с удовольствием бы посмотрел на удачное применение.

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое