Adiciona auto-reload do Chromium via CDP por stream
---
- 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;
---
This commit is contained in:
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# 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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -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)
|
- **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 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
|
- **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
|
## 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) |
|
| `gop` | `60` | Keyframe interval (auto-calculated as 2x FPS in the UI) |
|
||||||
| `threads` | `0` | ffmpeg encoding threads (`0` = auto-detect) |
|
| `threads` | `0` | ffmpeg encoding threads (`0` = auto-detect) |
|
||||||
| `gpu` | `false` | Enable Chromium GPU acceleration (requires host GPU + container access) |
|
| `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
|
## Architecture
|
||||||
|
|
||||||
@@ -146,6 +149,7 @@ Each stream gets a slug ID you define (e.g. `grafana-prod`):
|
|||||||
│ ├── xvfb (display) │
|
│ ├── xvfb (display) │
|
||||||
│ ├── chromium (browser) │
|
│ ├── chromium (browser) │
|
||||||
│ ├── autologin (CDP) │
|
│ ├── autologin (CDP) │
|
||||||
|
│ ├── autoreload (CDP) │
|
||||||
│ ├── x11vnc (VNC) │
|
│ ├── x11vnc (VNC) │
|
||||||
│ └── ffmpeg (encode) │
|
│ └── ffmpeg (encode) │
|
||||||
│ │ │
|
│ │ │
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -48,7 +48,9 @@ for (const stream of streams) {
|
|||||||
THREADS: stream.threads ?? 0,
|
THREADS: stream.threads ?? 0,
|
||||||
USER: stream.user ?? '',
|
USER: stream.user ?? '',
|
||||||
PASS: stream.pass ?? '',
|
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')
|
fs.writeFileSync(path.join(dir, 'stream.conf'), render(confTpl, vars), 'utf-8')
|
||||||
|
|||||||
@@ -45,6 +45,16 @@ environment=DISPLAY="{{DISPLAY}}",LOGIN_USER="{{USER}}",LOGIN_PASS="{{PASS}}",DE
|
|||||||
stdout_logfile=/app/data/logs/{{STREAM_ID}}/autologin.log
|
stdout_logfile=/app/data/logs/{{STREAM_ID}}/autologin.log
|
||||||
stderr_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}}]
|
[program:x11vnc-{{STREAM_ID}}]
|
||||||
environment=DISPLAY={{DISPLAY}}
|
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"
|
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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react"
|
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 { cn } from "@/lib/utils"
|
||||||
import { Toggle } from "@/components/Toggle"
|
import { Toggle } from "@/components/Toggle"
|
||||||
import type { Stream } from "@/types/stream"
|
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 [thumbError, setThumbError] = useState(false)
|
||||||
const [thumbCapturing, setThumbCapturing] = useState(false)
|
const [thumbCapturing, setThumbCapturing] = useState(false)
|
||||||
const [confirmDelete, setConfirmDelete] = 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<ReturnType<typeof setInterval> | null>(null)
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
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() {
|
async function refreshThumb() {
|
||||||
setMenuOpen(false)
|
setMenuOpen(false)
|
||||||
setThumbError(false)
|
setThumbError(false)
|
||||||
@@ -275,6 +295,35 @@ export function StreamCard({ stream, status, localStatus, cardSize = "md", onRef
|
|||||||
{thumbCapturing ? "Capturing..." : "Refresh thumbnail"}
|
{thumbCapturing ? "Capturing..." : "Refresh thumbnail"}
|
||||||
</button>
|
</button>
|
||||||
<div className="border-t border-border" />
|
<div className="border-t border-border" />
|
||||||
|
<div className="px-3 py-2 flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-sm flex items-center gap-2">
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 shrink-0" /> Auto-reload
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={toggleAutoReload}
|
||||||
|
className={cn("relative w-9 h-5 rounded-full transition-colors shrink-0 overflow-hidden", autoReload ? "bg-blue-600" : "bg-zinc-600")}
|
||||||
|
>
|
||||||
|
<span className={cn("absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all", autoReload ? "left-[18px]" : "left-0.5")} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{autoReload && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">Every</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={autoReloadMins}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">min</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border" />
|
||||||
<button onClick={remove} className={cn(menuItem, "text-destructive")}>
|
<button onClick={remove} className={cn(menuItem, "text-destructive")}>
|
||||||
<Trash2 className="w-3.5 h-3.5" /> Delete
|
<Trash2 className="w-3.5 h-3.5" /> Delete
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+12
-3
@@ -63,7 +63,9 @@ export function provisionStream(stream: Stream): void {
|
|||||||
THREADS: stream.threads ?? 0,
|
THREADS: stream.threads ?? 0,
|
||||||
USER: stream.user ?? "",
|
USER: stream.user ?? "",
|
||||||
PASS: stream.pass ?? "",
|
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")
|
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 {
|
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}`)
|
for (const p of programs) supervisorctl(`start ${p}-${id}`)
|
||||||
captureThumb(id, 60)
|
captureThumb(id, 60)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopStream(id: string): void {
|
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}`)
|
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 {
|
export function restartStream(id: string): void {
|
||||||
stopStream(id)
|
stopStream(id)
|
||||||
startStream(id)
|
startStream(id)
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export interface Stream {
|
|||||||
|
|
||||||
gpu: boolean
|
gpu: boolean
|
||||||
|
|
||||||
|
autoReload?: boolean
|
||||||
|
autoReloadInterval?: number // seconds
|
||||||
|
|
||||||
desiredState: "running" | "stopped" // persisted desired state, restored on container restart
|
desiredState: "running" | "stopped" // persisted desired state, restored on container restart
|
||||||
|
|
||||||
order: number
|
order: number
|
||||||
|
|||||||
Reference in New Issue
Block a user