2026-04-23 23:40:34 -03:00
|
|
|
|
import fs from "fs"
|
|
|
|
|
|
import path from "path"
|
2026-04-24 23:08:42 -03:00
|
|
|
|
import { execSync, spawn } from "child_process"
|
2026-04-23 23:40:34 -03:00
|
|
|
|
import type { Stream } from "@/types/stream"
|
2026-04-25 03:24:20 -03:00
|
|
|
|
import { getStream } from "./db"
|
2026-04-23 23:40:34 -03:00
|
|
|
|
|
|
|
|
|
|
const DATA_DIR = process.env.DATA_DIR ?? "/app/data"
|
|
|
|
|
|
const STREAMS_DIR = path.join(DATA_DIR, "streams")
|
2026-04-24 23:08:42 -03:00
|
|
|
|
const VNC_TOKENS_DIR = path.join(DATA_DIR, "vnc-tokens")
|
|
|
|
|
|
const IS_DEV = process.env.NODE_ENV !== "production"
|
2026-04-23 23:40:34 -03:00
|
|
|
|
|
|
|
|
|
|
function streamDir(id: string) {
|
|
|
|
|
|
return path.join(STREAMS_DIR, id)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function render(template: string, vars: Record<string, string | number>): string {
|
|
|
|
|
|
return template.replace(/\{\{(\w+)\}\}/g, (_, k) => String(vars[k] ?? ""))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function supervisorctl(cmd: string) {
|
|
|
|
|
|
if (IS_DEV) {
|
|
|
|
|
|
console.log(`[supervisor mock] supervisorctl ${cmd}`)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
execSync(`supervisorctl -c /etc/supervisor/supervisord.conf ${cmd}`, { stdio: "pipe" })
|
|
|
|
|
|
} catch {
|
2026-04-26 03:02:31 -03:00
|
|
|
|
// supervisorctl returns exit 1 in some non-fatal cases
|
2026-04-23 23:40:34 -03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-26 03:02:31 -03:00
|
|
|
|
// converts "1920x1080" → "1920,1080" for Chrome --window-size flag
|
2026-04-24 23:08:42 -03:00
|
|
|
|
function resolutionToChrome(res: string): string {
|
|
|
|
|
|
return res.replace("x", ",")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-26 03:02:31 -03:00
|
|
|
|
// normalizes scale: accepts "1280x720" or "1280:720", always saves as "1280:720"
|
2026-04-24 23:08:42 -03:00
|
|
|
|
export function normalizeScale(scale: string): string {
|
|
|
|
|
|
return scale.replace("x", ":")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-23 23:40:34 -03:00
|
|
|
|
export function provisionStream(stream: Stream): void {
|
|
|
|
|
|
const dir = streamDir(stream.id)
|
|
|
|
|
|
fs.mkdirSync(path.join(dir, "chrome-profile"), { recursive: true })
|
2026-04-25 15:08:25 -03:00
|
|
|
|
fs.mkdirSync(path.join(DATA_DIR, "logs", stream.id), { recursive: true })
|
2026-04-23 23:40:34 -03:00
|
|
|
|
|
|
|
|
|
|
const vars: Record<string, string | number> = {
|
2026-04-24 23:08:42 -03:00
|
|
|
|
STREAM_ID: stream.id,
|
|
|
|
|
|
DISPLAY: stream.display,
|
|
|
|
|
|
RESOLUTION: stream.resolution,
|
|
|
|
|
|
CHROME_SIZE: resolutionToChrome(stream.resolution),
|
|
|
|
|
|
STREAM_URL: stream.url,
|
|
|
|
|
|
DEBUG_PORT: stream.debugPort,
|
|
|
|
|
|
VNC_PORT: stream.vncPort,
|
2026-04-23 23:40:34 -03:00
|
|
|
|
STREAM_DELAY: stream.delay,
|
2026-04-24 23:08:42 -03:00
|
|
|
|
FPS: stream.fps,
|
|
|
|
|
|
PRESET: stream.preset,
|
|
|
|
|
|
TUNE: stream.tune,
|
|
|
|
|
|
GOP: stream.gop,
|
|
|
|
|
|
BITRATE: stream.bitrate,
|
|
|
|
|
|
BUFSIZE: stream.bufsize,
|
|
|
|
|
|
SCALE: normalizeScale(stream.scale),
|
|
|
|
|
|
THREADS: stream.threads ?? 0,
|
|
|
|
|
|
USER: stream.user ?? "",
|
|
|
|
|
|
PASS: stream.pass ?? "",
|
2026-04-26 03:02:31 -03:00
|
|
|
|
GPU_FLAGS: stream.gpu ? "" : " --disable-gpu \\\n",
|
2026-04-23 23:40:34 -03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const confTpl = fs.readFileSync("/opt/scripts/stream.template.conf", "utf-8")
|
2026-04-25 15:08:25 -03:00
|
|
|
|
fs.writeFileSync(path.join(dir, "stream.conf"), render(confTpl, vars), "utf-8")
|
2026-04-23 23:40:34 -03:00
|
|
|
|
|
2026-04-24 23:08:42 -03:00
|
|
|
|
fs.mkdirSync(VNC_TOKENS_DIR, { recursive: true })
|
|
|
|
|
|
fs.writeFileSync(
|
|
|
|
|
|
path.join(VNC_TOKENS_DIR, `${stream.id}.cfg`),
|
|
|
|
|
|
`${stream.id}: localhost:${stream.vncPort}\n`,
|
|
|
|
|
|
"utf-8"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-23 23:40:34 -03:00
|
|
|
|
supervisorctl("reread")
|
|
|
|
|
|
supervisorctl("update")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-25 15:08:25 -03:00
|
|
|
|
export function recreateStream(id: string): void {
|
|
|
|
|
|
const stream = getStream(id)
|
|
|
|
|
|
if (!stream) return
|
|
|
|
|
|
stopStream(id)
|
|
|
|
|
|
const dir = streamDir(id)
|
|
|
|
|
|
fs.rmSync(path.join(dir, "chrome-profile"), { recursive: true, force: true })
|
|
|
|
|
|
provisionStream(stream)
|
|
|
|
|
|
startStream(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-23 23:40:34 -03:00
|
|
|
|
export function startStream(id: string): void {
|
2026-04-24 23:08:42 -03:00
|
|
|
|
const programs = ["xvfb", "chromium", "autologin", "x11vnc", "ffmpeg"]
|
|
|
|
|
|
for (const p of programs) supervisorctl(`start ${p}-${id}`)
|
2026-04-25 03:24:20 -03:00
|
|
|
|
captureThumb(id, 60)
|
2026-04-23 23:40:34 -03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function stopStream(id: string): void {
|
2026-04-24 23:08:42 -03:00
|
|
|
|
const programs = ["ffmpeg", "x11vnc", "autologin", "chromium", "xvfb"]
|
|
|
|
|
|
for (const p of programs) supervisorctl(`stop ${p}-${id}`)
|
2026-04-23 23:40:34 -03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function restartStream(id: string): void {
|
|
|
|
|
|
stopStream(id)
|
|
|
|
|
|
startStream(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function removeStream(id: string): void {
|
|
|
|
|
|
stopStream(id)
|
|
|
|
|
|
const confPath = path.join(streamDir(id), "stream.conf")
|
|
|
|
|
|
if (fs.existsSync(confPath)) fs.unlinkSync(confPath)
|
2026-04-24 23:08:42 -03:00
|
|
|
|
const tokenPath = path.join(VNC_TOKENS_DIR, `${id}.cfg`)
|
|
|
|
|
|
if (fs.existsSync(tokenPath)) fs.unlinkSync(tokenPath)
|
2026-04-23 23:40:34 -03:00
|
|
|
|
supervisorctl("reread")
|
|
|
|
|
|
supervisorctl("update")
|
|
|
|
|
|
fs.rmSync(streamDir(id), { recursive: true, force: true })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 23:08:42 -03:00
|
|
|
|
export function captureThumb(streamId: string, delay = 60): void {
|
|
|
|
|
|
if (IS_DEV) { console.log(`[thumb mock] captureThumb ${streamId} delay=${delay}s`); return }
|
2026-04-25 03:24:20 -03:00
|
|
|
|
const stream = getStream(streamId)
|
|
|
|
|
|
if (!stream) return
|
2026-04-24 23:08:42 -03:00
|
|
|
|
const thumbPath = path.join(STREAMS_DIR, streamId, "thumb.jpg")
|
2026-04-25 03:24:20 -03:00
|
|
|
|
const tmpPath = path.join(STREAMS_DIR, streamId, "thumb.tmp.jpg")
|
|
|
|
|
|
// capture directly from Xvfb — doesn't depend on RTMP/HLS being up
|
2026-04-24 23:08:42 -03:00
|
|
|
|
const child = spawn("bash", ["-c",
|
2026-04-25 03:24:20 -03:00
|
|
|
|
`sleep ${delay} && ffmpeg -y -loglevel error -f x11grab -video_size ${stream.resolution} -i ${stream.display} -vframes 1 -q:v 2 "${tmpPath}" && mv "${tmpPath}" "${thumbPath}"`
|
2026-04-24 23:08:42 -03:00
|
|
|
|
], { detached: true, stdio: "ignore" })
|
|
|
|
|
|
child.unref()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-23 23:40:34 -03:00
|
|
|
|
export type ProgramStatus = "RUNNING" | "STOPPED" | "FATAL" | "STARTING" | "UNKNOWN"
|
|
|
|
|
|
|
2026-04-27 10:41:23 -03:00
|
|
|
|
// Cache for supervisorctl status — refreshed at most once every 3 seconds across all callers
|
|
|
|
|
|
let _statusCache: Record<string, Record<string, ProgramStatus>> | null = null
|
|
|
|
|
|
let _statusCacheAt = 0
|
|
|
|
|
|
const STATUS_CACHE_TTL = 3000
|
2026-04-23 23:40:34 -03:00
|
|
|
|
|
2026-04-27 10:41:23 -03:00
|
|
|
|
function fetchAllStatuses(): Record<string, Record<string, ProgramStatus>> {
|
|
|
|
|
|
const now = Date.now()
|
|
|
|
|
|
if (_statusCache && now - _statusCacheAt < STATUS_CACHE_TTL) return _statusCache
|
2026-04-23 23:40:34 -03:00
|
|
|
|
|
2026-04-27 10:41:23 -03:00
|
|
|
|
const result: Record<string, Record<string, ProgramStatus>> = {}
|
|
|
|
|
|
try {
|
|
|
|
|
|
// One call for all programs — avoid N×5 blocking execSync calls per poll cycle
|
|
|
|
|
|
const out = execSync(
|
|
|
|
|
|
`supervisorctl -c /etc/supervisor/supervisord.conf status`,
|
|
|
|
|
|
{ stdio: "pipe" }
|
|
|
|
|
|
).toString()
|
|
|
|
|
|
for (const line of out.split("\n")) {
|
|
|
|
|
|
// e.g. "ffmpeg-abc123 RUNNING pid 42, uptime 0:01:00"
|
|
|
|
|
|
const m = line.match(/^(\S+)-(\S+)\s+(RUNNING|STOPPED|FATAL|STARTING)/)
|
|
|
|
|
|
if (!m) continue
|
|
|
|
|
|
const [, program, id, status] = m
|
|
|
|
|
|
if (!result[id]) result[id] = {}
|
|
|
|
|
|
result[id][program] = status as ProgramStatus
|
2026-04-23 23:40:34 -03:00
|
|
|
|
}
|
2026-04-27 10:41:23 -03:00
|
|
|
|
} catch {
|
|
|
|
|
|
// supervisorctl can exit non-zero; return whatever was parsed
|
2026-04-23 23:40:34 -03:00
|
|
|
|
}
|
2026-04-27 10:41:23 -03:00
|
|
|
|
|
|
|
|
|
|
_statusCache = result
|
|
|
|
|
|
_statusCacheAt = now
|
2026-04-23 23:40:34 -03:00
|
|
|
|
return result
|
2026-04-24 23:08:42 -03:00
|
|
|
|
}
|
2026-04-27 10:41:23 -03:00
|
|
|
|
|
|
|
|
|
|
export function getStreamStatus(id: string): Record<string, ProgramStatus> {
|
|
|
|
|
|
const programs = ["xvfb", "chromium", "autologin", "x11vnc", "ffmpeg"]
|
|
|
|
|
|
if (IS_DEV) return Object.fromEntries(programs.map((p) => [p, "STOPPED" as ProgramStatus]))
|
|
|
|
|
|
const all = fetchAllStatuses()
|
|
|
|
|
|
return all[id] ?? Object.fromEntries(programs.map((p) => [p, "UNKNOWN" as ProgramStatus]))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function getAllStreamStatuses(): Record<string, Record<string, ProgramStatus>> {
|
|
|
|
|
|
const programs = ["xvfb", "chromium", "autologin", "x11vnc", "ffmpeg"]
|
|
|
|
|
|
if (IS_DEV) {
|
|
|
|
|
|
const { readStreams } = require("./db") as typeof import("./db")
|
|
|
|
|
|
return Object.fromEntries(readStreams().map((s) => [s.id, Object.fromEntries(programs.map((p) => [p, "STOPPED" as ProgramStatus]))]))
|
|
|
|
|
|
}
|
|
|
|
|
|
return fetchAllStatuses()
|
|
|
|
|
|
}
|