Merge branch 'develop' into 'master'

Develop -> Master

See merge request knotteye/satyr!5
merge-requests/5/merge
knotteye 5 years ago
commit c1e5d0691e
  1. 2
      install/satyr.service
  2. 2
      package.json
  3. 25
      src/http.ts
  4. 92
      src/server.ts
  5. 6
      templates/user.njk

@ -1,5 +1,5 @@
[Unit] [Unit]
Description=A livestreaming server. Description=satyr livestreaming server
After=network.target After=network.target
[Service] [Service]

@ -1,6 +1,6 @@
{ {
"name": "satyr", "name": "satyr",
"version": "0.4.3", "version": "0.4.4",
"description": "A livestreaming server.", "description": "A livestreaming server.",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"author": "knotteye", "author": "knotteye",

@ -39,37 +39,27 @@ async function init(satyr: any, port: number, ircconf: any){
}); });
app.get('/users', (req, res) => { app.get('/users', (req, res) => {
db.query('select username from users').then((result) => { db.query('select username from users').then((result) => {
njkconf.list = result; res.render('list.njk', Object.assign({list: result}, njkconf));
res.render('list.njk', njkconf);
njkconf.list = '';
}); });
}); });
app.get('/users/live', (req, res) => { app.get('/users/live', (req, res) => {
db.query('select username,title from user_meta where live=1;').then((result) => { db.query('select username,title from user_meta where live=1;').then((result) => {
njkconf.list = result; res.render('live.njk', Object.assign({list: result}, njkconf));
res.render('live.njk', njkconf);
njkconf.list = '';
}); });
}); });
app.get('/users/*', (req, res) => { app.get('/users/*', (req, res) => {
db.query('select username,title,about from user_meta where username='+db.raw.escape(req.url.split('/')[2].toLowerCase())).then((result) => { db.query('select username,title,about from user_meta where username='+db.raw.escape(req.url.split('/')[2].toLowerCase())).then((result) => {
if(result[0]){ if(result[0]){
njkconf.user = result[0].username; res.render('user.njk', Object.assign(result[0], njkconf));
njkconf.streamtitle = result[0].title;
njkconf.about = result[0].about;
res.render('user.njk', njkconf);
} }
else res.render('404.njk', njkconf); else res.render('404.njk', njkconf);
}); });
}); });
app.get('/vods/*', (req, res) => { app.get('/vods/*', (req, res) => {
njkconf.user = req.url.split('/')[2].toLowerCase(); db.query('select username from user_meta where username='+db.raw.escape(req.url.split('/')[2].toLowerCase())).then((result) => {
db.query('select username from user_meta where username='+db.raw.escape(njkconf.user)).then((result) => {
if(result[0]){ if(result[0]){
fs.readdir('./site/live/'+njkconf.user, {withFileTypes: true} , (err, files) => { fs.readdir('./site/live/'+njkconf.user, {withFileTypes: true} , (err, files) => {
if(files) njkconf.list = files.filter(fn => fn.name.endsWith('.mp4')); res.render('vods.njk', Object.assign({user: result[0].username, list: files.filter(fn => fn.name.endsWith('.mp4'))}, njkconf));
else njkconf.list = [];
res.render('vods.njk', njkconf);
}); });
} }
else res.render('404.njk', njkconf); else res.render('404.njk', njkconf);
@ -182,7 +172,7 @@ async function init(satyr: any, port: number, ircconf: any){
} }
}); });
socket.on('MSG', (data) => { socket.on('MSG', (data) => {
if(data.msg === "") return; if(data.msg === "" || !data.msg.replace(/\s/g, '').length) return;
io.to(data.room).emit('MSG', {nick: socket.nick, msg: data.msg}); io.to(data.room).emit('MSG', {nick: socket.nick, msg: data.msg});
if(ircconf.enable) irc.send(socket.nick, data.room, data.msg); if(ircconf.enable) irc.send(socket.nick, data.room, data.msg);
}); });
@ -204,7 +194,8 @@ async function init(satyr: any, port: number, ircconf: any){
} }
async function newNick(socket) { async function newNick(socket) {
let n: string = 'Guest'+Math.floor(Math.random() * Math.floor(100)); //i just realized how shitty of an idea this is
let n: string = 'Guest'+Math.floor(Math.random() * Math.floor(1000));
if(store.get(n)) return newNick(socket); if(store.get(n)) return newNick(socket);
else { else {
store.set(n, socket.id); store.set(n, socket.id);

@ -1,10 +1,13 @@
import * as NodeMediaServer from "node-media-server"; import * as NodeMediaServer from "node-media-server";
import * as dirty from "dirty";
import { mkdir, fstat, access } from "fs"; import { mkdir, fstat, access } from "fs";
import * as strf from "strftime"; import * as strf from "strftime";
import * as db from "./database"; import * as db from "./database";
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
const { exec, execFile } = require('child_process'); const { exec, execFile } = require('child_process');
const keystore = dirty();
function init (mediaconfig: any, satyrconfig: any) { function init (mediaconfig: any, satyrconfig: any) {
const nms = new NodeMediaServer(mediaconfig); const nms = new NodeMediaServer(mediaconfig);
nms.run(); nms.run();
@ -15,45 +18,11 @@ function init (mediaconfig: any, satyrconfig: any) {
let app: string = StreamPath.split("/")[1]; let app: string = StreamPath.split("/")[1];
let key: string = StreamPath.split("/")[2]; let key: string = StreamPath.split("/")[2];
//disallow urls not formatted exactly right //disallow urls not formatted exactly right
if (StreamPath.split("/").length !== 3){ if (StreamPath.split("/").length !== 3 || key.includes(' ')){
console.log("[NodeMediaServer] Malformed URL, closing connection for stream:",id); console.log("[NodeMediaServer] Malformed URL, closing connection for stream:",id);
session.reject(); session.reject();
return false; return false;
} }
if(app === satyrconfig.publicEndpoint) {
if(session.isLocal) {
//only allow publish to public endpoint from localhost
console.log("[NodeMediaServer] Local publish, stream:",`${id} ok.`);
}
else{
console.log("[NodeMediaServer] Non-local Publish to public endpoint, rejecting stream:",id);
session.reject();
return false;
}
console.log("[NodeMediaServer] Public endpoint, checking record flag.");
//set live flag
db.query('update user_meta set live=true where username=\''+key+'\' limit 1');
//if this stream is from the public endpoint, check if we should be recording
return db.query('select username,record_flag from users where username=\''+key+'\' limit 1').then((results) => {
if(results[0].record_flag && satyrconfig.record){
console.log('[NodeMediaServer] Initiating recording for stream:',id);
mkdir(satyrconfig.directory+'/'+satyrconfig.publicEndpoint+'/'+results[0].username, { recursive : true }, (err) => {
if (err) throw err;
execFile(satyrconfig.ffmpeg, ['-loglevel', 'fatal', '-i', 'rtmp://127.0.0.1:'+mediaconfig.rtmp.port+'/'+satyrconfig.publicEndpoint+'/'+results[0].username, '-vcodec', 'copy', '-acodec', 'copy', satyrconfig.directory+'/'+satyrconfig.publicEndpoint+'/'+results[0].username+'/'+strf('%d%b%Y-%H%M')+'.mp4'], {
detached : true,
stdio : 'inherit',
maxBuffer: Infinity
}).unref();
//spawn an ffmpeg process to record the stream, then detach it completely
//ffmpeg can then (probably) finalize the recording if satyr crashes mid-stream
});
}
else {
console.log('[NodeMediaServer] Skipping recording for stream:',id);
}
return true;
});
}
if(app !== satyrconfig.privateEndpoint){ if(app !== satyrconfig.privateEndpoint){
//app isn't at public endpoint if we've reached this point //app isn't at public endpoint if we've reached this point
console.log("[NodeMediaServer] Wrong endpoint, rejecting stream:",id); console.log("[NodeMediaServer] Wrong endpoint, rejecting stream:",id);
@ -63,17 +32,13 @@ function init (mediaconfig: any, satyrconfig: any) {
//if the url is formatted correctly and the user is streaming to the correct private endpoint //if the url is formatted correctly and the user is streaming to the correct private endpoint
//grab the username from the database and redirect the stream there if the key is valid //grab the username from the database and redirect the stream there if the key is valid
//otherwise kill the session //otherwise kill the session
if(key.includes(' ')) { db.query('select username,record_flag from users where stream_key='+db.raw.escape(key)+' limit 1').then(async (results) => {
session.reject();
return false;
}
db.query('select username from users where stream_key='+db.raw.escape(key)+' limit 1').then(async (results) => {
if(results[0]){ if(results[0]){
//push to rtmp //push to rtmp
execFile(satyrconfig.ffmpeg, ['-loglevel', 'fatal', '-analyzeduration', '0', '-i', 'rtmp://127.0.0.1:'+mediaconfig.rtmp.port+'/'+satyrconfig.privateEndpoint+'/'+key, '-vcodec', 'copy', '-acodec', 'copy', '-crf', '18', '-f', 'flv', 'rtmp://127.0.0.1:'+mediaconfig.rtmp.port+'/'+satyrconfig.publicEndpoint+'/'+results[0].username], {maxBuffer: Infinity}); //execFile(satyrconfig.ffmpeg, ['-loglevel', 'fatal', '-analyzeduration', '0', '-i', 'rtmp://127.0.0.1:'+mediaconfig.rtmp.port+'/'+satyrconfig.privateEndpoint+'/'+key, '-vcodec', 'copy', '-acodec', 'copy', '-crf', '18', '-f', 'flv', 'rtmp://127.0.0.1:'+mediaconfig.rtmp.port+'/'+satyrconfig.publicEndpoint+'/'+results[0].username], {maxBuffer: Infinity});
//exec('ffmpeg -analyzeduration 0 -i rtmp://127.0.0.1:'+mediaconfig.rtmp.port+'/'+satyrconfig.privateEndpoint+'/'+key+' -vcodec copy -acodec copy -crf 18 -f flv rtmp://127.0.0.1:'+mediaconfig.rtmp.port+'/'+satyrconfig.publicEndpoint+'/'+results[0].username);
//push to mpd after making sure directory exists //push to mpd after making sure directory exists
mkdir(satyrconfig.directory+'/'+satyrconfig.publicEndpoint+'/'+results[0].username, { recursive : true }, (err) => {;}); keystore[results[0].username] = key;
mkdir(satyrconfig.directory+'/'+satyrconfig.publicEndpoint+'/'+results[0].username, { recursive : true }, ()=>{;});
while(true){ while(true){
if(session.audioCodec !== 0 && session.videoCodec !== 0){ if(session.audioCodec !== 0 && session.videoCodec !== 0){
execFile(satyrconfig.ffmpeg, ['-loglevel', 'fatal', '-y', '-i', 'rtmp://127.0.0.1:'+mediaconfig.rtmp.port+'/'+satyrconfig.privateEndpoint+'/'+key, '-map', '0:2', '-map', '0:2', '-map', '0:2', '-map', '0:1', '-c:a', 'copy', '-c:v:0', 'copy', '-c:v:1', 'libx264', '-c:v:2', 'libx264', '-crf:1', '33', '-crf:2', '40', '-b:v:1', '3000K', '-b:v:2', '1500K', '-remove_at_exit', '1', '-seg_duration', '1', '-window_size', '30', '-f', 'dash', satyrconfig.directory+'/'+satyrconfig.publicEndpoint+'/'+results[0].username+'/index.mpd'], {maxBuffer: Infinity}); execFile(satyrconfig.ffmpeg, ['-loglevel', 'fatal', '-y', '-i', 'rtmp://127.0.0.1:'+mediaconfig.rtmp.port+'/'+satyrconfig.privateEndpoint+'/'+key, '-map', '0:2', '-map', '0:2', '-map', '0:2', '-map', '0:1', '-c:a', 'copy', '-c:v:0', 'copy', '-c:v:1', 'libx264', '-c:v:2', 'libx264', '-crf:1', '33', '-crf:2', '40', '-b:v:1', '3000K', '-b:v:2', '1500K', '-remove_at_exit', '1', '-seg_duration', '1', '-window_size', '30', '-f', 'dash', satyrconfig.directory+'/'+satyrconfig.publicEndpoint+'/'+results[0].username+'/index.mpd'], {maxBuffer: Infinity});
@ -81,6 +46,24 @@ function init (mediaconfig: any, satyrconfig: any) {
} }
await sleep(300); await sleep(300);
} }
if(results[0].record_flag && satyrconfig.record){
console.log('[NodeMediaServer] Initiating recording for stream:',id);
mkdir(satyrconfig.directory+'/'+satyrconfig.publicEndpoint+'/'+results[0].username, { recursive : true }, (err) => {
if (err) throw err;
execFile(satyrconfig.ffmpeg, ['-loglevel', 'fatal', '-i', 'rtmp://127.0.0.1:'+mediaconfig.rtmp.port+'/'+satyrconfig.prviateEndpoint+'/'+key, '-vcodec', 'copy', '-acodec', 'copy', satyrconfig.directory+'/'+satyrconfig.publicEndpoint+'/'+results[0].username+'/'+strf('%d%b%Y-%H%M')+'.mp4'], {
detached : true,
stdio : 'inherit',
maxBuffer: Infinity
}).unref();
//spawn an ffmpeg process to record the stream, then detach it completely
//ffmpeg can then (probably) finalize the recording if satyr crashes mid-stream
});
}
else {
console.log('[NodeMediaServer] Skipping recording for stream:',id);
}
db.query('update user_meta set live=true where username=\''+results[0].username+'\' limit 1');
console.log('[NodeMediaServer] Stream key ok for stream:',id);
} }
else{ else{
console.log('[NodeMediaServer] Invalid stream key for stream:',id); console.log('[NodeMediaServer] Invalid stream key for stream:',id);
@ -91,8 +74,12 @@ function init (mediaconfig: any, satyrconfig: any) {
nms.on('donePublish', (id, StreamPath, args) => { nms.on('donePublish', (id, StreamPath, args) => {
let app: string = StreamPath.split("/")[1]; let app: string = StreamPath.split("/")[1];
let key: string = StreamPath.split("/")[2]; let key: string = StreamPath.split("/")[2];
if(app === satyrconfig.publicEndpoint) { if(app === satyrconfig.privateEndpoint) {
db.query('update user_meta set live=false where username=\''+key+'\' limit 1'); db.query('update user_meta,users set user_meta.live=false where users.stream_key='+db.raw.escape(key));
db.query('select username from users where stream_key='+db.raw.escape(key)+' limit 1').then(async (results) => {
keystore.rm(results[0].username);
});
} }
}); });
nms.on('prePlay', (id, StreamPath, args) => { nms.on('prePlay', (id, StreamPath, args) => {
@ -105,11 +92,20 @@ function init (mediaconfig: any, satyrconfig: any) {
session.reject(); session.reject();
return false; return false;
} }
//disallow playing from the private endpoint for anyone except localhost //localhost can play from whatever endpoint
//(this will be the ffmpeg instance redirecting the stream) //other clients must use private endpoint
if(app === satyrconfig.privateEndpoint && !session.isLocal) { if(app !== satyrconfig.publicEndpoint && !session.isLocal) {
console.log("[NodeMediaServer] Non-local Play from private endpoint, rejecting client:",id); console.log("[NodeMediaServer] Non-local Play from private endpoint, rejecting client:",id);
session.reject(); session.reject();
return false;
}
//rewrite playpath to private endpoint serverside
//(hopefully)
if(app === satyrconfig.publicEndpoint) {
if(keystore[key]){
session.playStreamPath = '/'+satyrconfig.privateEndpoint+'/'+keystore[key];
return true;
}
} }
}); });
} }

@ -7,13 +7,13 @@ function newPopup(url) {
} }
</script> </script>
</br> </br>
<span style="float: left;font-size: large;"><a href="/live/{{ user }}/index.mpd">{{ user }}</a> | {{ streamtitle | escape }}</b></span><span style="float: right;font-size: large;"> Links | <a href="rtmp://{{ domain }}/live/{{ user }}">Watch</a> <a href="JavaScript:newPopup('/chat?room={{ user }}');">Chat</a> <a href="/vods/{{ user }}">VODs</a></span> <span style="float: left;font-size: large;"><a href="/live/{{ username }}/index.mpd">{{ username }}</a> | {{ title | escape }}</b></span><span style="float: right;font-size: large;"> Links | <a href="rtmp://{{ domain }}/live/{{ username }}">Watch</a> <a href="JavaScript:newPopup('/chat?room={{ username }}');">Chat</a> <a href="/vods/{{ username }}">VODs</a></span>
<div id="jscontainer"> <div id="jscontainer">
<div id="jschild" style="width: 70%;height: 100%;"> <div id="jschild" style="width: 70%;height: 100%;">
<video controls poster="/thumbnail.jpg" class="video-js vjs-default-skin" id="live-video" style="width:100%;height:100%;"></video> <video controls poster="/thumbnail.jpg" class="video-js vjs-default-skin" id="live-video" style="width:100%;height:100%;"></video>
</div> </div>
<div id="jschild" class="webchat" style="width: 30%;height: 100%;"> <div id="jschild" class="webchat" style="width: 30%;height: 100%;">
<iframe src="/chat?room={{ user }}" frameborder="0" style="width: 100%;height: 100%;"></iframe> <iframe src="/chat?room={{ username }}" frameborder="0" style="width: 100%;height: 100%;"></iframe>
</div> </div>
</div> </div>
<script>window.HELP_IMPROVE_VIDEOJS = false;</script> <script>window.HELP_IMPROVE_VIDEOJS = false;</script>
@ -32,7 +32,7 @@ function newPopup(url) {
document.querySelector(".vjs-modal-dialog-content").textContent = "The stream is currently offline."; document.querySelector(".vjs-modal-dialog-content").textContent = "The stream is currently offline.";
}); });
player.src({ player.src({
src: '/live/{{ user }}/index.mpd', src: '/live/{{ username }}/index.mpd',
type: 'application/dash+xml' type: 'application/dash+xml'
}); });
}) })