const crypto = require("crypto"); const EventEmitter = require("events"); const { db } = require("./db"); const { publishWebEvent } = require("./web-events"); const { getOverlayModuleType, normalizeModuleConfig } = require("./overlay-modules"); const { duplicateOverlayEventHooks, eventOnlyModuleIds } = require("./overlay-event-hooks"); const { createStoredToken, decryptSecret, encryptSecret, scopedSignature, tokenHash, verifyScopedSignature } = require("./overlay-secrets"); const overlayChanges = new EventEmitter(); overlayChanges.setMaxListeners(100); function requiredName(value, label = "Name") { const normalized = String(value || "").trim().slice(0, 120); if (!normalized) throw new Error(`${label} is required.`); return normalized; } function canvasDimension(value, fallback) { const parsed = Math.round(Number(value)); return Number.isFinite(parsed) ? Math.min(7680, Math.max(240, parsed)) : fallback; } function parseConfig(value) { try { const parsed = typeof value === "string" ? JSON.parse(value || "{}") : value; return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; } catch { return {}; } } function nextOrder(table, parentColumn = null, parentId = null) { const allowed = { overlays: null, overlay_scenes: "overlay_id", overlay_modules: "scene_id" }; if (!(table in allowed) || allowed[table] !== parentColumn) throw new Error("Invalid ordering target."); const where = parentColumn ? ` WHERE ${parentColumn} = ?` : ""; const row = db.prepare(`SELECT COALESCE(MAX(sort_order), -1) + 1 AS value FROM ${table}${where}`) .get(...(parentColumn ? [parentId] : [])); return Number(row?.value || 0); } function touchOverlay(overlayId, at = Date.now()) { db.prepare("UPDATE overlays SET updated_at = ? WHERE id = ?").run(at, overlayId); } function overlayIdForScene(sceneId) { return db.prepare("SELECT overlay_id FROM overlay_scenes WHERE id = ?").get(sceneId)?.overlay_id || null; } function overlayIdForModule(moduleId) { return db.prepare( "SELECT s.overlay_id FROM overlay_modules m JOIN overlay_scenes s ON s.id = m.scene_id WHERE m.id = ?" ).get(moduleId)?.overlay_id || null; } function notifyOverlayChanged(overlayId, source = "server") { const overlay = db.prepare("SELECT updated_at FROM overlays WHERE id = ?").get(overlayId); if (!overlay) return; publishWebEvent("overlay:changed", { revision: overlay.updated_at }, { scope: `overlay:${overlayId}` }); overlayChanges.emit("changed", { overlayId, source, revision: overlay.updated_at }); } function rowWithToken(row, includeSecrets) { if (!row) return null; const result = { ...row, enabled: Boolean(row.enabled) }; delete result.public_token_hash; delete result.public_token_encrypted; if (includeSecrets) result.public_token = decryptSecret(row.public_token_encrypted); return result; } function listOverlays({ includeSecrets = false } = {}) { return db.prepare( `SELECT o.*, (SELECT COUNT(*) FROM overlay_scenes s WHERE s.overlay_id = o.id) AS scene_count, (SELECT COUNT(*) FROM overlay_modules m JOIN overlay_scenes s ON s.id = m.scene_id WHERE s.overlay_id = o.id) AS module_count, (SELECT name FROM overlay_scenes s WHERE s.id = o.active_scene_id) AS active_scene_name FROM overlays o ORDER BY o.sort_order, o.created_at` ).all().map((row) => rowWithToken(row, includeSecrets)); } function getOverlay(id, { includeSecrets = false } = {}) { const row = db.prepare("SELECT * FROM overlays WHERE id = ?").get(id); if (!row) return null; const overlay = rowWithToken(row, includeSecrets); overlay.scenes = db.prepare("SELECT * FROM overlay_scenes WHERE overlay_id = ? ORDER BY sort_order, created_at") .all(id) .map((scene) => { const result = rowWithToken(scene, includeSecrets); result.modules = db.prepare("SELECT * FROM overlay_modules WHERE scene_id = ? ORDER BY sort_order, created_at") .all(scene.id) .map((module) => ({ ...module, enabled: Boolean(module.enabled), config: module.type === "audio" ? normalizeModuleConfig("audio", parseConfig(module.config_json)) : parseConfig(module.config_json) })); return result; }); overlay.active_scene_id = overlay.active_scene_id || overlay.scenes[0]?.id || null; overlay.obs = getObsSettings(id, { includeSecret: includeSecrets }); return overlay; } function createOverlay(input = {}) { const overlayToken = createStoredToken(); const sceneToken = createStoredToken(); const now = Date.now(); const overlayId = crypto.randomUUID(); const sceneId = crypto.randomUUID(); const create = db.transaction(() => { db.prepare( "INSERT INTO overlays (id, name, description, enabled, canvas_width, canvas_height, sort_order, active_scene_id, public_token_hash, public_token_encrypted, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ).run( overlayId, requiredName(input.name, "Overlay name"), String(input.description || "").trim().slice(0, 1000), input.enabled === false ? 0 : 1, canvasDimension(input.canvasWidth, 1920), canvasDimension(input.canvasHeight, 1080), nextOrder("overlays"), sceneId, overlayToken.hash, overlayToken.encrypted, now, now ); db.prepare( "INSERT INTO overlay_scenes (id, overlay_id, name, enabled, sort_order, public_token_hash, public_token_encrypted, obs_scene_name, created_at, updated_at) VALUES (?, ?, ?, 1, 0, ?, ?, NULL, ?, ?)" ).run(sceneId, overlayId, requiredName(input.sceneName || "Main", "Scene name"), sceneToken.hash, sceneToken.encrypted, now, now); }); create(); notifyOverlayChanged(overlayId, "create"); return getOverlay(overlayId, { includeSecrets: true }); } function updateOverlay(id, input = {}) { const existing = db.prepare("SELECT * FROM overlays WHERE id = ?").get(id); if (!existing) throw new Error("Overlay not found."); const now = Date.now(); db.prepare("UPDATE overlays SET name = ?, description = ?, enabled = ?, canvas_width = ?, canvas_height = ?, updated_at = ? WHERE id = ?") .run( requiredName(input.name === undefined ? existing.name : input.name, "Overlay name"), String(input.description === undefined ? existing.description : input.description).trim().slice(0, 1000), input.enabled === undefined ? existing.enabled : input.enabled ? 1 : 0, canvasDimension(input.canvasWidth, existing.canvas_width || 1920), canvasDimension(input.canvasHeight, existing.canvas_height || 1080), now, id ); notifyOverlayChanged(id, "overlay_update"); return getOverlay(id); } function duplicateOverlay(id, name) { const source = getOverlay(id); if (!source) throw new Error("Overlay not found."); const copy = createOverlay({ name: name || `${source.name} copy`, description: source.description, enabled: source.enabled, canvasWidth: source.canvas_width, canvasHeight: source.canvas_height, sceneName: source.scenes[0]?.name || "Main" }); const copyId = copy.id; const moduleIds = new Map(); const clone = db.transaction(() => { db.prepare("DELETE FROM overlay_modules WHERE scene_id IN (SELECT id FROM overlay_scenes WHERE overlay_id = ?)").run(copyId); db.prepare("DELETE FROM overlay_scenes WHERE overlay_id = ?").run(copyId); let firstSceneId = null; for (const scene of source.scenes) { const newSceneId = crypto.randomUUID(); const storedToken = createStoredToken(); if (!firstSceneId) firstSceneId = newSceneId; db.prepare( "INSERT INTO overlay_scenes (id, overlay_id, name, enabled, sort_order, public_token_hash, public_token_encrypted, obs_scene_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ).run(newSceneId, copyId, scene.name, scene.enabled ? 1 : 0, scene.sort_order, storedToken.hash, storedToken.encrypted, scene.obs_scene_name, Date.now(), Date.now()); for (const module of scene.modules) { const newModuleId = crypto.randomUUID(); db.prepare( "INSERT INTO overlay_modules (id, scene_id, type, name, enabled, sort_order, config_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" ).run(newModuleId, newSceneId, module.type, module.name, module.enabled ? 1 : 0, module.sort_order, module.config_json, Date.now(), Date.now()); moduleIds.set(module.id, newModuleId); } } duplicateOverlayEventHooks(id, copyId, moduleIds); db.prepare("UPDATE overlays SET active_scene_id = ?, updated_at = ? WHERE id = ?").run(firstSceneId, Date.now(), copyId); }); clone(); notifyOverlayChanged(copyId, "duplicate"); return getOverlay(copyId, { includeSecrets: true }); } function reorderRows(table, ids, parentColumn = null, parentId = null) { const allowed = { overlays: { parent: null, idQuery: "SELECT id FROM overlays" }, overlay_scenes: { parent: "overlay_id", idQuery: "SELECT id FROM overlay_scenes WHERE overlay_id = ?" }, overlay_modules: { parent: "scene_id", idQuery: "SELECT id FROM overlay_modules WHERE scene_id = ?" } }; const target = allowed[table]; if (!target || target.parent !== parentColumn) throw new Error("Invalid reorder target."); const existing = db.prepare(target.idQuery).all(...(parentColumn ? [parentId] : [])).map((row) => row.id); const requested = Array.isArray(ids) ? ids.map(String) : []; if (requested.length !== existing.length || new Set(requested).size !== existing.length || existing.some((id) => !requested.includes(id))) { throw new Error("Reorder list must contain every item exactly once."); } const update = db.prepare(`UPDATE ${table} SET sort_order = ?, updated_at = ? WHERE id = ?`); db.transaction(() => requested.forEach((id, index) => update.run(index, Date.now(), id)))(); } function reorderOverlays(ids) { reorderRows("overlays", ids); ids.forEach((id) => notifyOverlayChanged(id, "reorder")); } function deleteOverlay(id) { const exists = db.prepare("SELECT id FROM overlays WHERE id = ?").get(id); if (!exists) throw new Error("Overlay not found."); db.transaction(() => { db.prepare("DELETE FROM overlay_event_hooks WHERE overlay_id = ?").run(id); db.prepare("DELETE FROM overlay_modules WHERE scene_id IN (SELECT id FROM overlay_scenes WHERE overlay_id = ?)").run(id); db.prepare("DELETE FROM overlay_scenes WHERE overlay_id = ?").run(id); db.prepare("DELETE FROM overlay_obs_settings WHERE overlay_id = ?").run(id); db.prepare("DELETE FROM overlays WHERE id = ?").run(id); })(); publishWebEvent("overlay:changed", { deleted: true, revision: Date.now() }, { scope: `overlay:${id}` }); overlayChanges.emit("changed", { overlayId: id, source: "delete", deleted: true, revision: Date.now() }); } function regenerateOverlayToken(id) { const token = createStoredToken(); const result = db.prepare("UPDATE overlays SET public_token_hash = ?, public_token_encrypted = ?, updated_at = ? WHERE id = ?") .run(token.hash, token.encrypted, Date.now(), id); if (!result.changes) throw new Error("Overlay not found."); notifyOverlayChanged(id, "token_regenerated"); return token.token; } function addScene(overlayId, input = {}) { if (!db.prepare("SELECT id FROM overlays WHERE id = ?").get(overlayId)) throw new Error("Overlay not found."); const token = createStoredToken(); const id = crypto.randomUUID(); const now = Date.now(); db.prepare( "INSERT INTO overlay_scenes (id, overlay_id, name, enabled, sort_order, public_token_hash, public_token_encrypted, obs_scene_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ).run( id, overlayId, requiredName(input.name, "Scene name"), input.enabled === false ? 0 : 1, nextOrder("overlay_scenes", "overlay_id", overlayId), token.hash, token.encrypted, String(input.obsSceneName || "").trim().slice(0, 256) || null, now, now ); touchOverlay(overlayId, now); notifyOverlayChanged(overlayId, "scene_create"); return getOverlay(overlayId, { includeSecrets: true }).scenes.find((scene) => scene.id === id); } function updateScene(sceneId, input = {}) { const scene = db.prepare("SELECT * FROM overlay_scenes WHERE id = ?").get(sceneId); if (!scene) throw new Error("Scene not found."); const now = Date.now(); db.prepare("UPDATE overlay_scenes SET name = ?, enabled = ?, obs_scene_name = ?, updated_at = ? WHERE id = ?") .run( requiredName(input.name === undefined ? scene.name : input.name, "Scene name"), input.enabled === undefined ? scene.enabled : input.enabled ? 1 : 0, String(input.obsSceneName === undefined ? scene.obs_scene_name || "" : input.obsSceneName).trim().slice(0, 256) || null, now, sceneId ); touchOverlay(scene.overlay_id, now); notifyOverlayChanged(scene.overlay_id, "scene_update"); } function duplicateScene(sceneId, name) { const source = db.prepare("SELECT * FROM overlay_scenes WHERE id = ?").get(sceneId); if (!source) throw new Error("Scene not found."); const scene = addScene(source.overlay_id, { name: name || `${source.name} copy`, enabled: Boolean(source.enabled), obsSceneName: source.obs_scene_name }); const modules = db.prepare("SELECT * FROM overlay_modules WHERE scene_id = ? ORDER BY sort_order, created_at").all(sceneId); const insert = db.prepare( "INSERT INTO overlay_modules (id, scene_id, type, name, enabled, sort_order, config_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" ); db.transaction(() => modules.forEach((module) => insert.run(crypto.randomUUID(), scene.id, module.type, module.name, module.enabled, module.sort_order, module.config_json, Date.now(), Date.now())))(); touchOverlay(source.overlay_id); notifyOverlayChanged(source.overlay_id, "scene_duplicate"); return getOverlay(source.overlay_id, { includeSecrets: true }).scenes.find((item) => item.id === scene.id); } function reorderScenes(overlayId, ids) { reorderRows("overlay_scenes", ids, "overlay_id", overlayId); touchOverlay(overlayId); notifyOverlayChanged(overlayId, "scene_reorder"); } function deleteScene(sceneId) { const scene = db.prepare("SELECT * FROM overlay_scenes WHERE id = ?").get(sceneId); if (!scene) throw new Error("Scene not found."); const count = db.prepare("SELECT COUNT(*) AS count FROM overlay_scenes WHERE overlay_id = ?").get(scene.overlay_id).count; if (count <= 1) throw new Error("Every overlay must have at least one scene."); db.transaction(() => { db.prepare("DELETE FROM overlay_event_hooks WHERE module_id IN (SELECT id FROM overlay_modules WHERE scene_id = ?)").run(sceneId); db.prepare("DELETE FROM overlay_modules WHERE scene_id = ?").run(sceneId); db.prepare("DELETE FROM overlay_scenes WHERE id = ?").run(sceneId); if (db.prepare("SELECT active_scene_id FROM overlays WHERE id = ?").get(scene.overlay_id)?.active_scene_id === sceneId) { const fallback = db.prepare("SELECT id FROM overlay_scenes WHERE overlay_id = ? ORDER BY enabled DESC, sort_order, created_at LIMIT 1").get(scene.overlay_id); db.prepare("UPDATE overlays SET active_scene_id = ?, updated_at = ? WHERE id = ?").run(fallback.id, Date.now(), scene.overlay_id); } else { touchOverlay(scene.overlay_id); } })(); notifyOverlayChanged(scene.overlay_id, "scene_delete"); } function regenerateSceneToken(sceneId) { const scene = db.prepare("SELECT overlay_id FROM overlay_scenes WHERE id = ?").get(sceneId); if (!scene) throw new Error("Scene not found."); const token = createStoredToken(); db.prepare("UPDATE overlay_scenes SET public_token_hash = ?, public_token_encrypted = ?, updated_at = ? WHERE id = ?") .run(token.hash, token.encrypted, Date.now(), sceneId); touchOverlay(scene.overlay_id); notifyOverlayChanged(scene.overlay_id, "scene_token_regenerated"); return token.token; } function setActiveScene(overlayId, sceneId, { source = "server" } = {}) { const scene = db.prepare("SELECT id, enabled FROM overlay_scenes WHERE id = ? AND overlay_id = ?").get(sceneId, overlayId); if (!scene) throw new Error("Scene not found for this overlay."); if (!scene.enabled) throw new Error("Disabled scenes cannot be activated."); db.prepare("UPDATE overlays SET active_scene_id = ?, updated_at = ? WHERE id = ?").run(sceneId, Date.now(), overlayId); notifyOverlayChanged(overlayId, source); } function addModule(sceneId, input = {}) { const overlayId = overlayIdForScene(sceneId); if (!overlayId) throw new Error("Scene not found."); const type = String(input.type || "text"); const config = normalizeModuleConfig(type, input.config || input); const id = crypto.randomUUID(); const now = Date.now(); db.prepare( "INSERT INTO overlay_modules (id, scene_id, type, name, enabled, sort_order, config_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" ).run(id, sceneId, type, requiredName(input.name || "Module", "Module name"), input.enabled === false ? 0 : 1, nextOrder("overlay_modules", "scene_id", sceneId), JSON.stringify(config), now, now); touchOverlay(overlayId, now); notifyOverlayChanged(overlayId, "module_create"); return id; } function updateModule(moduleId, input = {}) { const module = db.prepare("SELECT * FROM overlay_modules WHERE id = ?").get(moduleId); if (!module) throw new Error("Overlay module not found."); const overlayId = overlayIdForModule(moduleId); const type = String(input.type || module.type); const config = normalizeModuleConfig(type, input.config || input); const now = Date.now(); db.prepare("UPDATE overlay_modules SET type = ?, name = ?, enabled = ?, config_json = ?, updated_at = ? WHERE id = ?") .run(type, requiredName(input.name === undefined ? module.name : input.name, "Module name"), input.enabled === undefined ? module.enabled : input.enabled ? 1 : 0, JSON.stringify(config), now, moduleId); touchOverlay(overlayId, now); notifyOverlayChanged(overlayId, "module_update"); } function updateModuleTransform(moduleId, input = {}) { const module = db.prepare("SELECT * FROM overlay_modules WHERE id = ?").get(moduleId); if (!module) throw new Error("Overlay source not found."); if ((getOverlayModuleType(module.type)?.renderType || module.type) === "audio") { throw new Error("Audio sources do not have a canvas position or size."); } const current = parseConfig(module.config_json); updateModule(moduleId, { type: module.type, name: module.name, enabled: Boolean(module.enabled), config: { ...current, x: input.x === undefined ? current.x : input.x, y: input.y === undefined ? current.y : input.y, width: input.width === undefined ? current.width : input.width, height: input.height === undefined ? current.height : input.height, anchor: input.anchor === undefined ? current.anchor : input.anchor } }); const updated = db.prepare("SELECT * FROM overlay_modules WHERE id = ?").get(moduleId); return { ...updated, enabled: Boolean(updated.enabled), config: parseConfig(updated.config_json) }; } function refreshModule(moduleId) { const overlayId = overlayIdForModule(moduleId); if (!overlayId) throw new Error("Overlay source not found."); publishWebEvent("overlay:module-refresh", { module_id: moduleId }, { scope: `overlay:${overlayId}` }); return { overlayId, moduleId }; } function duplicateModule(moduleId, name) { const module = db.prepare("SELECT * FROM overlay_modules WHERE id = ?").get(moduleId); if (!module) throw new Error("Overlay module not found."); return addModule(module.scene_id, { type: module.type, name: name || `${module.name} copy`, enabled: Boolean(module.enabled), config: parseConfig(module.config_json) }); } function reorderModules(sceneId, ids) { const overlayId = overlayIdForScene(sceneId); if (!overlayId) throw new Error("Scene not found."); const modules = db.prepare("SELECT id, type FROM overlay_modules WHERE scene_id = ? ORDER BY sort_order, created_at").all(sceneId); const visualIds = modules.filter((module) => (getOverlayModuleType(module.type)?.renderType || module.type) !== "audio").map((module) => module.id); const requested = Array.isArray(ids) ? ids.map(String) : []; if (requested.length === visualIds.length && new Set(requested).size === visualIds.length && visualIds.every((id) => requested.includes(id))) { let visualIndex = 0; const completeOrder = modules.map((module) => (getOverlayModuleType(module.type)?.renderType || module.type) === "audio" ? module.id : requested[visualIndex++] ); reorderRows("overlay_modules", completeOrder, "scene_id", sceneId); } else { reorderRows("overlay_modules", requested, "scene_id", sceneId); } touchOverlay(overlayId); notifyOverlayChanged(overlayId, "module_reorder"); } function deleteModule(moduleId) { const overlayId = overlayIdForModule(moduleId); if (!overlayId) throw new Error("Overlay module not found."); db.transaction(() => { db.prepare("DELETE FROM overlay_event_hooks WHERE module_id = ?").run(moduleId); db.prepare("DELETE FROM overlay_modules WHERE id = ?").run(moduleId); })(); touchOverlay(overlayId); notifyOverlayChanged(overlayId, "module_delete"); } function resolvePublicOverlay(overlayToken, sceneToken = null) { const overlay = db.prepare("SELECT * FROM overlays WHERE public_token_hash = ?").get(tokenHash(overlayToken)); if (!overlay) return null; let fixedScene = null; if (sceneToken) { fixedScene = db.prepare("SELECT * FROM overlay_scenes WHERE overlay_id = ? AND public_token_hash = ?").get(overlay.id, tokenHash(sceneToken)); if (!fixedScene) return null; } return { overlay, fixedScene }; } function webSourceTicketRecord(moduleId) { return db.prepare( `SELECT m.*, s.overlay_id, s.public_token_hash AS scene_token_hash, s.enabled AS scene_enabled, o.public_token_hash AS overlay_token_hash, o.enabled AS overlay_enabled FROM overlay_modules m JOIN overlay_scenes s ON s.id = m.scene_id JOIN overlays o ON o.id = s.overlay_id WHERE m.id = ?` ).get(moduleId); } function webSourceTicketValue(row) { return [row.id, row.scene_id, row.overlay_id, row.overlay_token_hash, row.scene_token_hash].join(":"); } function webSourceRenderUrl(moduleId) { const row = webSourceTicketRecord(moduleId); if (!row || getOverlayModuleType(row.type)?.renderType !== "web") return null; const signature = scopedSignature("overlay-web-source", webSourceTicketValue(row)); return `/overlay-web/${row.id}.${row.scene_id}.${signature}`; } function chatDockTicketValue(row) { return [row.id, row.scene_id, row.overlay_id, row.overlay_token_hash, row.scene_token_hash].join(":"); } function chatDockRenderUrl(moduleId) { const row = webSourceTicketRecord(moduleId); if (!row || getOverlayModuleType(row.type)?.renderType !== "chat") return null; const signature = scopedSignature("overlay-chat-dock", chatDockTicketValue(row)); return `/overlay-chat/${row.id}.${row.scene_id}.${signature}`; } function resolveChatDockTicket(ticket) { const [moduleId, sceneId, signature, ...extra] = String(ticket || "").split("."); if (!moduleId || !sceneId || !signature || extra.length) return null; const row = webSourceTicketRecord(moduleId); if (!row || row.scene_id !== sceneId || !row.overlay_enabled || !row.scene_enabled || !row.enabled) return null; if (getOverlayModuleType(row.type)?.renderType !== "chat") return null; if (!verifyScopedSignature("overlay-chat-dock", chatDockTicketValue(row), signature)) return null; return { overlayId: row.overlay_id, sceneId: row.scene_id, module: { id: row.id, type: row.type, renderType: "chat", name: row.name, config: parseConfig(row.config_json) } }; } function resolveWebSourceTicket(ticket) { const [moduleId, sceneId, signature, ...extra] = String(ticket || "").split("."); if (!moduleId || !sceneId || !signature || extra.length) return null; const row = webSourceTicketRecord(moduleId); if (!row || row.scene_id !== sceneId || !row.overlay_enabled || !row.scene_enabled || !row.enabled) return null; if (getOverlayModuleType(row.type)?.renderType !== "web") return null; if (!verifyScopedSignature("overlay-web-source", webSourceTicketValue(row), signature)) return null; return { overlayId: row.overlay_id, sceneId: row.scene_id, moduleId: row.id, config: parseConfig(row.config_json) }; } function buildPublicState(overlayId, fixedSceneId = null) { const overlay = db.prepare("SELECT id, enabled, active_scene_id, canvas_width, canvas_height, updated_at FROM overlays WHERE id = ?").get(overlayId); if (!overlay) return { exists: false, enabled: false, revision: Date.now(), scene: null }; const canvas = { width: overlay.canvas_width || 1920, height: overlay.canvas_height || 1080 }; if (!overlay.enabled) return { exists: true, enabled: false, revision: overlay.updated_at, canvas, scene: null }; const sceneId = fixedSceneId || overlay.active_scene_id; const scene = sceneId ? db.prepare("SELECT id, name, enabled FROM overlay_scenes WHERE id = ? AND overlay_id = ?").get(sceneId, overlayId) : null; if (!scene || !scene.enabled) return { exists: true, enabled: true, revision: overlay.updated_at, canvas, scene: null }; const eventOnlyModules = eventOnlyModuleIds(scene.id); const modules = db.prepare("SELECT id, type, name, config_json FROM overlay_modules WHERE scene_id = ? AND enabled = 1 ORDER BY sort_order, created_at") .all(scene.id) .map((module) => { const renderType = getOverlayModuleType(module.type)?.renderType || null; const config = parseConfig(module.config_json); if (eventOnlyModules.has(module.id)) config.eventOnly = true; if (renderType === "web" && config.injectPageCss !== false) config.renderUrl = webSourceRenderUrl(module.id); return { id: module.id, type: module.type, renderType, name: module.name, config }; }) .filter((module) => module.renderType); return { exists: true, enabled: true, revision: overlay.updated_at, canvas, scene: { id: scene.id, name: scene.name, modules } }; } function getObsSettings(overlayId, { includeSecret = false } = {}) { const row = db.prepare("SELECT * FROM overlay_obs_settings WHERE overlay_id = ?").get(overlayId); const result = row || { overlay_id: overlayId, enabled: 0, provider: "local_obs_websocket", endpoint: "ws://127.0.0.1:4455", password_encrypted: null, sync_direction: "none", reconnect: 1 }; const output = { ...result, enabled: Boolean(result.enabled), reconnect: Boolean(result.reconnect), has_password: Boolean(result.password_encrypted) }; delete output.password_encrypted; if (includeSecret) output.password = result.password_encrypted ? decryptSecret(result.password_encrypted) : ""; return output; } function saveObsSettings(overlayId, input = {}) { if (!db.prepare("SELECT id FROM overlays WHERE id = ?").get(overlayId)) throw new Error("Overlay not found."); const requestedProvider = String(input.provider || "local_obs_websocket").trim(); const provider = /^[a-z0-9][a-z0-9_-]{0,63}$/i.test(requestedProvider) ? requestedProvider : "local_obs_websocket"; const endpoint = String(input.endpoint || "ws://127.0.0.1:4455").trim().slice(0, 2048); if (provider === "local_obs_websocket" && !/^wss?:\/\//i.test(endpoint)) { throw new Error("OBS endpoint must start with ws:// or wss://."); } const syncDirection = ["none", "obs_to_overlay", "overlay_to_obs", "bidirectional"].includes(input.syncDirection) ? input.syncDirection : "none"; const existing = db.prepare("SELECT password_encrypted FROM overlay_obs_settings WHERE overlay_id = ?").get(overlayId); let passwordEncrypted = existing?.password_encrypted || null; if (input.clearPassword) passwordEncrypted = null; else if (input.password) passwordEncrypted = encryptSecret(input.password); const now = Date.now(); db.prepare( `INSERT INTO overlay_obs_settings (overlay_id, enabled, provider, endpoint, password_encrypted, sync_direction, reconnect, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(overlay_id) DO UPDATE SET enabled = excluded.enabled, provider = excluded.provider, endpoint = excluded.endpoint, password_encrypted = excluded.password_encrypted, sync_direction = excluded.sync_direction, reconnect = excluded.reconnect, updated_at = excluded.updated_at` ).run(overlayId, input.enabled ? 1 : 0, provider, endpoint, passwordEncrypted, syncDirection, input.reconnect === false ? 0 : 1, now, now); return getObsSettings(overlayId, { includeSecret: true }); } function listEnabledObsSettings() { return db.prepare("SELECT overlay_id FROM overlay_obs_settings WHERE enabled = 1").all().map((row) => getObsSettings(row.overlay_id, { includeSecret: true })); } function onOverlayChanged(listener) { overlayChanges.on("changed", listener); return () => overlayChanges.off("changed", listener); } module.exports = { addModule, addScene, buildPublicState, chatDockRenderUrl, createOverlay, deleteModule, deleteOverlay, deleteScene, duplicateModule, duplicateOverlay, duplicateScene, getObsSettings, getOverlay, listEnabledObsSettings, listOverlays, notifyOverlayChanged, onOverlayChanged, regenerateOverlayToken, regenerateSceneToken, refreshModule, reorderModules, reorderOverlays, reorderScenes, resolvePublicOverlay, resolveChatDockTicket, resolveWebSourceTicket, saveObsSettings, setActiveScene, updateModule, updateModuleTransform, updateOverlay, updateScene, webSourceRenderUrl };