diff --git a/docker/Dockerfile b/docker/Dockerfile
index dd33d96..c4c8abe 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -3,7 +3,8 @@ FROM node:22-alpine AS builder
WORKDIR /build
COPY package.json package-lock.json ./
-RUN npm ci
+RUN --mount=type=cache,target=/root/.npm \
+ npm ci
COPY src/ ./src/
COPY public/ ./public/
@@ -16,8 +17,13 @@ ENV DEBIAN_FRONTEND=noninteractive
ARG MEDIAMTX_VERSION=1.17.1
-# Tudo em um único RUN para que o cleanup seja efetivo no tamanho final
-RUN apt-get update \
+# Cache mounts: .deb baixados e índice de pacotes ficam no cache do BuildKit (não entram na imagem)
+# /var/lib/apt/lists: índice apt (apt-get update) — seguro cachear
+# /var/cache/apt: .deb baixados — seguro cachear
+# /var/lib/apt inteiro NÃO é cacheado: extended_states rastreia auto/manual e corromperia o estado entre builds
+RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
+ --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
+ apt-get update \
&& apt-get install -y --no-install-recommends \
xvfb x11vnc novnc websockify \
ffmpeg supervisor xdotool tzdata \
@@ -34,11 +40,11 @@ RUN apt-get update \
-o /tmp/mediamtx.tar.gz \
&& tar -xzf /tmp/mediamtx.tar.gz -C /usr/local/bin mediamtx \
\
- # Remove ferramentas usadas só no build
+ # Remove apenas as ferramentas de build — qualquer remoção além disso causa cascata em deps do chromium/novnc
&& apt-get remove -y curl gnupg \
&& apt-get autoremove -y \
&& apt-get clean \
- && find /usr/lib/chromium/locales -name '*.pak' ! -name 'en-US.pak' -delete \
+ && find /usr/lib/chromium/locales -name '*.pak' ! -name 'en-US.pak' -delete 2>/dev/null || true \
\
# Chromium managed policy: disable password manager and autofill save prompts
&& mkdir -p /etc/chromium/policies/managed \
diff --git a/docker/Makefile b/docker/Makefile
index d5b60ba..f138e7c 100644
--- a/docker/Makefile
+++ b/docker/Makefile
@@ -9,7 +9,7 @@ build:
echo "❌ TAG não definida. Use ./build.sh ou passe TAG=x.x.x"; \
exit 1; \
fi
- docker build --no-cache \
+ docker build \
-f Dockerfile \
-t $(IMAGE):$(TAG) \
..
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index d01b059..539f4d6 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -14,7 +14,8 @@ services:
- "8888:8888" # HLS (MediaMTX)
- "6080:6080" # VNC (noVNC)
volumes:
- - decap-stream:/app/data
+ - streams:/app/data/streams # Persistent: streams.json, chrome profiles, thumbs
+ # - logs:/app/data/logs # Optional
volumes:
- decap-stream:
\ No newline at end of file
+ streams:
\ No newline at end of file
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
index 3e39403..aa0cbbe 100644
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -5,17 +5,13 @@ mkdir -p /app/data/streams
mkdir -p /app/data/logs
mkdir -p /app/data/vnc-tokens
-find /app/data/streams -name "*.sh" -exec chmod +x {} \;
+# Migrate streams.json from old location (/app/data/streams.json → /app/data/streams/streams.json)
+if [ -f /app/data/streams.json ] && [ ! -f /app/data/streams/streams.json ]; then
+ mv /app/data/streams.json /app/data/streams/streams.json
+ echo "[entrypoint] migrated streams.json to /app/data/streams/streams.json"
+fi
-# #19 — restaura streams para o desiredState após restart do container
-NODE_PATH=/app/node_modules node -e "
-const fs = require('fs');
-const { execSync } = require('child_process');
-const streamsFile = '/app/data/streams.json';
-if (!fs.existsSync(streamsFile)) process.exit(0);
-const streams = JSON.parse(fs.readFileSync(streamsFile, 'utf-8'));
-// Apenas aguarda o supervisord estar pronto, o restore ocorre via script separado
-fs.writeFileSync('/app/data/.pending-restore', JSON.stringify(streams.map(s => ({ id: s.id, desiredState: s.desiredState }))));
-" 2>/dev/null || true
+# Regenerate stream configs from current image templates before supervisord reads them
+node /opt/scripts/reprovision.mjs
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
diff --git a/scripts/autologin.sh b/scripts/autologin.sh
new file mode 100644
index 0000000..0792c26
--- /dev/null
+++ b/scripts/autologin.sh
@@ -0,0 +1,33 @@
+#!/bin/bash
+
+[ -z "${LOGIN_USER:-}" ] && exit 0
+
+sleep "${STREAM_DELAY:-0}"
+
+CURRENT_URL=$(node -e "
+const http = require('http');
+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');
+ process.stdout.write(page ? page.url : '');
+ } catch { process.stdout.write(''); }
+ });
+}).on('error', () => process.stdout.write(''));
+" 2>/dev/null)
+
+if [ -n "$CURRENT_URL" ] && ! echo "$CURRENT_URL" | grep -qiE '/(login|signin|sign-in|auth|sso|oauth)'; then
+ exit 0
+fi
+
+DISPLAY=${DISPLAY} xdotool search --sync --onlyvisible --class chromium windowfocus windowraise
+sleep 1
+
+DISPLAY=${DISPLAY} xdotool type --clearmodifiers --delay 50 "${LOGIN_USER}"
+DISPLAY=${DISPLAY} xdotool key Tab
+sleep 0.3
+DISPLAY=${DISPLAY} xdotool type --clearmodifiers --delay 50 "${LOGIN_PASS}"
+DISPLAY=${DISPLAY} xdotool key Return
diff --git a/scripts/autologin.template.sh b/scripts/autologin.template.sh
deleted file mode 100644
index 78358d0..0000000
--- a/scripts/autologin.template.sh
+++ /dev/null
@@ -1,37 +0,0 @@
-#!/bin/bash
-# Auto-generated by API — do not edit manually
-# Stream: {{STREAM_ID}}
-
-[ -z "{{USER}}" ] && exit 0
-
-sleep {{STREAM_DELAY}}
-
-# Query Chrome DevTools Protocol to detect current page URL
-CURRENT_URL=$(node -e "
-const http = require('http');
-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');
- process.stdout.write(page ? page.url : '');
- } catch { process.stdout.write(''); }
- });
-}).on('error', () => process.stdout.write(''));
-" 2>/dev/null)
-
-# If we got a URL and it doesn't look like a login page, skip autologin
-if [ -n "$CURRENT_URL" ] && ! echo "$CURRENT_URL" | grep -qiE '/(login|signin|sign-in|auth|sso|oauth)'; then
- exit 0
-fi
-
-DISPLAY={{DISPLAY}} xdotool search --sync --onlyvisible --class chromium windowfocus windowraise
-sleep 1
-
-DISPLAY={{DISPLAY}} xdotool type --clearmodifiers --delay 50 "{{USER}}"
-DISPLAY={{DISPLAY}} xdotool key Tab
-sleep 0.3
-DISPLAY={{DISPLAY}} xdotool type --clearmodifiers --delay 50 "{{PASS}}"
-DISPLAY={{DISPLAY}} xdotool key Return
diff --git a/scripts/reprovision.mjs b/scripts/reprovision.mjs
new file mode 100644
index 0000000..c9f74d8
--- /dev/null
+++ b/scripts/reprovision.mjs
@@ -0,0 +1,63 @@
+#!/usr/bin/env node
+// Regenerates stream.conf and VNC token files from current image templates.
+// Runs at container startup (before supervisord) so configs always match the image.
+
+import fs from 'fs'
+import path from 'path'
+
+const DATA_DIR = process.env.DATA_DIR ?? '/app/data'
+const STREAMS_FILE = path.join(DATA_DIR, 'streams', 'streams.json')
+const STREAMS_DIR = path.join(DATA_DIR, 'streams')
+const VNC_TOKENS_DIR = path.join(DATA_DIR, 'vnc-tokens')
+const LOGS_DIR = path.join(DATA_DIR, 'logs')
+const CONF_TPL = '/opt/scripts/stream.template.conf'
+
+if (!fs.existsSync(STREAMS_FILE) || !fs.existsSync(CONF_TPL)) process.exit(0)
+
+const streams = JSON.parse(fs.readFileSync(STREAMS_FILE, 'utf-8'))
+if (streams.length === 0) process.exit(0)
+
+const confTpl = fs.readFileSync(CONF_TPL, 'utf-8')
+
+function render(tpl, vars) {
+ return tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => String(vars[k] ?? ''))
+}
+
+for (const stream of streams) {
+ const dir = path.join(STREAMS_DIR, stream.id)
+ fs.mkdirSync(path.join(dir, 'chrome-profile'), { recursive: true })
+ fs.mkdirSync(path.join(LOGS_DIR, stream.id), { recursive: true })
+ fs.mkdirSync(VNC_TOKENS_DIR, { recursive: true })
+
+ const vars = {
+ STREAM_ID: stream.id,
+ DISPLAY: stream.display,
+ RESOLUTION: stream.resolution,
+ CHROME_SIZE: stream.resolution.replace('x', ','),
+ STREAM_URL: stream.url,
+ DEBUG_PORT: stream.debugPort,
+ VNC_PORT: stream.vncPort,
+ STREAM_DELAY: stream.delay,
+ FPS: stream.fps,
+ PRESET: stream.preset,
+ TUNE: stream.tune,
+ GOP: stream.gop,
+ BITRATE: stream.bitrate,
+ BUFSIZE: stream.bufsize,
+ SCALE: String(stream.scale).replace('x', ':'),
+ THREADS: stream.threads ?? 0,
+ USER: stream.user ?? '',
+ PASS: stream.pass ?? '',
+ }
+
+ fs.writeFileSync(path.join(dir, 'stream.conf'), render(confTpl, vars), 'utf-8')
+ fs.writeFileSync(
+ path.join(VNC_TOKENS_DIR, `${stream.id}.cfg`),
+ `${stream.id}: localhost:${stream.vncPort}\n`,
+ 'utf-8'
+ )
+
+ console.log(`[reprovision] ${stream.id}`)
+}
+
+console.log(`[reprovision] done (${streams.length} stream(s))`)
diff --git a/scripts/stream.template.conf b/scripts/stream.template.conf
index 73e3c85..b3db23f 100644
--- a/scripts/stream.template.conf
+++ b/scripts/stream.template.conf
@@ -5,8 +5,8 @@
command=Xvfb {{DISPLAY}} -screen 0 {{RESOLUTION}}x24 -ac
autorestart=true
priority=10
-stdout_logfile=/app/data/streams/{{STREAM_ID}}/xvfb.log
-stderr_logfile=/app/data/streams/{{STREAM_ID}}/xvfb.log
+stdout_logfile=/app/data/logs/{{STREAM_ID}}/xvfb.log
+stderr_logfile=/app/data/logs/{{STREAM_ID}}/xvfb.log
[program:chromium-{{STREAM_ID}}]
command=bash -c "rm -rf \
@@ -34,25 +34,25 @@ environment=DISPLAY={{DISPLAY}}
autorestart=true
priority=20
startsecs=5
-stdout_logfile=/app/data/streams/{{STREAM_ID}}/chromium.log
-stderr_logfile=/app/data/streams/{{STREAM_ID}}/chromium.log
+stdout_logfile=/app/data/logs/{{STREAM_ID}}/chromium.log
+stderr_logfile=/app/data/logs/{{STREAM_ID}}/chromium.log
[program:autologin-{{STREAM_ID}}]
-command=/app/data/streams/{{STREAM_ID}}/autologin.sh
+command=/opt/scripts/autologin.sh
autorestart=false
priority=30
startsecs=0
-environment=DISPLAY={{DISPLAY}}
-stdout_logfile=/app/data/streams/{{STREAM_ID}}/autologin.log
-stderr_logfile=/app/data/streams/{{STREAM_ID}}/autologin.log
+environment=DISPLAY="{{DISPLAY}}",LOGIN_USER="{{USER}}",LOGIN_PASS="{{PASS}}",DEBUG_PORT="{{DEBUG_PORT}}",STREAM_DELAY="{{STREAM_DELAY}}"
+stdout_logfile=/app/data/logs/{{STREAM_ID}}/autologin.log
+stderr_logfile=/app/data/logs/{{STREAM_ID}}/autologin.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"
autorestart=true
priority=40
-stdout_logfile=/app/data/streams/{{STREAM_ID}}/vnc.log
-stderr_logfile=/app/data/streams/{{STREAM_ID}}/vnc.log
+stdout_logfile=/app/data/logs/{{STREAM_ID}}/vnc.log
+stderr_logfile=/app/data/logs/{{STREAM_ID}}/vnc.log
[program:ffmpeg-{{STREAM_ID}}]
command=bash -c "sleep {{STREAM_DELAY}} && ffmpeg \
@@ -85,5 +85,5 @@ command=bash -c "sleep {{STREAM_DELAY}} && ffmpeg \
autorestart=true
startretries=999
priority=60
-stdout_logfile=/app/data/streams/{{STREAM_ID}}/ffmpeg.log
-stderr_logfile=/app/data/streams/{{STREAM_ID}}/ffmpeg.log
\ No newline at end of file
+stdout_logfile=/app/data/logs/{{STREAM_ID}}/ffmpeg.log
+stderr_logfile=/app/data/logs/{{STREAM_ID}}/ffmpeg.log
diff --git a/src/app/api/streams/[id]/recreate/route.ts b/src/app/api/streams/[id]/recreate/route.ts
new file mode 100644
index 0000000..04f75b9
--- /dev/null
+++ b/src/app/api/streams/[id]/recreate/route.ts
@@ -0,0 +1,12 @@
+import { NextResponse } from "next/server"
+import { getStream } from "@/lib/db"
+import { recreateStream } from "@/lib/supervisor"
+
+type Ctx = { params: Promise<{ id: string }> }
+
+export async function POST(_req: Request, { params }: Ctx) {
+ const { id } = await params
+ if (!getStream(id)) return NextResponse.json({ error: "not found" }, { status: 404 })
+ recreateStream(id)
+ return NextResponse.json({ ok: true })
+}
diff --git a/src/app/api/streams/[id]/thumb/route.ts b/src/app/api/streams/[id]/thumb/route.ts
index 4a88620..921a958 100644
--- a/src/app/api/streams/[id]/thumb/route.ts
+++ b/src/app/api/streams/[id]/thumb/route.ts
@@ -11,11 +11,21 @@ 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" },
- })
+ const tmpPath = path.join(DATA_DIR, "streams", id, "thumb.tmp.jpg")
+
+ if (fs.existsSync(thumbPath)) {
+ const buffer = fs.readFileSync(thumbPath)
+ return new Response(buffer, {
+ headers: { "Content-Type": "image/jpeg", "Cache-Control": "no-cache, no-store" },
+ })
+ }
+
+ // Auto-trigger capture when no thumb exists and no capture is already in progress
+ if (!fs.existsSync(tmpPath) && getStream(id)) {
+ captureThumb(id, 5)
+ }
+
+ return new Response("not found", { status: 404 })
}
export async function POST(_req: Request, { params }: Ctx) {
diff --git a/src/components/StreamCard.tsx b/src/components/StreamCard.tsx
index dff6168..0ca77c3 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 } from "lucide-react"
+import { MoreHorizontal, Play, Globe, Monitor, Pencil, RotateCcw, Square, Trash2, Circle, Copy, Check, Video, ImageUp, GripVertical, Wrench } from "lucide-react"
import { cn } from "@/lib/utils"
import type { Stream } from "@/types/stream"
import type { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities"
@@ -230,6 +230,9 @@ export function StreamCard({ stream, status, localStatus, cardSize = "md", onRef
+
{status?.ffmpeg === "RUNNING" || localStatus === "restarting" ? (