Lumi/src/services/overlays.js
2026-07-24 22:12:39 +02:00

644 lines
29 KiB
JavaScript

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
};