refactor: replace MJPEG streaming with screenshot + meta-refresh #1
+461
-266
@@ -5,10 +5,12 @@ import puppeteer from "puppeteer";
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// TYPES
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
interface URLItem {
|
||||
url: string;
|
||||
durationSec: number;
|
||||
}
|
||||
|
||||
interface View {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -17,33 +19,42 @@ interface View {
|
||||
metaRefreshEnabled: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface ViewRow {
|
||||
id: string;
|
||||
name: string;
|
||||
urls: string; // JSON text
|
||||
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;
|
||||
}
|
||||
|
||||
interface UpdateViewBody extends Partial<CreateViewBody> {}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 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,
|
||||
@@ -54,18 +65,21 @@ function rowToView(row: ViewRow): View {
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function loadView(id: string): View | null {
|
||||
const row = db.query("SELECT * FROM views WHERE id = ?").get(id) as
|
||||
| ViewRow
|
||||
| undefined;
|
||||
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 (?, ?, ?, ?, ?, ?)",
|
||||
@@ -76,9 +90,10 @@ function insertView(v: View): void {
|
||||
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=?",
|
||||
@@ -88,20 +103,25 @@ function updateView(v: View): void {
|
||||
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
|
||||
// BROWSER MANAGER (single browser, pages created per-request)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
let browser: Browser | null = null;
|
||||
|
||||
async function getBrowser(): Promise<Browser> {
|
||||
if (browser && browser.isConnected()) return browser;
|
||||
console.log("[browser] Launching headless Chromium…");
|
||||
if (browser?.isConnected()) return browser;
|
||||
|
||||
console.log("[browser] Launching headless Chrome…");
|
||||
browser = await puppeteer.launch({
|
||||
executablePath: "/usr/bin/chromium",
|
||||
headless: true,
|
||||
@@ -111,131 +131,26 @@ async function getBrowser(): Promise<Browser> {
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
"--disable-software-rasterizer",
|
||||
"--disable-extensions",
|
||||
"--mute-audio",
|
||||
],
|
||||
});
|
||||
// Handle browser crash/disconnect
|
||||
|
||||
browser.on("disconnected", () => {
|
||||
console.log(
|
||||
"[browser] Browser disconnected — will restart on next request",
|
||||
);
|
||||
console.log("[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
|
||||
// Block heavy/font/media resources for speed
|
||||
await page.setRequestInterception(true);
|
||||
page.on("request", (req) => {
|
||||
const type = req.resourceType();
|
||||
@@ -251,162 +166,443 @@ async function serveMJPEG(view: View): Promise<Response> {
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
return page;
|
||||
}
|
||||
|
||||
// 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);
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ROTATION LOGIC (wall-clock based, stateless)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
return view.urls[0].durationSec;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SSR RENDERING
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function injectMetaRefresh(html: string, intervalSec: number): string {
|
||||
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, "");
|
||||
}
|
||||
|
||||
// ── Inline external CSS <link> → <style> ──
|
||||
|
||||
async function inlineCSS(page: Page, html: string): Promise<string> {
|
||||
// Collect all <link rel="stylesheet"> hrefs
|
||||
const linkRegex = /<link\b[^>]*\brel\s*=\s*"stylesheet"[^>]*\bhref\s*=\s*"([^"]*)"[^>]*>/gi;
|
||||
const links: { full: string; href: string }[] = [];
|
||||
let m;
|
||||
while ((m = linkRegex.exec(html)) !== null) {
|
||||
links.push({ full: m[0], href: m[1] });
|
||||
}
|
||||
|
||||
for (const link of links) {
|
||||
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}`);
|
||||
// Resolve relative URLs
|
||||
const resolved = new URL(link.href, page.url()).href;
|
||||
const cssText = await page.evaluate(async (url) => {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
return res.text();
|
||||
}, resolved);
|
||||
|
||||
if (cssText) {
|
||||
html = html.replace(
|
||||
link.full,
|
||||
`<style>/* inlined: ${link.href} */\n${cssText}\n</style>`
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Leave as-is if fetch fails
|
||||
}
|
||||
}
|
||||
|
||||
const enc = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const FRAME_MS = 200; // ~5 fps
|
||||
return html;
|
||||
}
|
||||
|
||||
const enqueueFrame = async () => {
|
||||
if (streamRecord.aborted) return;
|
||||
// ── Inline images → base64 data URIs ──
|
||||
|
||||
const idx = getCurrentURLIndex(view);
|
||||
if (idx !== currentIdx) {
|
||||
async function inlineImages(page: Page, html: string): Promise<string> {
|
||||
const imgRegex = /<img\b[^>]*\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 {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
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<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const result = reader.result as string;
|
||||
resolve(result);
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}, resolved);
|
||||
|
||||
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));
|
||||
if (base64) {
|
||||
html = html.replace(
|
||||
img.full,
|
||||
`<img src="${base64}"${img.rest}>`
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Leave as-is
|
||||
}
|
||||
}
|
||||
|
||||
controller.close();
|
||||
},
|
||||
cancel() {
|
||||
streamRecord.aborted = true;
|
||||
if (streamRecord.page) {
|
||||
streamRecord.page.close().catch(() => {});
|
||||
streamRecord.page = null;
|
||||
// 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] });
|
||||
}
|
||||
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",
|
||||
},
|
||||
|
||||
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<string>((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 <link> icons ──
|
||||
|
||||
async function inlineFavicons(page: Page, html: string): Promise<string> {
|
||||
const iconRegex = /<link\b[^>]*\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<string>((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<string> {
|
||||
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(/<link\b[^>]*\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<string> {
|
||||
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, status: number): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>Error</title>
|
||||
<meta http-equiv="refresh" content="10">
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
body{background:#1a1a2e;color:#e94560;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;font-size:clamp(14px,3vw,24px);}
|
||||
.box{text-align:center;padding:40px;}
|
||||
.icon{font-size:clamp(32px,8vw,64px);margin:0;}
|
||||
.tag{font-size:clamp(10px,2vw,14px);color:#888;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<p class="icon">⚠</p>
|
||||
<p>${message}</p>
|
||||
<p class="tag">Samsung TV Proxy — retrying in 10s</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 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.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
async function renderScreenshotFrame(url: string): Promise<Buffer> {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function screenshotHTML(jpegBase64: string, refreshSec: number): string {
|
||||
const sec = Math.max(2, Math.ceil(refreshSec));
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="refresh" content="${sec}">
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
html,body{width:100%;height:100%;background:#000;}
|
||||
img{display:block;width:100%;height:100%;object-fit:contain;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="data:image/jpeg;base64,${jpegBase64}" alt="">
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ── Per-view render lock: prevent overlapping Puppeteer renders
|
||||
// when meta-refresh fires faster than render completes ──
|
||||
|
||||
const renderLocks = new Map<string, Promise<void>>();
|
||||
|
||||
async function withRenderLock<T>(
|
||||
viewId: string,
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
// 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<Response> {
|
||||
if (view.method === "mjpeg") {
|
||||
return serveMJPEG(view);
|
||||
}
|
||||
// SSR method
|
||||
const idx = getCurrentURLIndex(view);
|
||||
const urlItem = view.urls[idx];
|
||||
let html: string;
|
||||
|
||||
if (view.method === "mjpeg") {
|
||||
return withRenderLock(view.id, async () => {
|
||||
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);
|
||||
}
|
||||
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" },
|
||||
});
|
||||
}
|
||||
function readBody(req: Request): Promise<string> {
|
||||
|
||||
async 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
|
||||
|
||||
// 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<Response> {
|
||||
}
|
||||
}
|
||||
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<Response> {
|
||||
};
|
||||
|
||||
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<Response> {
|
||||
}
|
||||
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,55 +672,53 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// API routes
|
||||
// API
|
||||
if (path.startsWith("/api/")) {
|
||||
return handleAPI(req, path);
|
||||
}
|
||||
|
||||
// View serving — /v/:id
|
||||
// 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"), {
|
||||
return new Response(errorPage("View not found", 404), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
if (view.urls.length === 0) {
|
||||
return new Response(errorPage("View has no URLs configured"), {
|
||||
return new Response(errorPage("No URLs configured", 500), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
@@ -533,55 +726,57 @@ const server = Bun.serve({
|
||||
return serveView(view);
|
||||
}
|
||||
|
||||
// Health check
|
||||
// Health
|
||||
if (path === "/health") {
|
||||
return json({ status: "ok", uptime: process.uptime() });
|
||||
}
|
||||
|
||||
// Static files from public/
|
||||
// Static files
|
||||
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
|
||||
await file.slice(0, 0).text();
|
||||
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(() => { });
|
||||
|
||||
// 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);
|
||||
}
|
||||
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);
|
||||
Reference in New Issue
Block a user