Lumi/src/services/overlay-routes.js
2026-07-19 11:45:21 +02:00

604 lines
27 KiB
JavaScript

const { subscribeWebEvents } = require("./web-events");
const { createLogger } = require("./logger");
const {
OVERLAY_CAPABILITIES,
canAccessOverlay
} = require("./overlay-permissions");
const { getOverlayModuleType, listOverlayModuleTypes } = require("./overlay-modules");
const {
addModule,
addScene,
buildPublicState,
createOverlay,
deleteModule,
deleteOverlay,
deleteScene,
duplicateModule,
duplicateOverlay,
duplicateScene,
getObsSettings,
getOverlay,
listOverlays,
regenerateOverlayToken,
regenerateSceneToken,
refreshModule,
reorderModules,
reorderOverlays,
reorderScenes,
resolvePublicOverlay,
resolveWebSourceTicket,
saveObsSettings,
setActiveScene,
updateModule,
updateModuleTransform,
updateOverlay,
updateScene,
webSourceRenderUrl
} = require("./overlays");
const { OBS_BROWSER_DEFAULT_CSS } = require("./obs-browser-defaults");
const { fetchPublicOverlay, loadInjectedOverlayDocument } = require("./overlay-web-documents");
const { recentOverlayChatMessages } = require("./overlay-chat");
const {
listOverlayConnectorProviders,
overlayConnectorManager
} = require("./overlay-connectors");
const externalHealthCache = new Map();
const overlayWebLog = createLogger("core:overlay-web", { category: "integration" });
async function probeExternalOverlay(url) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
try {
const response = await fetchPublicOverlay(url, controller.signal);
if (!response) {
return {
healthy: true,
skipped: true,
status: null,
reason: "Private and local website addresses are refreshed but not probed by Lumi."
};
}
if (!response.ok) {
response.body?.cancel?.().catch?.(() => {});
return { healthy: false, status: response.status, reason: `The website returned HTTP ${response.status}.` };
}
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
if (contentType && !/(html|xhtml|text|javascript|json|svg)/i.test(contentType)) {
response.body?.cancel?.().catch?.(() => {});
return { healthy: false, status: response.status, reason: "The website returned an unexpected content type." };
}
const reader = response.body?.getReader?.();
let sample = "";
if (reader) {
const decoder = new TextDecoder();
while (sample.length < 65536) {
const { done, value } = await reader.read();
if (done) break;
sample += decoder.decode(value, { stream: true });
}
await reader.cancel().catch(() => {});
}
if (!sample.trim()) {
return { healthy: false, status: response.status, reason: "The website returned an empty reply." };
}
if (contentType.includes("html") && !/<(?:!doctype|html|head|body|script|style|div|canvas|svg)\b/i.test(sample)) {
return { healthy: false, status: response.status, reason: "The website reply did not look like a usable overlay page." };
}
return {
healthy: true,
status: response.status,
reason: null,
checked_at: Date.now(),
etag: response.headers.get("etag") || null,
last_modified: response.headers.get("last-modified") || null
};
} catch (error) {
return {
healthy: false,
status: null,
reason: error?.name === "AbortError" ? "The website took too long to reply." : "The website could not be reached."
};
} finally {
clearTimeout(timeout);
}
}
function noStore(res) {
res.set("Cache-Control", "no-store, private");
res.set("Pragma", "no-cache");
}
function browserSourceResponseHeaders(res) {
noStore(res);
res.set("Referrer-Policy", "no-referrer");
res.set("Cross-Origin-Resource-Policy", "cross-origin");
res.set("Content-Security-Policy", [
"sandbox allow-scripts allow-forms allow-popups allow-presentation",
"default-src * data: blob: 'unsafe-inline' 'unsafe-eval'",
"connect-src * data: blob: ws: wss:",
"font-src * data: blob:",
"frame-src * data: blob:",
"img-src * data: blob:",
"media-src * data: blob:",
"script-src * data: blob: 'unsafe-inline' 'unsafe-eval'",
"style-src * data: blob: 'unsafe-inline'",
"worker-src * data: blob:"
].join("; "));
}
function browserSourceErrorHtml(message) {
const safe = String(message || "The website could not be loaded.")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
return `<!doctype html><html><head><style>${OBS_BROWSER_DEFAULT_CSS}body{color:#fff;font:600 16px/1.4 system-ui,sans-serif;padding:18px;box-sizing:border-box}.error{display:inline-block;padding:10px 12px;border:1px solid rgba(248,113,113,.8);border-radius:8px;background:rgba(15,23,42,.9)}</style></head><body><div class="error">${safe}</div></body></html>`;
}
function publicOverlayContext(req) {
return resolvePublicOverlay(req.params.overlayToken, req.params.sceneToken || null);
}
function registerPublicOverlayRoutes(app) {
app.get("/overlay-web/:ticket", async (req, res) => {
browserSourceResponseHeaders(res);
const source = resolveWebSourceTicket(req.params.ticket);
if (!source?.config?.url || source.config.injectPageCss === false) {
return res.status(404).send(browserSourceErrorHtml("Website source unavailable."));
}
try {
const document = await loadInjectedOverlayDocument(source.config.url, source.config.customCss, {
userAgent: req.get("user-agent"),
acceptLanguage: req.get("accept-language")
});
return res.type("html").send(document.html);
} catch (error) {
overlayWebLog.warn("Website CSS injection failed.", {
module_id: source.moduleId,
error_code: error?.code || null,
error: error?.message || "Unknown website loading error."
}, { event: "overlay.web_css_injection_failed" });
return res.status(error?.code === "PRIVATE_TARGET" ? 422 : 502).type("html").send(browserSourceErrorHtml(error.message));
}
});
const registerVariant = (basePath, fixed) => {
app.post(`${basePath}/obs-bridge`, (req, res) => {
noStore(res);
const context = publicOverlayContext(req);
if (!context || !context.overlay.enabled || (fixed && !context.fixedScene?.enabled)) {
return res.status(404).json({ accepted: false, error: "Overlay unavailable." });
}
try {
const result = overlayConnectorManager.reportBrowserBridge(context.overlay.id, req.body || {});
return res.json({
accepted: Boolean(result.accepted),
enabled: Boolean(result.enabled),
state: result.state || "disabled"
});
} catch (error) {
return res.status(400).json({ accepted: false, error: error.message });
}
});
app.get(`${basePath}/module-health`, async (req, res) => {
noStore(res);
const context = publicOverlayContext(req);
if (!context) return res.status(404).json({ healthy: false, reason: "Overlay unavailable." });
const state = buildPublicState(context.overlay.id, fixed ? context.fixedScene.id : null);
const module = state.scene?.modules?.find((item) => item.id === req.query.module_id && item.renderType === "web");
if (!module?.config?.url) return res.status(404).json({ healthy: false, reason: "Website source unavailable." });
const cacheKey = `${context.overlay.id}:${module.id}`;
const cached = externalHealthCache.get(cacheKey);
if (cached && Date.now() - cached.at < 15000) return res.json(cached.result);
const result = await probeExternalOverlay(module.config.url);
externalHealthCache.set(cacheKey, { at: Date.now(), result });
return res.status(result.healthy ? 200 : 502).json(result);
});
app.get(`${basePath}/state`, (req, res) => {
noStore(res);
const context = publicOverlayContext(req);
if (!context) return res.status(404).json({ exists: false, enabled: false, scene: null });
return res.json(buildPublicState(context.overlay.id, fixed ? context.fixedScene.id : null));
});
app.get(`${basePath}/events`, (req, res) => {
const context = publicOverlayContext(req);
if (!context) return res.status(404).end();
return subscribeWebEvents(req, res, {
scopes: [`overlay:${context.overlay.id}`],
initialEvents: [
{ event: "overlay:changed", payload: { revision: context.overlay.updated_at } },
...recentOverlayChatMessages(context.overlay.id)
]
});
});
app.get(basePath, (req, res) => {
noStore(res);
const context = publicOverlayContext(req);
if (!context || !context.overlay.enabled || (fixed && !context.fixedScene.enabled)) {
return res.status(404).send("Overlay unavailable.");
}
const statePath = `${req.path.replace(/\/$/, "")}/state`;
const eventsPath = `${req.path.replace(/\/$/, "")}/events`;
const healthPath = `${req.path.replace(/\/$/, "")}/module-health`;
const bridgePath = `${req.path.replace(/\/$/, "")}/obs-bridge`;
res.set("Content-Security-Policy", "default-src 'none'; img-src https: http: data:; frame-src https: http:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self';");
return res.render("overlay-render", {
statePath,
eventsPath,
healthPath,
bridgePath,
obsBrowserDefaultCss: OBS_BROWSER_DEFAULT_CSS,
initialStateJson: JSON.stringify(buildPublicState(context.overlay.id, fixed ? context.fixedScene.id : null)).replace(/</g, "\\u003c")
});
});
};
registerVariant("/overlay/:overlayToken/scene/:sceneToken", true);
registerVariant("/overlay/:overlayToken", false);
}
function requireOverlayAccess(capability = OVERLAY_CAPABILITIES.MANAGE) {
return (req, res, next) => {
if (!canAccessOverlay(req.session?.user, req.params.id, capability)) {
if (req.path.startsWith("/api/")) return res.status(403).json({ error: "Access denied." });
return res.status(403).render("error", { title: "Access denied", message: "You do not have access to that overlay." });
}
next();
};
}
function requireSceneInOverlay(req, res, next) {
const overlay = getOverlay(req.params.id);
if (!overlay?.scenes.some((scene) => scene.id === req.params.sceneId)) {
if (req.path.startsWith("/api/")) return res.status(404).json({ error: "Scene not found." });
return res.status(404).render("error", { title: "Scene not found", message: "That scene is not part of this overlay." });
}
next();
}
function requireModuleInOverlay(req, res, next) {
const overlay = getOverlay(req.params.id);
if (!overlay?.scenes.some((scene) => scene.modules.some((module) => module.id === req.params.moduleId))) {
if (req.path.startsWith("/api/")) return res.status(404).json({ error: "Source not found." });
return res.status(404).render("error", { title: "Source not found", message: "That source is not part of this overlay." });
}
next();
}
function adminOnly(req, res, next) {
if (!req.session?.user?.isAdmin) {
if (req.path.startsWith("/api/")) return res.status(403).json({ error: "Access denied." });
return res.status(403).render("error", { title: "Access denied", message: "Administrator access is required." });
}
next();
}
function flash(req, type, message) {
req.session.flash = { type, message };
}
function redirectWithResult(req, res, pathValue, work, success) {
try {
const value = work();
flash(req, "success", success);
return res.redirect(pathValue);
} catch (error) {
flash(req, "error", error.message);
return res.redirect(pathValue);
}
}
function booleanField(body, name) {
return body[name] === true || body[name] === "true" || body[name] === "on" || body[name] === "1";
}
function orderedIds(body) {
if (Array.isArray(body.ids)) return body.ids;
if (typeof body.ids === "string") {
try {
const parsed = JSON.parse(body.ids);
if (Array.isArray(parsed)) return parsed;
} catch {}
return body.ids.split(",").map((id) => id.trim()).filter(Boolean);
}
return [];
}
function moduleInput(body) {
return {
name: body.name,
type: body.type,
enabled: booleanField(body, "enabled"),
config: {
text: body.text,
url: body.url,
x: body.x,
y: body.y,
width: body.width,
height: body.height,
opacity: body.opacity,
fontSize: body.font_size,
fontWeight: body.font_weight,
color: body.color,
background: body.background,
align: body.align,
padding: body.padding,
fit: body.fit,
playBehavior: body.play_behavior,
muted: booleanField(body, "muted"),
volume: body.volume,
playbackRate: body.playback_rate,
startAt: body.start_at,
showControls: booleanField(body, "show_controls"),
allowPointerEvents: booleanField(body, "allow_pointer_events"),
injectPageCss: booleanField(body, "inject_page_css"),
anchor: body.anchor,
horizontalAlign: body.horizontal_align,
verticalAlign: body.vertical_align,
overflow: body.text_overflow,
autoRefresh: booleanField(body, "auto_refresh"),
healthCheck: booleanField(body, "health_check"),
refreshIntervalSeconds: body.refresh_interval_seconds,
retryLimit: body.retry_limit,
cropTop: body.crop_top,
cropRight: body.crop_right,
cropBottom: body.crop_bottom,
cropLeft: body.crop_left,
zoom: body.zoom,
platforms: ["twitch", "youtube", "discord"].filter((platform) => booleanField(body, `chat_platform_${platform}`)),
channels: body.chat_channels,
maxMessages: body.chat_max_messages,
messageTimeoutSeconds: body.chat_timeout_seconds,
newestAt: body.chat_newest_at,
fontFamily: body.chat_font_family,
lineHeight: body.chat_line_height,
usernameColor: body.chat_username_color,
messageBackground: body.chat_message_background,
gap: body.chat_gap,
borderRadius: body.chat_border_radius,
showAvatars: booleanField(body, "chat_show_avatars"),
showBadges: booleanField(body, "chat_show_badges"),
showPlatform: booleanField(body, "chat_show_platform"),
showTimestamp: booleanField(body, "chat_show_timestamp"),
inAnimation: body.chat_in_animation,
outAnimation: body.chat_out_animation,
animationDurationMs: body.chat_animation_duration_ms,
customCss: body.type === "chat" ? body.chat_custom_css : body.custom_css
}
};
}
function baseUrl(req) {
return `${req.protocol}://${req.get("host")}`;
}
function registerOverlayAdminRoutes(app) {
app.get("/admin/overlays", adminOnly, (req, res) => {
noStore(res);
res.render("admin-overlays", {
title: "OBS overlays",
overlays: listOverlays({ includeSecrets: true }),
baseUrl: baseUrl(req)
});
});
app.post("/admin/overlays", adminOnly, (req, res) => redirectWithResult(
req,
res,
"/admin/overlays",
() => createOverlay({ name: req.body.name, description: req.body.description, sceneName: req.body.scene_name || "Main" }),
"Overlay created."
));
app.post("/admin/overlays/reorder", adminOnly, (req, res) => redirectWithResult(req, res, "/admin/overlays", () => reorderOverlays(orderedIds(req.body)), "Overlay order saved."));
app.get("/admin/overlays/:id", requireOverlayAccess(OVERLAY_CAPABILITIES.MANAGE), async (req, res) => {
noStore(res);
const canViewSecrets = canAccessOverlay(req.session.user, req.params.id, OVERLAY_CAPABILITIES.VIEW_SECRETS);
const canManageObs = canAccessOverlay(req.session.user, req.params.id, OVERLAY_CAPABILITIES.MANAGE_OBS);
const overlay = getOverlay(req.params.id, { includeSecrets: canViewSecrets });
if (!overlay) return res.status(404).render("error", { title: "Overlay not found", message: "That overlay does not exist." });
res.render("admin-overlay-detail", {
title: overlay.name,
overlay,
canViewSecrets,
canManageObs,
baseUrl: baseUrl(req),
obsBrowserDefaultCss: OBS_BROWSER_DEFAULT_CSS,
moduleTypes: listOverlayModuleTypes(),
editorStateJson: JSON.stringify({
overlayId: overlay.id,
canvas: { width: overlay.canvas_width || 1920, height: overlay.canvas_height || 1080 },
activeSceneId: overlay.active_scene_id,
scenes: overlay.scenes.map((scene) => ({
id: scene.id,
name: scene.name,
enabled: scene.enabled,
modules: scene.modules.map((module) => ({
id: module.id,
name: module.name,
type: module.type,
renderType: getOverlayModuleType(module.type)?.renderType || module.type,
enabled: module.enabled,
config: {
...module.config,
...((getOverlayModuleType(module.type)?.renderType || module.type) === "web" && module.config?.injectPageCss !== false
? { renderUrl: webSourceRenderUrl(module.id) }
: {})
}
}))
}))
}).replace(/</g, "\\u003c"),
connectorProviders: canManageObs ? listOverlayConnectorProviders() : [],
obsStatus: canManageObs
? await overlayConnectorManager.status(overlay.id, { includeScenes: true })
: { state: "restricted", current_scene: null, error: null, scenes: [] }
});
});
app.post("/admin/overlays/:id", requireOverlayAccess(), (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => updateOverlay(req.params.id, {
name: req.body.name,
description: req.body.description,
enabled: booleanField(req.body, "enabled"),
canvasWidth: req.body.canvas_width,
canvasHeight: req.body.canvas_height
}), "Overlay saved."));
app.post("/admin/overlays/:id/duplicate", requireOverlayAccess(), (req, res) => redirectWithResult(req, res, "/admin/overlays", () => duplicateOverlay(req.params.id, req.body.name), "Overlay duplicated with new private URLs."));
app.post("/admin/overlays/:id/delete", requireOverlayAccess(), (req, res) => redirectWithResult(req, res, "/admin/overlays", () => deleteOverlay(req.params.id), "Overlay deleted."));
app.post("/admin/overlays/:id/token/revoke", requireOverlayAccess(OVERLAY_CAPABILITIES.VIEW_SECRETS), (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => regenerateOverlayToken(req.params.id), "All Browser Source URLs for this overlay were regenerated. The old URLs are revoked."));
app.post("/admin/overlays/:id/active-scene", requireOverlayAccess(OVERLAY_CAPABILITIES.CONTROL_SCENE), (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => setActiveScene(req.params.id, req.body.scene_id, { source: "admin" }), "Active overlay scene changed."));
app.post("/admin/overlays/:id/scenes", requireOverlayAccess(), (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => addScene(req.params.id, { name: req.body.name, obsSceneName: req.body.obs_scene_name }), "Scene created."));
app.post("/admin/overlays/:id/scenes/reorder", requireOverlayAccess(), (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => reorderScenes(req.params.id, orderedIds(req.body)), "Scene order saved."));
app.post("/admin/overlays/:id/scenes/:sceneId", requireOverlayAccess(), requireSceneInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => updateScene(req.params.sceneId, { name: req.body.name, enabled: booleanField(req.body, "enabled"), obsSceneName: req.body.obs_scene_name }), "Scene saved."));
app.post("/admin/overlays/:id/scenes/:sceneId/duplicate", requireOverlayAccess(), requireSceneInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => duplicateScene(req.params.sceneId, req.body.name), "Scene duplicated with a new private URL."));
app.post("/admin/overlays/:id/scenes/:sceneId/delete", requireOverlayAccess(), requireSceneInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => deleteScene(req.params.sceneId), "Scene deleted."));
app.post("/admin/overlays/:id/scenes/:sceneId/token/revoke", requireOverlayAccess(OVERLAY_CAPABILITIES.VIEW_SECRETS), requireSceneInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => regenerateSceneToken(req.params.sceneId), "Fixed scene URL regenerated. The old URL is revoked."));
app.post("/admin/overlays/:id/scenes/:sceneId/modules", requireOverlayAccess(), requireSceneInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => addModule(req.params.sceneId, moduleInput(req.body)), "Source added."));
app.post("/admin/overlays/:id/scenes/:sceneId/modules/reorder", requireOverlayAccess(), requireSceneInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => reorderModules(req.params.sceneId, orderedIds(req.body)), "Layer order saved."));
app.post("/admin/overlays/:id/modules/:moduleId", requireOverlayAccess(), requireModuleInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => updateModule(req.params.moduleId, moduleInput(req.body)), "Source saved."));
app.post("/admin/overlays/:id/modules/:moduleId/duplicate", requireOverlayAccess(), requireModuleInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => duplicateModule(req.params.moduleId, req.body.name), "Source copied."));
app.post("/admin/overlays/:id/modules/:moduleId/delete", requireOverlayAccess(), requireModuleInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => deleteModule(req.params.moduleId), "Source deleted."));
app.post("/api/admin/overlays/:id/modules/:moduleId/transform", requireOverlayAccess(), requireModuleInOverlay, (req, res) => {
noStore(res);
try {
res.json({ ok: true, module: updateModuleTransform(req.params.moduleId, req.body || {}) });
} catch (error) {
res.status(400).json({ ok: false, error: error.message });
}
});
app.post("/api/admin/overlays/:id/modules/:moduleId/refresh", requireOverlayAccess(), requireModuleInOverlay, (req, res) => {
noStore(res);
try {
refreshModule(req.params.moduleId);
res.json({ ok: true, message: "Source refresh requested." });
} catch (error) {
res.status(400).json({ ok: false, error: error.message });
}
});
app.get("/api/admin/overlays/:id/modules/:moduleId/health", requireOverlayAccess(), requireModuleInOverlay, async (req, res) => {
noStore(res);
const overlay = getOverlay(req.params.id);
const module = overlay?.scenes.flatMap((scene) => scene.modules).find((item) => item.id === req.params.moduleId);
if (!module || module.type !== "web" || !module.config?.url) {
return res.status(404).json({ ok: false, healthy: false, reason: "Website source unavailable." });
}
const result = await probeExternalOverlay(module.config.url);
return res.json({ ok: true, ...result });
});
app.post("/admin/overlays/:id/obs", requireOverlayAccess(OVERLAY_CAPABILITIES.MANAGE_OBS), async (req, res) => {
try {
saveObsSettings(req.params.id, {
enabled: booleanField(req.body, "enabled"),
provider: req.body.provider,
endpoint: req.body.endpoint,
password: req.body.password,
clearPassword: booleanField(req.body, "clear_password"),
syncDirection: req.body.sync_direction,
reconnect: booleanField(req.body, "reconnect")
});
await overlayConnectorManager.restart(req.params.id);
flash(req, "success", "OBS connector settings saved.");
} catch (error) {
flash(req, "error", error.message);
}
res.redirect(`/admin/overlays/${req.params.id}`);
});
app.get("/api/admin/overlays/:id/obs/status", requireOverlayAccess(OVERLAY_CAPABILITIES.MANAGE_OBS), async (req, res) => {
noStore(res);
res.json({ ok: true, ...(await overlayConnectorManager.status(req.params.id, { includeScenes: true })) });
});
app.post("/api/admin/overlays/:id/obs/test", requireOverlayAccess(OVERLAY_CAPABILITIES.MANAGE_OBS), async (req, res) => {
noStore(res);
try {
const existing = getObsSettings(req.params.id, { includeSecret: true });
const result = await overlayConnectorManager.test({
overlayId: req.params.id,
provider: req.body.provider || existing.provider,
endpoint: req.body.endpoint || existing.endpoint,
password: req.body.password || existing.password
});
res.json(result);
} catch (error) {
res.status(400).json({ ok: false, error: error.message });
}
});
app.post("/api/admin/overlays/:id/obs/scene", requireOverlayAccess(OVERLAY_CAPABILITIES.MANAGE_OBS), async (req, res) => {
noStore(res);
try {
res.json({ ok: true, ...(await overlayConnectorManager.setObsScene(req.params.id, req.body.scene_name)) });
} catch (error) {
res.status(400).json({ ok: false, error: error.message });
}
});
app.post("/api/admin/overlays/:id/obs/import", requireOverlayAccess(OVERLAY_CAPABILITIES.MANAGE_OBS), async (req, res) => {
noStore(res);
let syncSuppressed = false;
try {
const status = await overlayConnectorManager.status(req.params.id, { includeScenes: true });
if (status.state !== "connected" || !status.scenes?.length) {
throw new Error("Connect OBS with scene-reading permission before importing its setup.");
}
overlayConnectorManager.suppressSync(req.params.id, true);
syncSuppressed = true;
let overlay = getOverlay(req.params.id);
let created = 0;
let mapped = 0;
for (const obsSceneName of status.scenes) {
if (overlay.scenes.some((scene) => scene.obs_scene_name === obsSceneName)) continue;
const sameName = overlay.scenes.find((scene) => !scene.obs_scene_name && scene.name.toLowerCase() === obsSceneName.toLowerCase());
if (sameName) {
updateScene(sameName.id, { name: sameName.name, enabled: sameName.enabled, obsSceneName });
mapped += 1;
} else {
addScene(req.params.id, { name: obsSceneName, obsSceneName });
created += 1;
}
overlay = getOverlay(req.params.id);
}
if (status.canvas_width && status.canvas_height) {
updateOverlay(req.params.id, {
name: overlay.name,
description: overlay.description,
enabled: overlay.enabled,
canvasWidth: status.canvas_width,
canvasHeight: status.canvas_height
});
}
overlay = getOverlay(req.params.id);
const current = overlay.scenes.find((scene) => scene.obs_scene_name === status.current_scene && scene.enabled);
if (current && current.id !== overlay.active_scene_id) setActiveScene(req.params.id, current.id, { source: "obs" });
return res.json({
ok: true,
created,
mapped,
canvas_updated: Boolean(status.canvas_width && status.canvas_height),
message: `OBS setup applied: ${created} scene${created === 1 ? "" : "s"} added, ${mapped} existing scene${mapped === 1 ? "" : "s"} matched.`
});
} catch (error) {
return res.status(400).json({ ok: false, error: error.message });
} finally {
if (syncSuppressed) overlayConnectorManager.suppressSync(req.params.id, false);
}
});
}
module.exports = {
registerOverlayAdminRoutes,
registerPublicOverlayRoutes
};