Хабрахабр

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

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

image

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

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

Давайте создадим простой веб-сервер, основанный на 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' , ); 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-сессию на использование файлового хранилища. Хранение сессий позволит восстанавливать их после перезагрузки сервера.

Создадим в папке server папку auth и поместим в неё файл passport.js. Теперь опишем стратегии 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.

UserSchema.js: Вот код файла server/database.

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 для обеспечения их высокой доступности.

Этот медиа-сервер принимает RTMP-потоки и преобразует их в HLS/DASH с использованием мультимедийного фреймворка ffmpeg. Мы используем модуль node-media-server — Node.js-реализацию медиа-сервера RTMP. Если вы работаете на Linux и у вас уже установлен ffmpeg, вы можете выяснить путь к нему, выполнив следующую команду из терминала: Для успешной работы проекта в вашей системе должен быть установлен 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

Для того чтобы увидеть этот список — можно перейти в браузере по адресу http://127. Медиа-сервер даёт доступ к API, который позволяет получить список подключённых клиентов. 0. 0. Позже мы воспользуемся этим API в React-приложении для показа списка пользователей, ведущих трансляции. 1:8888/api/streams. Вот что можно увидеть, обратившись к этому 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> }

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

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

API NMS выдаёт не особенно много сведений о пользователях. После монтирования компонента <LiveStreams/> выполняется обращение к API NMS для получения списка подключённых к системе клиентов. Эти ключи мы будем использовать при формировании запросов к базе данных для получения сведений об учётных записях пользователей. В частности, от него мы можем получить сведения о стриминговых ключах, посредством которых пользователи подключены к RTMP-серверу.

Создадим файл server/routes/streams.js со следующим содержимым: В методе getStreamsInfo мы выполняем XHR-запрос к /streams/info, но мы пока не создали то, что способно ответить на этот запрос.

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, бэкенду, делая это для получения информации о подключённых клиентах.

Полученный список мы возвращаем в формате JSON. Мы выполняем запрос к базе данных для получения списка пользователей, стриминговые ключи которых совпадают с теми, что мы получили от API NMS. Зарегистрируем маршрут в файле 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.

Этот вспомогательный метод будем вызывать в замыкании prePublish после проверки стримингового ключа (server/media_server.js): Он запускает отдельный ffmpeg-процесс, который создаёт изображение на основе HLS-потока.

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();

Итоги

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

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть