Files
streamwall/src/node/index.js
2020-07-05 20:37:49 -07:00

379 lines
10 KiB
JavaScript

import fs from 'fs'
import yargs from 'yargs'
import TOML from '@iarna/toml'
import * as Y from 'yjs'
import { create as createJSONDiffPatch } from 'jsondiffpatch'
import { Repeater } from '@repeaterjs/repeater'
import { app, shell, session, BrowserWindow } from 'electron'
import { ensureValidURL } from '../util'
import {
pollDataURL,
watchDataFile,
StreamIDGenerator,
markDataSource,
combineDataSources,
} from './data'
import StreamWindow from './StreamWindow'
import TwitchBot from './TwitchBot'
import StreamdelayClient from './StreamdelayClient'
import initWebServer from './server'
function parseArgs() {
return yargs
.config('config', (configPath) => {
return TOML.parse(fs.readFileSync(configPath, 'utf-8'))
})
.group(['grid.count'], 'Grid dimensions')
.option('grid.count', {
number: true,
default: 3,
})
.group(
[
'window.width',
'window.height',
'window.x',
'window.y',
'window.frameless',
'window.background-color',
],
'Window settings',
)
.option('window.x', {
number: true,
})
.option('window.y', {
number: true,
})
.option('window.width', {
number: true,
default: 1920,
})
.option('window.height', {
number: true,
default: 1080,
})
.option('window.frameless', {
boolean: true,
default: false,
})
.option('window.background-color', {
describe: 'Background color of wall (useful for chroma-keying)',
default: '#000',
})
.group(['data.json-url', 'data.toml-file'], 'Datasources')
.option('data.json-url', {
describe: 'Fetch streams from the specified URL(s)',
array: true,
default: ['https://woke.net/api/streams.json'],
})
.option('data.toml-file', {
describe: 'Fetch streams from the specified file(s)',
normalize: true,
array: true,
default: [],
})
.group(
[
'twitch.channel',
'twitch.username',
'twitch.password',
'twitch.color',
'twitch.announce.template',
'twitch.announce.interval-seconds',
],
'Twitch Chat',
)
.option('twitch.channel', {
describe: 'Name of Twitch channel',
default: null,
})
.option('twitch.username', {
describe: 'Username of Twitch bot account',
default: null,
})
.option('twitch.password', {
describe: 'Password of Twitch bot account',
default: null,
})
.option('twitch.color', {
describe: 'Color of Twitch bot username',
default: '#ff0000',
})
.option('twitch.announce.template', {
describe: 'Message template for stream announcements',
default:
'SingsMic <%- stream.source %> <%- stream.city && stream.state ? `(${stream.city} ${stream.state})` : `` %> <%- stream.link %>',
})
.option('twitch.announce.interval', {
describe:
'Minimum time interval (in seconds) between re-announcing the same stream',
number: true,
default: 60,
})
.option('twitch.announce.delay', {
describe: 'Time to dwell on a stream before its details are announced',
number: true,
default: 30,
})
.group(
[
'control.username',
'control.password',
'control.address',
'control.hostname',
'control.port',
'control.open',
],
'Control Webserver',
)
.option('control.username', {
describe: 'Web control server username',
})
.option('control.password', {
describe: 'Web control server password',
})
.option('control.open', {
describe: 'After launching, open the control website in a browser',
boolean: true,
default: true,
})
.option('control.address', {
describe: 'Enable control webserver and specify the URL',
implies: ['control.username', 'control.password'],
})
.option('control.hostname', {
describe: 'Override hostname the control server listens on',
})
.option('control.port', {
describe: 'Override port the control server listens on',
number: true,
})
.group(
['cert.dir', 'cert.production', 'cert.email'],
'Automatic SSL Certificate',
)
.option('cert.dir', {
describe: 'Private directory to store SSL certificate in',
implies: ['email'],
default: null,
})
.option('cert.production', {
describe: 'Obtain a real SSL certificate using production servers',
})
.option('cert.email', {
describe: 'Email for owner of SSL certificate',
})
.group(['streamdelay.endpoint', 'streamdelay.key'], 'Streamdelay')
.option('streamdelay.endpoint', {
describe: 'URL of Streamdelay endpoint',
default: 'http://localhost:8404',
})
.option('streamdelay.key', {
describe: 'Streamdelay API key',
default: null,
})
.help().argv
}
async function main() {
const argv = parseArgs()
if (argv.help) {
return
}
// Reject all permission requests from web content.
session
.fromPartition('persist:session')
.setPermissionRequestHandler((webContents, permission, callback) => {
callback(false)
})
const idGen = new StreamIDGenerator()
let updateCustomStreams
const customStreamData = new Repeater(async (push) => {
await push([])
updateCustomStreams = push
})
const streamWindow = new StreamWindow({
gridCount: argv.grid.count,
width: argv.window.width,
height: argv.window.height,
x: argv.window.x,
y: argv.window.y,
frameless: argv.window.frameless,
backgroundColor: argv.window['background-color'],
})
streamWindow.init()
let browseWindow = null
let twitchBot = null
let streamdelayClient = null
let clientState = {
config: {
width: argv.window.width,
height: argv.window.height,
gridCount: argv.grid.count,
},
streams: [],
customStreams: [],
views: [],
streamdelay: null,
}
const stateDoc = new Y.Doc()
const viewsState = stateDoc.getMap('views')
stateDoc.transact(() => {
for (let i = 0; i < argv.grid.count ** 2; i++) {
const data = new Y.Map()
data.set('streamId', '')
viewsState.set(i, data)
}
})
viewsState.observeDeep(() => {
const viewContentMap = new Map()
for (const [key, viewData] of viewsState) {
const stream = clientState.streams.find(
(s) => s._id === viewData.get('streamId'),
)
if (!stream) {
continue
}
viewContentMap.set(key, {
url: stream.link,
kind: stream.kind || 'video',
})
}
streamWindow.setViews(viewContentMap)
})
const getInitialState = () => clientState
let broadcast = () => {}
const onMessage = (msg) => {
if (msg.type === 'set-listening-view') {
streamWindow.setListeningView(msg.viewIdx)
} else if (msg.type === 'set-view-blurred') {
streamWindow.setViewBlurred(msg.viewIdx, msg.blurred)
} else if (msg.type === 'set-custom-streams') {
updateCustomStreams(msg.streams)
} else if (msg.type === 'reload-view') {
streamWindow.reloadView(msg.viewIdx)
} else if (msg.type === 'browse' || msg.type === 'dev-tools') {
if (
msg.type === 'dev-tools' &&
browseWindow &&
!browseWindow.isDestroyed()
) {
// DevTools needs a fresh webContents to work. Close any existing window.
browseWindow.destroy()
browseWindow = null
}
if (!browseWindow || browseWindow.isDestroyed()) {
browseWindow = new BrowserWindow({
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
partition: 'persist:session',
sandbox: true,
},
})
}
if (msg.type === 'browse') {
ensureValidURL(msg.url)
browseWindow.loadURL(msg.url)
} else if (msg.type === 'dev-tools') {
streamWindow.openDevTools(msg.viewIdx, browseWindow.webContents)
}
} else if (msg.type === 'set-stream-censored' && streamdelayClient) {
streamdelayClient.setCensored(msg.isCensored)
}
}
const stateDiff = createJSONDiffPatch({
objectHash: (obj, idx) => obj._id || `$$index:${idx}`,
})
function updateState(newState) {
const lastClientState = clientState
clientState = { ...clientState, ...newState }
const delta = stateDiff.diff(lastClientState, clientState)
if (!delta) {
return
}
broadcast({
type: 'state-delta',
delta,
})
streamWindow.send('state', clientState)
if (twitchBot) {
twitchBot.onState(clientState)
}
}
if (argv.control.address) {
;({ broadcast } = await initWebServer({
certDir: argv.cert.dir,
certProduction: argv.cert.production,
email: argv.cert.email,
url: argv.control.address,
hostname: argv.control.hostname,
port: argv.control.port,
username: argv.control.username,
password: argv.control.password,
getInitialState,
onMessage,
stateDoc,
}))
if (argv.control.open) {
shell.openExternal(argv.control.address)
}
}
if (argv.streamdelay.key) {
streamdelayClient = new StreamdelayClient({
endpoint: argv.streamdelay.endpoint,
key: argv.streamdelay.key,
})
streamdelayClient.on('state', (state) => {
updateState({ streamdelay: state })
})
streamdelayClient.connect()
}
if (argv.twitch.token) {
twitchBot = new TwitchBot(argv.twitch)
twitchBot.connect()
}
streamWindow.on('state', (viewStates) => {
updateState({ views: viewStates })
})
const dataSources = [
...argv.data['json-url'].map((url) =>
markDataSource(pollDataURL(url), 'json-url'),
),
...argv.data['toml-file'].map((path) =>
markDataSource(watchDataFile(path), 'toml-file'),
),
markDataSource(customStreamData, 'custom'),
]
for await (const rawStreams of combineDataSources(dataSources)) {
const streams = idGen.process(rawStreams)
updateState({ streams })
}
}
if (require.main === module) {
app
.whenReady()
.then(main)
.catch((err) => {
console.trace(err.toString())
process.exit(1)
})
}