mirror of
https://github.com/streamwall/streamwall.git
synced 2026-01-25 22:52:48 -05:00
379 lines
10 KiB
JavaScript
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)
|
|
})
|
|
}
|