var express = require('express'); var router = express.Router(); const { rateLimit } = require("express-rate-limit"); const sessionChecker = require("../middleware/sessionChecker.js"); const MAX_BUFFER_SIZE = 300; const DEFAULT_INITIAL_LIMIT = 40; const MAX_INITIAL_LIMIT = 120; const INACTIVITY_RESET_MS = 10 * 60 * 1000; const CAPTION_META_KEYS = new Set(["sequence", "createdAt", "original", "draft", "sourceLang", "lang", "isDraft", "status", "translations"]); const liveCaptionState = { startedAt: Date.now(), lastIngestAt: 0, latestSequence: 0, captions: [], }; const liveCaptionsLimiter = rateLimit({ windowMs: 10 * 60 * 1000, limit: 6000, standardHeaders: "draft-8", legacyHeaders: false, keyGenerator: (req) => { const forwarded = req.headers["x-forwarded-for"]?.split(",")[0]; const ip = forwarded || req.ip || ""; return ip.includes(":") ? ip.split(":")[0] : ip; }, }); router.use(liveCaptionsLimiter); const normalizeLang = (lang = "") => { const value = String(lang || "").trim().toLowerCase(); if (!value) return ""; const base = value.split(",")[0].split("-")[0].trim(); return base || value; }; const normalizeTranslations = (translations) => { if (!translations || typeof translations !== "object" || Array.isArray(translations)) return {}; const normalized = {}; for (const [langKey, translatedText] of Object.entries(translations)) { const lang = normalizeLang(langKey); const text = typeof translatedText === "string" ? translatedText.trim() : ""; if (!lang || !text) continue; normalized[lang] = text; } return normalized; }; const readText = (value) => { if (typeof value === "string") return value.trim(); return ""; }; const extractDraftText = (body = {}) => { const directDraft = readText(body?.draft); if (directDraft) return directDraft; const nestedDraft = readText(body?.draft?.text); if (nestedDraft) return nestedDraft; const fallbackText = readText(body?.text); if (fallbackText) return fallbackText; return ""; }; const buildTranslationsFromFlatPayload = (payload) => { const ignoredKeys = new Set(["original", "draft", "sourceLang", "lang", "isDraft", "status", "translations"]); const normalized = {}; for (const [key, value] of Object.entries(payload || {})) { if (ignoredKeys.has(key)) continue; const lang = normalizeLang(key); const text = typeof value === "string" ? value.trim() : ""; if (!lang || !text) continue; normalized[lang] = text; } return normalized; }; const inferSourceLangFromTranslations = (original, translations) => { const normalizedOriginal = String(original || "").trim(); if (!normalizedOriginal) return "original"; for (const [lang, text] of Object.entries(translations || {})) { if (String(text || "").trim() === normalizedOriginal) return normalizeLang(lang); } return "original"; }; const getAvailableLanguages = () => { const langs = new Set(); for (const caption of liveCaptionState.captions) { Object.keys(caption || {}).forEach((key) => { if (CAPTION_META_KEYS.has(key)) return; const lang = normalizeLang(key); if (lang) langs.add(lang); }); } return Array.from(langs).filter(Boolean).sort(); }; const resetLiveCaptionState = () => { liveCaptionState.startedAt = Date.now(); liveCaptionState.lastIngestAt = 0; liveCaptionState.latestSequence = 0; liveCaptionState.captions = []; }; const maybeResetForInactivity = () => { if (!liveCaptionState.lastIngestAt) return; if ((Date.now() - liveCaptionState.lastIngestAt) < INACTIVITY_RESET_MS) return; resetLiveCaptionState(); }; router.get("/stream", async (req, res) => { try { maybeResetForInactivity(); const sinceSequence = Number.parseInt(req.query?.sinceSequence, 10); const requestedLimit = Number.parseInt(req.query?.limit, 10); const initialLimit = Number.isFinite(requestedLimit) ? Math.max(1, Math.min(requestedLimit, MAX_INITIAL_LIMIT)) : DEFAULT_INITIAL_LIMIT; let captions = []; if (Number.isFinite(sinceSequence) && sinceSequence >= 0) { captions = liveCaptionState.captions.filter((item) => item.sequence > sinceSequence); } else { captions = liveCaptionState.captions.slice(-initialLimit); } return res.json({ status: "ok", latestSequence: liveCaptionState.latestSequence, startedAt: new Date(liveCaptionState.startedAt).toISOString(), availableLanguages: getAvailableLanguages(), captions, }); } catch (error) { console.error("Error getting live captions stream", error); return res.status(500).json({ status: "Internal server error", latestSequence: liveCaptionState.latestSequence, captions: [], availableLanguages: [], }); } }); router.post("/ingest", async (req, res) => { try { // TODO: Add basic auth/API key validation before production roll-out. const draft = extractDraftText(req.body || {}); const originalFromPayload = readText(req.body?.original); const original = originalFromPayload || draft; const requestedLang = normalizeLang(req.body?.lang); const sourceLangFromRequest = normalizeLang(req.body?.sourceLang || (requestedLang && requestedLang !== "draft" ? requestedLang : "")); const isDraft = !!draft || requestedLang === "draft" || sourceLangFromRequest === "draft" || req.body?.isDraft === true || req.body?.status === "draft"; const mapFromNested = normalizeTranslations(req.body?.translations); const mapFromFlat = buildTranslationsFromFlatPayload(req.body); const translations = isDraft ? {} : { ...mapFromNested, ...mapFromFlat }; const inferredSource = inferSourceLangFromTranslations(original, translations); const sourceLang = isDraft ? "" : (sourceLangFromRequest || inferredSource); if (!original) { return res.status(400).json({ status: "Original text is required" }); } if (sourceLang && sourceLang !== "original" && sourceLang !== "draft" && !translations[sourceLang]) { translations[sourceLang] = original; } const sequence = liveCaptionState.latestSequence + 1; const caption = { sequence, createdAt: new Date().toISOString(), original, sourceLang: sourceLang || undefined, lang: isDraft ? "draft" : (sourceLang || undefined), isDraft, status: isDraft ? "draft" : "final", ...translations, }; liveCaptionState.latestSequence = sequence; liveCaptionState.lastIngestAt = Date.now(); liveCaptionState.captions.push(caption); if (liveCaptionState.captions.length > MAX_BUFFER_SIZE) { liveCaptionState.captions.splice(0, liveCaptionState.captions.length - MAX_BUFFER_SIZE); } return res.json({ status: "ok", caption, latestSequence: liveCaptionState.latestSequence, availableLanguages: getAvailableLanguages(), }); } catch (error) { console.error("Error ingesting live captions", error); return res.status(500).json({ status: "Internal server error" }); } }); router.post("/reset", async (_, res) => { try { // TODO: Add admin authorization before exposing this endpoint. resetLiveCaptionState(); return res.json({ status: "ok" }); } catch (error) { console.error("Error resetting live captions state", error); return res.status(500).json({ status: "Internal server error" }); } }); router.get("/public/stream", async (req, res) => { try { maybeResetForInactivity(); const sinceSequence = Number.parseInt(req.query?.sinceSequence, 10); const requestedLimit = Number.parseInt(req.query?.limit, 10); const initialLimit = Number.isFinite(requestedLimit) ? Math.max(1, Math.min(requestedLimit, MAX_INITIAL_LIMIT)) : DEFAULT_INITIAL_LIMIT; let captions = []; if (Number.isFinite(sinceSequence) && sinceSequence >= 0) { captions = liveCaptionState.captions.filter((item) => item.sequence > sinceSequence); } else { captions = liveCaptionState.captions.slice(-initialLimit); } return res.json({ status: "ok", latestSequence: liveCaptionState.latestSequence, startedAt: new Date(liveCaptionState.startedAt).toISOString(), availableLanguages: getAvailableLanguages(), captions, }); } catch (error) { console.error("Error getting public live captions stream", error); return res.status(500).json({ status: "Internal server error", latestSequence: liveCaptionState.latestSequence, captions: [], availableLanguages: [], }); } }); module.exports = router;