Repo init

This commit is contained in:
2026-04-23 23:40:34 -03:00
parent 214158a174
commit 30b0597380
34 changed files with 13201 additions and 2 deletions
@@ -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 })
}
+41
View File
@@ -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 })
}
+11
View File
@@ -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))
}
+22
View File
@@ -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"',
},
})
}
+40
View File
@@ -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 })
}
+33
View File
@@ -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;
}
+21
View File
@@ -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>
)
}
+107
View File
@@ -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>
)
}
+65
View File
@@ -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" },
})
}
+134
View File
@@ -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>
)
}
+10
View File
@@ -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} />
}
+5
View File
@@ -0,0 +1,5 @@
import { StreamForm } from "@/components/StreamForm"
export default function NewStreamPage() {
return <StreamForm />
}
+156
View File
@@ -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>
)
}
+215
View File
@@ -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>
)
}
+58
View File
@@ -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,
}
}
+127
View File
@@ -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
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+44
View File
@@ -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,
}