// ===============================================================
//
// "Web Graphics Controller"
// (c) 2020- Sh Graphics
//
// No front-end frameworks.
// Core functionality: NodeJS, Express, Handlebars -templating.
//
// Most serverside code is in /routes and /utils.
// Most clientside code is in /static/js/spx_gc.js
//
// Basic usage:
// node server.js [config.json]
//
// ===============================================================
const express = require('express')
const session = require('express-session')
const bodyParser = require('body-parser')
const cors = require('cors')
const exphbs = require('express-handlebars')
const app = express()
const ip = require('ip')
const fs = require('fs')
const morgan = require('morgan')
const path = require('path')
const rfs = require('rotating-file-stream')
var macaddress = require('macaddress');
console.log('\n WebCG Controller server is starting.\n Closing this window/process will stop the server.\n');
// EXIT HANDLER
process.on('exit', function(code) {
// usage: process.exit(1)
let codestr="Unspecified exit code";
switch (code) {
case 1:
codestr = "Error while reading config.json"
break;
case 2:
codestr = "Kill request received from WebCG client. If pm2 used, the server will restart automatically."
break;
case 3:
codestr = "Port is already in use on this machine."
break;
default:
break;
}
return console.log(`\n\nWebCG exit! (Errorcode ${code}: ${codestr})\n\n`);
});
// global.config
const cfg = require('./utils/spx_getconf.js');
cfg.readConfig()
if (!configfileref) {
process.exit(1)
}
const spx = require('./utils/spx_server_functions.js');
const logger = require('./utils/logger.js');
// STATICS
app.use(express.static(path.join(__dirname,('static'))))
app.use(express.static(path.resolve(spx.getStartUpFolder(),'ASSETS')))
const ipad = ip.address();
var pjson = require('./package.json');
var packageversion = pjson.version;
const vers = process.env.npm_package_version || packageversion || 'X.X.X';
global.vers = vers;
global.excel = {'readtime':0, 'filename':'', 'data':''}; // used as Excel cache
// Added in 1.0.15. An memory persistent object to handle -------------
// rundown state to avoid unnecessary disk I/O and improve performance.
global.rundownData = {}
// --------------------------------------------------------------------
const port = config.general.port || 5656;
global.CCGSockets = [];
global.LastBrowsedTemplateFolder = '';
macaddress.one(function (err, mc) {
let macaddress = String(mc);
let pseudomac = macaddress.split(':').join('').substring(0,8);
global.hwid = config.general.hostname || pseudomac;
global.pmac = pseudomac;
});
// Setup Morgan
let LOGFOLDER = config.general.logfolder || spx.getStartUpFolder() + '/LOG';
var logDirectory = path.normalize(LOGFOLDER);
fs.existsSync(logDirectory) || fs.mkdirSync(logDirectory)
var accessLogStream = rfs.createStream('access.log', {
interval: '1d', // rotate daily
path: logDirectory
})
app.use(morgan('combined', { stream: accessLogStream }))
// Handlebars templating, updated in 1.3.3
app.engine('handlebars', exphbs.engine({
extname: '.handlebars',
//defaultLayout: 'filelist',
helpers: {
max5: function (data) {
if (parseInt(data)>5) { data = 5; }
return String(data);
},
// Convert JSON data and return as JSONstring.
// (used to store arrays to hidden form fields)
DataToJSONString: function (data) {
let s = encodeURI(JSON.stringify(data));
return s;
},
// conditional IF helper
ifValue: function(a, b, options) {
// console.log('a: ' + a + ', b: ' + b);
if(String(a) === String(b) ) {
return options.fn(this);
}
return options.inverse(this);
},
// conditional IF helper return value A if "value" == "compareTo", B if not
ifValueMatch: function(value, compareTo, A, B ) {
if(value == compareTo) {
return A;
} else {
return B;
}
},
// conditional IF helper
itemCount: function(arr) {
// console.log('a: ' + a + ', b: ' + b);
return arr.length;
},
// conditional IF helper
ifGreater: function(a, b, options) {
if(a > b) {
// console.log('TRUE. A (' + a + ') was bigger.');
return options.fn(this);
}
// console.log('FALSE. B (' + b + ') was bigger.');
return options.inverse(this);
},
// populate CasparCG options for show config templates
generateDataFormatOptions(currentDataformat='json') {
let html="";
let AvailableFormats=['json', 'xml'];
AvailableFormats.forEach((Option,i) => {
let curFormat = Option.toLowerCase();
let cfgFormat = currentDataformat.toLowerCase();
let sel = "";
if (curFormat==cfgFormat){ sel="selected" }
html += '';
});
return html
},
// populate CasparCG options for show config templates
generateCasparCgPlayoutOptions(currentServer) {
let html="";
let AvailableServers=['-'];
if (config.casparcg){
config.casparcg.servers.forEach((element,i) => {
AvailableServers.push(element.name);
});
}
AvailableServers.forEach((serverOption,i) => {
let curSrv = serverOption.toUpperCase();
let cfgSrv = currentServer.toUpperCase();
let sel = "";
if (curSrv==cfgSrv){ sel="selected" }
html += '';
});
return html
},
// generate Open datafolder -commands in the UI menu for different operating systems
GenerateOpenFolderCommands(){
let platform = process.platform;
let html="";
switch (platform) {
case 'win32':
html += '\n';
html += ' '+ spx.lang('button.exploredataroot') +'\n';
html += ' '+ spx.lang('button.exploretemplates') +'\n';
break;
default:
html = "\n";
break;
}
return html;
},
// draw all custom controls in the argument array
GenerateCustomControls(arr){
if (!arr) {return}; // if no extras, do nuthin'
let html = "";
html += '
\n';
arr.forEach((control,index) => {
html += '
'
html += '
'
html += '' + control.description + ''
html += '
'
html += '
'
switch (control.ftype){
case 'button':
html += '\n';
break;
case 'togglebutton':
html += '\n';
break;
case 'selectbutton':
html += '
\n';
html += '
\n';
html += '
\n';
html += '\n';
html += '
\n';
break;
default:
logger.warn('server.GenerateCustomControls error in item [' + index + '] unknown controltype [' + control.ftype + '].');
break;
}
html += '
\n'
});
html += '
\n'
return html;
},
// generate a pretty string for each template to display playout configs
GeneratePlayoutInfo(playserver='', playchannel='', playlayer='', webplayout='', out='' , itemid='') {
webplayout = spx.max5(webplayout);
let html = '
';
// html += '';
if (playserver !="-") {
html += "" + playserver + "-";
html += "" + playchannel + "-";
html += "" + playlayer + "";
html += " ";
// html += " ";
};
if (webplayout !="-") {
html += "" + webplayout + "";
// html += " ";
};
if (out) {
html += "" + out + "";
};
html += "ID";
// help button
html += "?";
html += "
"
return html
},
// populate filelist for the controller add CSV file view (for instance)
generateFileList(requestType) {
let folder = '' // folder to start to scan
let filter = '' // extensions to show ('csv' for example)
// requestType = search command, such as "CSV"
switch (requestType) {
case 'CSV':
folder = path.join(config.general.dataroot, currentShow, 'profile.json');
filter = 'csv';
break;
default:
logger.error('Unknown requestType ' + requestType + ', doing nothing');
return; // exit function
break;
}
let html="";
// get show profile
// iterate all templates
let profileFile = path.join(config.general.dataroot, currentShow, 'profile.json');
showProfileData = spx.GetJsonData(profileFile);
if(!showProfileData.templates) {
// if no templates assigned to this project
html += '' + spx.lang('warning.notemplates') + '';
return html
}
showProfileData.templates.forEach((template,i) => {
html += '
\n';
html += '\n';
html += '' + spx.prettifyName(template.relpath) + ' \n';
html += '' + template.description + ' \n';
html += '
\n';
});
return html
},
// populate templatelist for the controller add template view
generateTemplateList(currentShow) {
let html="";
// get show profile
// iterate all templates
let profileFile = path.join(config.general.dataroot, currentShow, 'profile.json');
showProfileData = spx.GetJsonData(profileFile);
if(!showProfileData.templates) {
// if no templates assigned to this project
html += '' + spx.lang('warning.notemplates') + '
';
return html
}
showProfileData.templates.forEach((template,i) => {
html += '
\n';
html += '\n';
html += '' + spx.prettifyName(template.relpath) + ' \n';
html += '' + template.description + ' \n';
html += '
\n';
});
return html
},
// convert timestamp (epoch) to locale date string "31.12.2021 17:45"
generateTimestamp(epoch) {
let date = new Date(parseInt(epoch));
let fDate = date.toLocaleDateString();
let fTime = date.toLocaleTimeString();
return fDate + ' ' + fTime;
},
// populate WebPlayout options for show config templates
generateButtonColorClasses(currentValue)
{
let html="";
let AvailableOptions=[
{
text:spx.lang('color.red'),
value:'bg_red'
},
{
text:spx.lang('color.green'),
value:'bg_green'
},
{
text:spx.lang('color.grey'),
value:'bg_grey'
},
{
text:spx.lang('color.orange'),
value:'bg_orange'
},
{
text:spx.lang('color.blue'),
value:'bg_blue'
},
{
text:spx.lang('color.black'),
value:'bg_black'
},
{
text:spx.lang('color.light'),
value:'bg_grey15'
}
];
AvailableOptions.forEach((OptionItem,i) => {
let sel = "";
if (OptionItem.value==currentValue){ sel="selected" }
html += '';
});
return html
},
// return username
CurrentUserName() {
return global.user;
},
// populate out options for show config templates
generateOutOptions(currentOut) {
logger.debug('Generating out options. This selection: ' + currentOut + '.');
let html="";
let AvailableOptions=[
{value:'manual', text:'Manually'},
{value:'none', text:'None (just play)'},
{value:'1000', text:'In 1 second'},
{value:'2000', text:'In 2 seconds'},
{value:'4000', text:'In 4 seconds'},
{value:'6000', text:'In 6 seconds'},
{value:'10000', text:'In 10 seconds'}
];
AvailableOptions.forEach((outOption,i) => {
let sel = "";
let CurVal = currentOut.toLowerCase();
let CurOpt = outOption.value.toLowerCase();
if (CurVal==CurOpt){ sel="selected" }
html += '';
});
return html
},
// populate WebPlayout options for show config templates
generateWebPlayoutOptions(currentLayer='-') {
// console.log('generateWebPlayoutOptions got currentLayer: ' + currentLayer);
currentLayer = spx.max5(currentLayer);
logger.debug('Generating webplayout options. This selection: '+ currentLayer);
let html="";
let AvailableLayers=['-',1,2,3,4,5];
AvailableLayers.forEach((layerOption,i) => {
let curSrv = layerOption || '-';
let cfgSrv = currentLayer || '-';
let sel = "";
if (curSrv==cfgSrv){ sel="selected" }
html += '';
});
return html
},
// preview span for collapsed view of each rundown item
generateCollapsedHeadline(DataFields) {
return spx.generateCollapsedHeadline(DataFields);
},
// generate radio buttons for show config templates
generateColorAccents(selectedIndex, templateIndex) {
let colors=['gra','red','ora','gre','blu','pin','vio','bla'];
let html ="";
let sel = "";
colors.forEach((color,loopindex) => {
if (selectedIndex==loopindex) { sel = "checked" } else {sel=""};
html += '';
});
return html
},
// Returns IP for socket server of this app's IP address
getServerAddress: function () {
var ip = require('ip');
var myIP = ip.address() + ':' + port;
return myIP;
},
// Returns resolution options for App Config > resolution
// Added in 1.3.0
generateResolutionOptions: function (resolutionOption) {
let html = '';
let options = [
{ 'value': 'HD', 'text': 'HD (1080p)' },
{ 'value': '4K', 'text': '4K (2160p)' },
{ 'value': 'AUTO', 'text': 'AUTO (responsive)' },
];
let selectedFound = false;
options.forEach((opt,i) => {
let sele = '';
if (opt.value.toUpperCase() == resolutionOption.toUpperCase()) {
selectedFound = true;
sele = 'selected'
};
html += '\n';
});
return html;
},
// See if cssSizeValue has 'vh' or 'vw' in it then return HD values
// Added in 1.3.0
localPreviewSize: function (cssSizeValue) {
if (cssSizeValue.includes('vw')) {
return '1920';
} else if (cssSizeValue.includes('vh')) {
return '1080';
} else {
return cssSizeValue;
}
},
// Returns unique ID for this host machine
getHostID: function () {
return global.pmac;
},
hookLoadControllerPlugins: function () {
// Loads init.js files from all sub folders of ASSETS/plugins//
let html = '';
try {
let BrowseFolder = path.join(spx.getStartUpFolder(), 'ASSETS', 'plugins');
const list = spx.GetFilesAndFolders(BrowseFolder);
list.foldArr.forEach(pluginName => {
if ( pluginName.charAt(0) == '_' || pluginName.charAt(0) == '.' || pluginName === 'lib' ) {
html += '\n'
} else {
logger.verbose('Loading plugin ' + pluginName );
html += '\n'
}
})
} catch (error) {
html = '';
}
return html
},
// Convert JSON \\n to newline for textarea
injectNewlines(jsonString) {
let multiline = jsonString.replace('\\n', '\r\n')
return multiline;
},
// Language string helper
lang(str) {
return spx.lang(str);
},
// Replace underscores with spaces (used by breadcrumbs)
prettyfyBreadcrumb(str) {
let fixed = str
fixed = fixed.replace(/_/g, ' ');
fixed = fixed.replace(/-/g, ' ');
return fixed;
},
// Return object as JSON string
myStringify(obj) {
return "// Stringified JSON\n\n" + JSON.stringify(obj, null, 4);
},
// Get template name from filepath
nameFromTemplatePath(filepath) {
return spx.prettifyName(filepath);
},
// Check config and return value
allowOpeningFolder() {
let disableButton = config.general.disableOpenFolderCommand || false;
// console.log('allowOpeningFolder: ' + disableButton);
if (disableButton==true || disableButton=="true") {
return 'disabled';
} else {
return '';
}
},
// Make long strings nicer to look at
shortifyUIstrings(str) {
return spx.shortifyName(str);
},
// generate checkbox to appconfig
OpenChromeCheck() {
let launch = config.general.launchBrowser || false;
if (launch){
return '';
}
else {
return '';
}
},
// Get a correct class for a play button
playButtonClass(onair) {
let value = onair || "false";
let ONAIR = String(value).toUpperCase();
if (ONAIR=="TRUE") {
return "bg_red";
} else {
return "bg_green";
}
},
// Get a correct class for a play button
playButtonText(onair) {
let value = onair || "false";
let ONAIR = String(value).toUpperCase();
if (ONAIR=="TRUE") {
return spx.lang('button.stop');
} else {
return spx.lang('button.play');
}
},
// Define a class for the playicon
playIconClass(onair) {
// console.log('Play icon [' + onair + ']');
let value = onair || "false";
let ONAIR = String(value).toUpperCase();
if (ONAIR=="TRUE") {
return "playTrue";
} else {
return "playFalse";
}
},
// Show username / password in the login view
ShowDemoLoginInfo() {
let showuserpass = config.general.showusercommapass || '';
if (showuserpass==""){
return "";
}
let u = showuserpass.split(',')[0]
let p = showuserpass.split(',')[1]
let html="";
html += '';
html += '
';
html += '
';
html += '';
html += 'Login to this instance with ';
html += 'user ' + u + ', ';
html += 'pass ' + p + '';
html += '';
html += '
';
html += '
';
return html;
},
// Print out version number
ShowVersion() {
return vers;
},
// Return available lang files as options with selected one
DropdownOptionsLANG() {
let html = '';
let sel = '';
let localesFolder = path.join(spx.getStartUpFolder(), 'locales');
let fileListData = spx.getJSONFileList(localesFolder);
fileListData.files.forEach((element,i) => {
sel = '';
if (element.name == config.general.langfile)
{
sel = 'selected';
}
html += '';
});
return html
},
// This is used to populate filelist dropdown control type in templates.
// Note: these files are returned as http-assets from SPX server
// Feature added in 1.0.3. and improved in 1.0.6, 1.0.9
// v1.0.15 adds relative assets (within template root folder).
PopulateFilelistOptions(assetfolder, extension, value, relpath='') {
let html = '';
let sel = '';
let fullFilePath = '';
let fullPath = '';
if ( assetfolder.startsWith('./') ) {
// relative path support added in 1.0.15
let templateFile = path.join(spx.getStartUpFolder(), 'ASSETS', 'templates', relpath);
let templateRootFolder = path.dirname(templateFile)
fullPath = path.join(templateRootFolder, assetfolder)
} else {
// absolute path in ASSETS
let assetPath = path.normalize(assetfolder || '');
fullPath = path.join(spx.getStartUpFolder(), 'ASSETS', assetPath); // fixed (again in 1.0.12)
}
let selectedValue = value;
let fileList = spx.getFileList(fullPath, extension);
// console.log('files', fileList);
if (fileList) {
fileList.forEach((fileRef,index) => {
if (assetfolder.substr(assetfolder.length - 1)!="/") { // fixed a bug in 1.1.1
assetfolder = assetfolder + "/";
}
fullFilePath = assetfolder + fileRef;
sel = "";
if (fullFilePath == selectedValue) {
sel = 'selected';
}
html += '';
});
}
return html
},
// Servers status check request
ServerStatus() {
let html = "";
if (config.casparcg) {
config.casparcg.servers.forEach((element,i) => {
let disabledClass = ''
if (element.disabled) {
disabledClass = 'disabledServer';
}
html += '