644 lines
29 KiB
JavaScript
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
|
|
};
|