1050 lines
40 KiB
JavaScript
1050 lines
40 KiB
JavaScript
const crypto = require("crypto");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const { createLogger } = require("../../src/services/logger");
|
|
let overlayService = null;
|
|
let encryptSecret = null;
|
|
let decryptSecret = null;
|
|
let twitchRequest = null;
|
|
|
|
const PLUGIN_ID = "now_playing";
|
|
const FIELD_ID = "plugin.song_overlay.announcement_template";
|
|
const PROTOCOL_VERSION = 1;
|
|
const DATA_DIR = path.join(process.cwd(), "data", "plugins", PLUGIN_ID);
|
|
const COVER_DIR = path.join(DATA_DIR, "covers");
|
|
const MAX_COVER_BYTES = 600 * 1024;
|
|
const ALLOWED_EVENTS = new Set([
|
|
"snapshot",
|
|
"track_changed",
|
|
"next",
|
|
"previous",
|
|
"play",
|
|
"resume",
|
|
"pause",
|
|
"stop",
|
|
"seek",
|
|
"heartbeat"
|
|
]);
|
|
const ALLOWED_STATUSES = new Set(["playing", "paused", "stopped", "closed", "unknown"]);
|
|
const DEFAULTS = Object.freeze({
|
|
enabled: true,
|
|
display_mode: "always",
|
|
display_seconds: 10,
|
|
show_cover: true,
|
|
show_title: true,
|
|
show_artist: true,
|
|
show_album: false,
|
|
show_release_year: false,
|
|
show_timeline: true,
|
|
show_provider: false,
|
|
scale: 1,
|
|
entry_animation: "slide-up",
|
|
exit_animation: "fade",
|
|
animation_ms: 450,
|
|
opacity: 0.92,
|
|
transparency_scope: "background",
|
|
background_mode: "cover-gradient",
|
|
custom_color_primary: "#111827",
|
|
custom_color_secondary: "#312e81",
|
|
corner_radius: 18,
|
|
announce_enabled: false,
|
|
announce_twitch: true,
|
|
announce_youtube: false,
|
|
announce_only_when_live: true,
|
|
announcement_template: "🎵 Now playing: {{plugin.song_overlay.song_name}} — {{plugin.song_overlay.artist}}",
|
|
announcement_cooldown_seconds: 8,
|
|
heartbeat_stale_seconds: 90,
|
|
overlay_module_id: "",
|
|
overlay_scene_id: "",
|
|
overlay_id: ""
|
|
});
|
|
|
|
const renderClients = new Set();
|
|
const announcementTimers = new Map();
|
|
let pluginLogger = createLogger("plugin:now_playing", { category: "plugin" });
|
|
let activeDb = null;
|
|
let activeSettings = null;
|
|
let activeClients = null;
|
|
let placeholderApi = null;
|
|
let webApi = null;
|
|
let twitchLiveCache = { checkedAt: 0, live: null };
|
|
|
|
module.exports = {
|
|
id: PLUGIN_ID,
|
|
init({ web, db, settings, twitchClient, youtubeClient, commandRouter, placeholders, logger }) {
|
|
const express = require("express");
|
|
overlayService = require("../../src/services/overlays");
|
|
({ encryptSecret, decryptSecret } = require("../../src/services/overlay-secrets"));
|
|
({ twitchRequest } = require("../../src/services/command-platform"));
|
|
activeDb = db;
|
|
activeSettings = settings;
|
|
activeClients = { twitchClient, youtubeClient };
|
|
placeholderApi = placeholders;
|
|
webApi = web;
|
|
pluginLogger = logger || pluginLogger;
|
|
|
|
fs.mkdirSync(COVER_DIR, { recursive: true });
|
|
ensureTables(db);
|
|
ensureDefaults(db);
|
|
ensureRenderToken(db);
|
|
registerPlaceholderSupport(placeholders);
|
|
registerMusicCommands({ db, commandRouter });
|
|
|
|
const router = web.createRouter();
|
|
router.use("/assets", express.static(path.join(__dirname, "public"), {
|
|
immutable: true,
|
|
maxAge: "1h"
|
|
}));
|
|
|
|
router.get("/", async (req, res) => {
|
|
if (!isAdmin(req.session?.user)) return renderDenied(res);
|
|
const config = getConfig(db);
|
|
const current = getPublicState(db, req);
|
|
const overlayTargets = listOverlayTargets();
|
|
const moduleStatus = findInstalledModule(config);
|
|
return res.render(path.join(__dirname, "views", "admin.ejs"), {
|
|
title: "Song Overlay",
|
|
config,
|
|
current,
|
|
overlayTargets,
|
|
moduleStatus,
|
|
assetVersion: require("./plugin.json").version,
|
|
placeholders: [
|
|
"plugin.song_overlay.song_name",
|
|
"plugin.song_overlay.artist",
|
|
"plugin.song_overlay.album",
|
|
"plugin.song_overlay.release_year",
|
|
"plugin.song_overlay.link"
|
|
]
|
|
});
|
|
});
|
|
|
|
router.post("/settings", async (req, res) => {
|
|
if (!isAdmin(req.session?.user)) return renderDenied(res);
|
|
try {
|
|
const config = normalizeConfig(req.body || {});
|
|
saveConfig(db, config);
|
|
await refreshInstalledModule(req, config);
|
|
req.session.flash = { type: "success", message: "Song Overlay settings saved." };
|
|
broadcastState(db);
|
|
} catch (error) {
|
|
req.session.flash = { type: "error", message: error?.message || "Unable to save settings." };
|
|
}
|
|
return res.redirect(`/plugins/${PLUGIN_ID}`);
|
|
});
|
|
|
|
router.post("/overlay/install", (req, res) => {
|
|
if (!isAdmin(req.session?.user)) return renderDenied(res);
|
|
try {
|
|
const sceneId = String(req.body.scene_id || "").trim();
|
|
if (!sceneId) throw new Error("Choose a Lumi Overlay scene.");
|
|
const overlayId = overlayIdForScene(sceneId);
|
|
if (!overlayId) throw new Error("The selected Lumi Overlay scene no longer exists.");
|
|
const config = getConfig(db);
|
|
const renderUrl = buildRenderUrl(req, db, settings);
|
|
const moduleId = overlayService.addModule(sceneId, {
|
|
type: "web",
|
|
name: "Song Overlay",
|
|
config: {
|
|
x: 50,
|
|
y: 96,
|
|
width: 42,
|
|
height: 18,
|
|
opacity: 1,
|
|
anchor: "bottom-center",
|
|
url: renderUrl,
|
|
allowPointerEvents: false,
|
|
autoRefresh: false,
|
|
healthCheck: false,
|
|
injectPageCss: false,
|
|
forceTransparentBackground: true,
|
|
cropTop: 0,
|
|
cropRight: 0,
|
|
cropBottom: 0,
|
|
cropLeft: 0,
|
|
zoom: 1,
|
|
customCss: ""
|
|
}
|
|
});
|
|
saveConfig(db, {
|
|
...config,
|
|
overlay_id: overlayId,
|
|
overlay_scene_id: sceneId,
|
|
overlay_module_id: moduleId
|
|
});
|
|
req.session.flash = { type: "success", message: "Song Overlay was added to the selected Lumi Overlay scene." };
|
|
} catch (error) {
|
|
req.session.flash = { type: "error", message: error?.message || "Unable to add the overlay source." };
|
|
}
|
|
return res.redirect(`/plugins/${PLUGIN_ID}`);
|
|
});
|
|
|
|
router.post("/overlay/remove", (req, res) => {
|
|
if (!isAdmin(req.session?.user)) return renderDenied(res);
|
|
const config = getConfig(db);
|
|
try {
|
|
if (config.overlay_module_id && findInstalledModule(config)?.exists) {
|
|
overlayService.deleteModule(config.overlay_module_id);
|
|
}
|
|
saveConfig(db, {
|
|
...config,
|
|
overlay_id: "",
|
|
overlay_scene_id: "",
|
|
overlay_module_id: ""
|
|
});
|
|
req.session.flash = { type: "success", message: "The Song Overlay source was removed. Your plugin settings were kept." };
|
|
} catch (error) {
|
|
req.session.flash = { type: "error", message: error?.message || "Unable to remove the overlay source." };
|
|
}
|
|
return res.redirect(`/plugins/${PLUGIN_ID}`);
|
|
});
|
|
|
|
router.post("/test-announcement", async (req, res) => {
|
|
if (!isAdmin(req.session?.user)) return renderDenied(res);
|
|
try {
|
|
const state = getStateRow(db) || sampleState();
|
|
const results = await announceTrack({ db, state, force: true, reqUser: req.session.user });
|
|
req.session.flash = {
|
|
type: results.some((item) => item.success) ? "success" : "error",
|
|
message: results.length
|
|
? results.map((item) => `${item.platform}: ${item.success ? "sent" : item.error}`).join(" · ")
|
|
: "No announcement platforms are enabled."
|
|
};
|
|
} catch (error) {
|
|
req.session.flash = { type: "error", message: error?.message || "Unable to test the announcement." };
|
|
}
|
|
return res.redirect(`/plugins/${PLUGIN_ID}`);
|
|
});
|
|
|
|
router.get("/api/companion/ping", requireCompanionDevice, (req, res) => {
|
|
res.set("Cache-Control", "no-store");
|
|
return res.json({
|
|
ok: true,
|
|
protocol_version: PROTOCOL_VERSION,
|
|
plugin: PLUGIN_ID,
|
|
server_time: Date.now()
|
|
});
|
|
});
|
|
|
|
router.post("/api/companion/state", requireCompanionDevice, async (req, res) => {
|
|
res.set("Cache-Control", "no-store");
|
|
try {
|
|
const normalized = normalizeIncomingEvent(withAuthenticatedDevice(req, req.body || {}));
|
|
const previous = getStateRow(db);
|
|
if (isStaleSequence(previous, normalized)) {
|
|
return res.status(202).json({ accepted: false, duplicate: true, server_time: Date.now() });
|
|
}
|
|
const cover = storeCover(normalized.track?.cover);
|
|
const next = applyIncomingEvent(previous, normalized, cover);
|
|
saveStateRow(db, next);
|
|
cleanupCovers(next.cover_hash);
|
|
broadcastState(db);
|
|
web.emitEvent?.("now-playing:changed", publicEventState(next), { scope: `plugin:${PLUGIN_ID}` });
|
|
|
|
const changedTrack = Boolean(next.track_key && next.track_key !== previous?.track_key);
|
|
if (changedTrack && next.playback_status === "playing") scheduleAnnouncement(db, next.track_key);
|
|
return res.json({ accepted: true, server_time: Date.now(), sequence: next.sequence });
|
|
} catch (error) {
|
|
pluginLogger.warn("Rejected Companion song update", error, {
|
|
event: "companion_song_update_rejected"
|
|
});
|
|
return res.status(error?.status || 400).json({ accepted: false, error: error?.message || "Invalid song update." });
|
|
}
|
|
});
|
|
|
|
router.get("/render/:token", (req, res) => {
|
|
if (!validRenderToken(db, req.params.token)) return res.status(404).send("Overlay unavailable.");
|
|
noStore(res);
|
|
res.set("Content-Security-Policy", [
|
|
"default-src 'none'",
|
|
"img-src 'self' data:",
|
|
"style-src 'self'",
|
|
"script-src 'self'",
|
|
"connect-src 'self'"
|
|
].join("; "));
|
|
return res.render(path.join(__dirname, "views", "render.ejs"), {
|
|
token: req.params.token,
|
|
assetVersion: require("./plugin.json").version,
|
|
initialJson: JSON.stringify(getPublicState(db, req, req.params.token)).replace(/</g, "\\u003c")
|
|
});
|
|
});
|
|
|
|
router.get("/render/:token/state", (req, res) => {
|
|
if (!validRenderToken(db, req.params.token)) return res.status(404).json({ available: false });
|
|
noStore(res);
|
|
allowTokenRenderAccess(res);
|
|
return res.json(getPublicState(db, req, req.params.token));
|
|
});
|
|
|
|
router.get("/render/:token/events", (req, res) => {
|
|
if (!validRenderToken(db, req.params.token)) return res.status(404).end();
|
|
res.status(200);
|
|
res.set({
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache, no-store",
|
|
Connection: "keep-alive",
|
|
"X-Accel-Buffering": "no",
|
|
"Access-Control-Allow-Origin": "*"
|
|
});
|
|
res.flushHeaders?.();
|
|
const client = { res, token: req.params.token };
|
|
renderClients.add(client);
|
|
writeSse(res, "state", getPublicState(db, req, req.params.token));
|
|
const heartbeat = setInterval(() => res.write(": keepalive\n\n"), 20000);
|
|
req.on("close", () => {
|
|
clearInterval(heartbeat);
|
|
renderClients.delete(client);
|
|
});
|
|
});
|
|
|
|
router.get("/render/:token/cover/:hash", (req, res) => {
|
|
if (!validRenderToken(db, req.params.token)) return res.status(404).end();
|
|
const state = getStateRow(db);
|
|
if (!state?.cover_hash || !safeEqual(state.cover_hash, req.params.hash)) return res.status(404).end();
|
|
const coverPath = coverPathFor(state.cover_hash, state.cover_mime);
|
|
if (!coverPath || !fs.existsSync(coverPath)) return res.status(404).end();
|
|
res.set("Cache-Control", "public, max-age=86400, immutable");
|
|
res.set("X-Content-Type-Options", "nosniff");
|
|
res.type(state.cover_mime || "image/jpeg");
|
|
return res.sendFile(coverPath);
|
|
});
|
|
|
|
web.mount(`/plugins/${PLUGIN_ID}`, router, {
|
|
label: "Song Overlay",
|
|
role: "admin",
|
|
section: "plugins"
|
|
});
|
|
|
|
return () => {
|
|
for (const client of renderClients) {
|
|
try { client.res.end(); } catch {}
|
|
}
|
|
renderClients.clear();
|
|
for (const timer of announcementTimers.values()) clearTimeout(timer);
|
|
announcementTimers.clear();
|
|
commandRouter?.clearCommands?.(PLUGIN_ID);
|
|
};
|
|
}
|
|
};
|
|
|
|
function ensureTables(db) {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS now_playing_state (
|
|
singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1),
|
|
protocol_version INTEGER NOT NULL DEFAULT 1,
|
|
provider TEXT,
|
|
device_id TEXT,
|
|
session_id TEXT,
|
|
sequence INTEGER NOT NULL DEFAULT 0,
|
|
event_type TEXT,
|
|
playback_status TEXT,
|
|
track_key TEXT,
|
|
title TEXT,
|
|
artist TEXT,
|
|
album TEXT,
|
|
release_year TEXT,
|
|
link TEXT,
|
|
duration_ms INTEGER NOT NULL DEFAULT 0,
|
|
position_ms INTEGER NOT NULL DEFAULT 0,
|
|
playback_rate REAL NOT NULL DEFAULT 1,
|
|
cover_hash TEXT,
|
|
cover_mime TEXT,
|
|
color_primary TEXT,
|
|
color_secondary TEXT,
|
|
occurred_at INTEGER,
|
|
reported_at INTEGER,
|
|
track_changed_at INTEGER,
|
|
updated_at INTEGER NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS now_playing_announcements (
|
|
id TEXT PRIMARY KEY,
|
|
track_key TEXT NOT NULL,
|
|
platform TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
detail TEXT,
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_now_playing_announcements_track
|
|
ON now_playing_announcements(track_key, platform, created_at);
|
|
`);
|
|
}
|
|
|
|
function ensureDefaults(db) {
|
|
const stored = getPluginSetting(db, "config", "");
|
|
if (!stored) saveConfig(db, DEFAULTS);
|
|
}
|
|
|
|
function ensureRenderToken(db) {
|
|
const existing = getPluginSetting(db, "render_token_encrypted", "");
|
|
if (existing) return;
|
|
const token = crypto.randomBytes(32).toString("base64url");
|
|
setPluginSetting(db, "render_token_hash", sha256(token));
|
|
setPluginSetting(db, "render_token_encrypted", encryptSecret(token));
|
|
}
|
|
|
|
function getPluginSetting(db, key, fallback = "") {
|
|
const row = db.prepare("SELECT value FROM plugin_settings WHERE plugin_id = ? AND key = ?").get(PLUGIN_ID, key);
|
|
return row ? row.value : fallback;
|
|
}
|
|
|
|
function setPluginSetting(db, key, value) {
|
|
db.prepare(
|
|
"INSERT INTO plugin_settings (plugin_id, key, value, updated_at) VALUES (?, ?, ?, ?) " +
|
|
"ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
|
|
).run(PLUGIN_ID, key, String(value ?? ""), Date.now());
|
|
}
|
|
|
|
function getConfig(db) {
|
|
try {
|
|
return { ...DEFAULTS, ...JSON.parse(getPluginSetting(db, "config", "{}")) };
|
|
} catch {
|
|
return { ...DEFAULTS };
|
|
}
|
|
}
|
|
|
|
function saveConfig(db, config) {
|
|
setPluginSetting(db, "config", JSON.stringify({ ...DEFAULTS, ...config }));
|
|
}
|
|
|
|
function normalizeConfig(input) {
|
|
const previous = getConfig(activeDb);
|
|
const boolean = (name, fallback = false) => input[name] === "on" || input[name] === "true" || input[name] === true || (input[name] === undefined ? fallback : false);
|
|
const number = (name, fallback, min, max) => {
|
|
const parsed = Number(input[name]);
|
|
return Number.isFinite(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback;
|
|
};
|
|
const option = (name, allowed, fallback) => allowed.includes(input[name]) ? input[name] : fallback;
|
|
const color = (name, fallback) => /^#[0-9a-f]{6}$/i.test(String(input[name] || "")) ? input[name] : fallback;
|
|
return {
|
|
...previous,
|
|
enabled: boolean("enabled"),
|
|
display_mode: option("display_mode", ["always", "timed"], DEFAULTS.display_mode),
|
|
display_seconds: number("display_seconds", DEFAULTS.display_seconds, 1, 600),
|
|
show_cover: boolean("show_cover"),
|
|
show_title: boolean("show_title"),
|
|
show_artist: boolean("show_artist"),
|
|
show_album: boolean("show_album"),
|
|
show_release_year: boolean("show_release_year"),
|
|
show_timeline: boolean("show_timeline"),
|
|
show_provider: boolean("show_provider"),
|
|
scale: number("scale", DEFAULTS.scale, 0.5, 2.5),
|
|
entry_animation: option("entry_animation", ["none", "fade", "slide-left", "slide-right", "slide-up", "slide-down", "jump", "drop"], DEFAULTS.entry_animation),
|
|
exit_animation: option("exit_animation", ["none", "fade", "slide-left", "slide-right", "slide-up", "slide-down", "jump", "drop"], DEFAULTS.exit_animation),
|
|
animation_ms: Math.round(number("animation_ms", DEFAULTS.animation_ms, 0, 3000)),
|
|
opacity: number("opacity", DEFAULTS.opacity, 0, 1),
|
|
transparency_scope: option("transparency_scope", ["everything", "background", "everything_except_image_text", "everything_except_text"], DEFAULTS.transparency_scope),
|
|
background_mode: option("background_mode", ["cover-solid", "cover-gradient", "custom-solid", "custom-gradient"], DEFAULTS.background_mode),
|
|
custom_color_primary: color("custom_color_primary", DEFAULTS.custom_color_primary),
|
|
custom_color_secondary: color("custom_color_secondary", DEFAULTS.custom_color_secondary),
|
|
corner_radius: Math.round(number("corner_radius", DEFAULTS.corner_radius, 0, 80)),
|
|
announce_enabled: boolean("announce_enabled"),
|
|
announce_twitch: boolean("announce_twitch"),
|
|
announce_youtube: boolean("announce_youtube"),
|
|
announce_only_when_live: boolean("announce_only_when_live"),
|
|
announcement_template: String(input.announcement_template || DEFAULTS.announcement_template).slice(0, 500),
|
|
announcement_cooldown_seconds: Math.round(number("announcement_cooldown_seconds", DEFAULTS.announcement_cooldown_seconds, 0, 3600)),
|
|
heartbeat_stale_seconds: Math.round(number("heartbeat_stale_seconds", DEFAULTS.heartbeat_stale_seconds, 15, 600))
|
|
};
|
|
}
|
|
|
|
function requireCompanionDevice(req, res, next) {
|
|
const requireDevice = global.lumiFrameworks?.companion?.requireDevice;
|
|
if (typeof requireDevice !== "function") {
|
|
return res.status(503).json({ accepted: false, error: "Companion device authentication is unavailable." });
|
|
}
|
|
return requireDevice(req, res, next);
|
|
}
|
|
|
|
function withAuthenticatedDevice(req, input) {
|
|
return {
|
|
...(input && typeof input === "object" ? input : {}),
|
|
device_id: String(req.lumiDevice?.id || "")
|
|
};
|
|
}
|
|
|
|
function normalizeIncomingEvent(input) {
|
|
const protocolVersion = Number(input.protocol_version || 0);
|
|
if (protocolVersion !== PROTOCOL_VERSION) {
|
|
const error = new Error(`Unsupported protocol version ${protocolVersion || "missing"}.`);
|
|
error.status = 409;
|
|
throw error;
|
|
}
|
|
const event = String(input.event || "snapshot").trim().toLowerCase();
|
|
if (!ALLOWED_EVENTS.has(event)) throw new Error("Unsupported playback event.");
|
|
const sequence = Math.max(0, Math.floor(Number(input.sequence) || 0));
|
|
const occurredAt = Math.max(0, Math.floor(Number(input.occurred_at) || Date.now()));
|
|
const provider = clean(input.provider, 64) || "unknown";
|
|
const deviceId = clean(input.device_id, 128) || "unknown";
|
|
const sessionId = clean(input.session_id, 128) || "default";
|
|
const playback = input.playback || {};
|
|
const status = ALLOWED_STATUSES.has(String(playback.status || "").toLowerCase())
|
|
? String(playback.status).toLowerCase()
|
|
: event === "pause" ? "paused" : ["stop", "closed"].includes(event) ? "stopped" : "playing";
|
|
const track = input.track && typeof input.track === "object" ? {
|
|
key: clean(input.track.key, 256),
|
|
title: clean(input.track.title, 500),
|
|
artist: clean(input.track.artist, 500),
|
|
album: clean(input.track.album, 500),
|
|
release_year: clean(input.track.release_year, 16),
|
|
link: validUrl(input.track.link),
|
|
cover: normalizeCover(input.track.cover)
|
|
} : null;
|
|
return {
|
|
protocol_version: protocolVersion,
|
|
provider,
|
|
device_id: deviceId,
|
|
session_id: sessionId,
|
|
sequence,
|
|
event,
|
|
occurred_at: occurredAt,
|
|
playback: {
|
|
status,
|
|
position_ms: boundedInteger(playback.position_ms, 0, 0, 7 * 24 * 60 * 60 * 1000),
|
|
duration_ms: boundedInteger(playback.duration_ms, 0, 0, 7 * 24 * 60 * 60 * 1000),
|
|
rate: boundedNumber(playback.rate, 1, 0, 4)
|
|
},
|
|
track
|
|
};
|
|
}
|
|
|
|
function normalizeCover(cover) {
|
|
if (!cover || typeof cover !== "object") return null;
|
|
const mime = ["image/jpeg", "image/png", "image/webp"].includes(cover.mime) ? cover.mime : "image/jpeg";
|
|
const base64 = String(cover.base64 || "");
|
|
return {
|
|
mime,
|
|
base64,
|
|
primary: normalizeHex(cover.primary, "#1f2937"),
|
|
secondary: normalizeHex(cover.secondary, "#111827")
|
|
};
|
|
}
|
|
|
|
function isStaleSequence(previous, incoming) {
|
|
return Boolean(previous && previous.device_id === incoming.device_id && previous.session_id === incoming.session_id && incoming.sequence <= Number(previous.sequence || 0));
|
|
}
|
|
|
|
function applyIncomingEvent(previous, incoming, cover) {
|
|
const now = Date.now();
|
|
const previousTrack = previous?.track_key || "";
|
|
const incomingTrack = incoming.track?.key || fingerprintTrack(incoming.track);
|
|
const trackChanged = Boolean(incoming.track && incomingTrack && incomingTrack !== previousTrack);
|
|
const clearTrack = ["stop", "closed"].includes(incoming.event) && !incoming.track;
|
|
return {
|
|
singleton_id: 1,
|
|
protocol_version: incoming.protocol_version,
|
|
provider: incoming.provider,
|
|
device_id: incoming.device_id,
|
|
session_id: incoming.session_id,
|
|
sequence: incoming.sequence,
|
|
event_type: incoming.event,
|
|
playback_status: incoming.playback.status,
|
|
track_key: clearTrack ? null : (incomingTrack || previous?.track_key || null),
|
|
title: clearTrack ? null : (incoming.track?.title || previous?.title || null),
|
|
artist: clearTrack ? null : (incoming.track?.artist || previous?.artist || null),
|
|
album: clearTrack ? null : (incoming.track?.album || previous?.album || null),
|
|
release_year: clearTrack ? null : (incoming.track?.release_year || previous?.release_year || null),
|
|
link: clearTrack ? null : (incoming.track?.link || previous?.link || null),
|
|
duration_ms: incoming.playback.duration_ms || previous?.duration_ms || 0,
|
|
position_ms: incoming.playback.position_ms,
|
|
playback_rate: incoming.playback.rate,
|
|
cover_hash: clearTrack ? null : (cover?.hash || previous?.cover_hash || null),
|
|
cover_mime: clearTrack ? null : (cover?.mime || previous?.cover_mime || null),
|
|
color_primary: clearTrack ? null : (cover?.primary || incoming.track?.cover?.primary || previous?.color_primary || "#1f2937"),
|
|
color_secondary: clearTrack ? null : (cover?.secondary || incoming.track?.cover?.secondary || previous?.color_secondary || "#111827"),
|
|
occurred_at: incoming.occurred_at,
|
|
reported_at: now,
|
|
track_changed_at: trackChanged ? now : (previous?.track_changed_at || now),
|
|
updated_at: now
|
|
};
|
|
}
|
|
|
|
function saveStateRow(db, state) {
|
|
db.prepare(`
|
|
INSERT INTO now_playing_state (
|
|
singleton_id, protocol_version, provider, device_id, session_id, sequence, event_type, playback_status,
|
|
track_key, title, artist, album, release_year, link, duration_ms, position_ms, playback_rate,
|
|
cover_hash, cover_mime, color_primary, color_secondary, occurred_at, reported_at, track_changed_at, updated_at
|
|
) VALUES (
|
|
@singleton_id, @protocol_version, @provider, @device_id, @session_id, @sequence, @event_type, @playback_status,
|
|
@track_key, @title, @artist, @album, @release_year, @link, @duration_ms, @position_ms, @playback_rate,
|
|
@cover_hash, @cover_mime, @color_primary, @color_secondary, @occurred_at, @reported_at, @track_changed_at, @updated_at
|
|
) ON CONFLICT(singleton_id) DO UPDATE SET
|
|
protocol_version=excluded.protocol_version, provider=excluded.provider, device_id=excluded.device_id,
|
|
session_id=excluded.session_id, sequence=excluded.sequence, event_type=excluded.event_type,
|
|
playback_status=excluded.playback_status, track_key=excluded.track_key, title=excluded.title,
|
|
artist=excluded.artist, album=excluded.album, release_year=excluded.release_year, link=excluded.link,
|
|
duration_ms=excluded.duration_ms, position_ms=excluded.position_ms, playback_rate=excluded.playback_rate,
|
|
cover_hash=excluded.cover_hash, cover_mime=excluded.cover_mime, color_primary=excluded.color_primary,
|
|
color_secondary=excluded.color_secondary, occurred_at=excluded.occurred_at, reported_at=excluded.reported_at,
|
|
track_changed_at=excluded.track_changed_at, updated_at=excluded.updated_at
|
|
`).run(state);
|
|
}
|
|
|
|
function getStateRow(db) {
|
|
return db.prepare("SELECT * FROM now_playing_state WHERE singleton_id = 1").get() || null;
|
|
}
|
|
|
|
function getPublicState(db, req, tokenOverride) {
|
|
const state = getStateRow(db);
|
|
const config = getConfig(db);
|
|
const now = Date.now();
|
|
const stale = !state || now - Number(state.reported_at || 0) > config.heartbeat_stale_seconds * 1000;
|
|
const token = tokenOverride || renderToken(db);
|
|
const position = calculatePosition(state, now);
|
|
return {
|
|
available: Boolean(config.enabled && state?.track_key && !["stopped", "closed"].includes(state.playback_status) && !stale),
|
|
stale,
|
|
now,
|
|
config: publicConfig(config),
|
|
playback: state ? {
|
|
status: state.playback_status,
|
|
position_ms: position,
|
|
duration_ms: Number(state.duration_ms || 0),
|
|
rate: Number(state.playback_rate || 1),
|
|
reported_at: Number(state.reported_at || 0),
|
|
track_changed_at: Number(state.track_changed_at || 0)
|
|
} : null,
|
|
track: state?.track_key ? {
|
|
key: state.track_key,
|
|
name: state.title || "",
|
|
artist: state.artist || "",
|
|
album: state.album || "",
|
|
release_year: state.release_year || "",
|
|
link: state.link || "",
|
|
provider: state.provider || "",
|
|
color_primary: normalizeHex(state.color_primary, "#1f2937"),
|
|
color_secondary: normalizeHex(state.color_secondary, "#111827"),
|
|
cover_url: state.cover_hash && token
|
|
? `/plugins/${PLUGIN_ID}/render/${encodeURIComponent(token)}/cover/${encodeURIComponent(state.cover_hash)}`
|
|
: ""
|
|
} : null
|
|
};
|
|
}
|
|
|
|
function publicConfig(config) {
|
|
const allowed = [
|
|
"display_mode", "display_seconds", "show_cover", "show_title", "show_artist", "show_album",
|
|
"show_release_year", "show_timeline", "show_provider", "scale", "entry_animation", "exit_animation",
|
|
"animation_ms", "opacity", "transparency_scope", "background_mode", "custom_color_primary",
|
|
"custom_color_secondary", "corner_radius"
|
|
];
|
|
return Object.fromEntries(allowed.map((key) => [key, config[key]]));
|
|
}
|
|
|
|
function calculatePosition(state, now = Date.now()) {
|
|
if (!state) return 0;
|
|
let position = Number(state.position_ms || 0);
|
|
if (state.playback_status === "playing") {
|
|
position += Math.max(0, now - Number(state.reported_at || now)) * Number(state.playback_rate || 1);
|
|
}
|
|
const duration = Math.max(0, Number(state.duration_ms || 0));
|
|
return Math.round(duration > 0 ? Math.min(duration, Math.max(0, position)) : Math.max(0, position));
|
|
}
|
|
|
|
function storeCover(cover) {
|
|
if (!cover?.base64) return null;
|
|
let bytes;
|
|
try { bytes = Buffer.from(cover.base64, "base64"); } catch { return null; }
|
|
if (!bytes.length || bytes.length > MAX_COVER_BYTES) return null;
|
|
const hash = sha256(bytes);
|
|
const filePath = coverPathFor(hash, cover.mime);
|
|
if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, bytes, { flag: "wx" });
|
|
return { hash, mime: cover.mime, primary: cover.primary, secondary: cover.secondary };
|
|
}
|
|
|
|
function coverPathFor(hash, mime) {
|
|
if (!/^[a-f0-9]{64}$/i.test(String(hash || ""))) return null;
|
|
const ext = mime === "image/png" ? ".png" : mime === "image/webp" ? ".webp" : ".jpg";
|
|
return path.join(COVER_DIR, `${hash}${ext}`);
|
|
}
|
|
|
|
function cleanupCovers(currentHash) {
|
|
try {
|
|
const files = fs.readdirSync(COVER_DIR).map((name) => ({
|
|
name,
|
|
path: path.join(COVER_DIR, name),
|
|
stat: fs.statSync(path.join(COVER_DIR, name))
|
|
})).sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
|
|
for (const file of files.slice(8)) {
|
|
if (!file.name.startsWith(currentHash || "__none__")) fs.unlinkSync(file.path);
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
function broadcastState(db) {
|
|
for (const client of Array.from(renderClients)) {
|
|
try {
|
|
writeSse(client.res, "changed", { updated_at: Date.now() });
|
|
} catch {
|
|
renderClients.delete(client);
|
|
}
|
|
}
|
|
}
|
|
|
|
function writeSse(res, event, payload) {
|
|
res.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
}
|
|
|
|
function noStore(res) {
|
|
res.set("Cache-Control", "no-store, private");
|
|
res.set("Pragma", "no-cache");
|
|
res.set("Referrer-Policy", "no-referrer");
|
|
}
|
|
|
|
function allowTokenRenderAccess(res) {
|
|
// Lumi's website overlay module intentionally gives same-origin pages an
|
|
// opaque sandbox origin. The unguessable render token is the authority, so
|
|
// allow that frame to re-fetch only its public display state.
|
|
res.set("Access-Control-Allow-Origin", "*");
|
|
}
|
|
|
|
function renderToken(db) {
|
|
try { return decryptSecret(getPluginSetting(db, "render_token_encrypted", "")); } catch { return ""; }
|
|
}
|
|
|
|
function validRenderToken(db, token) {
|
|
const expected = getPluginSetting(db, "render_token_hash", "");
|
|
return Boolean(expected && token && safeEqual(expected, sha256(token)));
|
|
}
|
|
|
|
function buildRenderUrl(req, db, settings) {
|
|
return `${publicBaseUrl(req, settings)}/plugins/${PLUGIN_ID}/render/${encodeURIComponent(renderToken(db))}`;
|
|
}
|
|
|
|
function publicBaseUrl(req, settings) {
|
|
const configured = String(settings?.getSetting?.("public_base_url", "") || "").trim().replace(/\/$/, "");
|
|
return configured || `${req.protocol}://${req.get("host")}`;
|
|
}
|
|
|
|
function listOverlayTargets() {
|
|
try {
|
|
return overlayService.listOverlays().flatMap((overlay) => {
|
|
const full = overlayService.getOverlay(overlay.id, { includeSecrets: false });
|
|
return (full?.scenes || []).map((scene) => ({
|
|
overlayId: overlay.id,
|
|
overlayName: overlay.name,
|
|
sceneId: scene.id,
|
|
sceneName: scene.name,
|
|
enabled: Boolean(overlay.enabled && scene.enabled)
|
|
}));
|
|
});
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function overlayIdForScene(sceneId) {
|
|
return listOverlayTargets().find((target) => target.sceneId === sceneId)?.overlayId || null;
|
|
}
|
|
|
|
function findInstalledModule(config) {
|
|
if (!config.overlay_id || !config.overlay_module_id) return { exists: false };
|
|
try {
|
|
const overlay = overlayService.getOverlay(config.overlay_id, { includeSecrets: false });
|
|
const scene = overlay?.scenes?.find((item) => item.id === config.overlay_scene_id);
|
|
const module = scene?.modules?.find((item) => item.id === config.overlay_module_id);
|
|
return {
|
|
exists: Boolean(module),
|
|
overlayName: overlay?.name || "",
|
|
sceneName: scene?.name || "",
|
|
moduleName: module?.name || ""
|
|
};
|
|
} catch {
|
|
return { exists: false };
|
|
}
|
|
}
|
|
|
|
async function refreshInstalledModule(req, config) {
|
|
const status = findInstalledModule(config);
|
|
if (!status.exists) return;
|
|
const overlay = overlayService.getOverlay(config.overlay_id, { includeSecrets: false });
|
|
const scene = overlay.scenes.find((item) => item.id === config.overlay_scene_id);
|
|
const module = scene.modules.find((item) => item.id === config.overlay_module_id);
|
|
overlayService.updateModule(module.id, {
|
|
name: module.name,
|
|
enabled: module.enabled,
|
|
type: "web",
|
|
config: {
|
|
...module.config,
|
|
url: buildRenderUrl(req, activeDb, activeSettings),
|
|
injectPageCss: false,
|
|
healthCheck: false,
|
|
forceTransparentBackground: true
|
|
}
|
|
});
|
|
}
|
|
|
|
function registerPlaceholderSupport(placeholders) {
|
|
if (!placeholders?.registerFieldPolicy || !placeholders?.registerPlaceholders) return;
|
|
placeholders.registerFieldPolicy({
|
|
field_id: FIELD_ID,
|
|
label: "Song Overlay chat announcement",
|
|
field_type: "chat_message",
|
|
output_audience: "user",
|
|
min_editor_role: "admin",
|
|
allowed_namespaces: ["plugin.song_overlay"],
|
|
max_sensitivity: "public_safe"
|
|
});
|
|
const definitions = [
|
|
["song_name", "Song name", "Current song title."],
|
|
["artist", "Artist", "Current song artist."],
|
|
["album", "Album", "Current song album."],
|
|
["release_year", "Release year", "Current song release year when the provider supplies it."],
|
|
["link", "Song link", "Provider song link when available."]
|
|
].map(([key, label, description]) => ({
|
|
id: `plugin.song_overlay.${key}`,
|
|
namespace: "plugin.song_overlay",
|
|
label,
|
|
description,
|
|
value_type: key === "link" ? "url" : "string",
|
|
sensitivity: "public_safe",
|
|
min_editor_role: "admin",
|
|
min_viewer_role: "user",
|
|
allowed_field_types: ["chat_message"],
|
|
plugin_id: PLUGIN_ID,
|
|
aliases: [key],
|
|
resolver: ({ runtimeContext }) => runtimeContext?.songOverlay?.[key] ?? ""
|
|
}));
|
|
placeholders.registerPlaceholders(definitions);
|
|
}
|
|
|
|
function registerMusicCommands({ db, commandRouter }) {
|
|
if (!commandRouter) return;
|
|
commandRouter.registerCommands(PLUGIN_ID, [{
|
|
id: "music",
|
|
triggers: ["music"],
|
|
platforms: ["discord", "twitch", "youtube"],
|
|
description: "Show the song currently playing through Lumi Companion.",
|
|
handler: (ctx) => handleMusicCommand({ ctx, db })
|
|
}]);
|
|
}
|
|
|
|
async function handleMusicCommand({ ctx, db }) {
|
|
// This handler is the extension point for future chat-driven music actions.
|
|
// For now, !music is read-only and shares the automatic announcement text.
|
|
const publicState = getPublicState(db, null);
|
|
const state = getStateRow(db);
|
|
if (!publicState.available || !state?.track_key || !state.title) {
|
|
return "Nothing is playing right now.";
|
|
}
|
|
return await renderAnnouncementMessage({ db, state, user: ctx?.user });
|
|
}
|
|
|
|
function scheduleAnnouncement(db, trackKey) {
|
|
for (const [key, timer] of announcementTimers) {
|
|
if (key !== trackKey) {
|
|
clearTimeout(timer);
|
|
announcementTimers.delete(key);
|
|
}
|
|
}
|
|
if (announcementTimers.has(trackKey)) return;
|
|
const timer = setTimeout(() => {
|
|
announcementTimers.delete(trackKey);
|
|
const current = getStateRow(db);
|
|
if (!current || current.track_key !== trackKey || current.playback_status !== "playing") return;
|
|
announceTrack({ db, state: current }).catch((error) => {
|
|
pluginLogger.warn("Song Overlay chat announcement failed", error, {
|
|
event: "chat_announcement_failed"
|
|
});
|
|
});
|
|
}, 1800);
|
|
timer.unref?.();
|
|
announcementTimers.set(trackKey, timer);
|
|
}
|
|
|
|
async function announceTrack({ db, state, force = false, reqUser = null }) {
|
|
const config = getConfig(db);
|
|
if (!force && !config.announce_enabled) return [];
|
|
if (!state?.track_key || !state.title) return [];
|
|
const platforms = [
|
|
config.announce_twitch ? "twitch" : null,
|
|
config.announce_youtube ? "youtube" : null
|
|
].filter(Boolean);
|
|
const message = await renderAnnouncementMessage({ db, state, user: reqUser });
|
|
if (!message) return [];
|
|
const results = [];
|
|
for (const platform of platforms) {
|
|
if (!force && alreadyAnnounced(db, state.track_key, platform, config.announcement_cooldown_seconds)) continue;
|
|
try {
|
|
if (!force && config.announce_only_when_live && !(await platformIsLive(platform))) continue;
|
|
await sendPlatformMessage(platform, message);
|
|
recordAnnouncement(db, state.track_key, platform, "sent", null);
|
|
results.push({ platform, success: true });
|
|
} catch (error) {
|
|
recordAnnouncement(db, state.track_key, platform, "failed", error?.message || String(error));
|
|
results.push({ platform, success: false, error: error?.message || String(error) });
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
function announcementTokens(state) {
|
|
return {
|
|
song_name: state.title || "",
|
|
artist: state.artist || "",
|
|
album: state.album || "",
|
|
release_year: state.release_year || "",
|
|
link: state.link || ""
|
|
};
|
|
}
|
|
|
|
async function renderAnnouncementMessage({ db, state, user = null }) {
|
|
const config = getConfig(db);
|
|
const tokens = announcementTokens(state);
|
|
const rendered = await placeholderApi.renderTemplate({
|
|
fieldId: FIELD_ID,
|
|
template: config.announcement_template,
|
|
user: user || { isAdmin: true },
|
|
outputAudience: "user",
|
|
runtimeContext: { runtime: true, songOverlay: tokens },
|
|
fallback: ""
|
|
});
|
|
return String(rendered.rendered || "").replace(/\s{2,}/g, " ").trim().slice(0, 450);
|
|
}
|
|
|
|
function alreadyAnnounced(db, trackKey, platform, cooldownSeconds) {
|
|
const row = db.prepare(
|
|
"SELECT created_at FROM now_playing_announcements WHERE track_key = ? AND platform = ? AND status = 'sent' ORDER BY created_at DESC LIMIT 1"
|
|
).get(trackKey, platform);
|
|
return Boolean(row && Date.now() - row.created_at < Math.max(0, cooldownSeconds) * 1000);
|
|
}
|
|
|
|
function recordAnnouncement(db, trackKey, platform, status, detail) {
|
|
db.prepare(
|
|
"INSERT INTO now_playing_announcements (id, track_key, platform, status, detail, created_at) VALUES (?, ?, ?, ?, ?, ?)"
|
|
).run(crypto.randomUUID(), trackKey, platform, status, detail ? String(detail).slice(0, 500) : null, Date.now());
|
|
db.prepare(
|
|
"DELETE FROM now_playing_announcements WHERE id NOT IN (SELECT id FROM now_playing_announcements ORDER BY created_at DESC LIMIT 1000)"
|
|
).run();
|
|
}
|
|
|
|
async function platformIsLive(platform) {
|
|
if (platform === "youtube") return Boolean(activeClients?.youtubeClient?.liveChatId || activeClients?.youtubeClient?.sendMessage);
|
|
if (platform !== "twitch") return false;
|
|
if (twitchLiveCache.live === true && Date.now() - twitchLiveCache.checkedAt < 30000) return true;
|
|
if (twitchLiveCache.live === false && Date.now() - twitchLiveCache.checkedAt < 5000) return false;
|
|
const channels = activeClients?.twitchClient?.getChannels?.() || [];
|
|
const login = String(channels[0] || "").replace(/^#/, "");
|
|
if (!login) return false;
|
|
try {
|
|
const users = await twitchRequest("GET", "/helix/users", { login });
|
|
const id = users?.data?.[0]?.id;
|
|
const streams = id ? await twitchRequest("GET", "/helix/streams", { user_id: id }) : null;
|
|
twitchLiveCache = { checkedAt: Date.now(), live: Boolean(streams?.data?.[0]?.id) };
|
|
return twitchLiveCache.live;
|
|
} catch {
|
|
twitchLiveCache = { checkedAt: Date.now(), live: false };
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function sendPlatformMessage(platform, message) {
|
|
if (platform === "twitch") {
|
|
const client = activeClients?.twitchClient;
|
|
const channel = client?.getChannels?.()?.[0];
|
|
if (!client?.say || !channel) throw new Error("Twitch chat is unavailable.");
|
|
return client.say(channel, message);
|
|
}
|
|
if (platform === "youtube") {
|
|
const client = activeClients?.youtubeClient;
|
|
if (!client?.sendMessage) throw new Error("YouTube live chat is unavailable.");
|
|
return client.sendMessage(message);
|
|
}
|
|
throw new Error("Unsupported streaming platform.");
|
|
}
|
|
|
|
function publicEventState(state) {
|
|
return {
|
|
provider: state.provider,
|
|
event: state.event_type,
|
|
status: state.playback_status,
|
|
track_key: state.track_key,
|
|
title: state.title,
|
|
artist: state.artist,
|
|
updated_at: state.updated_at
|
|
};
|
|
}
|
|
|
|
function sampleState() {
|
|
return {
|
|
track_key: "sample",
|
|
title: "Sample Song",
|
|
artist: "Sample Artist",
|
|
album: "Sample Album",
|
|
release_year: "2026",
|
|
link: "https://example.com/song",
|
|
playback_status: "playing"
|
|
};
|
|
}
|
|
|
|
function fingerprintTrack(track) {
|
|
if (!track) return "";
|
|
const raw = [track.title, track.artist, track.album].map((value) => String(value || "").trim().toLowerCase()).join("|");
|
|
return raw.replace(/^\|+|\|+$/g, "") ? sha256(raw) : "";
|
|
}
|
|
|
|
function clean(value, max) {
|
|
return String(value || "").replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, max);
|
|
}
|
|
|
|
function validUrl(value) {
|
|
const raw = clean(value, 2048);
|
|
if (!raw) return "";
|
|
try {
|
|
const parsed = new URL(raw);
|
|
return ["http:", "https:"].includes(parsed.protocol) ? parsed.toString() : "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function normalizeHex(value, fallback) {
|
|
const normalized = String(value || "").trim();
|
|
return /^#[0-9a-f]{6}$/i.test(normalized) ? normalized : fallback;
|
|
}
|
|
|
|
function boundedInteger(value, fallback, min, max) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? Math.round(Math.min(max, Math.max(min, parsed))) : fallback;
|
|
}
|
|
|
|
function boundedNumber(value, fallback, min, max) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback;
|
|
}
|
|
|
|
function sha256(value) {
|
|
return crypto.createHash("sha256").update(value).digest("hex");
|
|
}
|
|
|
|
function safeEqual(left, right) {
|
|
const a = Buffer.from(String(left || ""));
|
|
const b = Buffer.from(String(right || ""));
|
|
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
}
|
|
|
|
function isAdmin(user) {
|
|
return Boolean(user?.isAdmin);
|
|
}
|
|
|
|
function renderDenied(res) {
|
|
return res.status(403).render("error", {
|
|
title: "Access denied",
|
|
message: "Administrator access is required for Song Overlay settings."
|
|
});
|
|
}
|
|
|
|
// Intentionally small test surface used by the standalone verifier. This is not
|
|
// part of Lumi's runtime plugin API.
|
|
module.exports._test = {
|
|
normalizeIncomingEvent,
|
|
applyIncomingEvent,
|
|
calculatePosition,
|
|
normalizeConfig,
|
|
publicConfig,
|
|
fingerprintTrack,
|
|
announcementTokens,
|
|
requireCompanionDevice,
|
|
withAuthenticatedDevice
|
|
};
|