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" ? (