Port the application to node.js. I am not confident using Python so I went with the better option for me

This commit is contained in:
Evert Prants 2019-10-23 11:24:20 +03:00
parent 8201e932fc
commit 8cbf946715
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
10 changed files with 1917 additions and 727 deletions

4
.gitignore vendored
View File

@ -1,6 +1,8 @@
/config.ini config.*
!config.example.*
/node_modules/ /node_modules/
/dist/ /dist/
/venv/ /venv/
/demo/
*.db *.db
*.pyc *.pyc

376
app.js Normal file
View File

@ -0,0 +1,376 @@
const express = require('express')
const session = require('express-session')
const bodyParser = require('body-parser')
const request = require('request')
const nunjucks = require('nunjucks')
const sqlite = require('sqlite')
const xml2js = require('xml2js')
const path = require('path')
const toml = require('toml')
const fs = require('fs')
const uuid = require('uuid/v4')
require('express-async-errors')
const util = require('util')
const get = util.promisify(request.get)
const post = util.promisify(request.post)
const dev = process.env.NODE_ENV === 'development'
// Load Configuration
const filename = path.join(__dirname, 'config.toml')
let config
let cache = { _updated: 0, streamers: {} }
try {
config = toml.parse(fs.readFileSync(filename))
} catch (e) {
console.error(e)
process.exit(1)
}
config = Object.assign({
'Streaming': {
'Port': '9322',
'Database': 'streaming.db',
'StreamServer': 'https://tv.icynet.eu/live/',
'ServerHost': 'icynet.eu',
'PublishAddress': 'rtmp://{host}:1935/hls-live/{streamer}',
'Secret': 'changeme'
},
'Auth': {
'Server': 'http://localhost:8282',
'Redirect': 'http://localhost:5000/auth/_callback/'
},
'OAuth2': {
'ClientID': '1',
'ClientSecret': 'changeme'
}
}, config)
// Constants
const oauthAuth = '{server}/oauth2/authorize?response_type=code&state={state}&redirect_uri={redirect}&client_id={client}&scope=image'
const port = parseInt(config['Streaming']['Port'])
const streamServer = config['Streaming']['StreamServer']
const streamServerHost = config['Streaming']['ServerHost']
const authServer = config['Auth']['Server']
const oauthRedirect = config['Auth']['Redirect']
const oauthId = config['OAuth2']['ClientID'].toString()
const oauthSecret = config['OAuth2']['ClientSecret']
const streamAppName = streamServer.match(/\/([\w-_]+)\/$/)[1]
function teval (str, obj) {
let res = str + ''
for (let key in obj) {
if (res.indexOf('{' + key + '}') === -1) continue
res = res.replace('{' + key + '}', obj[key])
}
return res
}
// Database
const dbPromise = Promise.resolve()
.then(() => sqlite.open(path.join(process.cwd(), config['Streaming']['Database']), { Promise, cache: true }))
.then(db => db.migrate())
// Setup server
let app = express()
app.enable('trust proxy', 1)
app.use(bodyParser.urlencoded({ extended: false }))
app.use(bodyParser.json())
app.disable('x-powered-by')
nunjucks.configure('templates', {
autoescape: true,
express: app
})
app.use(session({
secret: config['Streaming']['Secret'],
resave: false,
saveUninitialized: true,
cookie: {
secure: !dev,
maxAge: 2678400000 // 1 month
}
}))
// Parse stream metrics from the stat.xml file
async function pullMetrics (uuid) {
let statPath = streamServer + 'stat'
if (!cache.stats || cache._updated < Date.now() - 5000) {
let { body } = await get(statPath)
let rip = await xml2js.parseStringPromise(body)
if (!rip.rtmp.server) throw new Error('Invalid response from server.')
// Autofind the correct server
let rtmpserver = rip.rtmp.server[0].application
let rtmpapp
for (let i in rtmpserver) {
if (rtmpserver[i].name[0] !== streamAppName) continue
rtmpapp = rtmpserver[i]
}
if (!rtmpapp) throw new Error('Invalid response from server.')
cache.stats = rtmpapp.live
cache._updated = Date.now()
}
// Extract applicable stream data
let forUser
for (let i in cache.stats) {
if (!cache.stats[i].stream) continue
if (cache.stats[i].stream[0].name[0] !== uuid) continue
forUser = cache.stats[i].stream[0]
}
if (!forUser) return null
// Generic data object
let data = {
time: forUser.time[0],
bytes: forUser.bytes_in[0],
video: null,
audio: null
}
// Add video metadata, if applicable
if (forUser.meta[0].video[0] !== '') {
data['video'] = {
width: forUser.meta[0].video[0].width[0],
height: forUser.meta[0].video[0].height[0],
frame_rate: forUser.meta[0].video[0].frame_rate[0],
codec: forUser.meta[0].video[0].codec[0]
}
}
// Add audio metadata, if applicable
if (forUser.meta[0].audio[0] !== '') {
data['audio'] = {
sample_rate: forUser.meta[0].audio[0].sample_rate[0],
channels: forUser.meta[0].audio[0].channels[0],
codec: forUser.meta[0].audio[0].codec[0]
}
}
return data
}
// Handle requests from nginx-rtmp-module
app.post('/publish', async (req, res) => {
if (!req.body.name) throw new Error('Invalid request.')
let db = await dbPromise
// Validate stream key
let streamer = await db.get('SELECT * FROM channels WHERE key=?', req.body.name)
if (!streamer) throw new Error('Invalid stream key.')
console.log('Streamer %s has started streaming!', streamer.name)
// Generate real publish address for the server
let publishAddress = config['Streaming']['PublishAddress']
.replace('{streamer}', streamer.name)
.replace('{host}', '127.0.0.1')
// Set channel streaming status
db.run('UPDATE channels SET live_at=? WHERE id=?', Date.now(), streamer.id)
// Redirect the streaming server to the target
res.set('Location', publishAddress)
res.status(302)
res.end()
})
app.post('/publish_done', async (req, res) => {
if (!req.body.name) throw new Error('Invalid request.')
let db = await dbPromise
db.run('UPDATE channels SET live_at=NULL, last_stream=? WHERE key=?', Date.now(), req.body.name)
res.send('OK')
})
// Front-end server
// OAuth2 authenticator
app.get('/login', async (req, res) => {
if (req.session.user) return res.redirect('/')
req.session.state = uuid()
res.redirect(teval(oauthAuth, { state: req.session.state, redirect: oauthRedirect, client: oauthId, server: authServer }))
})
app.get('/auth/_callback', async (req, res) => {
let state = req.session.state
if (!state) throw new Error('Something went wrong!')
let code = req.query.code
let provState = req.query.state
if (!code || state !== provState) throw new Error('Something went wrong!')
delete req.session.state
// Aquire token
let { body } = await post(authServer + '/oauth2/token', {
form: {
grant_type: 'authorization_code',
code: code,
redirect_uri: oauthRedirect,
client_id: oauthId,
client_secret: oauthSecret
},
auth: {
user: oauthId,
pass: oauthSecret
}
})
if (!body) throw new Error('Could not obtain access token!')
try {
body = JSON.parse(body)
} catch (e) {
console.error(e, body)
throw new Error('Authorization server gave us an invalid response!')
}
if (body['error']) {
throw new Error(body['error'] + ': ' + body['error_description'])
}
let token = body.access_token
// Get user information
let { body: bodyNew } = await get(authServer + '/oauth2/user', { auth: { bearer: token } })
try {
bodyNew = JSON.parse(bodyNew)
} catch (e) {
console.error(e, bodyNew)
throw new Error('Authorization server gave us an invalid response for user!')
}
// Get user from database
let db = await dbPromise
let user = await db.get('SELECT * FROM signed_users WHERE uuid=?', bodyNew.uuid)
if (!user) {
await db.run('INSERT INTO signed_users (uuid,name) VALUES (?,?)', bodyNew.uuid, bodyNew.username)
}
req.session.login = bodyNew.uuid
req.session.username = bodyNew.username
// Lets see if this user is a streamer
let streamer = await db.get('SELECT * FROM channels WHERE user_uuid = ?', bodyNew.uuid)
if (streamer) cache.streamers[bodyNew.uuid] = streamer
res.redirect('/')
})
app.get('/logout', (req, res) => {
req.session.destroy()
res.redirect('/')
})
// Views
app.use('/dist', express.static(path.join(__dirname, 'dist'), { maxAge: dev ? 0 : 2678400000 }))
app.use(async function (req, res, next) {
req.isStreamer = false
if (!req.session.login) return next()
res.locals.session = { uuid: req.session.login, username: req.session.username }
if (cache.streamers[req.session.login]) {
req.isStreamer = true
return next()
}
next()
})
// Index
app.get('/', (req, res) => {
res.render('index.html', { streamer: req.isStreamer })
})
// Dashboard
app.get('/dashboard', (req, res, next) => {
if (!req.isStreamer) return next(new Error('Unauthorized'))
let stream = cache.streamers[req.session.login]
res.render('dashboard.html', { stream: stream.key, server: 'rtmp://' + streamServerHost + '/live/' })
})
// Stats
app.get('/dashboard/stats', async (req, res) => {
if (!req.isStreamer) return res.jsonp({ error: 'Unauthorized' })
let stream = cache.streamers[req.session.login]
let data
try {
data = await pullMetrics(stream.key)
} catch (e) {
return res.jsonp({ error: e.message })
}
if (!data) return res.jsonp({ error: 'No data was returned.' })
res.jsonp(data)
})
// Data
app.get('/dashboard/data', async (req, res) => {
if (!req.isStreamer) return res.jsonp({ error: 'Unauthorized' })
let stream = cache.streamers[req.session.login]
let data
let db = await dbPromise
try {
data = await db.get('SELECT * FROM channels WHERE key=?', stream.key)
} catch (e) {
return res.jsonp({ error: 'Unauthorized' })
}
if (!data) return res.jsonp({ error: 'Unauthorized' })
res.jsonp({
'name': data.name,
'key': stream.key,
'uuid': req.session.login,
'live': data.live_at != null,
'live_at': new Date(parseInt(data.live_at)),
'last_stream': new Date(parseInt(data.last_stream))
})
})
// Player
app.get('/watch/:name', (req, res) => {
res.render('player.html', { name: req.params.name, server: streamServer })
})
app.get('/player/:name', (req, res) => {
res.redirect('/watch/' + req.params.name)
})
// Public data
app.get('/api/channel/:name', async (req, res) => {
let name = req.params.name
let db = await dbPromise
let data = await db.get('SELECT name,live_at,last_stream FROM channels WHERE name=?', name)
if (!data) return res.jsonp({ error: 'No such channel!' })
data.live = data.live_at != null
data.live_at = new Date(parseInt(data.live_at))
data.last_stream = new Date(parseInt(data.last_stream))
res.jsonp(data)
})
// Error handler
app.use((error, req, res, next) => {
if (dev) console.error(error.stack)
res.send(error.message)
})
// Start server
const host = dev ? '0.0.0.0' : '127.0.0.1'
app.listen(port, host, () => console.log('Listening on %s:%d', host, port))

16
config.example.toml Normal file
View File

@ -0,0 +1,16 @@
[Streaming]
Port = 5000
Database = "streaming.db"
StreamServer = "http://localhost:5000/live/"
ServerHost = "localhost:1935"
PublishAddress = "rtmp://{host}:1935/hls-live/{streamer}"
Secret = "changeme"
[Auth]
Server = "http://localhost:8282"
Redirect = "http://localhost:5000/auth/_callback/"
[OAuth2]
ClientID = 1
ClientSecret = "hackme"

View File

@ -0,0 +1,20 @@
-- Up
CREATE TABLE channels (
"id" INTEGER PRIMARY KEY,
"name" TEXT NOT NULL,
"key" TEXT NOT NULL,
"live_at" TEXT,
"password" TEXT,
"user_uuid" TEXT
, "last_stream" TEXT);
CREATE TABLE signed_users (
"id" INTEGER PRIMARY KEY,
"uuid" TEXT NOT NULL,
"name" TEXT NOT NULL
);
-- Down
DROP TABLE channels;
DROP TABLE signed_users;

93
nginx.example.conf Normal file
View File

@ -0,0 +1,93 @@
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server {
listen 80;
server_name localhost;
location / {
root html;
try_files $uri @distrib;
}
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
location /live/stat {
rtmp_stat all;
}
location /live {
types {
application/vnd.apple.mpegurl m3u8;
video/mp2t ts;
}
root /tmp;
add_header Cache-Control no-cache;
add_header Access-Control-Allow-Origin $http_origin;
}
location @distrib {
proxy_pass http://localhost:5000;
proxy_redirect off;
}
}
}
rtmp {
server {
listen 1935;
chunk_size 4096;
publish_time_fix off;
# Entry application
# The streamer publishes here through a streaming software with their key
# The authenticator will give the streamer a proper name on hls-live
application live {
# enable live streaming
live on;
record off;
allow play 127.0.0.1;
deny play all;
on_publish 'http://127.0.0.1:9321/publish';
on_publish_done 'http://127.0.0.1:9321/publish_done';
push rtmp://127.0.0.1:1935/hls-live;
}
# Output application
application hls-live {
# enable live streaming
live on;
allow publish 127.0.0.1;
deny publish all;
hls on;
hls_path /tmp/live;
hls_fragment 2s;
hls_cleanup on;
hls_sync 100ms;
hls_playlist_length 60;
}
}
}

2047
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{ {
"name": "icytv", "name": "icytv",
"version": "1.0.0", "version": "2.0.0",
"description": "Frontend builder", "description": "IcyTV - nginx-rtmp-server authenticator",
"main": "index.js", "main": "index.js",
"private": true, "private": true,
"scripts": { "scripts": {
@ -10,17 +10,26 @@
"build": "webpack -p" "build": "webpack -p"
}, },
"devDependencies": { "devDependencies": {
"bootstrap": "^4.1.3", "bootstrap": "^4.3.1",
"copy-webpack-plugin": "^5.0.2", "copy-webpack-plugin": "^5.0.2",
"css-loader": "^1.0.0",
"file-loader": "^1.1.11", "file-loader": "^1.1.11",
"font-awesome": "^4.7.0",
"hls.js": "^0.10.1", "hls.js": "^0.10.1",
"jquery": "^3.3.1", "jquery": "^3.4.1",
"popper.js": "^1.14.4", "popper.js": "^1.14.4",
"style-loader": "^0.21.0",
"url-loader": "^1.0.1",
"webpack": "^4.16.4", "webpack": "^4.16.4",
"webpack-command": "^0.4.1" "webpack-command": "^0.4.1"
},
"dependencies": {
"body-parser": "^1.18.3",
"ejs": "^2.6.1",
"express": "^4.16.4",
"express-async-errors": "^3.1.1",
"express-session": "^1.16.1",
"nunjucks": "^3.2.0",
"request": "^2.88.0",
"sqlite": "^3.0.3",
"toml": "^3.0.0",
"uuid": "^3.3.2",
"xml2js": "^0.4.22"
} }
} }

View File

@ -2,7 +2,7 @@ import $ from 'jquery'
// https://stackoverflow.com/a/18650828 // https://stackoverflow.com/a/18650828
function formatBytes (a, b) { function formatBytes (a, b) {
if (0 == a) return '0 Bytes' if (a === 0) return '0 Bytes'
let c = 1024 let c = 1024
let d = b || 2 let d = b || 2
let e = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] let e = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
@ -13,7 +13,7 @@ function formatBytes (a, b) {
function recursiveStats (table, subtable) { function recursiveStats (table, subtable) {
for (let key in table) { for (let key in table) {
let val = table[key] let val = table[key]
if (typeof(val) == 'object') { if (typeof val === 'object') {
recursiveStats(val, key) recursiveStats(val, key)
} else { } else {
if (key === 'time') { if (key === 'time') {
@ -41,7 +41,8 @@ function dashboard (k) {
$('#stream_live').text(res.live ? 'Yes' : 'No') $('#stream_live').text(res.live ? 'Yes' : 'No')
}) })
$('#show_key').click(function () { $('#show_key').click(function (e) {
e.preventDefault()
$('#show_key').html(k) $('#show_key').html(k)
}) })

View File

@ -1,3 +1,4 @@
/* global alert, XMLHttpRequest, STREAM_SERVER, STREAM_NAME */
import Hls from 'hls.js' import Hls from 'hls.js'
// Elements // Elements
@ -7,6 +8,7 @@ let overlay = player.querySelector('.overlay')
let btn = overlay.querySelector('#playbtn') let btn = overlay.querySelector('#playbtn')
let time = overlay.querySelector('#duration') let time = overlay.querySelector('#duration')
let fullscreenbtn = overlay.querySelector('#fullscrbtn') let fullscreenbtn = overlay.querySelector('#fullscrbtn')
let playbtn = overlay.querySelector('#playbtn')
let mutebtn = overlay.querySelector('#mutebtn') let mutebtn = overlay.querySelector('#mutebtn')
let lstat = overlay.querySelector('.live') let lstat = overlay.querySelector('.live')
let opts = overlay.querySelector('.controls') let opts = overlay.querySelector('.controls')
@ -22,6 +24,27 @@ let retryTimeout
let vidReady = false let vidReady = false
let shouldHide = true let shouldHide = true
let inFullscreen = false let inFullscreen = false
let errored = false
function GET (url, istext) {
return new Promise((resolve, reject) => {
var xmlHttp = new XMLHttpRequest()
xmlHttp.onreadystatechange = function () {
if (xmlHttp.readyState === 4 && xmlHttp.status === 200) {
resolve(xmlHttp.responseText)
} else if (xmlHttp.readyState === 4 && xmlHttp.status >= 400) {
let err = new Error(xmlHttp.status)
err.request = xmlHttp
reject(err)
}
}
xmlHttp.open('GET', url, true)
istext && (xmlHttp.responseType = 'text')
xmlHttp.send(null)
})
}
function clampAddition (val) { function clampAddition (val) {
let volume = vid.volume let volume = vid.volume
@ -204,17 +227,20 @@ volumebar.addEventListener(mousewheelevt, (e) => {
updateVolume() updateVolume()
}, false) }, false)
function loadSource () {
if (!hls) return
hls.loadSource(STREAM_SERVER + STREAM_NAME + '.m3u8')
}
if (Hls.isSupported()) { if (Hls.isSupported()) {
hls = new Hls() hls = new Hls()
hls.loadSource(STREAM_SERVER + STREAM_NAME + '.m3u8')
hls.attachMedia(vid) hls.attachMedia(vid)
loadSource()
hls.on(Hls.Events.MANIFEST_PARSED, () => { hls.on(Hls.Events.MANIFEST_PARSED, () => {
vidReady = true vidReady = true
liveStatus(true)
}) })
hls.on(Hls.Events.ERROR, (e) => { hls.on(Hls.Events.ERROR, (e) => {
vidReady = false vidReady = false
liveStatus(false)
if (!vid.paused) { if (!vid.paused) {
toggleStream() toggleStream()
@ -225,6 +251,24 @@ if (Hls.isSupported()) {
alert('Your browser does not support HLS streaming!') alert('Your browser does not support HLS streaming!')
} }
function getStreamStatus () {
GET('/api/channel/' + STREAM_NAME).then((data) => {
let jd = JSON.parse(data)
if (jd.error) {
errored = true
return alert(jd.error)
}
if (jd.live && !vidReady) loadSource()
liveStatus(jd.live)
}, (e) => {
errored = true
liveStatus(false)
})
if (!errored) setTimeout(getStreamStatus, 8000)
}
playbtn.addEventListener('click', toggleStream) playbtn.addEventListener('click', toggleStream)
mutebtn.addEventListener('click', toggleSound) mutebtn.addEventListener('click', toggleSound)
fullscreenbtn.addEventListener('click', toggleFullscreen) fullscreenbtn.addEventListener('click', toggleFullscreen)
@ -235,3 +279,5 @@ document.addEventListener('fullscreenchange', exitHandler, false)
document.addEventListener('MSFullscreenChange', exitHandler, false) document.addEventListener('MSFullscreenChange', exitHandler, false)
vid.addEventListener('timeupdate', updateTime, false) vid.addEventListener('timeupdate', updateTime, false)
getStreamStatus()

View File

@ -6,8 +6,8 @@
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"> <link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<link rel="stylesheet" type="text/css" href="/dist/css/player.css"> <link rel="stylesheet" type="text/css" href="/dist/css/player.css">
<script type="text/javascript"> <script type="text/javascript">
const STREAM_SERVER = "{{ server }}" const STREAM_SERVER = '{{ server }}'
const STREAM_NAME = "{{ name }}" const STREAM_NAME = '{{ name }}'
</script> </script>
</head> </head>
<body> <body>