From 671c2d177f07d61370be4e5dd148270ac85eca64 Mon Sep 17 00:00:00 2001 From: Jonatan Nilsson Date: Tue, 8 Dec 2020 11:09:46 +0000 Subject: [PATCH] Refactor to be service-core compatible --- .circleci/config.yml | 33 ++- api/casparcg/client.mjs | 250 ++++++++++++++++---- api/casparcg/{connection.mjs => status.mjs} | 2 +- api/config.mjs | 67 ------ api/db.mjs | 162 ------------- api/graphic/routes.mjs | 2 +- api/io/helper.mjs | 6 +- api/log.mjs | 20 -- api/routerio.mjs | 7 +- api/server.mjs | 78 +++--- api/settings/routes.mjs | 6 +- app/main/add/module.js | 63 +++-- app/main/common/components.js | 8 +- app/main/graphic/engine/schedule.js | 2 +- app/main/graphic/engine/text.js | 18 +- app/main/menu.js | 121 ++++++---- app/main/module.js | 81 +++++++ app/shared/defaults.js | 34 +++ config.json | 9 + index.mjs | 14 +- package.json | 22 +- public/main.css | 6 + runner.mjs | 10 + 23 files changed, 570 insertions(+), 451 deletions(-) rename api/casparcg/{connection.mjs => status.mjs} (68%) delete mode 100644 api/config.mjs delete mode 100644 api/db.mjs delete mode 100644 api/log.mjs create mode 100644 app/main/module.js create mode 100644 app/shared/defaults.js create mode 100644 config.json create mode 100644 runner.mjs diff --git a/.circleci/config.yml b/.circleci/config.yml index 5b68739..e8c439f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,21 +3,36 @@ jobs: build: docker: - image: circleci/node:latest - working_directory: ~/caspar-sup + working_directory: ~/app steps: - checkout - - setup_remote_docker - run: - name: Build docker image - command: docker build -t nfpis/caspar-sup:build_${CIRCLE_BUILD_NUM} -t nfpis/caspar-sup:${CIRCLE_SHA1} -t nfpis/caspar-sup:latest . - - deploy: - name: Push to docker + name: Install npm deployment app + command: sudo npm install -g github-release-cli + - run: + name: Build client javascript command: | - docker login -u $DOCKER_USER -p $DOCKER_PASS - docker push nfpis/caspar-sup + npm install + npm run build + - deploy: + name: Create a release + command: | + PACKAGE_VERSION=$(cat package.json | grep version | head -1 | awk -F: '{ print $2 }' | sed 's/[", ]//g') + echo "Packaging to ${CIRCLE_PROJECT_REPONAME}_build-sc.zip" + zip "${CIRCLE_PROJECT_REPONAME}_build-sc.zip" index.mjs package.json public/* api/**/* api/* + echo "Creating release '${PACKAGE_VERSION}.${CIRCLE_BUILD_NUM}'" + github-release upload \ + --commitish $CIRCLE_SHA1 \ + --token $GITHUB_TOKEN \ + --owner $CIRCLE_PROJECT_USERNAME \ + --repo $CIRCLE_PROJECT_REPONAME \ + --tag "v${PACKAGE_VERSION}.${CIRCLE_BUILD_NUM}" \ + --name "v${PACKAGE_VERSION}.${CIRCLE_BUILD_NUM}" \ + --body "Automatic CircleCI Build of v${PACKAGE_VERSION}.${CIRCLE_BUILD_NUM} from ${CIRCLE_SHA1}" \ + caspar_sup-sc.zip workflows: version: 2 build_deploy: jobs: - build: - context: org-global + context: github-thething diff --git a/api/casparcg/client.mjs b/api/casparcg/client.mjs index 01e0494..9de1aed 100644 --- a/api/casparcg/client.mjs +++ b/api/casparcg/client.mjs @@ -1,69 +1,219 @@ -import CasparConnection from 'casparcg-connection' - -const CasparCG = CasparConnection.CasparCG -const AMCP = CasparConnection.AMCP - -const timeoutDuration = 5000 +import net from 'net' +import parser from 'p3x-xml2json' let io let logger - -let connection -let casparIsPlaying -let casparIsConnected let currentHost +let client +let db -export function initialise(log, db, socket) { - io = socket - logger = log - db = db +let queue = [] +let reconnectInterval = 1000 +let isReconnecting = false +let connected = false +let playing = false +let lastError = '' - connect(db) +function startReconnecting() { + connected = false + playing = false + if (queue.length) { + queue.splice(0, queue.length) + } + if(isReconnecting !== false) return + reconnectInterval = Math.min(reconnectInterval * 1.5, 1000 * 60 * 5) + isReconnecting = setTimeout(connect, reconnectInterval) } -export function connect(db) { +function clearReconnect() { + if(isReconnecting === false) return + clearTimeout(isReconnecting) + isReconnecting = false +} + +export function queueCommand(command) { + return new Promise((res, rej) => { + if (isReconnecting) { + return rej(new Error('CasparCG is not connected, unable to play command')) + } + let request = { + command: command, + res: res, + rej: rej, + started: new Date(), + finished: null, + timeout: null, + } + queue.push(request) + + request.timeout = setTimeout(function() { + if (request.finished) return + queue.splice(queue.indexOf(request), 1) + rej(new Error(`CasparCGCommand "${command}" timed out after 15 seconds`)) + }, 15000) + + logger.info('CasparCG Command:', command) + client.write(command + '\r\n') + }) +} + +export async function checkPlaying(db, io, wasSuccess) { + if (!connected) return + let path = `http://${db.get('settings.casparplayhost').value()}/client.html` + + try { + logger.info('CasparCG: Checking if already playing') + let res = await queueCommand('INFO 1-100') + if (res.body.channel + && res.body.channel.stage + && res.body.channel.stage.layer + && res.body.channel.stage.layer.layer_100 + && res.body.channel.stage.layer.layer_100.foreground + && res.body.channel.stage.layer.layer_100.foreground.file + && res.body.channel.stage.layer.layer_100.foreground.file.path === path) { + logger.info('CasparCG: Player is playing') + playing = true + lastError = '' + io.emit('casparcg.status', currentStatus()) + return + } + if (wasSuccess) { + logger.warn(res.body, 'CasparCG: Playing was marked as succeeded but could not verify it') + playing = true + lastError = 'Sending play command succeeded but was unable to verify' + io.emit('casparcg.status', currentStatus()) + return + } + + playing = false + lastError = 'Sending play command' + io.emit('casparcg.status', currentStatus()) + logger.info(res.body, 'CasparCG: Sending play command') + res = await queueCommand(`PLAY 1-100 [HTML] "${path}" CUT 1 LINEAR RIGHT`) + return setTimeout(function() { + checkPlaying(db, io, true).then() + }, 300) + } catch (err) { + playing = false + lastError = `CasparCG: Error checking if playing: ${err.message}. Checking again in 5seconds` + logger.error(err, 'CasparCG: Error checking if playing') + io.emit('casparcg.status', currentStatus()) + } + + return setTimeout(function() { + checkPlaying(db, io, false).then() + }, 5000) +} + +export function initialise(log, database, ioOrg) { + io = ioOrg + logger = log + db = database + + client = new net.Socket() + client.setEncoding('utf8') + + client.on('connect', function () { + clearReconnect() + connected = true + lastError = '' + reconnectInterval = 1000 + logger.info('CasparCG: Connected to server') + io.emit('casparcg.status', currentStatus()) + checkPlaying(db, io, false).then() + // client.write('INFO 1-100\r\n'); + }) + + client.on('data', function (data) { + let request = null + + if (queue.length > 0) { + request = queue[0] + queue.splice(0, 1) + } + + if (!request) { + return logger.warn({ data }, 'Received unknown response with no command') + } + + let status + let splitted + let header + let body + let parsed + + try { + splitted = data.split('\n') + header = splitted[0].replace('\r', '') + status = Number(header.split(' ')[0]) + body = splitted.slice(1) + parsed = JSON.parse(parser.toJson(body.join('\n'))) + } catch (err) { + return request.rej(err) + } + + request.finished = new Date() + clearTimeout(request.timeout) + if (status && status < 300) { + request.res({ + status: status, + header: header, + body: parsed || {}, + raw: data + }) + } else { + request.err({ + status: status, + header: header, + body: parsed || {}, + raw: data + }) + } + }) + + client.on('error', function (err) { + lastError = 'CasparCG TCP Error: ' + err.code + ', retrying in ' + Math.round(reconnectInterval / 1000) + ' sec' + logger.warn(lastError) + io.emit('casparcg.status', currentStatus()) + startReconnecting() + }) + client.on('close', function() { + startReconnecting() + }) + client.on('end', function() { + startReconnecting() + }) + + connect() +} + +export function connect() { + clearReconnect() currentHost = db.get('settings').value().casparhost - casparIsPlaying = false - casparIsConnected = false - logger.info('CasparCG: Connectiong to', currentHost + ':' + 5250) + lastError = 'CasparCG: Connecting to ' + currentHost + ':' + 5250 + logger.info(lastError) + io.emit('casparcg.status', currentStatus()) - connection = new CasparCG({ - host: currentHost, + client.connect({ port: 5250, - queueMode: 2, - autoReconnectInterval: timeoutDuration, - onError: err => { - logger.error(err, 'CasparCG: Error') - }, - onConnectionStatus: data => { - if (casparIsPlaying) return - casparIsConnected = data.connected - - if (!casparIsConnected) { - logger.warn(`CasparCG: connection down, retrying in ${timeoutDuration / 1000} seconds`) - io.emit('casparcg.status', currentStatus()) - } - }, - onConnected: async connected => { - if (casparIsPlaying) return - logger.info('CasparCG: connected', connected) - if (!casparIsPlaying) { - startPlaying(db).then() - } else { - logger.warn('CasparCG: Stopped from starting play again.') - } - }, + host: currentHost }) } export function currentStatus(e) { return { - connected: casparIsConnected, - playing: casparIsPlaying, - error: e, + connected: connected, + playing: playing, + error: lastError, } } +export function sendCommand(command) { + return new Promise(function(res, rej) { + + }) +} +/* export async function startPlaying(db) { let ip = db.get('settings').value().casparplayhost @@ -96,9 +246,9 @@ export async function startPlaying(db) { io.emit('casparcg.status', currentStatus()) logger.info('CasparCG: client is up and playing') /* console.log(connection) - for (var key in connection) { + for (let key in connection) { console.log(key, '=', typeof(connection[key])) - } */ + } connection.autoConnect = false // connection.close() } else { @@ -106,4 +256,4 @@ export async function startPlaying(db) { casparIsPlaying = false io.emit('casparcg.status', currentStatus(e)) } -} +}*/ diff --git a/api/casparcg/connection.mjs b/api/casparcg/status.mjs similarity index 68% rename from api/casparcg/connection.mjs rename to api/casparcg/status.mjs index 142df1d..c3e5e51 100644 --- a/api/casparcg/connection.mjs +++ b/api/casparcg/status.mjs @@ -1,5 +1,5 @@ import { currentStatus } from './client.mjs' -export async function casparConnection(ctx) { +export async function casparStatus(ctx) { ctx.socket.emit('casparcg.status', currentStatus()) } diff --git a/api/config.mjs b/api/config.mjs deleted file mode 100644 index 52bfab5..0000000 --- a/api/config.mjs +++ /dev/null @@ -1,67 +0,0 @@ -import nconf from 'nconf' -import { readFileSync } from 'fs' - -// Helper method for global usage. -nconf.inTest = () => nconf.get('NODE_ENV') === 'test' - -// Config follow the following priority check order: -// 1. Arguments -// 2. package.json -// 3. Enviroment variables -// 4. config/config.json -// 5. default settings - - -// Load arguments as highest priority -nconf.argv() - - -// Load package.json for name and such -let pckg = JSON.parse(readFileSync('./package.json')) -let project = { - name: pckg.name, - version: pckg.version, - description: pckg.description, - author: pckg.author, - license: pckg.license, - homepage: pckg.homepage, -} - -// If we have global.it, there's a huge chance -// we're in test mode so we force node_env to be test. -if (typeof global.it === 'function') { - project.NODE_ENV = 'test' -} - - -// Load overrides as second priority -nconf.overrides(project) - - -// Load enviroment variables as third priority -nconf.env() - - -// Load any overrides from the appropriate config file -let configFile = 'config/config.json' - -if (nconf.get('NODE_ENV') === 'test') { - configFile = 'config/config.test.json' -} - - -nconf.file('main', configFile) - -// Load defaults -nconf.file('default', 'config/config.default.json') - - -// Final sanity checks -/* istanbul ignore if */ -if (typeof global.it === 'function' & !nconf.inTest()) { - // eslint-disable-next-line no-console - console.log('Critical: potentially running test on production enviroment. Shutting down.') - process.exit(1) -} - -export default nconf diff --git a/api/db.mjs b/api/db.mjs deleted file mode 100644 index 052faac..0000000 --- a/api/db.mjs +++ /dev/null @@ -1,162 +0,0 @@ -import lowdb from 'lowdb' -import FileAsync from 'lowdb/adapters/FileAsync.js' -import log from './log.mjs' - -let lastId = -1 - -// Take from https://github.com/typicode/lodash-id/blob/master/src/index.js -// from package lodash-id -const lodashId = { - // Empties properties - __empty: function (doc) { - this.forEach(doc, function (value, key) { - delete doc[key] - }) - }, - - // Copies properties from an object to another - __update: function (dest, src) { - this.forEach(src, function (value, key) { - dest[key] = value - }) - }, - - // Removes an item from an array - __remove: function (array, item) { - var index = this.indexOf(array, item) - if (index !== -1) array.splice(index, 1) - }, - - __id: function () { - var id = this.id || 'id' - return id - }, - - getById: function (collection, id) { - var self = this - return this.find(collection, function (doc) { - if (self.has(doc, self.__id())) { - return doc[self.__id()] === id - } - }) - }, - - createId: function (collection, doc) { - let next = new Date().getTime() - if (next <= lastId) { - next = lastId + 1 - } - lastId = next - return next - }, - - insert: function (collection, doc) { - doc[this.__id()] = doc[this.__id()] || this.createId(collection, doc) - var d = this.getById(collection, doc[this.__id()]) - if (d) throw new Error('Insert failed, duplicate id') - collection.push(doc) - return doc - }, - - upsert: function (collection, doc) { - if (doc[this.__id()]) { - // id is set - var d = this.getById(collection, doc[this.__id()]) - if (d) { - // replace properties of existing object - this.__empty(d) - this.assign(d, doc) - } else { - // push new object - collection.push(doc) - } - } else { - // create id and push new object - doc[this.__id()] = this.createId(collection, doc) - collection.push(doc) - } - - return doc - }, - - updateById: function (collection, id, attrs) { - var doc = this.getById(collection, id) - - if (doc) { - this.assign(doc, attrs, {id: doc.id}) - } - - return doc - }, - - updateWhere: function (collection, predicate, attrs) { - var self = this - var docs = this.filter(collection, predicate) - - docs.forEach(function (doc) { - self.assign(doc, attrs, {id: doc.id}) - }) - - return docs - }, - - replaceById: function (collection, id, attrs) { - var doc = this.getById(collection, id) - - if (doc) { - var docId = doc.id - this.__empty(doc) - this.assign(doc, attrs, {id: docId}) - } - - return doc - }, - - removeById: function (collection, id) { - var doc = this.getById(collection, id) - - this.__remove(collection, doc) - - return doc - }, - - removeWhere: function (collection, predicate) { - var self = this - var docs = this.filter(collection, predicate) - - docs.forEach(function (doc) { - self.__remove(collection, doc) - }) - - return docs - } -} - -const adapter = new FileAsync('db.json') - -export default function GetDB() { - return lowdb(adapter) - .then(function(db) { - db._.mixin(lodashId) - - db.defaults({ - graphics: [], - presets: [], - playing: [], - schedule: [], - settings: { - casparplayhost: 'localhost:3000', - casparhost: 'host.docker.internal', - }, - version: 1, - trash: [], - }) - .write() - .then( - function() { }, - function(e) { log.error(e, 'Error writing defaults to lowdb') } - ) - - return db - }) -} diff --git a/api/graphic/routes.mjs b/api/graphic/routes.mjs index 6d68966..b50d219 100644 --- a/api/graphic/routes.mjs +++ b/api/graphic/routes.mjs @@ -45,7 +45,7 @@ export async function create(ctx, data) { await graphics.insert(data).write() let graphic = graphics.last().value() - ctx.io.emit('graphic.single', graphic) + ctx.io.emit('graphic.created', graphic) } /* diff --git a/api/io/helper.mjs b/api/io/helper.mjs index 3078857..6a5fa4a 100644 --- a/api/io/helper.mjs +++ b/api/io/helper.mjs @@ -7,7 +7,11 @@ export function register(ctx, name, method) { } ctx.socket.on(name, async (data) => { - ctx.log.info('Got event', name) + if (name.indexOf('list') > 0 || name.indexOf('all') || name.indexOf('total')) { + ctx.log.debug('Got event', name) + } else { + ctx.log.info('Got event', name) + } try { await method(ctx, data) diff --git a/api/log.mjs b/api/log.mjs deleted file mode 100644 index 7939a95..0000000 --- a/api/log.mjs +++ /dev/null @@ -1,20 +0,0 @@ -import bunyan from 'bunyan-lite' -import defaults from './defaults.mjs' -import config from './config.mjs' - -// Clone the settings as we will be touching -// on them slightly. -let settings = defaults(config.get('bunyan'), null) - -// Replace any instance of 'process.stdout' with the -// actual reference to the process.stdout. -for (let i = 0; i < settings.streams.length; i++) { - if (settings.streams[i].stream === 'process.stdout') { - settings.streams[i].stream = process.stdout - } -} - -// Create our logger. -const logger = bunyan.createLogger(settings) - -export default logger diff --git a/api/routerio.mjs b/api/routerio.mjs index e30dc71..36df2ea 100644 --- a/api/routerio.mjs +++ b/api/routerio.mjs @@ -1,7 +1,6 @@ -import logger from './log.mjs' import { register } from './io/helper.mjs' import { contentConnection } from './content/connection.mjs' -import { casparConnection } from './casparcg/connection.mjs' +import { casparStatus } from './casparcg/status.mjs' import * as content from './content/routes.mjs' import * as engine from './engine/routes.mjs' @@ -10,7 +9,7 @@ import * as preset from './preset/routes.mjs' import * as settings from './settings/routes.mjs' import * as schedule from './schedule/routes.mjs' -function onConnection(server, db, data) { +function onConnection(server, db, logger, data) { const io = server const socket = data const log = logger.child({ @@ -20,7 +19,7 @@ function onConnection(server, db, data) { let ctx = { io, socket, log, db } contentConnection(ctx) - casparConnection(ctx) + casparStatus(ctx) register(ctx, 'content', content) register(ctx, 'engine', engine) diff --git a/api/server.mjs b/api/server.mjs index 48990c4..e4e7f09 100644 --- a/api/server.mjs +++ b/api/server.mjs @@ -1,30 +1,53 @@ +import path from 'path' +import { fileURLToPath } from 'url' import socket from 'socket.io-serveronly' -import http from 'http' -import nStatic from 'node-static' -import * as casparcg from './casparcg/client.mjs' +import nStatic from 'node-static-lib' -import lowdb from './db.mjs' -import config from './config.mjs' -import log from './log.mjs' +import * as casparcg from './casparcg/client.mjs' import onConnection from './routerio.mjs' -log.info('Server: Opening database db.json') +export function run(config, db, log, core, http, orgPort) { + log.info('Server: Opening database db.json') + + db.defaults({ + graphics: [], + presets: [], + playing: [], + schedule: [], + settings: { + casparplayhost: 'localhost:3000', + casparhost: 'localhost', + }, + version: 1, + trash: [], + }) + .write() + .then( + function() { }, + function(e) { log.error(e, 'Error writing defaults to lowdb') } + ) + + const __dirname = path.dirname(fileURLToPath(import.meta.url)) + const staticRoot = path.join(__dirname,'../public') + const fileServer = new nStatic.Server(staticRoot) -lowdb().then(function(db) { - const fileServer = new nStatic.Server('./public') const server = http.createServer(function (req, res) { const child = log.child({}) const d1 = new Date().getTime() + let isFinished = false + var done = function () { + if (isFinished) return + isFinished = true var requestTime = new Date().getTime() - d1 - let level = 'info' - if (res.status >= 400) { + let level = 'debug' + if (res.statusCode >= 400) { level = 'warn' } - if (res.status >= 500) { + if (res.statusCode >= 500) { level = 'error' } @@ -45,7 +68,9 @@ lowdb().then(function(db) { fileServer.serve(req, res, function (err) { if (err) { - log.error(err); + if (err.status !== 404) { + log.error(err, req.url); + } res.writeHead(err.status, err.headers); res.end(err.message); @@ -55,21 +80,20 @@ lowdb().then(function(db) { }) const io = new socket(server) - io.on('connection', onConnection.bind(this, io, db)) + io.on('connection', onConnection.bind(this, io, db, log)) casparcg.initialise(log, db, io) - server.listen(config.get('server:port'), '0.0.0.0', function(err) { - if (err) { - log.fatal(err) - return process.exit(2) - } - log.info(`Server is listening on ${config.get('server:port')}`) + let port = orgPort || 3000 + + return new Promise(function(resolve, reject) { + server.listen(port, '0.0.0.0', function(err) { + if (err) { + return reject(err) + } + log.event.info(`Server is listening on ${port} serving files on ${staticRoot}`) + log.info(`Server is listening on ${port} serving files on ${staticRoot}`) + resolve() + }) }) -}, function(e) { - log.fatal(e, 'Critical error loading database') - process.exit(1) -}).catch(function(e) { - log.fatal(e, 'Critical error starting server') - process.exit(1) -}) +} diff --git a/api/settings/routes.mjs b/api/settings/routes.mjs index f4a74db..8512fd7 100644 --- a/api/settings/routes.mjs +++ b/api/settings/routes.mjs @@ -25,12 +25,12 @@ export async function update(ctx, data) { return } - await Settings.setValue(data.name, data.value) + await ctx.db.set('settings.' + data.name, data.value).write() - let output = await Settings.getSettings() + let output = ctx.db.get('settings').value() ctx.io.emit('settings.all', output) - if (data.name === 'casparcg') { + if (data.name.startsWith('caspar')) { connect() } } diff --git a/app/main/add/module.js b/app/main/add/module.js index 7415ec8..2db4b25 100644 --- a/app/main/add/module.js +++ b/app/main/add/module.js @@ -1,18 +1,30 @@ const m = require('mithril') const createModule = require('../common/module') +const Module = require('../module') const components = require('../common/components') const socket = require('../../shared/socket') const store = require('../store') -const Add = createModule({ +const Add = Module({ init: function() { - this.monitor('engines', 'engine.all', []) - store.listen('graphic.single', data => { - if (data.name === this.graphic.name) { - m.route.set(`/graphic/${data.id}`) + this.engines = [] + this.graphic = { } + this._socketOn(() => this.socketOpen()) + }, + + socketOpen: function() { + socket.on('engine.all', (res) => { + this.engines = res + m.redraw() + }) + + socket.on('graphic.created', (res) => { + if (res.name === this.graphic.name) { + m.route.set(`/graphic/${res.id}`) } }) - this.graphic = { } + + socket.emit('engine.all', {}) }, updated: function(name, control) { @@ -34,24 +46,25 @@ const Add = createModule({ removing: function() { store.unlisten('graphic.single') }, -}, function() { - return [ - m('h4.header', 'Add graphic'), - components.error(this.error), - m('label', { for: 'create-name' }, 'Name'), - m('input#create-name[type=text]', { - oninput: (control) => this.updated('name', control), - }), - m('label', { for: 'create-engine' }, 'Engine'), - m('select', { - onchange: (control) => this.updated('engine', control), - }, this.engines.map(engine => - m('option', { key: engine, value: engine }, engine) - )), - m('input[type=submit]', { - value: 'Create', - onclick: () => this.create(), - }), - ] + view: function() { + return [ + m('h4.header', 'Add graphic'), + components.error(this.error), + m('label', { for: 'create-name' }, 'Name'), + m('input#create-name[type=text]', { + oninput: (control) => this.updated('name', control), + }), + m('label', { for: 'create-engine' }, 'Engine'), + m('select', { + onchange: (control) => this.updated('engine', control), + }, this.engines.map(engine => + m('option', { key: engine, value: engine }, engine) + )), + m('input[type=submit]', { + value: 'Create', + onclick: () => this.create(), + }), + ] + }, }) module.exports = Add diff --git a/app/main/common/components.js b/app/main/common/components.js index ef2b4eb..23541f2 100644 --- a/app/main/common/components.js +++ b/app/main/common/components.js @@ -7,10 +7,9 @@ exports.error = function(error) { } exports.presetOnlyList = function(module, graphic, title, color = 'green', button = 'Display now', schedule = 'Schedule') { - return [ - m('label.graphic-label', { key: 'first' }, title), + return m('div', [ + m('label.graphic-label', title), m('div.graphic-presetlist', { - key: `second-${graphic.id}`, oncreate: control => module.presetlistInit(control), }, module.presets.map(item => @@ -37,10 +36,9 @@ exports.presetOnlyList = function(module, graphic, title, color = 'green', butto ), module.presets.length && m('button.red.graphic-presetremove', { - key: 'third', onclick: () => (module.displayRemove = !module.displayRemove), }, 'Remove entries') || null, - ] + ]) } exports.presetButtons = function(module, green, blue) { diff --git a/app/main/graphic/engine/schedule.js b/app/main/graphic/engine/schedule.js index 263d7c9..eb3aca7 100644 --- a/app/main/graphic/engine/schedule.js +++ b/app/main/graphic/engine/schedule.js @@ -105,7 +105,7 @@ exports.settings = function(module, graphic) { m('div.graphic-property', [ m('input#graphic-newproperty[type=text]', { value: module.newProperty, - oninput: m.withAttr('value', val => (module.newProperty = val)), + oninput: (control) => { module.newProperty = control.target.value }, }), m('button', { onclick: module.addProperty.bind(module), diff --git a/app/main/graphic/engine/text.js b/app/main/graphic/engine/text.js index 4afc9d1..a417420 100644 --- a/app/main/graphic/engine/text.js +++ b/app/main/graphic/engine/text.js @@ -20,13 +20,15 @@ exports.view = function(module, graphic) { return [ m('div.graphic-presetadd', [ m('h3.graphic-presetadd-header', 'Create preset/display graphic'), - graphic.settings.properties.map((prop, index) => m.fragment({ key: `prop-${index}` }, [ - m('label', { for: `preset-add-${index}` }, prop), - m(`input#preset-add-${index}[type=text]`, { - value: module.current[prop] || '', - oninput: module.updated.bind(module, prop, 'current'), - }), - ])), + m.fragment( + graphic.settings.properties.map((prop, index) => m.fragment({ key: `prop-${index}` }, [ + m('label', { for: `preset-add-${index}` }, prop), + m(`input#preset-add-${index}[type=text]`, { + value: module.current[prop] || '', + oninput: module.updated.bind(module, prop, 'current'), + }), + ])) + ), components.presetButtons(module, 'Display live now', 'Add to preset list'), ]), components.presetOnlyList(module, graphic, 'Presets'), @@ -105,7 +107,7 @@ exports.settings = function(module, graphic) { m('div.graphic-property', [ m('input#graphic-newproperty[type=text]', { value: module.newProperty, - oninput: m.withAttr('value', val => (module.newProperty = val)), + oninput: (control) => { module.newProperty = control.target.value }, }), m('button', { onclick: module.addProperty.bind(module), diff --git a/app/main/menu.js b/app/main/menu.js index 5d3964f..6646a82 100644 --- a/app/main/menu.js +++ b/app/main/menu.js @@ -1,20 +1,54 @@ const m = require('mithril') -const createModule = require('./common/module') +const Module = require('./module') +// const createModule = require('./common/module') const socket = require('../shared/socket') -const Menu = createModule({ +const Menu = Module({ init: function() { - this.monitor('list', 'graphic.all', []) - this.monitor('settings', 'settings.all', {}) - this.monitor('schedule', 'schedule.total', { total: 0 }) - this.monitor('status', 'casparcg.status', { + this.list = [] + this.settings = {} + this.totalSchedule = 0 + this.status = { connected: false, playing: false, - }) + error: '', + } + this._socketOn(() => this.socketOpen()) this.newHost = '' this.enableEdit = false }, + socketOpen: function() { + socket.on('graphic.all', (res) => { + this.list = res + m.redraw() + }) + socket.on('graphic.created', (res) => { + this.list.push(res) + m.redraw() + }) + + this.on('settings.all', (res) => { + this.settings = res + m.redraw() + }) + + this.on('schedule.total', (res) => { + this.totalSchedule = res.total + m.redraw() + }) + + this.on('casparcg.status', (res) => { + this.status = res + m.redraw() + }) + + socket.emit('graphic.all', {}) + socket.emit('settings.all', {}) + socket.emit('schedule.total', {}) + socket.emit('casparcg.status', {}) + }, + setHost(value) { this.newHost = value this.enableEdit = true @@ -29,42 +63,41 @@ const Menu = createModule({ this.newHost = '' this.enableEdit = false }, -}, function() { - return [ - m('a', { - href: '/', - oncreate: m.route.link, - class: m.route.get() === '/' && 'active' || '', - }, `Schedule (${this.schedule.total})` ), - m('h4.header.header--space', 'Graphics'), - this.list.map((item) => - m('a', { - href: `/graphic/${item.id}`, - oncreate: m.route.link, - class: m.route.get() === `/graphic/${item.id}` && 'active' || '', - }, item.name) - ), - m('h5.header.header--space', 'Other'), - m('a', { - href: '/add', - oncreate: m.route.link, - class: m.route.get() === '/add' && 'active' || '', - }, 'Add graphic' ), - m('h5.header.header--space', 'CasparCG Status'), - m('input[type=text]', { - placeholder: 'Host IP', - value: this.newHost || this.settings.casparhost || '', - oninput: control => this.setHost(control.target.value), - }), - this.enableEdit && m('button', { - onclick: () => this.saveNewHost(), - }, 'Connect'), - m('div.status', { - class: this.status.connected && 'green', - }, 'connected'), - m('div.status', { - class: this.status.playing && 'green', - }, 'playing'), - ] + view: function() { + return [ + m(m.route.Link, { + href: '/', + class: m.route.get() === '/' && 'active' || '', + }, `Schedule (${this.totalSchedule})` ), + m('h4.header.header--space', 'Graphics'), + this.list.map((item) => + m(m.route.Link, { + href: `/graphic/${item.id}`, + class: m.route.get() === `/graphic/${item.id}` && 'active' || '', + }, item.name) + ), + m('h5.header.header--space', 'Other'), + m(m.route.Link, { + href: '/add', + class: m.route.get() === '/add' && 'active' || '', + }, 'Add graphic' ), + m('h5.header.header--space', 'CasparCG Status'), + m('input[type=text]', { + placeholder: 'Host IP', + value: this.newHost || this.settings.casparhost || '', + oninput: control => this.setHost(control.target.value), + }), + this.enableEdit && m('button', { + onclick: () => this.saveNewHost(), + }, 'Connect'), + m('div.status', { + class: this.status.connected && 'green', + }, 'connected'), + m('div.status', { + class: this.status.playing && 'green', + }, 'playing'), + m('div.status-error', { hidden: !this.status.error }, this.status.error) + ] + } }) module.exports = Menu diff --git a/app/main/module.js b/app/main/module.js new file mode 100644 index 0000000..0402be4 --- /dev/null +++ b/app/main/module.js @@ -0,0 +1,81 @@ +const defaults = require('../shared/defaults') +const socket = require('../shared/socket') + +// https://stackoverflow.com/questions/4817029/whats-the-best-way-to-detect-a-touch-screen-device-using-javascript/4819886#4819886 +// LOL +function is_touch_device() { + var prefixes = ' -webkit- -moz- -o- -ms- '.split(' ') + var mq = function(query) { + return window.matchMedia(query).matches + } + + if (('ontouchstart' in window) || window.DocumentTouch && document instanceof DocumentTouch) { + return true + } + + // include the 'heartz' as a way to have a non matching MQ to help terminate the join + // https://git.io/vznFH + var query = ['(', prefixes.join('touch-enabled),('), 'heartz', ')'].join('') + return mq(query) +} + +module.exports = function Module(module) { + return defaults(module, { + init: function() {}, + + oninit: function(vnode) { + this._listeners = [] + this.init(vnode) + }, + + _listeners: null, + + _socketOn: function(cb) { + socket.on('connect', () => cb()) + + if (socket.connected) { + cb() + } + }, + + _initDragula: function(control, cb) { + let dragContainer = document.getElementById('dragcontainer') + let out = dragula([control.dom], { + mirrorContainer: dragContainer, + invalid: el => el.className !== 'graphic-preset-reorder' + && el.className !== 'graphic-preset', + }) + out.on('dragend', () => { + if (is_touch_device()) { + document.body.style.cssText = '' + window.scroll(0, document.body.data) + } + }) + out.on('drag', () => { + if (is_touch_device()) { + document.body.data = window.scrollY + document.body.style.cssText = `position: fixed; left: 0; right: 0; overflow: hidden; top: -${window.scrollY}px;` + dragContainer.style.marginTop = `${document.body.data}px` + } + }) + out.on('drop', (a, b, c, d) => { + cb(a, d) + }) + }, + + on: function(name, cb) { + this._listeners.push([name, cb]) + socket.on(name, cb) + }, + + remove: function() {}, + + onremove: function() { + this.remove() + if (!this._listeners) return + for (let i = 0; i < this._listeners.length; i++) { + socket.removeListener(this._listeners[0], this._listeners[1]) + } + }, + }) +} diff --git a/app/shared/defaults.js b/app/shared/defaults.js new file mode 100644 index 0000000..01f6560 --- /dev/null +++ b/app/shared/defaults.js @@ -0,0 +1,34 @@ + +// taken from isobject npm library +function isObject(val) { + return val != null && typeof val === 'object' && Array.isArray(val) === false +} + +module.exports = function defaults(options, def) { + let out = { } + + if (options) { + Object.keys(options || {}).forEach(key => { + out[key] = options[key] + + if (Array.isArray(out[key])) { + out[key] = out[key].map(item => { + if (isObject(item)) return defaults(item) + return item + }) + } else if (out[key] && typeof out[key] === 'object') { + out[key] = defaults(options[key], def && def[key]) + } + }) + } + + if (def) { + Object.keys(def).forEach(function(key) { + if (typeof out[key] === 'undefined') { + out[key] = def[key] + } + }) + } + + return out +} diff --git a/config.json b/config.json new file mode 100644 index 0000000..31a28d2 --- /dev/null +++ b/config.json @@ -0,0 +1,9 @@ +{ + "name": "caspar-sup", + "serviceName": "Caspar Sup", + "description": "Caspar Sup manager", + "port": 3000, + "managePort": 3001, + "appRepository": null, + "manageRepository": null +} diff --git a/index.mjs b/index.mjs index 030aa05..6c8677a 100644 --- a/index.mjs +++ b/index.mjs @@ -1,9 +1,5 @@ -import log from './api/log.mjs' - -// Run the database script automatically. -log.info('Starting server.') - -import('./api/server.mjs').catch((error) => { - log.error(error, 'Error while starting server') - process.exit(1) -}) +export function start(config, db, log, core, http, port) { + return import('./api/server.mjs').then(function(module) { + return module.run(config, db, log, core, http, port) + }) +} diff --git a/package.json b/package.json index 82dee77..c791211 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,10 @@ "js:build:client": "asbundle app/client/index.js public/client.js", "js:build:status": "asbundle app/status/index.js public/status.js", "js:watch": "nodemon --watch app --exec \"npm run build\"", - "start:watch": "nodemon --experimental-modules --watch api index.mjs | bunyan -o short", + "start:watch": "nodemon --watch api --watch runner.mjs --watch index.mjs runner.mjs | bunyan", "start": "node --experimental-modules index.mjs | bunyan -o short", "dev": "run-p js:watch start:watch", - "build": "run-p js:build:main js:build:client js:build:status", - "docker": "docker run -it --rm --name my-running-script -p 3000:3000 -v \"%cd%\":/usr/src/app -w /usr/src/app node:alpine", - "docker:install": "npm run docker -- npm install", - "docker:dev": "npm run docker -- npm run dev" + "build": "npm run js:build:main && npm run js:build:client && npm run js:build:status" }, "repository": { "type": "git", @@ -33,19 +30,16 @@ }, "homepage": "https://github.com/nfp-projects/caspar-sup#readme", "dependencies": { - "bunyan-lite": "^1.0.1", - "casparcg-connection": "4.9.0", "lodash": "^4.5.0", - "lowdb": "^1.0.0", - "nconf": "^0.9.1", - "node-static": "^0.7.11", - "socket.io-serveronly": "^2.3.0", - "tslib": "^1.11.1" + "node-static-lib": "^1.0.0", + "p3x-xml2json": "^2020.10.131", + "socket.io-serveronly": "^2.3.0" }, "devDependencies": { "asbundle": "^2.6.1", "dragula": "^3.7.2", - "mithril": "^1.1.5", - "npm-run-all": "^4.1.2" + "mithril": "^2.0.4", + "npm-run-all": "^4.1.2", + "service-core": "^2.0.0" } } diff --git a/public/main.css b/public/main.css index b3cf9de..15ba835 100644 --- a/public/main.css +++ b/public/main.css @@ -149,6 +149,12 @@ nav .status { position: relative; margin-left: 1.8em; } +nav .status-error { + color: red; + font-size: 0.8rem; + text-align: center; + margin-top: 5px; +} nav .status::after { position: absolute; left: 0; diff --git a/runner.mjs b/runner.mjs new file mode 100644 index 0000000..3899831 --- /dev/null +++ b/runner.mjs @@ -0,0 +1,10 @@ +import ServiceCore from 'service-core' +import * as server from './index.mjs' + +const serviceCore = new ServiceCore('sc-manager', import.meta.url) + +serviceCore.init(server) + .then(function() {}) + .catch(function(err) { + serviceCore.log.error(err, 'Unknown error starting server') + })