diff --git a/package.json b/package.json index 58b56c8..b6c887e 100644 --- a/package.json +++ b/package.json @@ -12,4 +12,4 @@ "devDependencies": { "@types/bun": "^1.3.13" } -} +} \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index 3007eed..5e2702a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,408 +5,604 @@ import puppeteer from "puppeteer"; // ═══════════════════════════════════════════════════════════════ // TYPES // ═══════════════════════════════════════════════════════════════ + interface URLItem { - url: string; - durationSec: number; + url: string; + durationSec: number; } + interface View { - id: string; - name: string; - urls: URLItem[]; - method: "mjpeg" | "ssr"; - metaRefreshEnabled: boolean; - createdAt: number; + 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; + id: string; + name: string; + urls: string; + 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; + name: string; + urls: URLItem[]; + method: "mjpeg" | "ssr"; + metaRefreshEnabled?: boolean; } + +interface UpdateViewBody extends Partial {} + // ═══════════════════════════════════════════════════════════════ // 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()) )`, -); + +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, - }; + 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; + 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); + 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, - ], - ); + 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, - ], - ); + 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; + const result = db.run("DELETE FROM views WHERE id = ?", [id]); + return result.changes > 0; } + // ═══════════════════════════════════════════════════════════════ -// BROWSER MANAGER +// BROWSER MANAGER (single browser, pages created per-request) // ═══════════════════════════════════════════════════════════════ + let browser: Browser | null = null; + async function getBrowser(): Promise { - if (browser && browser.isConnected()) return browser; - console.log("[browser] Launching headless Chromium…"); - browser = await puppeteer.launch({ + if (browser?.isConnected()) return browser; + + console.log("[browser] Launching headless Chrome…"); + 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; + headless: true, + args: [ + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + "--disable-software-rasterizer", + "--disable-extensions", + "--mute-audio", + ], + }); + + browser.on("disconnected", () => { + console.log("[browser] Disconnected — will restart on next request"); + browser = null; + }); + + console.log("[browser] Ready"); + return browser; } + async function createPage(): Promise { - 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; + const b = await getBrowser(); + const page = await b.newPage(); + await page.setViewport({ width: 1920, height: 1080 }); + + // Block heavy/font/media resources for speed + 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(); + } + }); + + return page; } + // ═══════════════════════════════════════════════════════════════ -// ROTATION LOGIC +// ROTATION LOGIC (wall-clock based, stateless) // ═══════════════════════════════════════════════════════════════ -/** -* 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; + 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; + 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; + } + } + 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 = ``; - if (html.includes("")) { - return html.replace("", `${tag}\n`); - } - if (/]*>/i.test(html)) { - return html.replace(/(]*>)/i, `$1\n${tag}`); - } - return `${tag}${html}`; + const sec = Math.max(1, Math.ceil(intervalSec)); + const tag = ``; + if (html.includes("")) { + return html.replace("", ` ${tag}\n`); + } + if (/]*>/i.test(html)) { + return html.replace(/(]*>)/i, `$1\n${tag}`); + } + return `${tag}${html}`; } + function stripScriptTags(html: string): string { - return html - .replace(/)<[^<]*)*<\/script>/gi, "") - .replace(/\s+on\w+\s*=\s*"[^"]*"/gi, "") - .replace(/\s+on\w+\s*=\s*'[^']*'/gi, ""); + return html + .replace(/)<[^<]*)*<\/script>/gi, "") + .replace(/\s+on\w+\s*=\s*"[^"]*"/gi, "") + .replace(/\s+on\w+\s*=\s*'[^']*'/gi, ""); } + +// ── Inline external CSS ` + ); + } + } catch { + // Leave as-is if fetch fails + } + } + + return html; +} + +// ── Inline images → base64 data URIs ── + +async function inlineImages(page: Page, html: string): Promise { + const imgRegex = /]*\bsrc\s*=\s*"([^"]*)"([^>]*)>/gi; + const imgs: { full: string; src: string; rest: string }[] = []; + let m; + while ((m = imgRegex.exec(html)) !== null) { + imgs.push({ full: m[0], src: m[1], rest: m[2] }); + } + + for (const img of imgs) { + // Skip already-inlined + if (img.src.startsWith("data:")) continue; + try { + const resolved = new URL(img.src, page.url()).href; + const base64 = await page.evaluate(async (url) => { + const res = await fetch(url); + if (!res.ok) return null; + const blob = await res.blob(); + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => { + const result = reader.result as string; + resolve(result); + }; + reader.readAsDataURL(blob); + }); + }, resolved); + + if (base64) { + html = html.replace( + img.full, + `` + ); + } + } catch { + // Leave as-is + } + } + + // Also handle CSS background-image: url(...) — inline style blocks + const bgRegex = /url\s*\(\s*["']?([^)"'\s]+)["']?\s*\)/gi; + const bgs: { full: string; url: string }[] = []; + while ((m = bgRegex.exec(html)) !== null) { + if (m[1].startsWith("data:") || m[1].startsWith("#")) continue; + bgs.push({ full: m[0], url: m[1] }); + } + + for (const bg of bgs) { + try { + const resolved = new URL(bg.url, page.url()).href; + // Only process if it looks like an image URL + if (!/\.(png|jpe?g|gif|svg|webp|ico)/i.test(resolved)) continue; + + const base64 = await page.evaluate(async (url) => { + const res = await fetch(url); + if (!res.ok) return null; + const blob = await res.blob(); + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => { + const result = reader.result as string; + resolve(result); + }; + reader.readAsDataURL(blob); + }); + }, resolved); + + if (base64) { + html = html.replace(bg.full, `url(${base64})`); + } + } catch { + // Leave as-is + } + } + + return html; +} + +// ── Inline favicon / other icons ── + +async function inlineFavicons(page: Page, html: string): Promise { + const iconRegex = /]*\brel\s*=\s*"(?:icon|shortcut icon|apple-touch-icon)"[^>]*\bhref\s*=\s*"([^"]*)"[^>]*>/gi; + const icons: { full: string; href: string }[] = []; + let m; + while ((m = iconRegex.exec(html)) !== null) { + icons.push({ full: m[0], href: m[1] }); + } + + for (const icon of icons) { + if (icon.href.startsWith("data:")) continue; + try { + const resolved = new URL(icon.href, page.url()).href; + const base64 = await page.evaluate(async (url) => { + const res = await fetch(url); + if (!res.ok) return null; + const blob = await res.blob(); + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => { + const result = reader.result as string; + resolve(result); + }; + reader.readAsDataURL(blob); + }); + }, resolved); + + if (base64) { + html = html.replace(icon.full, icon.full.replace(icon.href, base64)); + } + } catch { + // Leave as-is + } + } + + return html; +} + +// ── Master inliner ── + +async function inlineAllResources(page: Page, html: string): Promise { + html = await inlineCSS(page, html); + html = await inlineImages(page, html); + html = await inlineFavicons(page, html); + + // Strip remaining external resource hints that the TV can't use + html = html.replace(/]*\brel\s*=\s*"(?:preload|prefetch|preconnect|dns-prefetch|modulepreload)"[^>]*>/gi, ""); + + // Remove crossorigin/integrity attributes that break without external context + html = html.replace(/\s+crossorigin\s*=\s*"[^"]*"/gi, ""); + html = html.replace(/\s+integrity\s*=\s*"[^"]*"/gi, ""); + + return html; +} + async function renderSSR(url: string): Promise { - 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(() => {}); - } + const page = await createPage(); + try { + await page.goto(url, { waitUntil: "networkidle0", timeout: 25000 }); + let html = await page.content(); + + // Inline all external resources so TV makes zero extra requests + html = await inlineAllResources(page, html); + + // Strip JS last — after inlining (fetch in evaluate needs JS) + html = stripScriptTags(html); + + return html; + } finally { + await page.close().catch(() => {}); + } } -function errorPage(message: string): string { - return ` -⚠ -${message} -Samsung TV Proxy -`; + +function errorPage(message: string, status: number): string { + return ` + +Error + + + + +
+

+

${message}

+

Samsung TV Proxy — retrying in 10s

+
+ +`; } + // ═══════════════════════════════════════════════════════════════ -// MJPEG STREAMING +// MJPEG/SCREENSHOT MODE — single frame via meta-refresh +// +// Instead of multipart/x-mixed-replace (broken on Chromium since +// ~2014), we take a Puppeteer screenshot, embed it as a base64 data +// URI in an HTML page, and use meta-refresh to reload. Every browser +// since HTML 2.0 handles this. Frame rate = 1 / refresh interval. // ═══════════════════════════════════════════════════════════════ -interface ActiveStream { - aborted: boolean; - page: Page | null; + +async function renderScreenshotFrame(url: string): Promise { + const page = await createPage(); + try { + await page.goto(url, { waitUntil: "networkidle0", timeout: 20000 }); + const buf = await page.screenshot({ + type: "jpeg", + quality: 75, + fullPage: false, + }); + return Buffer.from(buf); + } finally { + await page.close().catch(() => {}); + } } -const activeStreams = new Map(); -async function serveMJPEG(view: View): Promise { - 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", - }, - }); +function screenshotHTML(jpegBase64: string, refreshSec: number): string { + const sec = Math.max(2, Math.ceil(refreshSec)); + return ` + + + + + + + + + +`; } -// ═══════════════════════════════════════════════════════════════ -// VIEW SERVING — /v/:id + +// ── Per-view render lock: prevent overlapping Puppeteer renders +// when meta-refresh fires faster than render completes ── + +const renderLocks = new Map>(); + +async function withRenderLock( + viewId: string, + fn: () => Promise +): Promise { + // Wait for any in-flight render to finish + while (renderLocks.has(viewId)) { + await renderLocks.get(viewId)!.catch(() => {}); + } + + const promise = fn().finally(() => renderLocks.delete(viewId)); + renderLocks.set(viewId, promise.then(() => {})); + return promise; +} + // ═══════════════════════════════════════════════════════════════ +// VIEW SERVING — /v/:id +// ═══════════════════════════════════════════════════════════════ + async function serveView(view: View): Promise { - 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", - }, - }); + const idx = getCurrentURLIndex(view); + const urlItem = view.urls[idx]; + + if (view.method === "mjpeg") { + return withRenderLock(view.id, async () => { + try { + const jpeg = await renderScreenshotFrame(urlItem.url); + const b64 = jpeg.toString("base64"); + const interval = view.metaRefreshEnabled + ? getRemainingSec(view) + : Math.max(3, urlItem.durationSec); + const html = screenshotHTML(b64, interval); + + return new Response(html, { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-cache, no-store, must-revalidate", + }, + }); + } catch (err: any) { + console.error(`[shot:${view.id}] Failed: ${err.message}`); + return new Response(errorPage(`Failed: ${urlItem.url}`, 502), { + status: 502, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + } + }); + } + + // SSR method + return withRenderLock(view.id, async () => { + 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: ${urlItem.url}`, 502), { + 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" }, - }); + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); } -function readBody(req: Request): Promise { - return req.text(); + +async function readBody(req: Request): Promise { + return req.text(); } + async function handleAPI(req: Request, path: string): Promise { const method = req.method; - // GET /api/views — list all + + // GET /api/views if (path === "/api/views" && method === "GET") { return json(loadAllViews()); } - // POST /api/views — create + + // POST /api/views if (path === "/api/views" && method === "POST") { try { const body: CreateViewBody = JSON.parse(await readBody(req)); - if (!body.name || !body.name.trim()) { + + if (!body.name?.trim()) { return json({ error: "Name is required" }, 400); } - if (!body.urls || body.urls.length === 0) { + if (!body.urls?.length) { return json({ error: "At least one URL is required" }, 400); } for (const u of body.urls) { - if (!u.url || !u.url.trim()) { + if (!u.url?.trim()) { return json({ error: "Each URL entry must have a url" }, 400); } if (typeof u.durationSec !== "number" || u.durationSec <= 0) { @@ -414,7 +610,7 @@ async function handleAPI(req: Request, path: string): Promise { } } if (!["mjpeg", "ssr"].includes(body.method)) { - return json({ error: "method must be 'mjpeg' or 'ssr'" }, 400); + return json({ error: 'method must be "mjpeg" or "ssr"' }, 400); } const view: View = { @@ -430,25 +626,24 @@ async function handleAPI(req: Request, path: string): Promise { }; insertView(view); - console.log(`[api] Created view "${view.name}" (${view.id}) method=${view.method}`); + console.log(`[api] Created "${view.name}" (${view.id}) method=${view.method}`); return json(view, 201); } catch { return json({ error: "Invalid JSON body" }, 400); } } - // Match /api/views/:id + + // /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); @@ -468,7 +663,7 @@ async function handleAPI(req: Request, path: string): Promise { } if (body.method !== undefined) { if (!["mjpeg", "ssr"].includes(body.method)) { - return json({ error: "method must be 'mjpeg' or 'ssr'" }, 400); + return json({ error: 'method must be "mjpeg" or "ssr"' }, 400); } view.method = body.method; } @@ -477,111 +672,111 @@ async function handleAPI(req: Request, path: string): Promise { } updateView(view); - console.log(`[api] Updated view "${view.name}" (${view.id})`); + console.log(`[api] Updated "${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); - } + // Wait for any in-flight render before deleting + while (renderLocks.has(viewId)) { + await renderLocks.get(viewId)!.catch(() => {}); } const deleted = deleteViewById(viewId); if (!deleted) return json({ error: "View not found" }, 404); - console.log(`[api] Deleted view ${viewId}`); + console.log(`[api] Deleted ${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; + port: PORT, - // API routes - if (path.startsWith("/api/")) { - return handleAPI(req, path); + async fetch(req) { + const url = new URL(req.url); + const path = url.pathname; + + // API + if (path.startsWith("/api/")) { + return handleAPI(req, path); + } + + // View serving + const viewMatch = path.match(/^\/v\/([a-f0-9-]+)$/); + if (viewMatch) { + const view = loadView(viewMatch[1]); + if (!view) { + return new Response(errorPage("View not found", 404), { + status: 404, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); } - - // 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); + if (view.urls.length === 0) { + return new Response(errorPage("No URLs configured", 500), { + 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; + // Health + if (path === "/health") { + return json({ status: "ok", uptime: process.uptime() }); + } + // Static files + const filePath = path === "/" ? "/index.html" : path; + const fullPath = STATIC_DIR + filePath; + try { + const file = Bun.file(fullPath); + await file.slice(0, 0).text(); + return new Response(file); + } catch { 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); + return new Response(Bun.file(STATIC_DIR + "/index.html")); } 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 }); - } + 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/\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); + 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(` Views: http://localhost:${PORT}/v/\n`); + +// ── Graceful shutdown ── + +async function shutdown() { + console.log("\n[shutdown] Closing…"); + + // Wait for in-flight renders + const locks = [...renderLocks.values()]; + if (locks.length) { + console.log(`[shutdown] Waiting for ${locks.length} in-flight render(s)…`); + await Promise.allSettled(locks); } - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); + + if (browser) { + await browser.close().catch(() => {}); + browser = null; + } + + server.stop(); + process.exit(0); +} + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); \ No newline at end of file diff --git a/views.db b/views.db index 7db43a8..6f69db1 100644 Binary files a/views.db and b/views.db differ