const crypto = require("crypto"); const fs = require("fs"); const path = require("path"); 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 = console; 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 || console; 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: error?.message || String(error) }); 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(/ { 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: error?.message || String(error) }); }); }, 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 };