Repo init
This commit is contained in:
+77
@@ -0,0 +1,77 @@
|
|||||||
|
# =========================
|
||||||
|
# Dependencies / Package Managers
|
||||||
|
# =========================
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# Build / Production
|
||||||
|
# =========================
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# Testing / Coverage
|
||||||
|
# =========================
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# Environment Variables
|
||||||
|
# =========================
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# Logs / Debug
|
||||||
|
# =========================
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# Editor / IDE
|
||||||
|
# =========================
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# OS / System Files
|
||||||
|
# =========================
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# Temporary Files
|
||||||
|
# =========================
|
||||||
|
.temp/
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# Vercel
|
||||||
|
# =========================
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# TypeScript / Next.js
|
||||||
|
# =========================
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# Misc
|
||||||
|
# =========================
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# Data Folder
|
||||||
|
# =========================
|
||||||
|
data/
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
DEFAULT_IMAGE="git.kralot.cloud/decap-stream"
|
||||||
|
DEFAULT_VERSION="0.0.0"
|
||||||
|
DEFAULT_LATEST="latest"
|
||||||
|
|
||||||
|
echo "=== DecapStream Build ==="
|
||||||
|
echo
|
||||||
|
|
||||||
|
read -p "Image [${DEFAULT_IMAGE}]: " IMAGE
|
||||||
|
IMAGE=${IMAGE:-$DEFAULT_IMAGE}
|
||||||
|
|
||||||
|
read -p "Version/Tag [${DEFAULT_VERSION}]: " VERSION
|
||||||
|
VERSION=${VERSION:-$DEFAULT_VERSION}
|
||||||
|
|
||||||
|
read -p "Build tag 'latest' também? (y/n) [y]: " USE_LATEST
|
||||||
|
USE_LATEST=${USE_LATEST:-y}
|
||||||
|
|
||||||
|
LATEST_TAG=""
|
||||||
|
if [[ "$USE_LATEST" =~ ^[Yy]$ ]]; then
|
||||||
|
read -p "Nome da tag latest [${DEFAULT_LATEST}]: " LATEST_TAG
|
||||||
|
LATEST_TAG=${LATEST_TAG:-$DEFAULT_LATEST}
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -p "Fazer push após o build? (y/n) [n]: " DO_PUSH
|
||||||
|
DO_PUSH=${DO_PUSH:-n}
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== CONFIRMAÇÃO ==="
|
||||||
|
echo "Image : $IMAGE"
|
||||||
|
echo "Version : $VERSION"
|
||||||
|
[[ "$USE_LATEST" =~ ^[Yy]$ ]] && echo "Latest : $LATEST_TAG" || echo "Latest : não"
|
||||||
|
echo "Push : $DO_PUSH"
|
||||||
|
echo
|
||||||
|
|
||||||
|
read -p "Confirmar build? (y/n): " CONFIRM
|
||||||
|
[[ "$CONFIRM" =~ ^[Yy]$ ]] || exit 1
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== BUILDING ==="
|
||||||
|
|
||||||
|
make -C docker build \
|
||||||
|
IMAGE="$IMAGE" \
|
||||||
|
TAG="$VERSION"
|
||||||
|
|
||||||
|
if [[ "$USE_LATEST" =~ ^[Yy]$ ]]; then
|
||||||
|
echo "🏷️ Taggeando como ${IMAGE}:${LATEST_TAG}..."
|
||||||
|
docker tag "${IMAGE}:${VERSION}" "${IMAGE}:${LATEST_TAG}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$DO_PUSH" =~ ^[Yy]$ ]]; then
|
||||||
|
echo
|
||||||
|
echo "=== PUSH ==="
|
||||||
|
make -C docker push IMAGE="$IMAGE" TAG="$VERSION"
|
||||||
|
[[ "$USE_LATEST" =~ ^[Yy]$ ]] && make -C docker push IMAGE="$IMAGE" TAG="$LATEST_TAG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "✅ Concluído com sucesso."
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
logLevel: debug
|
||||||
|
logDestinations: [stdout]
|
||||||
|
|
||||||
|
# RTMP — ffmpeg publica aqui
|
||||||
|
rtmp: yes
|
||||||
|
rtmpAddress: :1935
|
||||||
|
|
||||||
|
# HLS — frontend e player consomem daqui
|
||||||
|
hls: yes
|
||||||
|
hlsAddress: :8888
|
||||||
|
hlsAlwaysRemux: no
|
||||||
|
hlsVariant: lowLatency
|
||||||
|
hlsSegmentCount: 7
|
||||||
|
hlsSegmentDuration: 1s
|
||||||
|
hlsPartDuration: 200ms
|
||||||
|
|
||||||
|
# Desabilita o que não usa
|
||||||
|
rtsp: no
|
||||||
|
webrtc: no
|
||||||
|
srt: no
|
||||||
|
|
||||||
|
paths:
|
||||||
|
all:
|
||||||
|
source: publisher
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
logfile=/app/data/logs/supervisord.log
|
||||||
|
logfile_maxbytes=10MB
|
||||||
|
logfile_backups=3
|
||||||
|
pidfile=/tmp/supervisord.pid
|
||||||
|
|
||||||
|
[unix_http_server]
|
||||||
|
file=/tmp/supervisor.sock
|
||||||
|
|
||||||
|
[supervisorctl]
|
||||||
|
serverurl=unix:///tmp/supervisor.sock
|
||||||
|
|
||||||
|
[rpcinterface:supervisor]
|
||||||
|
supervisor.rpcinterface_factory=supervisor.rpcinterface:make_main_rpcinterface
|
||||||
|
|
||||||
|
# ── Serviços base ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[program:mediamtx]
|
||||||
|
command=/usr/local/bin/mediamtx /etc/mediamtx.yml
|
||||||
|
autorestart=true
|
||||||
|
priority=1
|
||||||
|
stdout_logfile=/app/data/logs/mediamtx.log
|
||||||
|
stderr_logfile=/app/data/logs/mediamtx.log
|
||||||
|
|
||||||
|
[program:nextjs]
|
||||||
|
directory=/app
|
||||||
|
command=node server.js
|
||||||
|
environment=PORT=3000,HOSTNAME=0.0.0.0,DATA_DIR=/app/data
|
||||||
|
autorestart=true
|
||||||
|
priority=2
|
||||||
|
stdout_logfile=/app/data/logs/nextjs.log
|
||||||
|
stderr_logfile=/app/data/logs/nextjs.log
|
||||||
|
|
||||||
|
# ── Streams dinâmicas ────────────────────────────────────────────────────────
|
||||||
|
# Cada stream gera /app/data/streams/{id}/stream.conf
|
||||||
|
# O supervisord inclui todos automaticamente
|
||||||
|
|
||||||
|
[include]
|
||||||
|
files = /app/data/streams/*/stream.conf
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
FROM ubuntu:22.04
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
ENV TZ=America/Sao_Paulo
|
||||||
|
|
||||||
|
# ── Sistema base ─────────────────────────────────────────────────────────────
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
xvfb x11vnc novnc websockify \
|
||||||
|
ffmpeg supervisor curl wget gnupg xdotool tzdata \
|
||||||
|
--no-install-recommends && \
|
||||||
|
ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime && \
|
||||||
|
wget -q https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb && \
|
||||||
|
apt-get install -y ./google-chrome-stable_current_amd64.deb && \
|
||||||
|
rm google-chrome-stable_current_amd64.deb && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# ── Node.js 22 ───────────────────────────────────────────────────────────────
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
|
||||||
|
apt-get install -y nodejs && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# ── MediaMTX ─────────────────────────────────────────────────────────────────
|
||||||
|
ARG MEDIAMTX_VERSION=1.17.1
|
||||||
|
RUN wget -q "https://github.com/bluenviron/mediamtx/releases/download/v${MEDIAMTX_VERSION}/mediamtx_v${MEDIAMTX_VERSION}_linux_amd64.tar.gz" -O /tmp/mediamtx.tar.gz && \
|
||||||
|
tar -xzf /tmp/mediamtx.tar.gz -C /usr/local/bin mediamtx && \
|
||||||
|
rm /tmp/mediamtx.tar.gz
|
||||||
|
|
||||||
|
# ── Next.js build ────────────────────────────────────────────────────────────
|
||||||
|
WORKDIR /build
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY src/ ./src/
|
||||||
|
COPY next.config.ts tsconfig.json postcss.config.mjs ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Montar app standalone ────────────────────────────────────────────────────
|
||||||
|
RUN mkdir -p /app/.next && \
|
||||||
|
cp -r .next/standalone/. /app/ && \
|
||||||
|
cp -r .next/static /app/.next/static && \
|
||||||
|
mkdir -p /app/public && \
|
||||||
|
(cp -r public/. /app/public/ 2>/dev/null || true)
|
||||||
|
|
||||||
|
# ── Configs e scripts ────────────────────────────────────────────────────────
|
||||||
|
COPY config/supervisord.conf /etc/supervisor/supervisord.conf
|
||||||
|
COPY config/mediamtx.yml /etc/mediamtx.yml
|
||||||
|
COPY scripts/ /opt/scripts/
|
||||||
|
RUN chmod +x /opt/scripts/*.sh
|
||||||
|
|
||||||
|
# ── Entrypoint ───────────────────────────────────────────────────────────────
|
||||||
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
VOLUME ["/app/data"]
|
||||||
|
EXPOSE 3000 1935 8888 6081
|
||||||
|
|
||||||
|
CMD ["/entrypoint.sh"]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
SHELL := /bin/bash
|
||||||
|
IMAGE ?= git.kralot.cloud/decap-stream
|
||||||
|
TAG ?= ""
|
||||||
|
|
||||||
|
.PHONY: build push
|
||||||
|
|
||||||
|
build:
|
||||||
|
@if [ -z "$(TAG)" ]; then \
|
||||||
|
echo "❌ TAG não definida. Use ./build.sh ou passe TAG=x.x.x"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
docker build --no-cache \
|
||||||
|
-f Dockerfile \
|
||||||
|
-t $(IMAGE):$(TAG) \
|
||||||
|
..
|
||||||
|
|
||||||
|
push:
|
||||||
|
@if [ -z "$(TAG)" ]; then \
|
||||||
|
echo "❌ TAG não definida."; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
docker push $(IMAGE):$(TAG)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
services:
|
||||||
|
decap-stream:
|
||||||
|
image: git.kralot.cloud/decap-stream:latest
|
||||||
|
container_name: decap-stream
|
||||||
|
restart: unless-stopped
|
||||||
|
shm_size: "2gb"
|
||||||
|
environment:
|
||||||
|
TZ: America/Sao_Paulo
|
||||||
|
ports:
|
||||||
|
- "3000:3000" # Web UI
|
||||||
|
- "1935:1935" # RTMP (MediaMTX)
|
||||||
|
- "8888:8888" # HLS (MediaMTX)
|
||||||
|
- "6081:6081" # VNC (noVNC)
|
||||||
|
volumes:
|
||||||
|
- decap-stream:/app/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
decap-stream:
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Garante estrutura de dados persistente
|
||||||
|
mkdir -p /app/data/streams
|
||||||
|
mkdir -p /app/data/logs
|
||||||
|
|
||||||
|
# Restaura streams que existiam antes de um restart
|
||||||
|
# O supervisord já vai incluir os stream.conf via [include]
|
||||||
|
# Garante que os scripts têm permissão de execução
|
||||||
|
find /app/data/streams -name "*.sh" -exec chmod +x {} \;
|
||||||
|
|
||||||
|
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+11581
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "decap-stream",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tabler/icons-react": "^3.40.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.577.0",
|
||||||
|
"nanoid": "^5.1.7",
|
||||||
|
"next": "^16.2.2",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"shadcn": "^4.0.3",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tw-animate-css": "^1.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"autoprefixer": "^10.4.27",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "^16.2.2",
|
||||||
|
"postcss": "^8.5.9",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Gerado automaticamente pela API — não editar manualmente
|
||||||
|
# Stream: {{STREAM_ID}}
|
||||||
|
|
||||||
|
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
|
||||||
|
sleep 3
|
||||||
|
DISPLAY={{DISPLAY}} xdotool key F11
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Gerado automaticamente pela API — não editar manualmente
|
||||||
|
# Stream: {{STREAM_ID}}
|
||||||
|
|
||||||
|
[program:xvfb-{{STREAM_ID}}]
|
||||||
|
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
|
||||||
|
|
||||||
|
[program:chrome-{{STREAM_ID}}]
|
||||||
|
command=bash -c "rm -rf /app/data/streams/{{STREAM_ID}}/chrome-profile/Singleton* && google-chrome \
|
||||||
|
--no-sandbox \
|
||||||
|
--disable-gpu \
|
||||||
|
--window-size={{RESOLUTION}} \
|
||||||
|
--start-maximized \
|
||||||
|
--user-data-dir=/app/data/streams/{{STREAM_ID}}/chrome-profile \
|
||||||
|
--test-type \
|
||||||
|
--disable-infobars \
|
||||||
|
--no-first-run \
|
||||||
|
--disable-extensions \
|
||||||
|
--disable-background-networking \
|
||||||
|
--disable-sync \
|
||||||
|
--disable-translate \
|
||||||
|
--disable-background-timer-throttling \
|
||||||
|
--remote-debugging-port={{DEBUG_PORT}} \
|
||||||
|
"{{STREAM_URL}}""
|
||||||
|
environment=DISPLAY={{DISPLAY}}
|
||||||
|
autorestart=true
|
||||||
|
priority=20
|
||||||
|
startsecs=5
|
||||||
|
stdout_logfile=/app/data/streams/{{STREAM_ID}}/chrome.log
|
||||||
|
stderr_logfile=/app/data/streams/{{STREAM_ID}}/chrome.log
|
||||||
|
|
||||||
|
[program:autologin-{{STREAM_ID}}]
|
||||||
|
command=/app/data/streams/{{STREAM_ID}}/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
|
||||||
|
|
||||||
|
[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"
|
||||||
|
autorestart=true
|
||||||
|
priority=40
|
||||||
|
stdout_logfile=/app/data/streams/{{STREAM_ID}}/vnc.log
|
||||||
|
stderr_logfile=/app/data/streams/{{STREAM_ID}}/vnc.log
|
||||||
|
|
||||||
|
[program:novnc-{{STREAM_ID}}]
|
||||||
|
command=websockify --web /usr/share/novnc {{NOVNC_PORT}} localhost:{{VNC_PORT}}
|
||||||
|
autorestart=true
|
||||||
|
priority=50
|
||||||
|
stdout_logfile=/app/data/streams/{{STREAM_ID}}/novnc.log
|
||||||
|
stderr_logfile=/app/data/streams/{{STREAM_ID}}/novnc.log
|
||||||
|
|
||||||
|
[program:ffmpeg-{{STREAM_ID}}]
|
||||||
|
command=bash -c "sleep {{STREAM_DELAY}} && ffmpeg \
|
||||||
|
-loglevel warning \
|
||||||
|
-f x11grab \
|
||||||
|
-video_size {{RESOLUTION}} \
|
||||||
|
-framerate {{FPS}} \
|
||||||
|
-i {{DISPLAY}} \
|
||||||
|
-f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 \
|
||||||
|
-shortest \
|
||||||
|
-c:v libx264 \
|
||||||
|
-preset {{PRESET}} \
|
||||||
|
-tune {{TUNE}} \
|
||||||
|
-profile:v baseline \
|
||||||
|
-level 3.1 \
|
||||||
|
-pix_fmt yuv420p \
|
||||||
|
-g {{GOP}} \
|
||||||
|
-keyint_min {{GOP}} \
|
||||||
|
-sc_threshold 0 \
|
||||||
|
-b:v {{BITRATE}} \
|
||||||
|
-maxrate {{BITRATE}} \
|
||||||
|
-bufsize {{BUFSIZE}} \
|
||||||
|
-c:a aac \
|
||||||
|
-b:a 128k \
|
||||||
|
-ar 44100 \
|
||||||
|
-ac 2 \
|
||||||
|
-vsync cfr \
|
||||||
|
-f flv rtmp://localhost:1935/live/{{STREAM_ID}}"
|
||||||
|
autorestart=true
|
||||||
|
startretries=999
|
||||||
|
priority=60
|
||||||
|
stdout_logfile=/app/data/streams/{{STREAM_ID}}/ffmpeg.log
|
||||||
|
stderr_logfile=/app/data/streams/{{STREAM_ID}}/ffmpeg.log
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { getStream } 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 })
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case "start": startStream(id); break
|
||||||
|
case "stop": stopStream(id); break
|
||||||
|
case "restart": restartStream(id); break
|
||||||
|
default:
|
||||||
|
return NextResponse.json({ error: "ação inválida" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { getStream, saveStream, deleteStream } from "@/lib/db"
|
||||||
|
import { provisionStream, restartStream, removeStream } from "@/lib/supervisor"
|
||||||
|
import type { StreamUpdate } from "@/types/stream"
|
||||||
|
|
||||||
|
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 })
|
||||||
|
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 })
|
||||||
|
|
||||||
|
const body = (await req.json()) as StreamUpdate
|
||||||
|
// id e portas não podem ser alterados via PATCH
|
||||||
|
const { id: _id, ...safe } = body as StreamUpdate & { id?: string }
|
||||||
|
void _id
|
||||||
|
|
||||||
|
const updated = { ...stream, ...safe, updatedAt: new Date().toISOString() }
|
||||||
|
saveStream(updated)
|
||||||
|
provisionStream(updated)
|
||||||
|
restartStream(id)
|
||||||
|
|
||||||
|
return NextResponse.json(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_req: Request, { params }: Ctx) {
|
||||||
|
const { id } = await params
|
||||||
|
if (!getStream(id)) return NextResponse.json({ error: "não encontrado" }, { status: 404 })
|
||||||
|
|
||||||
|
removeStream(id)
|
||||||
|
deleteStream(id)
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { getStream } from "@/lib/db"
|
||||||
|
import { getStreamStatus } from "@/lib/supervisor"
|
||||||
|
|
||||||
|
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 })
|
||||||
|
return NextResponse.json(getStreamStatus(id))
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { readStreams } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const host = searchParams.get("host") ?? "localhost"
|
||||||
|
const port = searchParams.get("port") ?? "8888"
|
||||||
|
|
||||||
|
const streams = readStreams()
|
||||||
|
|
||||||
|
const lines = ["#EXTM3U"]
|
||||||
|
for (const s of streams) {
|
||||||
|
lines.push(`#EXTINF:-1,${s.name}`)
|
||||||
|
lines.push(`http://${host}:${port}/live/${s.id}/index.m3u8`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(lines.join("\n"), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-mpegurl",
|
||||||
|
"Content-Disposition": 'attachment; filename="decap-stream.m3u"',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { readStreams, saveStream, allocatePorts, getStream } from "@/lib/db"
|
||||||
|
import { provisionStream, startStream } from "@/lib/supervisor"
|
||||||
|
import { STREAM_DEFAULTS, type StreamCreate } from "@/types/stream"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json(readStreams())
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLUG_RE = /^[a-z0-9-]+$/
|
||||||
|
|
||||||
|
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 })
|
||||||
|
|
||||||
|
if (!body.name || !body.url)
|
||||||
|
return NextResponse.json({ error: "name e url são obrigatórios" }, { status: 400 })
|
||||||
|
|
||||||
|
if (getStream(body.id))
|
||||||
|
return NextResponse.json({ error: "já existe uma stream com esse id" }, { status: 409 })
|
||||||
|
|
||||||
|
const ports = allocatePorts()
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
const stream = {
|
||||||
|
...STREAM_DEFAULTS,
|
||||||
|
...body,
|
||||||
|
...ports,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
saveStream(stream)
|
||||||
|
provisionStream(stream)
|
||||||
|
startStream(stream.id)
|
||||||
|
|
||||||
|
return NextResponse.json(stream, { status: 201 })
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--foreground: #ededed;
|
||||||
|
--card: #111111;
|
||||||
|
--card-foreground: #ededed;
|
||||||
|
--border: #222222;
|
||||||
|
--muted: #1a1a1a;
|
||||||
|
--muted-foreground: #888888;
|
||||||
|
--primary: #ededed;
|
||||||
|
--primary-foreground: #0a0a0a;
|
||||||
|
--destructive: #ef4444;
|
||||||
|
--destructive-foreground: #fff;
|
||||||
|
--accent: #1a1a1a;
|
||||||
|
--accent-foreground: #ededed;
|
||||||
|
--ring: #444444;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
border-color: var(--border);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Metadata } from "next"
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google"
|
||||||
|
import "./globals.css"
|
||||||
|
|
||||||
|
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] })
|
||||||
|
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] })
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "DecapStream",
|
||||||
|
description: "Headless stream manager",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="pt-BR" suppressHydrationWarning>
|
||||||
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { Plus, Download, RefreshCw } from "lucide-react"
|
||||||
|
import { StreamCard } from "@/components/StreamCard"
|
||||||
|
import type { Stream } from "@/types/stream"
|
||||||
|
|
||||||
|
export default function GalleryPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [streams, setStreams] = useState<Stream[]>([])
|
||||||
|
const [statuses, setStatuses] = useState<Record<string, Record<string, string>>>({})
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetchStreams = useCallback(async () => {
|
||||||
|
const res = await fetch("/api/streams")
|
||||||
|
const data: Stream[] = await res.json()
|
||||||
|
setStreams(data)
|
||||||
|
setLoading(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchStatuses = useCallback(async (list: Stream[]) => {
|
||||||
|
const results = await Promise.all(
|
||||||
|
list.map(async (s) => {
|
||||||
|
const res = await fetch(`/api/streams/${s.id}/status`)
|
||||||
|
const data = await res.json()
|
||||||
|
return [s.id, data] as const
|
||||||
|
})
|
||||||
|
)
|
||||||
|
setStatuses(Object.fromEntries(results))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStreams()
|
||||||
|
}, [fetchStreams])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (streams.length === 0) return
|
||||||
|
fetchStatuses(streams)
|
||||||
|
const interval = setInterval(() => fetchStatuses(streams), 10000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [streams, fetchStatuses])
|
||||||
|
|
||||||
|
function downloadPlaylist() {
|
||||||
|
const host = window.location.hostname
|
||||||
|
window.location.href = `/api/streams/playlist?host=${host}&port=8888`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="border-b border-border px-6 py-4 flex items-center justify-between">
|
||||||
|
<h1 className="text-lg font-semibold tracking-tight">DecapStream</h1>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { fetchStreams() }}
|
||||||
|
className="flex items-center gap-1.5 text-sm px-3 py-1.5 rounded border border-border hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={downloadPlaylist}
|
||||||
|
className="flex items-center gap-1.5 text-sm px-3 py-1.5 rounded border border-border hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" /> Playlist .m3u
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/streams/new")}
|
||||||
|
className="flex items-center gap-1.5 text-sm px-3 py-1.5 rounded bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" /> Nova stream
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<main className="flex-1 p-6">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-64 text-muted-foreground text-sm">
|
||||||
|
Carregando...
|
||||||
|
</div>
|
||||||
|
) : streams.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-64 gap-3 text-muted-foreground">
|
||||||
|
<p className="text-sm">Nenhuma stream configurada.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/streams/new")}
|
||||||
|
className="flex items-center gap-1.5 text-sm px-3 py-1.5 rounded bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" /> Nova stream
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
{streams.map((s) => (
|
||||||
|
<StreamCard
|
||||||
|
key={s.id}
|
||||||
|
stream={s}
|
||||||
|
status={statuses[s.id]}
|
||||||
|
onRefresh={fetchStreams}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server"
|
||||||
|
|
||||||
|
type Ctx = { params: Promise<{ id: string }> }
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, { params }: Ctx) {
|
||||||
|
const { id } = await params
|
||||||
|
const host = req.headers.get("host")?.split(":")[0] ?? "localhost"
|
||||||
|
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
html,body{background:#000;overflow:hidden;width:100%;height:100%}
|
||||||
|
video{width:100vw;height:100vh;display:block;object-fit:contain}
|
||||||
|
#msg{
|
||||||
|
position:fixed;top:16px;left:50%;transform:translateX(-50%);
|
||||||
|
background:rgba(0,0,0,0.75);color:#fff;padding:8px 20px;
|
||||||
|
border-radius:8px;font-family:sans-serif;font-size:34px;
|
||||||
|
display:none;z-index:9
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<video id="v" autoplay muted playsinline></video>
|
||||||
|
<div id="msg"></div>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js"></script>
|
||||||
|
<script>
|
||||||
|
var src='http://${host}:8888/live/${id}/index.m3u8';
|
||||||
|
var hls;
|
||||||
|
function showMsg(t){
|
||||||
|
var m=document.getElementById('msg');
|
||||||
|
m.textContent=t;m.style.display='block';
|
||||||
|
setTimeout(function(){m.style.display='none';},4000);
|
||||||
|
}
|
||||||
|
function load(){
|
||||||
|
if(hls)hls.destroy();
|
||||||
|
hls=new Hls({
|
||||||
|
liveSyncDurationCount:2,liveMaxLatencyDurationCount:4,
|
||||||
|
manifestLoadingTimeOut:10000,manifestLoadingMaxRetry:10,
|
||||||
|
fragLoadingTimeOut:10000,fragLoadingMaxRetry:10
|
||||||
|
});
|
||||||
|
hls.loadSource(src);
|
||||||
|
hls.attachMedia(document.getElementById('v'));
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED,function(){document.getElementById('v').play();});
|
||||||
|
hls.on(Hls.Events.ERROR,function(e,d){
|
||||||
|
if(d.fatal){showMsg('Erro: '+d.type+' — reconectando...');setTimeout(load,3000);}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var last=0;
|
||||||
|
setInterval(function(){
|
||||||
|
var v=document.getElementById('v');
|
||||||
|
if(v.currentTime===last&&!v.paused){showMsg('Stream travada — recarregando...');load();}
|
||||||
|
last=v.currentTime;
|
||||||
|
},10000);
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
return new NextResponse(html, {
|
||||||
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Suspense } from "react"
|
||||||
|
import { useParams, useSearchParams, useRouter } from "next/navigation"
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import Script from "next/script"
|
||||||
|
import { ArrowLeft } from "lucide-react"
|
||||||
|
|
||||||
|
type Mode = "hls" | "m3u8" | "html"
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
Hls: any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function HLSPlayer({ src }: { src: string }) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
const hlsRef = useRef<unknown>(null)
|
||||||
|
const [msg, setMsg] = useState("")
|
||||||
|
|
||||||
|
function showMsg(text: string) {
|
||||||
|
setMsg(text)
|
||||||
|
setTimeout(() => setMsg(""), 4000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
if (!videoRef.current || !window.Hls) return
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const Hls = window.Hls as any
|
||||||
|
if (hlsRef.current) (hlsRef.current as { destroy: () => void }).destroy()
|
||||||
|
const hls = new Hls({
|
||||||
|
liveSyncDurationCount: 2,
|
||||||
|
liveMaxLatencyDurationCount: 4,
|
||||||
|
manifestLoadingTimeOut: 10000,
|
||||||
|
manifestLoadingMaxRetry: 10,
|
||||||
|
fragLoadingTimeOut: 10000,
|
||||||
|
fragLoadingMaxRetry: 10,
|
||||||
|
})
|
||||||
|
hlsRef.current = hls
|
||||||
|
hls.loadSource(src)
|
||||||
|
hls.attachMedia(videoRef.current)
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, () => videoRef.current?.play())
|
||||||
|
hls.on(Hls.Events.ERROR, (_: unknown, d: { fatal: boolean; type: string }) => {
|
||||||
|
if (d.fatal) {
|
||||||
|
showMsg(`Erro: ${d.type} — reconectando...`)
|
||||||
|
setTimeout(load, 3000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let last = 0
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const v = videoRef.current
|
||||||
|
if (!v) return
|
||||||
|
if (v.currentTime === last && !v.paused) {
|
||||||
|
showMsg("Stream travada — recarregando...")
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
last = v.currentTime
|
||||||
|
}, 10000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js" onLoad={load} />
|
||||||
|
<video ref={videoRef} autoPlay muted playsInline className="w-screen h-screen object-contain bg-black" />
|
||||||
|
{msg && (
|
||||||
|
<div className="fixed top-4 left-1/2 -translate-x-1/2 bg-black/75 text-white px-5 py-2 rounded-lg text-sm z-10">
|
||||||
|
{msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function M3U8Player({ src }: { src: string }) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
const v = videoRef.current
|
||||||
|
if (!v) return
|
||||||
|
if (v.canPlayType("application/vnd.apple.mpegurl")) {
|
||||||
|
v.src = src
|
||||||
|
v.play()
|
||||||
|
}
|
||||||
|
}, [src])
|
||||||
|
return (
|
||||||
|
<video ref={videoRef} src={src} autoPlay muted playsInline controls className="w-screen h-screen object-contain bg-black" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Componente interno que usa useSearchParams — precisa estar dentro de Suspense
|
||||||
|
function PlayerInner() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const mode = (searchParams.get("mode") ?? "hls") as Mode
|
||||||
|
|
||||||
|
const host = typeof window !== "undefined" ? window.location.hostname : "localhost"
|
||||||
|
const hlsSrc = `http://${host}:8888/live/${id}/index.m3u8`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative bg-black w-screen h-screen overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="absolute top-4 left-4 z-20 flex items-center gap-1.5 text-sm text-white/70 hover:text-white bg-black/50 px-3 py-1.5 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" /> Voltar
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{mode === "hls" && <HLSPlayer src={hlsSrc} />}
|
||||||
|
{mode === "m3u8" && <M3U8Player src={hlsSrc} />}
|
||||||
|
{mode === "html" && (
|
||||||
|
<iframe
|
||||||
|
src={`/player-static/${id}`}
|
||||||
|
className="w-screen h-screen border-0"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlayerPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="bg-black w-screen h-screen" />}>
|
||||||
|
<PlayerInner />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import { getStream } from "@/lib/db"
|
||||||
|
import { StreamForm } from "@/components/StreamForm"
|
||||||
|
|
||||||
|
export default async function EditStreamPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params
|
||||||
|
const stream = getStream(id)
|
||||||
|
if (!stream) notFound()
|
||||||
|
return <StreamForm initial={stream} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { StreamForm } from "@/components/StreamForm"
|
||||||
|
|
||||||
|
export default function NewStreamPage() {
|
||||||
|
return <StreamForm />
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { MoreHorizontal, Play, Globe, FileVideo, Monitor, Pencil, RotateCcw, Square, Trash2, Circle } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { Stream } from "@/types/stream"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
stream: Stream
|
||||||
|
status?: Record<string, string>
|
||||||
|
onRefresh: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status?: Record<string, string> }) {
|
||||||
|
if (!status) return null
|
||||||
|
const ffmpeg = status.ffmpeg
|
||||||
|
const color =
|
||||||
|
ffmpeg === "RUNNING" ? "bg-green-500" :
|
||||||
|
ffmpeg === "STARTING" ? "bg-yellow-500" :
|
||||||
|
ffmpeg === "FATAL" ? "bg-red-500" :
|
||||||
|
"bg-zinc-500"
|
||||||
|
const label =
|
||||||
|
ffmpeg === "RUNNING" ? "running" :
|
||||||
|
ffmpeg === "STARTING" ? "starting" :
|
||||||
|
ffmpeg === "FATAL" ? "error" :
|
||||||
|
"stopped"
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Circle className={cn("w-2 h-2 fill-current", color)} />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StreamCard({ stream, status, onRefresh }: Props) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
const [loading, setLoading] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function action(act: string) {
|
||||||
|
setLoading(act)
|
||||||
|
await fetch(`/api/streams/${stream.id}/${act}`, { method: "POST" })
|
||||||
|
setLoading(null)
|
||||||
|
onRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
if (!confirm(`Deletar stream "${stream.name}"?`)) return
|
||||||
|
await fetch(`/api/streams/${stream.id}`, { method: "DELETE" })
|
||||||
|
onRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openVNC() {
|
||||||
|
const host = window.location.hostname
|
||||||
|
window.open(`http://${host}:${stream.novncPort}/vnc.html`, "_blank")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative rounded-lg border border-border bg-card p-4 flex flex-col gap-3">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-semibold truncate">{stream.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground font-mono truncate">{stream.id}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<StatusBadge status={status} />
|
||||||
|
<button
|
||||||
|
onClick={() => setMenuOpen((v) => !v)}
|
||||||
|
className="p-1 rounded hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* URL */}
|
||||||
|
<p className="text-xs text-muted-foreground truncate" title={stream.url}>{stream.url}</p>
|
||||||
|
|
||||||
|
{/* Play buttons */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/player/${stream.id}?mode=hls`)}
|
||||||
|
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded bg-accent hover:bg-accent/80 transition-colors"
|
||||||
|
>
|
||||||
|
<Play className="w-3 h-3" /> Play HLS
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/player/${stream.id}?mode=html`)}
|
||||||
|
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded bg-accent hover:bg-accent/80 transition-colors"
|
||||||
|
>
|
||||||
|
<Globe className="w-3 h-3" /> Play HTML
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/player/${stream.id}?mode=m3u8`)}
|
||||||
|
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded bg-accent hover:bg-accent/80 transition-colors"
|
||||||
|
>
|
||||||
|
<FileVideo className="w-3 h-3" /> Play m3u8
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={openVNC}
|
||||||
|
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded bg-accent hover:bg-accent/80 transition-colors"
|
||||||
|
>
|
||||||
|
<Monitor className="w-3 h-3" /> Open VNC
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dropdown menu */}
|
||||||
|
{menuOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
|
||||||
|
<div className="absolute top-10 right-4 z-20 min-w-[160px] rounded-lg border border-border bg-card shadow-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => { setMenuOpen(false); router.push(`/streams/${stream.id}/edit`) }}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" /> Editar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setMenuOpen(false); action("restart") }}
|
||||||
|
disabled={!!loading}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" /> Restart
|
||||||
|
</button>
|
||||||
|
{status?.ffmpeg === "RUNNING" ? (
|
||||||
|
<button
|
||||||
|
onClick={() => { setMenuOpen(false); action("stop") }}
|
||||||
|
disabled={!!loading}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<Square className="w-3.5 h-3.5" /> Stop
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => { setMenuOpen(false); action("start") }}
|
||||||
|
disabled={!!loading}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<Play className="w-3.5 h-3.5" /> Start
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="border-t border-border" />
|
||||||
|
<button
|
||||||
|
onClick={() => { setMenuOpen(false); remove() }}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-destructive hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" /> Deletar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { STREAM_DEFAULTS, type Stream, type StreamCreate, type StreamUpdate } from "@/types/stream"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initial?: Stream
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLUG_RE = /^[a-z0-9-]+$/
|
||||||
|
|
||||||
|
const PRESETS = ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium"]
|
||||||
|
const TUNES = ["stillimage", "zerolatency", "film", "animation"]
|
||||||
|
|
||||||
|
function Field({ label, error, children }: { label: string; error?: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-sm font-medium">{label}</label>
|
||||||
|
{children}
|
||||||
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded border border-border bg-muted px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring transition-colors",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Select({ className, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded border border-border bg-muted px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring transition-colors",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StreamForm({ initial }: Props) {
|
||||||
|
const router = useRouter()
|
||||||
|
const isEdit = !!initial
|
||||||
|
|
||||||
|
const [form, setForm] = useState<StreamCreate>({
|
||||||
|
id: initial?.id ?? "",
|
||||||
|
name: initial?.name ?? "",
|
||||||
|
url: initial?.url ?? "",
|
||||||
|
user: initial?.user ?? "",
|
||||||
|
pass: initial?.pass ?? "",
|
||||||
|
...STREAM_DEFAULTS,
|
||||||
|
...(initial ? {
|
||||||
|
delay: initial.delay,
|
||||||
|
resolution: initial.resolution,
|
||||||
|
scale: initial.scale,
|
||||||
|
fps: initial.fps,
|
||||||
|
bitrate: initial.bitrate,
|
||||||
|
bufsize: initial.bufsize,
|
||||||
|
preset: initial.preset,
|
||||||
|
tune: initial.tune,
|
||||||
|
gop: initial.gop,
|
||||||
|
} : {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
function set(key: keyof StreamCreate, value: string | number) {
|
||||||
|
setForm((f) => ({ ...f, [key]: value }))
|
||||||
|
setErrors((e) => { const n = { ...e }; delete n[key]; return n })
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate(): boolean {
|
||||||
|
const e: Record<string, string> = {}
|
||||||
|
if (!isEdit && !SLUG_RE.test(form.id)) e.id = "Apenas letras minúsculas, números e hífen"
|
||||||
|
if (!form.name.trim()) e.name = "Obrigatório"
|
||||||
|
if (!form.url.trim()) e.url = "Obrigatório"
|
||||||
|
setErrors(e)
|
||||||
|
return Object.keys(e).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!validate()) return
|
||||||
|
setSaving(true)
|
||||||
|
|
||||||
|
if (isEdit) {
|
||||||
|
const body: StreamUpdate = { ...form }
|
||||||
|
await fetch(`/api/streams/${initial!.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await fetch("/api/streams", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(form),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(false)
|
||||||
|
router.push("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
<header className="border-b border-border px-6 py-4 flex items-center gap-4">
|
||||||
|
<button onClick={() => router.push("/")} className="text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
← Voltar
|
||||||
|
</button>
|
||||||
|
<h1 className="text-lg font-semibold">{isEdit ? `Editar — ${initial!.name}` : "Nova stream"}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 p-6 max-w-2xl mx-auto w-full flex flex-col gap-8">
|
||||||
|
|
||||||
|
{/* Identificação */}
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Identificação</h2>
|
||||||
|
<Field label="ID (slug)" error={errors.id}>
|
||||||
|
<Input
|
||||||
|
value={form.id}
|
||||||
|
disabled={isEdit}
|
||||||
|
placeholder="mapa-zabbix"
|
||||||
|
onChange={(e) => set("id", e.target.value.toLowerCase())}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Nome" error={errors.name}>
|
||||||
|
<Input value={form.name} placeholder="Mapa Zabbix" onChange={(e) => set("name", e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Fonte */}
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Fonte</h2>
|
||||||
|
<Field label="URL" error={errors.url}>
|
||||||
|
<Input value={form.url} placeholder="https://..." onChange={(e) => set("url", e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Field label="Usuário (opcional)">
|
||||||
|
<Input value={form.user ?? ""} onChange={(e) => set("user", e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Senha (opcional)">
|
||||||
|
<Input type="password" value={form.pass ?? ""} onChange={(e) => set("pass", e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FFmpeg */}
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">FFmpeg / Xvfb</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Field label="Resolução (Xvfb/Chrome)">
|
||||||
|
<Input value={form.resolution} placeholder="1920x1080" onChange={(e) => set("resolution", e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Scale (stream output)">
|
||||||
|
<Input value={form.scale} placeholder="1280:720" onChange={(e) => set("scale", e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="FPS">
|
||||||
|
<Input type="number" value={form.fps} onChange={(e) => set("fps", Number(e.target.value))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Delay (s)">
|
||||||
|
<Input type="number" value={form.delay} onChange={(e) => set("delay", Number(e.target.value))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Bitrate">
|
||||||
|
<Input value={form.bitrate} placeholder="1500k" onChange={(e) => set("bitrate", e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Bufsize">
|
||||||
|
<Input value={form.bufsize} placeholder="1500k" onChange={(e) => set("bufsize", e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="GOP">
|
||||||
|
<Input type="number" value={form.gop} onChange={(e) => set("gop", Number(e.target.value))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Preset">
|
||||||
|
<Select value={form.preset} onChange={(e) => set("preset", e.target.value)}>
|
||||||
|
{PRESETS.map((p) => <option key={p}>{p}</option>)}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Tune">
|
||||||
|
<Select value={form.tune} onChange={(e) => set("tune", e.target.value)}>
|
||||||
|
{TUNES.map((t) => <option key={t}>{t}</option>)}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 pb-8">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="px-4 py-2 rounded border border-border text-sm hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 rounded bg-primary text-primary-foreground text-sm hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? "Salvando..." : isEdit ? "Salvar alterações" : "Criar stream"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import fs from "fs"
|
||||||
|
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")
|
||||||
|
|
||||||
|
function ensureFile() {
|
||||||
|
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true })
|
||||||
|
if (!fs.existsSync(STREAMS_FILE)) fs.writeFileSync(STREAMS_FILE, "[]", "utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStreams(): Stream[] {
|
||||||
|
ensureFile()
|
||||||
|
return JSON.parse(fs.readFileSync(STREAMS_FILE, "utf-8")) as Stream[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeStreams(streams: Stream[]): void {
|
||||||
|
ensureFile()
|
||||||
|
fs.writeFileSync(STREAMS_FILE, JSON.stringify(streams, null, 2), "utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStream(id: string): Stream | undefined {
|
||||||
|
return readStreams().find((s) => s.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveStream(stream: Stream): void {
|
||||||
|
const streams = readStreams()
|
||||||
|
const idx = streams.findIndex((s) => s.id === stream.id)
|
||||||
|
if (idx >= 0) streams[idx] = stream
|
||||||
|
else streams.push(stream)
|
||||||
|
writeStreams(streams)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteStream(id: string): void {
|
||||||
|
writeStreams(readStreams().filter((s) => s.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aloca display, portas VNC, noVNC e debug sem conflito com streams existentes
|
||||||
|
export function allocatePorts(): {
|
||||||
|
display: string
|
||||||
|
vncPort: number
|
||||||
|
novncPort: number
|
||||||
|
debugPort: number
|
||||||
|
} {
|
||||||
|
const streams = readStreams()
|
||||||
|
const usedDisplays = new Set(streams.map((s) => s.display))
|
||||||
|
|
||||||
|
let n = 1
|
||||||
|
while (usedDisplays.has(`:${n}`)) n++
|
||||||
|
|
||||||
|
return {
|
||||||
|
display: `:${n}`,
|
||||||
|
vncPort: 5900 + n,
|
||||||
|
novncPort: 6080 + n,
|
||||||
|
debugPort: 9221 + n,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import { execSync } from "child_process"
|
||||||
|
import type { Stream } from "@/types/stream"
|
||||||
|
|
||||||
|
const DATA_DIR = process.env.DATA_DIR ?? "/app/data"
|
||||||
|
const STREAMS_DIR = path.join(DATA_DIR, "streams")
|
||||||
|
|
||||||
|
function streamDir(id: string) {
|
||||||
|
return path.join(STREAMS_DIR, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(template: string, vars: Record<string, string | number>): string {
|
||||||
|
return template.replace(/\{\{(\w+)\}\}/g, (_, k) => String(vars[k] ?? ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
const IS_DEV = process.env.NODE_ENV !== "production"
|
||||||
|
|
||||||
|
function supervisorctl(cmd: string) {
|
||||||
|
if (IS_DEV) {
|
||||||
|
console.log(`[supervisor mock] supervisorctl ${cmd}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
execSync(`supervisorctl -c /etc/supervisor/supervisord.conf ${cmd}`, { stdio: "pipe" })
|
||||||
|
} catch {
|
||||||
|
// supervisorctl retorna exit 1 em alguns casos não-fatais (ex: já parado)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function provisionStream(stream: Stream): void {
|
||||||
|
const dir = streamDir(stream.id)
|
||||||
|
fs.mkdirSync(path.join(dir, "chrome-profile"), { recursive: true })
|
||||||
|
|
||||||
|
const vars: Record<string, string | number> = {
|
||||||
|
STREAM_ID: stream.id,
|
||||||
|
DISPLAY: stream.display,
|
||||||
|
RESOLUTION: stream.resolution,
|
||||||
|
STREAM_URL: stream.url,
|
||||||
|
DEBUG_PORT: stream.debugPort,
|
||||||
|
VNC_PORT: stream.vncPort,
|
||||||
|
NOVNC_PORT: stream.novncPort,
|
||||||
|
STREAM_DELAY: stream.delay,
|
||||||
|
FPS: stream.fps,
|
||||||
|
PRESET: stream.preset,
|
||||||
|
TUNE: stream.tune,
|
||||||
|
GOP: stream.gop,
|
||||||
|
BITRATE: stream.bitrate,
|
||||||
|
BUFSIZE: stream.bufsize,
|
||||||
|
USER: stream.user ?? "",
|
||||||
|
PASS: stream.pass ?? "",
|
||||||
|
}
|
||||||
|
|
||||||
|
// autologin.sh
|
||||||
|
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)
|
||||||
|
|
||||||
|
// stream.conf
|
||||||
|
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")
|
||||||
|
|
||||||
|
// Recarrega supervisord para reconhecer o novo conf
|
||||||
|
supervisorctl("reread")
|
||||||
|
supervisorctl("update")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startStream(id: string): void {
|
||||||
|
const programs = ["xvfb", "chrome", "autologin", "x11vnc", "novnc", "ffmpeg"]
|
||||||
|
for (const p of programs) {
|
||||||
|
supervisorctl(`start ${p}-${id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopStream(id: string): void {
|
||||||
|
const programs = ["ffmpeg", "novnc", "x11vnc", "autologin", "chrome", "xvfb"]
|
||||||
|
for (const p of programs) {
|
||||||
|
supervisorctl(`stop ${p}-${id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restartStream(id: string): void {
|
||||||
|
stopStream(id)
|
||||||
|
startStream(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeStream(id: string): void {
|
||||||
|
stopStream(id)
|
||||||
|
|
||||||
|
const confPath = path.join(streamDir(id), "stream.conf")
|
||||||
|
if (fs.existsSync(confPath)) fs.unlinkSync(confPath)
|
||||||
|
|
||||||
|
supervisorctl("reread")
|
||||||
|
supervisorctl("update")
|
||||||
|
|
||||||
|
// Remove pasta da stream
|
||||||
|
fs.rmSync(streamDir(id), { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProgramStatus = "RUNNING" | "STOPPED" | "FATAL" | "STARTING" | "UNKNOWN"
|
||||||
|
|
||||||
|
export function getStreamStatus(id: string): Record<string, ProgramStatus> {
|
||||||
|
const programs = ["xvfb", "chrome", "autologin", "x11vnc", "novnc", "ffmpeg"]
|
||||||
|
|
||||||
|
if (IS_DEV) {
|
||||||
|
return Object.fromEntries(programs.map((p) => [p, "STOPPED" as ProgramStatus]))
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Record<string, ProgramStatus> = {}
|
||||||
|
|
||||||
|
for (const p of programs) {
|
||||||
|
try {
|
||||||
|
const out = execSync(
|
||||||
|
`supervisorctl -c /etc/supervisor/supervisord.conf status ${p}-${id}`,
|
||||||
|
{ stdio: "pipe" }
|
||||||
|
).toString()
|
||||||
|
const match = out.match(/\b(RUNNING|STOPPED|FATAL|STARTING)\b/)
|
||||||
|
result[p] = (match?.[1] as ProgramStatus) ?? "UNKNOWN"
|
||||||
|
} catch {
|
||||||
|
result[p] = "UNKNOWN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export type StreamStatus = "running" | "stopped" | "error" | "starting"
|
||||||
|
|
||||||
|
export interface Stream {
|
||||||
|
id: string // slug definido pelo usuário: [a-z0-9-]
|
||||||
|
name: string // nome amigável
|
||||||
|
url: string
|
||||||
|
user?: string
|
||||||
|
pass?: string
|
||||||
|
|
||||||
|
// ffmpeg / xvfb
|
||||||
|
delay: number // segundos antes do ffmpeg iniciar
|
||||||
|
resolution: string // tamanho do Xvfb/Chrome: "1920x1080"
|
||||||
|
scale: string // scale do ffmpeg: "1280:720"
|
||||||
|
fps: number
|
||||||
|
bitrate: string // "1500k"
|
||||||
|
bufsize: string // "1500k"
|
||||||
|
preset: string // ultrafast | superfast | veryfast | faster | fast
|
||||||
|
tune: string // stillimage | zerolatency | film
|
||||||
|
gop: number
|
||||||
|
|
||||||
|
// alocado pelo sistema em runtime
|
||||||
|
display: string // ":1", ":2"...
|
||||||
|
vncPort: number // 5901, 5902...
|
||||||
|
novncPort: number // 6081, 6082...
|
||||||
|
debugPort: number // 9222, 9223...
|
||||||
|
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StreamCreate = Omit<Stream, "display" | "vncPort" | "novncPort" | "debugPort" | "createdAt" | "updatedAt">
|
||||||
|
export type StreamUpdate = Partial<StreamCreate>
|
||||||
|
|
||||||
|
export const STREAM_DEFAULTS: Omit<StreamCreate, "id" | "name" | "url"> = {
|
||||||
|
delay: 15,
|
||||||
|
resolution: "1920x1080",
|
||||||
|
scale: "1280:720",
|
||||||
|
fps: 30,
|
||||||
|
bitrate: "1500k",
|
||||||
|
bufsize: "3000k",
|
||||||
|
preset: "ultrafast",
|
||||||
|
tune: "stillimage",
|
||||||
|
gop: 60,
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user