This commit is contained in:
2026-05-12 23:10:10 +02:00
commit edbe3f9bf2
6 changed files with 1334 additions and 0 deletions
+587
View File
@@ -0,0 +1,587 @@
import { Database } from "bun:sqlite";
import type { Browser, Page } from "puppeteer";
import puppeteer from "puppeteer";
// ═══════════════════════════════════════════════════════════════
// TYPES
// ═══════════════════════════════════════════════════════════════
interface URLItem {
url: string;
durationSec: number;
}
interface View {
id: string;
name: string;
urls: URLItem[];
method: "mjpeg" | "ssr";
metaRefreshEnabled: boolean;
createdAt: number;
}
interface ViewRow {
id: string;
name: string;
urls: string; // JSON text
method: string;
meta_refresh_enabled: number;
created_at: number;
}
interface CreateViewBody {
name: string;
urls: URLItem[];
method: "mjpeg" | "ssr";
metaRefreshEnabled?: boolean;
}
interface UpdateViewBody {
name?: string;
urls?: URLItem[];
method?: "mjpeg" | "ssr";
metaRefreshEnabled?: boolean;
}
// ═══════════════════════════════════════════════════════════════
// DATABASE
// ═══════════════════════════════════════════════════════════════
const db = new Database("views.db", { create: true });
db.run(
`CREATE TABLE IF NOT EXISTS views ( id TEXT PRIMARY KEY, name TEXT NOT NULL, urls TEXT NOT NULL DEFAULT '[]', method TEXT NOT NULL DEFAULT 'ssr', meta_refresh_enabled INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (unixepoch()) )`,
);
function rowToView(row: ViewRow): View {
return {
id: row.id,
name: row.name,
urls: JSON.parse(row.urls),
method: row.method as "mjpeg" | "ssr",
metaRefreshEnabled: row.meta_refresh_enabled === 1,
createdAt: row.created_at,
};
}
function loadView(id: string): View | null {
const row = db.query("SELECT * FROM views WHERE id = ?").get(id) as
| ViewRow
| undefined;
return row ? rowToView(row) : null;
}
function loadAllViews(): View[] {
const rows = db
.query("SELECT * FROM views ORDER BY created_at DESC")
.all() as ViewRow[];
return rows.map(rowToView);
}
function insertView(v: View): void {
db.run(
"INSERT INTO views (id, name, urls, method, meta_refresh_enabled, created_at) VALUES (?, ?, ?, ?, ?, ?)",
[
v.id,
v.name,
JSON.stringify(v.urls),
v.method,
v.metaRefreshEnabled ? 1 : 0,
v.createdAt,
],
);
}
function updateView(v: View): void {
db.run(
"UPDATE views SET name=?, urls=?, method=?, meta_refresh_enabled=? WHERE id=?",
[
v.name,
JSON.stringify(v.urls),
v.method,
v.metaRefreshEnabled ? 1 : 0,
v.id,
],
);
}
function deleteViewById(id: string): boolean {
const result = db.run("DELETE FROM views WHERE id = ?", [id]);
return result.changes > 0;
}
// ═══════════════════════════════════════════════════════════════
// BROWSER MANAGER
// ═══════════════════════════════════════════════════════════════
let browser: Browser | null = null;
async function getBrowser(): Promise<Browser> {
if (browser && browser.isConnected()) return browser;
console.log("[browser] Launching headless Chromium…");
browser = await puppeteer.launch({
executablePath: "/usr/bin/chromium",
headless: true,
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--disable-software-rasterizer",
],
});
// Handle browser crash/disconnect
browser.on("disconnected", () => {
console.log(
"[browser] Browser disconnected — will restart on next request",
);
browser = null;
});
console.log("[browser] Ready");
return browser;
}
async function createPage(): Promise<Page> {
const b = await getBrowser();
const page = await b.newPage();
await page.setViewport({ width: 1920, height: 1080 });
// Block unnecessary resource types for faster loads on TV-targeted renders
await page.setRequestInterception(true);
page.on("request", (req) => {
const type = req.resourceType();
if (type === "media" || type === "websocket" || type === "manifest") {
req.abort();
} else {
req.continue();
}
});
return page;
}
// ═══════════════════════════════════════════════════════════════
// ROTATION LOGIC
// ═══════════════════════════════════════════════════════════════
/**
* Returns which URL index is "current" based on wall-clock time
* and the cumulative durations of all URLs in the view.
*/
function getCurrentURLIndex(view: View): number {
if (view.urls.length <= 1) return 0;
const totalCycle = view.urls.reduce((sum, u) => sum + u.durationSec, 0);
if (totalCycle <= 0) return 0;
const elapsed = Math.floor(Date.now() / 1000) % totalCycle;
let accumulated = 0;
for (let i = 0; i < view.urls.length; i++) {
accumulated += view.urls[i].durationSec;
if (elapsed < accumulated) return i;
}
return view.urls.length - 1;
}
/**
* Returns how many seconds remain before the _next_ URL switch.
* Used for meta-refresh interval so the TV reloads exactly when
* the rotation advances.
*/
function getRemainingSec(view: View): number {
if (view.urls.length <= 1) return view.urls[0]?.durationSec || 30;
const totalCycle = view.urls.reduce((sum, u) => sum + u.durationSec, 0);
if (totalCycle <= 0) return 30;
const elapsed = Math.floor(Date.now() / 1000) % totalCycle;
let accumulated = 0;
for (let i = 0; i < view.urls.length; i++) {
accumulated += view.urls[i].durationSec;
if (elapsed < accumulated) {
return accumulated - elapsed;
}
}
// Shouldn't reach here — wrap around
return view.urls[0].durationSec;
}
// ═══════════════════════════════════════════════════════════════
// SSR RENDERING
// ═══════════════════════════════════════════════════════════════
function injectMetaRefresh(html: string, intervalSec: number): string {
// Use integer seconds for meta refresh
const sec = Math.max(1, Math.ceil(intervalSec));
const tag = `<meta http-equiv="refresh" content="${sec}">`;
if (html.includes("</head>")) {
return html.replace("</head>", `${tag}\n</head>`);
}
if (/<html[^>]*>/i.test(html)) {
return html.replace(/(<html[^>]*>)/i, `$1\n<head>${tag}</head>`);
}
return `<html><head>${tag}</head><body>${html}</body></html>`;
}
function stripScriptTags(html: string): string {
return html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
.replace(/\s+on\w+\s*=\s*"[^"]*"/gi, "")
.replace(/\s+on\w+\s*=\s*'[^']*'/gi, "");
}
async function renderSSR(url: string): Promise<string> {
const page = await createPage();
try {
await page.goto(url, { waitUntil: "networkidle0", timeout: 25000 });
const html = await page.content();
return stripScriptTags(html);
} finally {
await page.close().catch(() => {});
}
}
function errorPage(message: string): string {
return `
${message}
Samsung TV Proxy
`;
}
// ═══════════════════════════════════════════════════════════════
// MJPEG STREAMING
// ═══════════════════════════════════════════════════════════════
interface ActiveStream {
aborted: boolean;
page: Page | null;
}
const activeStreams = new Map<string, ActiveStream>();
async function serveMJPEG(view: View): Promise<Response> {
const b = await getBrowser();
const page = await b.newPage();
await page.setViewport({ width: 1920, height: 1080 });
// Block heavy resources during MJPEG capture
await page.setRequestInterception(true);
page.on("request", (req) => {
const type = req.resourceType();
if (
type === "media" ||
type === "websocket" ||
type === "manifest" ||
type === "font"
) {
req.abort();
} else {
req.continue();
}
});
// Per-connection key so multiple viewers of the same view don't collide
const connKey = `${view.id}:${crypto.randomUUID()}`;
const boundary = "MJPEG_FRAME";
let currentIdx = -1;
const streamRecord: ActiveStream = { aborted: false, page };
activeStreams.set(connKey, streamRecord);
// Navigate to the first URL before returning the response so the first
// frame is ready immediately and Chrome has something to display.
const firstIdx = getCurrentURLIndex(view);
try {
console.log(`[mjpeg:${connKey}] Initial navigation to URL ${firstIdx}: ${view.urls[firstIdx].url}`);
await page.goto(view.urls[firstIdx].url, {
waitUntil: "networkidle2",
timeout: 20000,
});
currentIdx = firstIdx;
} catch (err: any) {
console.error(`[mjpeg:${connKey}] Initial navigation failed: ${err.message}`);
}
const enc = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const FRAME_MS = 200; // ~5 fps
const enqueueFrame = async () => {
if (streamRecord.aborted) return;
const idx = getCurrentURLIndex(view);
if (idx !== currentIdx) {
try {
console.log(`[mjpeg:${connKey}] Switching to URL ${idx}: ${view.urls[idx].url}`);
await page.goto(view.urls[idx].url, {
waitUntil: "networkidle2",
timeout: 20000,
});
currentIdx = idx;
} catch (err: any) {
console.error(`[mjpeg:${connKey}] Navigation failed: ${err.message}`);
// Stay on current page, will retry next cycle
}
}
try {
const screenshot = await page.screenshot({
type: "jpeg",
quality: 70,
});
const part =
`--${boundary}\r\n` +
`Content-Type: image/jpeg\r\n` +
`Content-Length: ${screenshot.length}\r\n` +
`\r\n`;
controller.enqueue(enc.encode(part));
controller.enqueue(new Uint8Array(screenshot));
controller.enqueue(enc.encode("\r\n"));
} catch (err: any) {
if (!streamRecord.aborted) {
console.error(`[mjpeg:${connKey}] Screenshot failed: ${err.message}`);
}
}
};
while (!streamRecord.aborted) {
const start = Date.now();
await enqueueFrame();
const elapsed = Date.now() - start;
const delay = Math.max(0, FRAME_MS - elapsed);
if (delay > 0) await new Promise((r) => setTimeout(r, delay));
}
controller.close();
},
cancel() {
streamRecord.aborted = true;
if (streamRecord.page) {
streamRecord.page.close().catch(() => {});
streamRecord.page = null;
}
activeStreams.delete(connKey);
console.log(`[mjpeg:${connKey}] Stream closed`);
},
});
return new Response(stream, {
headers: {
"Content-Type": `multipart/x-mixed-replace; boundary=${boundary}`,
"Cache-Control": "no-cache, no-store, must-revalidate",
"Connection": "keep-alive",
"X-Content-Type-Options": "nosniff",
},
});
}
// ═══════════════════════════════════════════════════════════════
// VIEW SERVING — /v/:id
// ═══════════════════════════════════════════════════════════════
async function serveView(view: View): Promise<Response> {
if (view.method === "mjpeg") {
return serveMJPEG(view);
}
// SSR method
const idx = getCurrentURLIndex(view);
const urlItem = view.urls[idx];
let html: string;
try {
html = await renderSSR(urlItem.url);
} catch (err: any) {
console.error(`[ssr:${view.id}] Render failed: ${err.message}`);
return new Response(errorPage(`Failed to load: ${urlItem.url}`), {
status: 502,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
if (view.metaRefreshEnabled) {
const interval = getRemainingSec(view);
html = injectMetaRefresh(html, interval);
}
return new Response(html, {
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-cache, no-store, must-revalidate",
},
});
}
// ═══════════════════════════════════════════════════════════════
// HTTP SERVER
// ═══════════════════════════════════════════════════════════════
const PORT = parseInt(process.env.PORT || "3099");
const STATIC_DIR = new URL("../public", import.meta.url).pathname;
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
}
function readBody(req: Request): Promise<string> {
return req.text();
}
async function handleAPI(req: Request, path: string): Promise<Response> {
const method = req.method;
// GET /api/views — list all
if (path === "/api/views" && method === "GET") {
return json(loadAllViews());
}
// POST /api/views — create
if (path === "/api/views" && method === "POST") {
try {
const body: CreateViewBody = JSON.parse(await readBody(req));
if (!body.name || !body.name.trim()) {
return json({ error: "Name is required" }, 400);
}
if (!body.urls || body.urls.length === 0) {
return json({ error: "At least one URL is required" }, 400);
}
for (const u of body.urls) {
if (!u.url || !u.url.trim()) {
return json({ error: "Each URL entry must have a url" }, 400);
}
if (typeof u.durationSec !== "number" || u.durationSec <= 0) {
return json({ error: "Each URL entry must have a positive durationSec" }, 400);
}
}
if (!["mjpeg", "ssr"].includes(body.method)) {
return json({ error: "method must be 'mjpeg' or 'ssr'" }, 400);
}
const view: View = {
id: crypto.randomUUID(),
name: body.name.trim(),
urls: body.urls.map((u) => ({
url: u.url.trim(),
durationSec: Math.max(1, Math.round(u.durationSec)),
})),
method: body.method,
metaRefreshEnabled: body.metaRefreshEnabled ?? false,
createdAt: Math.floor(Date.now() / 1000),
};
insertView(view);
console.log(`[api] Created view "${view.name}" (${view.id}) method=${view.method}`);
return json(view, 201);
} catch {
return json({ error: "Invalid JSON body" }, 400);
}
}
// Match /api/views/:id
const viewMatch = path.match(/^\/api\/views\/([a-f0-9-]+)$/);
if (viewMatch) {
const viewId = viewMatch[1];
// GET /api/views/:id
if (method === "GET") {
const view = loadView(viewId);
if (!view) return json({ error: "View not found" }, 404);
return json(view);
}
// PUT /api/views/:id
if (method === "PUT") {
const view = loadView(viewId);
if (!view) return json({ error: "View not found" }, 404);
try {
const body: UpdateViewBody = JSON.parse(await readBody(req));
if (body.name !== undefined) view.name = body.name.trim();
if (body.urls !== undefined) {
if (body.urls.length === 0) {
return json({ error: "At least one URL is required" }, 400);
}
view.urls = body.urls.map((u) => ({
url: u.url.trim(),
durationSec: Math.max(1, Math.round(u.durationSec)),
}));
}
if (body.method !== undefined) {
if (!["mjpeg", "ssr"].includes(body.method)) {
return json({ error: "method must be 'mjpeg' or 'ssr'" }, 400);
}
view.method = body.method;
}
if (body.metaRefreshEnabled !== undefined) {
view.metaRefreshEnabled = body.metaRefreshEnabled;
}
updateView(view);
console.log(`[api] Updated view "${view.name}" (${view.id})`);
return json(view);
} catch {
return json({ error: "Invalid JSON body" }, 400);
}
}
// DELETE /api/views/:id
if (method === "DELETE") {
// Close all active MJPEG streams for this view (one per connected client)
for (const [key, active] of activeStreams) {
if (key.startsWith(viewId + ":")) {
active.aborted = true;
if (active.page) active.page.close().catch(() => {});
activeStreams.delete(key);
}
}
const deleted = deleteViewById(viewId);
if (!deleted) return json({ error: "View not found" }, 404);
console.log(`[api] Deleted view ${viewId}`);
return json({ ok: true });
}
}
return json({ error: "Not found" }, 404);
}
const server = Bun.serve({
port: PORT,
async fetch(req) {
const url = new URL(req.url);
const path = url.pathname;
// API routes
if (path.startsWith("/api/")) {
return handleAPI(req, path);
}
// View serving — /v/:id
const viewMatch = path.match(/^\/v\/([a-f0-9-]+)$/);
if (viewMatch) {
const view = loadView(viewMatch[1]);
if (!view) {
return new Response(errorPage("View not found"), {
status: 404,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
if (view.urls.length === 0) {
return new Response(errorPage("View has no URLs configured"), {
status: 500,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
return serveView(view);
}
// Health check
if (path === "/health") {
return json({ status: "ok", uptime: process.uptime() });
}
// Static files from public/
const filePath = path === "/" ? "/index.html" : path;
const fullPath = STATIC_DIR + filePath;
try {
const file = Bun.file(fullPath);
// Check if file exists (will throw if not)
await file.slice(0, 0).text(); // probe
return new Response(file);
} catch {
// SPA fallback: serve index.html for non-API routes
try {
return new Response(Bun.file(STATIC_DIR + "/index.html"));
} catch {
return new Response("Not found", { status: 404 });
}
}
},
error(err) {
console.error("[server]", err);
return new Response("Internal Server Error", { status: 500 });
},
});
console.log(`\n🚀 Samsung TV Proxy Server`);
console.log(`http://localhost:${PORT}`);
console.log(`Static: ${STATIC_DIR}`);
console.log(`Views: http://localhost:${PORT}/v/<view-id>\n`);
// ── Graceful shutdown ──
async function shutdown() {
console.log("\n[shutdown] Closing…");
// Close all active MJPEG streams
for (const [, stream] of activeStreams) {
stream.aborted = true;
if (stream.page) await stream.page.close().catch(() => { });
}
activeStreams.clear();
// Close browser
if (browser) {
await browser.close().catch(() => { });
browser = null;
}
server.stop();
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);