mirror of
https://github.com/streamwall/streamwall.git
synced 2025-12-06 01:45:37 -05:00
495 lines
14 KiB
JavaScript
495 lines
14 KiB
JavaScript
import fs from 'fs'
|
|
import path from 'path'
|
|
import yargs from 'yargs'
|
|
import TOML from '@iarna/toml'
|
|
import * as Y from 'yjs'
|
|
import * as Sentry from '@sentry/electron/main'
|
|
import { app, shell, session, BrowserWindow } from 'electron'
|
|
|
|
import { ensureValidURL } from '../util'
|
|
import {
|
|
pollDataURL,
|
|
watchDataFile,
|
|
LocalStreamData,
|
|
StreamIDGenerator,
|
|
markDataSource,
|
|
combineDataSources,
|
|
} from './data'
|
|
import * as persistence from './persistence'
|
|
import { Auth, StateWrapper } from './auth'
|
|
import StreamWindow from './StreamWindow'
|
|
import TwitchBot from './TwitchBot'
|
|
import StreamdelayClient from './StreamdelayClient'
|
|
import initWebServer from './server'
|
|
|
|
const SENTRY_DSN =
|
|
'https://e630a21dcf854d1a9eb2a7a8584cbd0b@o459879.ingest.sentry.io/5459505'
|
|
|
|
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.active-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',
|
|
})
|
|
.option('window.active-color', {
|
|
describe: 'Active (highlight) color of wall',
|
|
default: '#fff',
|
|
})
|
|
.option('video.timeout', {
|
|
describe: 'Timeout (in milliseconds) for loading videos',
|
|
number: true,
|
|
default: 10000,
|
|
})
|
|
.group(['data.interval', 'data.json-url', 'data.toml-file'], 'Datasources')
|
|
.option('data.interval', {
|
|
describe: 'Interval (in seconds) for refreshing polled data sources',
|
|
number: true,
|
|
default: 30,
|
|
})
|
|
.option('data.json-url', {
|
|
describe: 'Fetch streams from the specified URL(s)',
|
|
array: true,
|
|
default: [],
|
|
})
|
|
.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',
|
|
'twitch.vote.template',
|
|
'twitch.vote.interval',
|
|
],
|
|
'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,
|
|
})
|
|
.option('twitch.vote.template', {
|
|
describe: 'Message template for vote result announcements',
|
|
default: 'Switching to #<%- selectedIdx %> (with <%- voteCount %> votes)',
|
|
})
|
|
.option('twitch.vote.interval', {
|
|
describe: 'Time interval (in seconds) between votes (0 to disable)',
|
|
number: true,
|
|
default: 0,
|
|
})
|
|
.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,
|
|
})
|
|
.group(['telemetry.sentry'], 'Telemetry')
|
|
.option('telemetry.sentry', {
|
|
describe: 'Enable error reporting to Sentry',
|
|
boolean: true,
|
|
default: true,
|
|
})
|
|
.help().argv
|
|
}
|
|
|
|
async function main(argv) {
|
|
// Reject all permission requests from web content.
|
|
session
|
|
.fromPartition('persist:session')
|
|
.setPermissionRequestHandler((webContents, permission, callback) => {
|
|
callback(false)
|
|
})
|
|
|
|
console.debug('Loading persistence data...')
|
|
const persistData = await persistence.load()
|
|
|
|
console.debug('Creating StreamWindow...')
|
|
const idGen = new StreamIDGenerator()
|
|
const localStreamData = new LocalStreamData()
|
|
const overlayStreamData = new LocalStreamData()
|
|
|
|
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()
|
|
|
|
console.debug('Creating Auth...')
|
|
const auth = new Auth({
|
|
adminUsername: argv.control.username,
|
|
adminPassword: argv.control.password,
|
|
persistData: persistData.auth,
|
|
logEnabled: true,
|
|
})
|
|
|
|
let browseWindow = null
|
|
let twitchBot = null
|
|
let streamdelayClient = null
|
|
|
|
console.debug('Creating initial state...')
|
|
let clientState = new StateWrapper({
|
|
config: {
|
|
width: argv.window.width,
|
|
height: argv.window.height,
|
|
gridCount: argv.grid.count,
|
|
activeColor: argv.window['active-color'],
|
|
},
|
|
auth: auth.getState(),
|
|
streams: [],
|
|
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(() => {
|
|
try {
|
|
const viewContentMap = new Map()
|
|
for (const [key, viewData] of viewsState) {
|
|
const stream = clientState.info.streams.find(
|
|
(s) => s._id === viewData.get('streamId'),
|
|
)
|
|
if (!stream) {
|
|
continue
|
|
}
|
|
viewContentMap.set(key, {
|
|
url: stream.link,
|
|
kind: stream.kind || 'video',
|
|
timeout: argv.video.timeout,
|
|
})
|
|
}
|
|
streamWindow.setViews(viewContentMap, clientState.info.streams)
|
|
} catch (err) {
|
|
console.error('Error updating views', err)
|
|
}
|
|
})
|
|
|
|
const onMessage = async (msg, respond) => {
|
|
console.debug('Received message:', msg)
|
|
if (msg.type === 'set-listening-view') {
|
|
console.debug('Setting listening view:', msg.viewIdx)
|
|
streamWindow.setListeningView(msg.viewIdx)
|
|
} else if (msg.type === 'set-view-background-listening') {
|
|
console.debug('Setting view background listening:', msg.viewIdx, msg.listening)
|
|
streamWindow.setViewBackgroundListening(msg.viewIdx, msg.listening)
|
|
} else if (msg.type === 'set-view-blurred') {
|
|
console.debug('Setting view blurred:', msg.viewIdx, msg.blurred)
|
|
streamWindow.setViewBlurred(msg.viewIdx, msg.blurred)
|
|
} else if (msg.type === 'rotate-stream') {
|
|
console.debug('Rotating stream:', msg.url, msg.rotation)
|
|
overlayStreamData.update(msg.url, {
|
|
rotation: msg.rotation,
|
|
})
|
|
} else if (msg.type === 'update-custom-stream') {
|
|
console.debug('Updating custom stream:', msg.url)
|
|
localStreamData.update(msg.url, msg.data)
|
|
} else if (msg.type === 'delete-custom-stream') {
|
|
console.debug('Deleting custom stream:', msg.url)
|
|
localStreamData.delete(msg.url)
|
|
} else if (msg.type === 'reload-view') {
|
|
console.debug('Reloading view:', msg.viewIdx)
|
|
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') {
|
|
console.debug('Attempting to browse URL:', msg.url)
|
|
try {
|
|
ensureValidURL(msg.url)
|
|
browseWindow.loadURL(msg.url)
|
|
} catch (error) {
|
|
console.error('Invalid URL:', msg.url)
|
|
console.error('Error:', error)
|
|
}
|
|
} else if (msg.type === 'dev-tools') {
|
|
console.debug('Opening DevTools for view:', msg.viewIdx)
|
|
streamWindow.openDevTools(msg.viewIdx, browseWindow.webContents)
|
|
}
|
|
} else if (msg.type === 'set-stream-censored' && streamdelayClient) {
|
|
console.debug('Setting stream censored:', msg.isCensored)
|
|
streamdelayClient.setCensored(msg.isCensored)
|
|
} else if (msg.type === 'set-stream-running' && streamdelayClient) {
|
|
console.debug('Setting stream running:', msg.isStreamRunning)
|
|
streamdelayClient.setStreamRunning(msg.isStreamRunning)
|
|
} else if (msg.type === 'create-invite') {
|
|
console.debug('Creating invite for role:', msg.role)
|
|
const { secret } = await auth.createToken({
|
|
kind: 'invite',
|
|
role: msg.role,
|
|
name: msg.name,
|
|
})
|
|
respond({ name: msg.name, secret })
|
|
} else if (msg.type === 'delete-token') {
|
|
console.debug('Deleting token:', msg.tokenId)
|
|
auth.deleteToken(msg.tokenId)
|
|
}
|
|
}
|
|
|
|
function updateState(newState) {
|
|
clientState.update(newState)
|
|
streamWindow.onState(clientState.info)
|
|
if (twitchBot) {
|
|
twitchBot.onState(clientState.info)
|
|
}
|
|
}
|
|
|
|
if (argv.control.address) {
|
|
console.debug('Initializing web server...')
|
|
const webDistPath = path.join(app.getAppPath(), 'web')
|
|
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,
|
|
logEnabled: true,
|
|
webDistPath,
|
|
auth,
|
|
clientState,
|
|
onMessage,
|
|
stateDoc,
|
|
})
|
|
if (argv.control.open) {
|
|
shell.openExternal(argv.control.address)
|
|
}
|
|
}
|
|
|
|
if (argv.streamdelay.key) {
|
|
console.debug('Setting up Streamdelay client...')
|
|
streamdelayClient = new StreamdelayClient({
|
|
endpoint: argv.streamdelay.endpoint,
|
|
key: argv.streamdelay.key,
|
|
})
|
|
streamdelayClient.on('state', (state) => {
|
|
updateState({ streamdelay: state })
|
|
})
|
|
streamdelayClient.connect()
|
|
}
|
|
|
|
if (argv.twitch.token) {
|
|
console.debug('Setting up Twitch bot...')
|
|
twitchBot = new TwitchBot(argv.twitch)
|
|
twitchBot.on('setListeningView', (idx) => {
|
|
streamWindow.setListeningView(idx)
|
|
})
|
|
twitchBot.connect()
|
|
}
|
|
|
|
streamWindow.on('state', (viewStates) => {
|
|
updateState({ views: viewStates })
|
|
})
|
|
|
|
streamWindow.on('close', () => {
|
|
process.exit(0)
|
|
})
|
|
|
|
auth.on('state', (authState) => {
|
|
updateState({ auth: authState })
|
|
persistence.save({ auth: auth.getPersistData() })
|
|
})
|
|
|
|
const dataSources = [
|
|
...argv.data['json-url'].map((url) => {
|
|
console.debug('Setting data source from json-url:', url)
|
|
return markDataSource(pollDataURL(url, argv.data.interval), 'json-url')
|
|
}),
|
|
...argv.data['toml-file'].map((path) => {
|
|
console.debug('Setting data source from toml-file:', path)
|
|
return markDataSource(watchDataFile(path), 'toml-file')
|
|
}),
|
|
markDataSource(localStreamData.gen(), 'custom'),
|
|
overlayStreamData.gen(),
|
|
]
|
|
|
|
for await (const rawStreams of combineDataSources(dataSources)) {
|
|
console.debug('Processing streams:', rawStreams)
|
|
const streams = idGen.process(rawStreams)
|
|
updateState({ streams })
|
|
}
|
|
}
|
|
|
|
function init() {
|
|
console.debug('Parsing command line arguments...')
|
|
const argv = parseArgs()
|
|
if (argv.help) {
|
|
return
|
|
}
|
|
|
|
console.debug('Initializing Sentry...')
|
|
if (argv.telemetry.sentry) {
|
|
Sentry.init({ dsn: SENTRY_DSN })
|
|
}
|
|
|
|
console.debug('Setting up Electron...')
|
|
app.commandLine.appendSwitch('high-dpi-support', 1)
|
|
app.commandLine.appendSwitch('force-device-scale-factor', 1)
|
|
|
|
console.debug('Enabling Electron sandbox...')
|
|
app.enableSandbox()
|
|
app
|
|
.whenReady()
|
|
.then(() => main(argv))
|
|
.catch((err) => {
|
|
console.trace(err.toString())
|
|
process.exit(1)
|
|
})
|
|
}
|
|
|
|
if (require.main === module) {
|
|
console.debug('Starting Streamwall...')
|
|
init()
|
|
}
|