init
This commit is contained in:
+587
@@ -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);
|
||||
Reference in New Issue
Block a user