Migrate to Chromium, unified VNC, thumbnails, autologin CDP detection

---

- Migrado base Docker de ubuntu:22.04 + Google Chrome para debian:bookworm-slim + Chromium
- Dockerfile refatorado com multi-stage build (node:22-alpine builder + debian runtime) e single RUN layer para imagem menor
- VNC unificado: removido novnc por stream, substituído por websockify global na porta 6080 com token-based routing
- Implementado sistema de thumbnails por stream via ffmpeg (captura do HLS) com endpoint GET/POST e atualização no card
- Autologin reescrito com detecção via Chrome DevTools Protocol: pula credenciais se já autenticado
- Adicionado padrão desiredState (running/stopped) persistido no JSON, restaurado via restore-streams.sh ao reiniciar container
- UI traduzida para inglês, formulário reorganizado com tooltips, seção avançada colapsável e GOP automático
- Player simplificado: modos HLS e HTML unificados, removido modo m3u8 separado
- Adicionado campo threads no ffmpeg; suporte a seccomp:unconfined no docker-compose

---
This commit is contained in:
2026-04-24 23:08:42 -03:00
parent 30b0597380
commit 1f8385e450
29 changed files with 1084 additions and 5412 deletions
+16 -7
View File
@@ -1,20 +1,29 @@
import { NextResponse } from "next/server"
import { getStream } from "@/lib/db"
import { getStream, saveStream } from "@/lib/db"
import { startStream, stopStream, restartStream } from "@/lib/supervisor"
type Ctx = { params: Promise<{ id: string; action: string }> }
export async function POST(_req: Request, { params }: Ctx) {
const { id, action } = await params
if (!getStream(id)) return NextResponse.json({ error: "não encontrado" }, { status: 404 })
const stream = getStream(id)
if (!stream) return NextResponse.json({ error: "not found" }, { status: 404 })
switch (action) {
case "start": startStream(id); break
case "stop": stopStream(id); break
case "restart": restartStream(id); break
case "start":
saveStream({ ...stream, desiredState: "running", updatedAt: new Date().toISOString() }) // #19
startStream(id)
break
case "stop":
saveStream({ ...stream, desiredState: "stopped", updatedAt: new Date().toISOString() }) // #19
stopStream(id)
break
case "restart":
saveStream({ ...stream, desiredState: "running", updatedAt: new Date().toISOString() }) // #19
restartStream(id)
break
default:
return NextResponse.json({ error: "ação inválida" }, { status: 400 })
return NextResponse.json({ error: "invalid action" }, { status: 400 })
}
return NextResponse.json({ ok: true })
+3 -3
View File
@@ -8,14 +8,14 @@ type Ctx = { params: Promise<{ id: string }> }
export async function GET(_req: Request, { params }: Ctx) {
const { id } = await params
const stream = getStream(id)
if (!stream) return NextResponse.json({ error: "não encontrado" }, { status: 404 })
if (!stream) return NextResponse.json({ error: "not found" }, { status: 404 })
return NextResponse.json(stream)
}
export async function PATCH(req: Request, { params }: Ctx) {
const { id } = await params
const stream = getStream(id)
if (!stream) return NextResponse.json({ error: "não encontrado" }, { status: 404 })
if (!stream) return NextResponse.json({ error: "not found" }, { status: 404 })
const body = (await req.json()) as StreamUpdate
// id e portas não podem ser alterados via PATCH
@@ -32,7 +32,7 @@ export async function PATCH(req: Request, { params }: Ctx) {
export async function DELETE(_req: Request, { params }: Ctx) {
const { id } = await params
if (!getStream(id)) return NextResponse.json({ error: "não encontrado" }, { status: 404 })
if (!getStream(id)) return NextResponse.json({ error: "not found" }, { status: 404 })
removeStream(id)
deleteStream(id)
+1 -1
View File
@@ -6,6 +6,6 @@ type Ctx = { params: Promise<{ id: string }> }
export async function GET(_req: Request, { params }: Ctx) {
const { id } = await params
if (!getStream(id)) return NextResponse.json({ error: "não encontrado" }, { status: 404 })
if (!getStream(id)) return NextResponse.json({ error: "not found" }, { status: 404 })
return NextResponse.json(getStreamStatus(id))
}
+26
View File
@@ -0,0 +1,26 @@
import fs from "fs"
import path from "path"
import { NextResponse } from "next/server"
import { getStream } from "@/lib/db"
import { captureThumb } from "@/lib/supervisor"
const DATA_DIR = process.env.DATA_DIR ?? "/app/data"
type Ctx = { params: Promise<{ id: string }> }
export async function GET(_req: Request, { params }: Ctx) {
const { id } = await params
const thumbPath = path.join(DATA_DIR, "streams", id, "thumb.jpg")
if (!fs.existsSync(thumbPath)) return new Response("not found", { status: 404 })
const buffer = fs.readFileSync(thumbPath)
return new Response(buffer, {
headers: { "Content-Type": "image/jpeg", "Cache-Control": "no-cache, no-store" },
})
}
export async function POST(_req: Request, { params }: Ctx) {
const { id } = await params
if (!getStream(id)) return NextResponse.json({ error: "not found" }, { status: 404 })
captureThumb(id, 5)
return NextResponse.json({ ok: true })
}
+2 -2
View File
@@ -9,8 +9,8 @@ export async function GET(req: Request) {
const lines = ["#EXTM3U"]
for (const s of streams) {
lines.push(`#EXTINF:-1,${s.name}`)
lines.push(`http://${host}:${port}/live/${s.id}/index.m3u8`)
lines.push(`#EXTINF:-1 tvg-id="${s.id}" tvg-name="${s.name}" group-title="DecapStream",${s.name} [${s.id}] ${s.resolution} ${s.fps}fps`)
lines.push(`http://${host}:${port}/live/${s.id}`)
}
return new Response(lines.join("\n"), {
+8 -5
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"
import { readStreams, saveStream, allocatePorts, getStream } from "@/lib/db"
import { provisionStream, startStream } from "@/lib/supervisor"
import { provisionStream, startStream, normalizeScale, captureThumb } from "@/lib/supervisor"
import { STREAM_DEFAULTS, type StreamCreate } from "@/types/stream"
export async function GET() {
@@ -13,13 +13,13 @@ export async function POST(req: Request) {
const body = (await req.json()) as StreamCreate
if (!body.id || !SLUG_RE.test(body.id))
return NextResponse.json({ error: "id inválido: use apenas letras minúsculas, números e hífen" }, { status: 400 })
return NextResponse.json({ error: "invalid id: use only lowercase letters, numbers and hyphens" }, { status: 400 })
if (!body.name || !body.url)
return NextResponse.json({ error: "name e url são obrigatórios" }, { status: 400 })
return NextResponse.json({ error: "name and url are required" }, { status: 400 })
if (getStream(body.id))
return NextResponse.json({ error: "já existe uma stream com esse id" }, { status: 409 })
return NextResponse.json({ error: "a stream with this id already exists" }, { status: 409 })
const ports = allocatePorts()
const now = new Date().toISOString()
@@ -27,7 +27,9 @@ export async function POST(req: Request) {
const stream = {
...STREAM_DEFAULTS,
...body,
scale: normalizeScale(body.scale ?? STREAM_DEFAULTS.scale), // #13
...ports,
desiredState: "running" as const, // #19
createdAt: now,
updatedAt: now,
}
@@ -35,6 +37,7 @@ export async function POST(req: Request) {
saveStream(stream)
provisionStream(stream)
startStream(stream.id)
captureThumb(stream.id, 60)
return NextResponse.json(stream, { status: 201 })
}
}