Refatora infraestrutura de dados, build e provisionamento de streams
---
- Movido streams.json para /app/data/streams/streams.json; volume do compose mapeado especificamente em /app/data/streams, deixando logs fora do volume persistido;
- Adicionado scripts/reprovision.mjs que regenera stream.conf e tokens VNC a partir dos templates da imagem no startup, garantindo que updates de container não exijam recriar o volume;
- Removido autologin.template.sh por-stream; substituído por scripts/autologin.sh global na imagem, com variáveis passadas via environment= no supervisor conf (com valores entre aspas
para compatibilidade com valores vazios);
- Logs de processos por stream movidos de /app/data/streams/{id}/ para /app/data/logs/{id}/;
- Adicionada função recreateStream em supervisor.ts e rota POST /api/streams/[id]/recreate; botão "Recreate" adicionado ao menu do card para limpar chrome-profile e re-provisionar;
- Adicionado auto-disparo de captureThumb no GET /api/streams/[id]/thumb quando thumb.jpg não existe e nenhuma captura está em andamento;
- Dockerfile: adicionado --mount=type=cache para /var/cache/apt e /var/lib/apt/lists (não /var/lib/apt inteiro para evitar corrupção de estado); removido --no-cache do Makefile; remoção
de pacotes limitada a curl gnupg para evitar cascata em dependências do chromium/novnc;
- Migração automática de streams.json do caminho antigo adicionada ao entrypoint.sh;
---
This commit is contained in:
+11
-5
@@ -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 \
|
||||
|
||||
+1
-1
@@ -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) \
|
||||
..
|
||||
|
||||
@@ -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:
|
||||
streams:
|
||||
+7
-11
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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))`)
|
||||
@@ -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
|
||||
stdout_logfile=/app/data/logs/{{STREAM_ID}}/ffmpeg.log
|
||||
stderr_logfile=/app/data/logs/{{STREAM_ID}}/ffmpeg.log
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 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) {
|
||||
|
||||
@@ -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
|
||||
<button onClick={() => action("restart", "restarting")} className={menuItem}>
|
||||
<RotateCcw className="w-3.5 h-3.5" /> Restart
|
||||
</button>
|
||||
<button onClick={() => action("recreate", "restarting")} className={menuItem}>
|
||||
<Wrench className="w-3.5 h-3.5" /> Recreate
|
||||
</button>
|
||||
{status?.ffmpeg === "RUNNING" || localStatus === "restarting" ? (
|
||||
<button onClick={() => action("stop", "stopping")} className={menuItem}>
|
||||
<Square className="w-3.5 h-3.5" /> Stop
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import path from "path"
|
||||
import type { Stream } from "@/types/stream"
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR ?? "/app/data"
|
||||
const STREAMS_FILE = path.join(DATA_DIR, "streams.json")
|
||||
const STREAMS_FILE = path.join(DATA_DIR, "streams", "streams.json")
|
||||
|
||||
function ensureFile() {
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true })
|
||||
|
||||
+12
-7
@@ -42,6 +42,7 @@ export function normalizeScale(scale: string): string {
|
||||
export function provisionStream(stream: Stream): void {
|
||||
const dir = streamDir(stream.id)
|
||||
fs.mkdirSync(path.join(dir, "chrome-profile"), { recursive: true })
|
||||
fs.mkdirSync(path.join(DATA_DIR, "logs", stream.id), { recursive: true })
|
||||
|
||||
const vars: Record<string, string | number> = {
|
||||
STREAM_ID: stream.id,
|
||||
@@ -64,14 +65,8 @@ export function provisionStream(stream: Stream): void {
|
||||
PASS: stream.pass ?? "",
|
||||
}
|
||||
|
||||
const autologinTpl = fs.readFileSync("/opt/scripts/autologin.template.sh", "utf-8")
|
||||
const autologinPath = path.join(dir, "autologin.sh")
|
||||
fs.writeFileSync(autologinPath, render(autologinTpl, vars), "utf-8")
|
||||
fs.chmodSync(autologinPath, 0o755)
|
||||
|
||||
const confTpl = fs.readFileSync("/opt/scripts/stream.template.conf", "utf-8")
|
||||
const confPath = path.join(dir, "stream.conf")
|
||||
fs.writeFileSync(confPath, render(confTpl, vars), "utf-8")
|
||||
fs.writeFileSync(path.join(dir, "stream.conf"), render(confTpl, vars), "utf-8")
|
||||
|
||||
fs.mkdirSync(VNC_TOKENS_DIR, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
@@ -84,6 +79,16 @@ export function provisionStream(stream: Stream): void {
|
||||
supervisorctl("update")
|
||||
}
|
||||
|
||||
export function recreateStream(id: string): void {
|
||||
const stream = getStream(id)
|
||||
if (!stream) return
|
||||
stopStream(id)
|
||||
const dir = streamDir(id)
|
||||
fs.rmSync(path.join(dir, "chrome-profile"), { recursive: true, force: true })
|
||||
provisionStream(stream)
|
||||
startStream(id)
|
||||
}
|
||||
|
||||
export function startStream(id: string): void {
|
||||
const programs = ["xvfb", "chromium", "autologin", "x11vnc", "ffmpeg"]
|
||||
for (const p of programs) supervisorctl(`start ${p}-${id}`)
|
||||
|
||||
Reference in New Issue
Block a user