From 14094cf5ede081582bf9f6567a6e8f49bac6492b Mon Sep 17 00:00:00 2001 From: Kralot Date: Mon, 27 Apr 2026 22:05:41 -0300 Subject: [PATCH] Adiciona auto-reload do Chromium via CDP por stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- - Adicionado scripts/autoreload.sh: loop com reload via WebSocket CDP raw (net + frames manuais), sem dependências externas; trap de SIGTERM encerra limpo sem aguardar o sleep; - Adicionado [program:autoreload-{{STREAM_ID}}] em stream.template.conf com autostart=false e autorestart=unexpected; - Adicionados campos AUTO_RELOAD e AUTO_RELOAD_INTERVAL em reprovision.mjs e supervisor.ts (provisionStream); - Adicionados campos autoReload e autoReloadInterval em src/types/stream.ts; - Adicionado autoreload nas listas de startStream e stopStream em supervisor.ts; adicionada função applyAutoReload; - Adicionado endpoint dedicado POST /api/streams/[id]/autoreload: salva, re-provisiona e aplica sem reiniciar o stream inteiro; - Adicionado toggle + input de intervalo (minutos) no menu de 3 pontos do card em StreamCard.tsx; toggle pill corrigido com posicionamento left absoluto; - Atualizado README e CHANGELOG com a nova feature; --- --- CHANGELOG.md | 8 +++ README.md | 4 ++ scripts/autoreload.sh | 66 ++++++++++++++++++++ scripts/reprovision.mjs | 4 +- scripts/stream.template.conf | 10 +++ src/app/api/streams/[id]/autoreload/route.ts | 24 +++++++ src/components/StreamCard.tsx | 51 ++++++++++++++- src/lib/supervisor.ts | 15 ++++- src/types/stream.ts | 3 + 9 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 scripts/autoreload.sh create mode 100644 src/app/api/streams/[id]/autoreload/route.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 32c66f9..acfdaa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +### Added + +- **Chromium auto-reload** — per-stream toggle to reload the browser page on a configurable interval via Chrome DevTools Protocol. Implemented as a dedicated Supervisord process (`autoreload-{id}`) using a raw WebSocket CDP connection (no external dependencies). Responds cleanly to `supervisorctl stop` via SIGTERM trap. Interval is set in minutes from the card's 3-dot menu and persisted server-side. Toggle applies via `POST /api/streams/{id}/autoreload` without restarting the stream. + +--- + ## Decap Stream v1.0.0 Turn any web page into an RTMP/HLS stream. Chromium renders the page in a virtual display, ffmpeg captures it, and MediaMTX publishes it, all managed through a web UI. diff --git a/README.md b/README.md index 1bb0254..d871150 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ All processes are managed by Supervisord. The web UI is a Next.js app that contr - **Built-in HLS player** — watch any stream in the browser via a standalone HTML page optimized for TVs (Back + Mute buttons, reconnect on stall, direct MediaMTX connection when available) - **Per-card Pure mode** — toggle in the card menu to open Play Stream as a raw `.m3u8` link or Run HTML as a minimal `.html` page with no UI; works with native players and TV browsers - **Per-card new tab** — toggle to open any button in a new tab instead of navigating in place; both settings are per-card and saved in the browser +- **Chromium auto-reload** — per-card toggle to reload the browser page on a configurable interval; uses Chrome DevTools Protocol (no xdotool focus tricks); configured from the card menu and persisted on the server ## Platform Support @@ -132,6 +133,8 @@ Each stream gets a slug ID you define (e.g. `grafana-prod`): | `gop` | `60` | Keyframe interval (auto-calculated as 2x FPS in the UI) | | `threads` | `0` | ffmpeg encoding threads (`0` = auto-detect) | | `gpu` | `false` | Enable Chromium GPU acceleration (requires host GPU + container access) | +| `autoReload` | `false` | Reload the Chromium page on a fixed interval via CDP; toggled from the card menu | +| `autoReloadInterval` | `3600` | Interval in seconds between automatic page reloads | ## Architecture @@ -146,6 +149,7 @@ Each stream gets a slug ID you define (e.g. `grafana-prod`): │ ├── xvfb (display) │ │ ├── chromium (browser) │ │ ├── autologin (CDP) │ +│ ├── autoreload (CDP) │ │ ├── x11vnc (VNC) │ │ └── ffmpeg (encode) │ │ │ │ diff --git a/scripts/autoreload.sh b/scripts/autoreload.sh new file mode 100644 index 0000000..066568f --- /dev/null +++ b/scripts/autoreload.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +SLEEP_PID="" +_cleanup() { [ -n "$SLEEP_PID" ] && kill "$SLEEP_PID" 2>/dev/null; exit 0; } +trap '_cleanup' TERM INT + +[ "${AUTO_RELOAD:-false}" = "false" ] && exit 0 + +sleep "${STREAM_DELAY:-0}" + +INTERVAL="${AUTO_RELOAD_INTERVAL:-3600}" + +while true; do + sleep "$INTERVAL" & + SLEEP_PID=$! + wait $SLEEP_PID + SLEEP_PID="" + + node -e " +const http = require('http'); +const net = require('net'); +const crypto = require('crypto'); + +http.get('http://localhost:${DEBUG_PORT}/json', res => { + let d = ''; + res.on('data', c => d += c); + res.on('end', () => { + try { + const tabs = JSON.parse(d); + const page = tabs.find(t => t.type === 'page'); + if (!page) return; + const u = new URL(page.webSocketDebuggerUrl); + const key = crypto.randomBytes(16).toString('base64'); + const s = net.createConnection({ host: u.hostname, port: +u.port || 80 }); + s.on('connect', () => { + s.write( + 'GET ' + u.pathname + ' HTTP/1.1\r\n' + + 'Host: ' + u.host + '\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + 'Sec-WebSocket-Key: ' + key + '\r\n' + + 'Sec-WebSocket-Version: 13\r\n\r\n' + ); + }); + let upgraded = false, buf = Buffer.alloc(0); + s.on('data', chunk => { + if (upgraded) return; + buf = Buffer.concat([buf, chunk]); + if (buf.indexOf('\r\n\r\n') === -1) return; + upgraded = true; + const msg = Buffer.from(JSON.stringify({ id: 1, method: 'Page.reload', params: {} })); + const mask = crypto.randomBytes(4); + const frame = Buffer.alloc(6 + msg.length); + frame[0] = 0x81; + frame[1] = 0x80 | msg.length; + mask.copy(frame, 2); + for (let i = 0; i < msg.length; i++) frame[6 + i] = msg[i] ^ mask[i % 4]; + s.write(frame); + setTimeout(() => s.destroy(), 500); + }); + s.on('error', () => {}); + } catch (_) {} + }); +}).on('error', () => {}); +" 2>/dev/null || true +done diff --git a/scripts/reprovision.mjs b/scripts/reprovision.mjs index d3167d1..fb1698d 100644 --- a/scripts/reprovision.mjs +++ b/scripts/reprovision.mjs @@ -48,7 +48,9 @@ for (const stream of streams) { THREADS: stream.threads ?? 0, USER: stream.user ?? '', PASS: stream.pass ?? '', - GPU_FLAGS: stream.gpu ? '' : ' --disable-gpu \\\n', + GPU_FLAGS: stream.gpu ? '' : ' --disable-gpu \\\n', + AUTO_RELOAD: stream.autoReload ? 'true' : 'false', + AUTO_RELOAD_INTERVAL: stream.autoReloadInterval ?? 3600, } fs.writeFileSync(path.join(dir, 'stream.conf'), render(confTpl, vars), 'utf-8') diff --git a/scripts/stream.template.conf b/scripts/stream.template.conf index e01a11d..8e5969f 100644 --- a/scripts/stream.template.conf +++ b/scripts/stream.template.conf @@ -45,6 +45,16 @@ environment=DISPLAY="{{DISPLAY}}",LOGIN_USER="{{USER}}",LOGIN_PASS="{{PASS}}",DE stdout_logfile=/app/data/logs/{{STREAM_ID}}/autologin.log stderr_logfile=/app/data/logs/{{STREAM_ID}}/autologin.log +[program:autoreload-{{STREAM_ID}}] +command=/opt/scripts/autoreload.sh +autostart=false +autorestart=unexpected +priority=35 +startsecs=0 +environment=DEBUG_PORT="{{DEBUG_PORT}}",AUTO_RELOAD="{{AUTO_RELOAD}}",AUTO_RELOAD_INTERVAL="{{AUTO_RELOAD_INTERVAL}}",STREAM_DELAY="{{STREAM_DELAY}}" +stdout_logfile=/app/data/logs/{{STREAM_ID}}/autoreload.log +stderr_logfile=/app/data/logs/{{STREAM_ID}}/autoreload.log + [program:x11vnc-{{STREAM_ID}}] environment=DISPLAY={{DISPLAY}} command=bash -c "while [ ! -e /tmp/.X11-unix/X$(echo $DISPLAY | cut -d: -f2 | cut -d. -f1) ]; do sleep 0.2; done; exec x11vnc -nopw -listen 0.0.0.0 -rfbport {{VNC_PORT}} -xkb -forever -shared -threads" diff --git a/src/app/api/streams/[id]/autoreload/route.ts b/src/app/api/streams/[id]/autoreload/route.ts new file mode 100644 index 0000000..eabfc6f --- /dev/null +++ b/src/app/api/streams/[id]/autoreload/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server" +import { getStream, saveStream } from "@/lib/db" +import { provisionStream, applyAutoReload } from "@/lib/supervisor" + +type Ctx = { params: Promise<{ id: string }> } + +export async function POST(req: Request, { params }: Ctx) { + const { id } = await params + const stream = getStream(id) + if (!stream) return NextResponse.json({ error: "not found" }, { status: 404 }) + + const { enabled, interval } = await req.json() as { enabled: boolean; interval?: number } + const updated = { + ...stream, + autoReload: enabled, + ...(interval !== undefined ? { autoReloadInterval: interval } : {}), + updatedAt: new Date().toISOString(), + } + saveStream(updated) + provisionStream(updated) + applyAutoReload(id) + + return NextResponse.json(updated) +} diff --git a/src/components/StreamCard.tsx b/src/components/StreamCard.tsx index e9695fd..5ce1b61 100644 --- a/src/components/StreamCard.tsx +++ b/src/components/StreamCard.tsx @@ -1,7 +1,7 @@ "use client" import { useState, useEffect, useRef } from "react" -import { MoreHorizontal, Play, Globe, Monitor, Pencil, RotateCcw, Square, Trash2, Circle, Copy, Check, Video, ImageUp, GripVertical, Wrench } from "lucide-react" +import { MoreHorizontal, Play, Globe, Monitor, Pencil, RotateCcw, Square, Trash2, Circle, Copy, Check, Video, ImageUp, GripVertical, Wrench, RefreshCw } from "lucide-react" import { cn } from "@/lib/utils" import { Toggle } from "@/components/Toggle" import type { Stream } from "@/types/stream" @@ -106,6 +106,8 @@ export function StreamCard({ stream, status, localStatus, cardSize = "md", onRef const [thumbError, setThumbError] = useState(false) const [thumbCapturing, setThumbCapturing] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false) + const [autoReload, setAutoReload] = useState(stream.autoReload ?? false) + const [autoReloadMins, setAutoReloadMins] = useState(Math.round((stream.autoReloadInterval ?? 3600) / 60)) const pollRef = useRef | null>(null) useEffect(() => { @@ -164,6 +166,24 @@ export function StreamCard({ stream, status, localStatus, cardSize = "md", onRef }) } + async function toggleAutoReload() { + const next = !autoReload + setAutoReload(next) + await fetch(`/api/streams/${stream.id}/autoreload`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: next, interval: autoReloadMins * 60 }), + }) + } + + async function saveAutoReloadInterval(mins: number) { + await fetch(`/api/streams/${stream.id}/autoreload`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: autoReload, interval: mins * 60 }), + }) + } + async function refreshThumb() { setMenuOpen(false) setThumbError(false) @@ -275,6 +295,35 @@ export function StreamCard({ stream, status, localStatus, cardSize = "md", onRef {thumbCapturing ? "Capturing..." : "Refresh thumbnail"}
+
+
+ + Auto-reload + + +
+ {autoReload && ( +
+ Every + setAutoReloadMins(Math.max(1, Number(e.target.value) || 1))} + onBlur={() => saveAutoReloadInterval(autoReloadMins)} + onKeyDown={e => { if (e.key === "Enter") saveAutoReloadInterval(autoReloadMins) }} + className="w-16 text-xs bg-[#2a2a2a] border border-border rounded px-2 py-0.5 text-center" + /> + min +
+ )} +
+
diff --git a/src/lib/supervisor.ts b/src/lib/supervisor.ts index 03e19c7..f20305e 100644 --- a/src/lib/supervisor.ts +++ b/src/lib/supervisor.ts @@ -63,7 +63,9 @@ export function provisionStream(stream: Stream): void { THREADS: stream.threads ?? 0, USER: stream.user ?? "", PASS: stream.pass ?? "", - GPU_FLAGS: stream.gpu ? "" : " --disable-gpu \\\n", + GPU_FLAGS: stream.gpu ? "" : " --disable-gpu \\\n", + AUTO_RELOAD: stream.autoReload ? "true" : "false", + AUTO_RELOAD_INTERVAL: stream.autoReloadInterval ?? 3600, } const confTpl = fs.readFileSync("/opt/scripts/stream.template.conf", "utf-8") @@ -91,16 +93,23 @@ export function recreateStream(id: string): void { } export function startStream(id: string): void { - const programs = ["xvfb", "chromium", "autologin", "x11vnc", "ffmpeg"] + const programs = ["xvfb", "chromium", "autologin", "autoreload", "x11vnc", "ffmpeg"] for (const p of programs) supervisorctl(`start ${p}-${id}`) captureThumb(id, 60) } export function stopStream(id: string): void { - const programs = ["ffmpeg", "x11vnc", "autologin", "chromium", "xvfb"] + const programs = ["ffmpeg", "x11vnc", "autoreload", "autologin", "chromium", "xvfb"] for (const p of programs) supervisorctl(`stop ${p}-${id}`) } +export function applyAutoReload(id: string): void { + const stream = getStream(id) + if (!stream) return + supervisorctl(`stop autoreload-${id}`) + if (stream.autoReload) supervisorctl(`start autoreload-${id}`) +} + export function restartStream(id: string): void { stopStream(id) startStream(id) diff --git a/src/types/stream.ts b/src/types/stream.ts index 838b11a..8bcffc4 100644 --- a/src/types/stream.ts +++ b/src/types/stream.ts @@ -24,6 +24,9 @@ export interface Stream { gpu: boolean + autoReload?: boolean + autoReloadInterval?: number // seconds + desiredState: "running" | "stopped" // persisted desired state, restored on container restart order: number