Merge branch 'develop' into 'master'

Develop -> Master

See merge request knotteye/satyr!21
merge-requests/29/merge v0.9.2
knotteye 4 years ago
commit b1d4de4af6
  1. 9
      docs/CONFIGURATION.md
  2. 8
      docs/REST.md
  3. 2
      package.json
  4. 12
      src/api.ts
  5. 10
      src/chat.ts
  6. 4
      src/config.ts
  7. 102
      src/http.ts
  8. 15
      src/server.ts

@ -26,6 +26,15 @@ transcode:
# satyr will generate one source quality variant, and the remaining # satyr will generate one source quality variant, and the remaining
# variants will be of incrementally lower quality and bitrate # variants will be of incrementally lower quality and bitrate
inputflags: ""
# additional flags to apply to the input during transcoding
outputflags: ""
# additional flags to apply to the output during transcoding
# hardware acceleration is a bit difficult to configure programmatically
# this is a good place to do so for your system
# https://trac.ffmpeg.org/wiki/HWAccelIntro is a good place to start
# having more than 4-5 variants will start giving diminishing returns on stream quality for cpu load # having more than 4-5 variants will start giving diminishing returns on stream quality for cpu load
# if you can't afford to generate at least 3 variants, it's reccomended to leave adaptive streaming off # if you can't afford to generate at least 3 variants, it's reccomended to leave adaptive streaming off

@ -68,9 +68,11 @@ sort is the optional way to sort results. current options are "alphabet" and "al
page is the page number (i.e. skip the first num * page results) page is the page number (i.e. skip the first num * page results)
**Response**: Returns an array of objects containing the username and title of each stream. Returns an empty array if no one is streaming. Returns `{"error":"error code"}` in the event of an error. Will attempt to correct malformed requests to default values. **Response**: Returns an array of JSON objects containing the username and title of each stream. Returns an empty array if no one is streaming. Returns `{"error":"error code"}` in the event of an error. Will attempt to correct malformed requests to default values.
**Example**: `[{username:"foo", title:"bar"}]` The array will be wrapped in a JSON object under the key 'users'.
**Example**: `{users: [{username:"foo", title:"bar"}] }`
## /api/users/all ## /api/users/all
@ -194,7 +196,7 @@ Get a list of the named users VODs
**Parameters**: user **Parameters**: user
**Response**: Returns an array of VODs with the format `[{"name":"yote.mp4"},{"name":"yeet.mp4"}]` **Response**: Returns an array of VODs inside a JSON object with the format `{"vods": [{"name":"yote.mp4"},{"name":"yeet.mp4"}] }`
**Notes**: VODs are always available at http://domain.com/publicEndpoint/username/filename.mp4 **Notes**: VODs are always available at http://domain.com/publicEndpoint/username/filename.mp4

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

@ -14,7 +14,7 @@ async function register(name: string, password: string, confirm: string): Promis
let k = await db.query('select stream_key from users where username='+db.raw.escape(name)); let k = await db.query('select stream_key from users where username='+db.raw.escape(name));
return k; return k;
} }
return {"error":""}; return {error:""};
} }
async function update(fields: object): Promise<object>{ async function update(fields: object): Promise<object>{
@ -32,12 +32,12 @@ async function update(fields: object): Promise<object>{
qs += ' users.record_flag='+db.raw.escape(fields['rec']); qs += ' users.record_flag='+db.raw.escape(fields['rec']);
} }
await db.query('UPDATE users,user_meta SET'+qs+' WHERE users.username='+db.raw.escape(fields['name'])+' AND user_meta.username='+db.raw.escape(fields['name'])); await db.query('UPDATE users,user_meta SET'+qs+' WHERE users.username='+db.raw.escape(fields['name'])+' AND user_meta.username='+db.raw.escape(fields['name']));
return {"success":""}; return {success:""};
} }
async function updateChat(fields: object): Promise<object>{ async function updateChat(fields: object): Promise<object>{
await db.query('UPDATE chat_integration SET xmpp='+db.raw.escape(fields['xmpp'])+', discord='+db.raw.escape(fields['discord'])+', irc='+db.raw.escape(fields['irc'])+', twitch='+db.raw.escape(fields['twitch'])+' WHERE username='+db.raw.escape(fields['name'])); await db.query('UPDATE chat_integration SET xmpp='+db.raw.escape(fields['xmpp'])+', discord='+db.raw.escape(fields['discord'])+', irc='+db.raw.escape(fields['irc'])+', twitch='+db.raw.escape(fields['twitch'])+' WHERE username='+db.raw.escape(fields['name']));
return {"success":""}; return {success:""};
} }
async function changepwd(name: string, password: string, newpwd: string): Promise<object>{ async function changepwd(name: string, password: string, newpwd: string): Promise<object>{
@ -46,13 +46,13 @@ async function changepwd(name: string, password: string, newpwd: string): Promis
if(!auth) return {"error":"Username or Password Incorrect"}; if(!auth) return {"error":"Username or Password Incorrect"};
let newhash: string = await db.hash(newpwd); let newhash: string = await db.hash(newpwd);
await db.query('UPDATE users set password_hash='+db.raw.escape(newhash)+'where username='+db.raw.escape(name)+' limit 1'); await db.query('UPDATE users set password_hash='+db.raw.escape(newhash)+'where username='+db.raw.escape(name)+' limit 1');
return {"success":""}; return {success:""};
} }
async function changesk(name: string): Promise<object>{ async function changesk(name: string): Promise<object>{
let key: string = await db.genKey(); let key: string = await db.genKey();
await db.query('UPDATE users set stream_key='+db.raw.escape(key)+'where username='+db.raw.escape(name)+' limit 1'); await db.query('UPDATE users set stream_key='+db.raw.escape(key)+'where username='+db.raw.escape(name)+' limit 1');
return {"success":key}; return {success: key};
} }
async function login(name: string, password: string){ async function login(name: string, password: string){
@ -66,7 +66,7 @@ async function deleteVODs(vodlist: Array<string>, username: string): Promise<obj
for(var i=0;i<vodlist.length;i++){ for(var i=0;i<vodlist.length;i++){
unlink('./site/live/'+username+'/'+vodlist[i], ()=>{}); unlink('./site/live/'+username+'/'+vodlist[i], ()=>{});
} }
return {"success":""}; return {success: ""};
} }
async function getConfig(username: string, all?: boolean): Promise<object>{ async function getConfig(username: string, all?: boolean): Promise<object>{

@ -94,12 +94,14 @@ async function updateUsers() {
async function updateInteg() { async function updateInteg() {
if(liveUsers.length === 0) { if(liveUsers.length === 0) {
chatIntegration = []; chatIntegration = [];
if(config['chat']['irc']['enabled']) updateIRCChan();
if(config['chat']['twitch']['enabled']) updateTwitchChan();
return; return;
} }
if(liveUsers.length === 1) { if(liveUsers.length === 1) {
chatIntegration = await db.query('SELECT * FROM chat_integration WHERE username='+db.raw.escape(liveUsers[0]['username'])); chatIntegration = await db.query('SELECT * FROM chat_integration WHERE username='+db.raw.escape(liveUsers[0]['username']));
updateIRCChan(); if(config['chat']['irc']['enabled']) updateIRCChan();
updateTwitchChan(); if(config['chat']['twitch']['enabled']) updateTwitchChan();
return; return;
} }
var qs: string; var qs: string;
@ -108,8 +110,8 @@ async function updateInteg() {
} }
qs = qs.substring(0, qs.length - 13); qs = qs.substring(0, qs.length - 13);
chatIntegration = await db.query('SELECT * FROM chat_integration WHERE username='+qs); chatIntegration = await db.query('SELECT * FROM chat_integration WHERE username='+qs);
updateIRCChan(); if(config['chat']['irc']['enabled']) updateIRCChan();
updateTwitchChan(); if(config['chat']['twitch']['enabled']) updateTwitchChan();
} }
async function sendAll(user: string, msg: Array<string>, src: string) { async function sendAll(user: string, msg: Array<string>, src: string) {

@ -46,7 +46,9 @@ const config: Object = {
transcode: Object.assign({ transcode: Object.assign({
adapative: false, adapative: false,
variants: 3, variants: 3,
format: 'dash' format: 'dash',
inputflags: null,
outputflags: null
}, localconfig['transcode']), }, localconfig['transcode']),
chat: { chat: {
irc: Object.assign({ irc: Object.assign({

@ -141,31 +141,27 @@ async function parseCookie(c){
async function initAPI() { async function initAPI() {
app.get('/api/instance/info', (req, res) => { app.get('/api/instance/info', (req, res) => {
res.send( res.json({
JSON.stringify({ name: config['satyr']['name'],
name: config['satyr']['name'], domain: config['satyr']['domain'],
domain: config['satyr']['name'], registration: config['satyr']['registration'],
registration: config['satyr']['registration'], version: config['satyr']['version'],
version: config['satyr']['version'], email: config['satyr']['email']
email: config['satyr']['email'] });
})
);
}); });
app.get('/api/instance/config', (req, res) => { app.get('/api/instance/config', (req, res) => {
res.send( res.json({
JSON.stringify({ rtmp: {
rtmp: { port: config['rtmp']['port'],
port: config['rtmp']['port'], ping_timeout: config['rtmp']['ping_timeout']
ping_timeout: config['rtmp']['ping_timeout'] },
}, media: {
media: { vods: config['config']['media']['record'],
vods: config['config']['media']['record'], publicEndpoint: config['media']['publicEndpoint'],
publicEndpoint: config['media']['publicEndpoint'], privateEndpoint: config['media']['privateEndpoint'],
privateEndpoint: config['media']['privateEndpoint'], adaptive: config['transcode']['adaptive']
adaptive: config['transcode']['adaptive'] }
} });
})
);
}); });
app.post('/api/users/live', (req, res) => { app.post('/api/users/live', (req, res) => {
let qs = 'SELECT username,title FROM user_meta WHERE live=1'; let qs = 'SELECT username,title FROM user_meta WHERE live=1';
@ -193,8 +189,8 @@ async function initAPI() {
} }
db.query(qs+';').then((result) => { db.query(qs+';').then((result) => {
if(result) res.send(result); if(result) res.json({users: result});
else res.send('{"error":""}'); else res.json({error:""});
}); });
}); });
app.post('/api/users/all', (req, res) => { app.post('/api/users/all', (req, res) => {
@ -223,18 +219,18 @@ async function initAPI() {
} }
db.query(qs+';').then((result) => { db.query(qs+';').then((result) => {
if(result) res.send(result); if(result) res.json({users: result});
else res.send('{"error":""}'); else res.json({error:""});
}); });
}); });
app.post('/api/register', (req, res) => { app.post('/api/register', (req, res) => {
api.register(req.body.username, req.body.password, req.body.confirm).then( (result) => { api.register(req.body.username, req.body.password, req.body.confirm).then( (result) => {
if(result[0]) return genToken(req.body.username).then((t) => { if(result[0]) return genToken(req.body.username).then((t) => {
res.cookie('Authorization', t, {maxAge: 604800000, httpOnly: true, sameSite: 'Lax'}); res.cookie('Authorization', t, {maxAge: 604800000, httpOnly: true, sameSite: 'Lax'});
res.send(result); res.json(result);
return; return;
}); });
res.send(result); res.json(result);
}); });
}); });
app.post('/api/user/update', (req, res) => { app.post('/api/user/update', (req, res) => {
@ -247,12 +243,12 @@ async function initAPI() {
bio: "bio" in req.body ? req.body.bio : false, bio: "bio" in req.body ? req.body.bio : false,
rec: "record" in req.body ? req.body.record : "NA" rec: "record" in req.body ? req.body.record : "NA"
}).then((r) => { }).then((r) => {
res.send(r); res.json(r);
return; return;
}); });
} }
else { else {
res.send('{"error":"invalid token"}'); res.json({error:"invalid token"});
return; return;
} }
}); });
@ -269,12 +265,12 @@ async function initAPI() {
twitch: "twitch" in req.body ? req.body.twitch : false, twitch: "twitch" in req.body ? req.body.twitch : false,
irc: "irc" in req.body ? req.body.irc : false, irc: "irc" in req.body ? req.body.irc : false,
}).then((r) => { }).then((r) => {
res.send(r); res.json(r);
return; return;
}); });
} }
else { else {
res.send('{"error":"invalid token"}'); res.json({error:"invalid token"});
return; return;
} }
}); });
@ -284,19 +280,19 @@ async function initAPI() {
}); });
app.post('/api/user/vods/delete', (req, res) => { app.post('/api/user/vods/delete', (req, res) => {
if(req.body.vlist === undefined || req.body.vlist === null || req.body.vlist === []){ if(req.body.vlist === undefined || req.body.vlist === null || req.body.vlist === []){
res.send('{"error":"no vods specified"}'); res.json({error:"no vods specified"});
return; return;
} }
validToken(req.cookies.Authorization).then((t) => { validToken(req.cookies.Authorization).then((t) => {
if(t) { if(t) {
//token is valid, process deletion request //token is valid, process deletion request
return api.deleteVODs(req.body.vlist, t['username']).then((r)=> { return api.deleteVODs(req.body.vlist, t['username']).then((r)=> {
res.send(r) res.json(r)
return; return;
}); });
} }
else { else {
res.send('{"error":"invalid token"}'); res.json({error:"invalid token"});
return; return;
} }
}); });
@ -305,12 +301,12 @@ async function initAPI() {
validToken(req.cookies.Authorization).then((t) => { validToken(req.cookies.Authorization).then((t) => {
if(t) { if(t) {
return api.changepwd(t['username'], req.body.password, req.body.newpassword).then((r) => { return api.changepwd(t['username'], req.body.password, req.body.newpassword).then((r) => {
res.send(r); res.json(r);
return; return;
}); });
} }
else { else {
res.send('{"error":"invalid token"}'); res.json({error:"invalid token"});
return; return;
} }
}); });
@ -319,12 +315,12 @@ async function initAPI() {
validToken(req.cookies.Authorization).then((t) => { validToken(req.cookies.Authorization).then((t) => {
if(t) { if(t) {
db.query('SELECT stream_key FROM users WHERE username='+db.raw.escape(t['username'])).then(o => { db.query('SELECT stream_key FROM users WHERE username='+db.raw.escape(t['username'])).then(o => {
if(o[0]) res.send(o[0]); if(o[0]) res.json(o[0]);
else res.send('{"error":""}'); else res.json({error:""});
}); });
} }
else { else {
res.send('{"error":"invalid token"}'); res.json({error:"invalid token"});
} }
}); });
}); });
@ -332,11 +328,11 @@ async function initAPI() {
validToken(req.cookies.Authorization).then((t) => { validToken(req.cookies.Authorization).then((t) => {
if(t) { if(t) {
api.changesk(t['username']).then((r) => { api.changesk(t['username']).then((r) => {
res.send(r); res.json(r);
}); });
} }
else { else {
res.send('{"error":"invalid token"}'); res.json({error:"invalid token"});
} }
}); });
}); });
@ -346,17 +342,17 @@ async function initAPI() {
if(t['exp'] - 86400 < Math.floor(Date.now() / 1000)){ if(t['exp'] - 86400 < Math.floor(Date.now() / 1000)){
return genToken(t['username']).then((t) => { return genToken(t['username']).then((t) => {
res.cookie('Authorization', t, {maxAge: 604800000, httpOnly: true, sameSite: 'Lax'}); res.cookie('Authorization', t, {maxAge: 604800000, httpOnly: true, sameSite: 'Lax'});
res.send('{"success":""}'); res.json({success:""});
return; return;
}); });
} }
else { else {
res.send('{"success":"already verified"}'); res.json({success:"already verified"});
return; return;
} }
} }
else { else {
res.send('{"error":"invalid token"}'); res.json({error:"invalid token"});
return; return;
} }
}); });
@ -365,11 +361,11 @@ async function initAPI() {
if(!result){ if(!result){
genToken(req.body.username).then((t) => { genToken(req.body.username).then((t) => {
res.cookie('Authorization', t, {maxAge: 604800000, httpOnly: true, sameSite: 'Lax'}); res.cookie('Authorization', t, {maxAge: 604800000, httpOnly: true, sameSite: 'Lax'});
res.send('{"success":""}'); res.json({success:""});
}) })
} }
else { else {
res.send(result); res.json(result);
} }
}); });
} }
@ -377,28 +373,28 @@ async function initAPI() {
app.get('/api/:user/vods', (req, res) => { app.get('/api/:user/vods', (req, res) => {
readdir('./site/live/'+req.params.user, {withFileTypes: true} , (err, files) => { readdir('./site/live/'+req.params.user, {withFileTypes: true} , (err, files) => {
if(err) { if(err) {
res.send([]); res.json({vods: []});
return; return;
} }
var list = files.filter(fn => fn.name.endsWith('.mp4')); var list = files.filter(fn => fn.name.endsWith('.mp4'));
res.send(list); res.json({vods: list});
}); });
}); });
app.get('/api/:user/config', (req, res) => { app.get('/api/:user/config', (req, res) => {
if(req.cookies.Authorization) validToken(req.cookies.Authorization).then(r => { if(req.cookies.Authorization) validToken(req.cookies.Authorization).then(r => {
if(r && r['username'] === req.params.user) { if(r && r['username'] === req.params.user) {
api.getConfig(req.params.user, true).then(re => { api.getConfig(req.params.user, true).then(re => {
res.send(JSON.stringify(re)); res.json(re);
}); });
return; return;
} }
else api.getConfig(req.params.user).then(re => { else api.getConfig(req.params.user).then(re => {
res.send(JSON.stringify(re)); res.json(re);
}); });
return; return;
}); });
else api.getConfig(req.params.user).then(r => { else api.getConfig(req.params.user).then(r => {
res.send(JSON.stringify(r)); res.json(r);
}); });
}); });
} }

@ -41,7 +41,11 @@ function init () {
while(true){ while(true){
if(session.audioCodec !== 0 && session.videoCodec !== 0){ if(session.audioCodec !== 0 && session.videoCodec !== 0){
transCommand(results[0].username, key).then((r) => { transCommand(results[0].username, key).then((r) => {
execFile(config['media']['ffmpeg'], r, {maxBuffer: Infinity}, (err, stdout, stderr) => {}); execFile(config['media']['ffmpeg'], r, {maxBuffer: Infinity}, (err, stdout, stderr) => {
/*console.log(err);
console.log(stdout);
console.log(stderr);*/
});
}); });
break; break;
} }
@ -112,7 +116,9 @@ function init () {
} }
async function transCommand(user: string, key: string): Promise<string[]>{ async function transCommand(user: string, key: string): Promise<string[]>{
let args: string[] = [/*'-loglevel', 'fatal',*/ '-y', '-i', 'rtmp://127.0.0.1:'+config['rtmp']['port']+'/'+config['media']['privateEndpoint']+'/'+key]; let args: string[] = ['-loglevel', 'fatal', '-y'];
if(config['transcode']['inputflags'] !== null && config['transcode']['inputflags'] !== "") args = args.concat(config['transcode']['inputflags'].split(" "));
args = args.concat(['-i', 'rtmp://127.0.0.1:'+config['rtmp']['port']+'/'+config['media']['privateEndpoint']+'/'+key, '-movflags', '+faststart']);
if(config['transcode']['adaptive']===true && config['transcode']['variants'] > 1) { if(config['transcode']['adaptive']===true && config['transcode']['variants'] > 1) {
for(let i=0;i<config['transcode']['variants'];i++){ for(let i=0;i<config['transcode']['variants'];i++){
args = args.concat(['-map', '0:2']); args = args.concat(['-map', '0:2']);
@ -133,8 +139,11 @@ async function transCommand(user: string, key: string): Promise<string[]>{
else { else {
args = args.concat(['-c:a', 'aac', '-c:v', 'libx264']); args = args.concat(['-c:a', 'aac', '-c:v', 'libx264']);
} }
args = args.concat(['-preset', 'veryfast', '-tune', 'zerolatency']);
//if(config['transcode']['format'] === 'dash') //if(config['transcode']['format'] === 'dash')
args = args.concat(['-remove_at_exit', '1', '-seg_duration', '1', '-window_size', '30', '-f', 'dash', config['http']['directory']+'/'+config['media']['publicEndpoint']+'/'+user+'/index.mpd']); args = args.concat(['-remove_at_exit', '1', '-seg_duration', '1', '-window_size', '30']);
if(config['transcode']['outputflags'] !== null && config['transcode']['outputflags'] !== "") args = args.concat(config['transcode']['outputflags'].split(" "));
args = args.concat(['-f', 'dash', config['http']['directory']+'/'+config['media']['publicEndpoint']+'/'+user+'/index.mpd']);
//else if(config['transcode']['format'] === 'hls') //else if(config['transcode']['format'] === 'hls')
//args = args.concat(['-remove_at_exit', '1', '-hls_time', '1', '-hls_list_size', '30', '-f', 'hls', config['http']['directory']+'/'+config['media']['publicEndpoint']+'/'+user+'/index.m3u8']); //args = args.concat(['-remove_at_exit', '1', '-hls_time', '1', '-hls_list_size', '30', '-f', 'hls', config['http']['directory']+'/'+config['media']['publicEndpoint']+'/'+user+'/index.m3u8']);
return args; return args;