diff --git a/src/app/api/streams/statuses/route.ts b/src/app/api/streams/statuses/route.ts new file mode 100644 index 0000000..991f4bc --- /dev/null +++ b/src/app/api/streams/statuses/route.ts @@ -0,0 +1,6 @@ +import { NextResponse } from "next/server" +import { getAllStreamStatuses } from "@/lib/supervisor" + +export async function GET() { + return NextResponse.json(getAllStreamStatuses()) +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 1cf597e..48cd392 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -135,14 +135,9 @@ export default function GalleryPage() { const fetchStatuses = useCallback(async (list: Stream[]) => { if (list.length === 0) return - 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)) + const res = await fetch("/api/streams/statuses") + const data: Record> = await res.json() + setStatuses(data) }, []) useEffect(() => { diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 30d3769..a8b3a5a 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -3,17 +3,19 @@ export const AUTH_ENABLED = !!(process.env.AUTH_USER && process.env.AUTH_PASS) export const COOKIE_NAME = "ds_session" +// Cached promise — token is deterministic (env vars never change at runtime) +let _tokenCache: Promise | null = null + // HMAC-SHA256(user, key=pass) — deterministic, no in-memory state, survives restarts // Works in both Edge (SubtleCrypto) and Node.js runtime -export async function computeSessionToken(): Promise { +export function computeSessionToken(): Promise { + if (_tokenCache) return _tokenCache const user = process.env.AUTH_USER ?? "" const pass = process.env.AUTH_PASS ?? "" const enc = new TextEncoder() - const key = await globalThis.crypto.subtle.importKey( - "raw", enc.encode(pass), - { name: "HMAC", hash: "SHA-256" }, - false, ["sign"] - ) - const sig = await globalThis.crypto.subtle.sign("HMAC", key, enc.encode(user)) - return Array.from(new Uint8Array(sig), b => b.toString(16).padStart(2, "0")).join("") + _tokenCache = globalThis.crypto.subtle + .importKey("raw", enc.encode(pass), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]) + .then(key => globalThis.crypto.subtle.sign("HMAC", key, enc.encode(user))) + .then(sig => Array.from(new Uint8Array(sig), b => b.toString(16).padStart(2, "0")).join("")) + return _tokenCache } diff --git a/src/lib/supervisor.ts b/src/lib/supervisor.ts index fc1478c..03e19c7 100644 --- a/src/lib/supervisor.ts +++ b/src/lib/supervisor.ts @@ -132,25 +132,51 @@ export function captureThumb(streamId: string, delay = 60): void { export type ProgramStatus = "RUNNING" | "STOPPED" | "FATAL" | "STARTING" | "UNKNOWN" -export function getStreamStatus(id: string): Record { - const programs = ["xvfb", "chromium", "autologin", "x11vnc", "ffmpeg"] +// Cache for supervisorctl status — refreshed at most once every 3 seconds across all callers +let _statusCache: Record> | null = null +let _statusCacheAt = 0 +const STATUS_CACHE_TTL = 3000 - if (IS_DEV) { - return Object.fromEntries(programs.map((p) => [p, "STOPPED" as ProgramStatus])) - } +function fetchAllStatuses(): Record> { + const now = Date.now() + if (_statusCache && now - _statusCacheAt < STATUS_CACHE_TTL) return _statusCache - const result: Record = {} - 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" + const result: Record> = {} + try { + // One call for all programs — avoid N×5 blocking execSync calls per poll cycle + const out = execSync( + `supervisorctl -c /etc/supervisor/supervisord.conf status`, + { stdio: "pipe" } + ).toString() + for (const line of out.split("\n")) { + // e.g. "ffmpeg-abc123 RUNNING pid 42, uptime 0:01:00" + const m = line.match(/^(\S+)-(\S+)\s+(RUNNING|STOPPED|FATAL|STARTING)/) + if (!m) continue + const [, program, id, status] = m + if (!result[id]) result[id] = {} + result[id][program] = status as ProgramStatus } + } catch { + // supervisorctl can exit non-zero; return whatever was parsed } + + _statusCache = result + _statusCacheAt = now return result } + +export function getStreamStatus(id: string): Record { + const programs = ["xvfb", "chromium", "autologin", "x11vnc", "ffmpeg"] + if (IS_DEV) return Object.fromEntries(programs.map((p) => [p, "STOPPED" as ProgramStatus])) + const all = fetchAllStatuses() + return all[id] ?? Object.fromEntries(programs.map((p) => [p, "UNKNOWN" as ProgramStatus])) +} + +export function getAllStreamStatuses(): Record> { + const programs = ["xvfb", "chromium", "autologin", "x11vnc", "ffmpeg"] + if (IS_DEV) { + const { readStreams } = require("./db") as typeof import("./db") + return Object.fromEntries(readStreams().map((s) => [s.id, Object.fromEntries(programs.map((p) => [p, "STOPPED" as ProgramStatus]))])) + } + return fetchAllStatuses() +} diff --git a/src/middleware.ts b/src/middleware.ts index ffd6f2c..b7dc91b 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -22,7 +22,10 @@ export async function middleware(request: NextRequest) { return NextResponse.redirect(url) } - // Rolling session — refresh cookie on every request, resetting the 30-day timer + // Rolling session — refresh cookie only on page navigations, not API/HLS/asset requests + if (pathname.startsWith("/api/") || pathname.startsWith("/player")) { + return NextResponse.next() + } const res = NextResponse.next() res.cookies.set(COOKIE_NAME, cookie, { httpOnly: true,