From 503c5ef1f4078f625478110cf700217cffc4cb7d Mon Sep 17 00:00:00 2001 From: Adolfo Reyna Date: Thu, 26 Feb 2026 22:31:00 -0500 Subject: [PATCH] Add live captions stream endpoints and test sender --- index.js | 2 + package.json | 1 + routes/liveCaptions.js | 129 ++++++++++++++++++++++++++++++ scripts/liveCaptionsTestSender.js | 82 +++++++++++++++++++ 4 files changed, 214 insertions(+) create mode 100644 routes/liveCaptions.js create mode 100644 scripts/liveCaptionsTestSender.js diff --git a/index.js b/index.js index 9dde57d..a9c4741 100644 --- a/index.js +++ b/index.js @@ -184,6 +184,7 @@ const songsRoute = require('./routes/songs.js'); const paymentsRoute = require('./routes/payments.js'); const bibleRoute = require('./routes/bible.js'); const chatRoute = require('./routes/chat.js'); +const liveCaptionsRoute = require('./routes/liveCaptions.js'); const sessionChecker = require('./middleware/sessionChecker'); // -- Private Routes app.use('/user', sessionChecker, profileRoute); @@ -192,6 +193,7 @@ app.use('/payments', paymentsRoute); app.use('/bible', sessionChecker, bibleRoute); app.use('/songs', sessionChecker, songsRoute); app.use('/chat', sessionChecker, chatRoute); +app.use('/live-captions', liveCaptionsRoute); // -- Public Routes const subsplashRoute = require('./routes/subsplash.js'); app.use('/subsplash', subsplashRoute); diff --git a/package.json b/package.json index 8085319..0487d8d 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "test": "npx mocha test/auth.test.js", "start": "node index.js", + "live-captions:test-sender": "node scripts/liveCaptionsTestSender.js", "docker": "docker compose up -d", "docker_restore": "docker-compose exec mongo mongorestore --db EMI_SOCIAL /dump/EMI_SOCIAL/", "docker_dump": "docker-compose exec mongo mongodump --uri ${MONGO_URL} --out /dump" diff --git a/routes/liveCaptions.js b/routes/liveCaptions.js new file mode 100644 index 0000000..03da2f9 --- /dev/null +++ b/routes/liveCaptions.js @@ -0,0 +1,129 @@ +var express = require('express'); +var router = express.Router(); + +const sessionChecker = require("../middleware/sessionChecker.js"); + +const MAX_BUFFER_SIZE = 300; +const DEFAULT_INITIAL_LIMIT = 40; +const MAX_INITIAL_LIMIT = 120; + +const liveCaptionState = { + startedAt: Date.now(), + latestSequence: 0, + captions: [], +}; + +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 getAvailableLanguages = () => { + const langs = new Set(); + for (const caption of liveCaptionState.captions) { + if (caption?.sourceLang) langs.add(caption.sourceLang); + const translationMap = caption?.translations || {}; + Object.keys(translationMap).forEach((lang) => langs.add(normalizeLang(lang))); + } + return Array.from(langs).filter(Boolean).sort(); +}; + +router.get("/stream", sessionChecker, async (req, res) => { + try { + 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 original = typeof req.body?.original === "string" ? req.body.original.trim() : ""; + const sourceLang = normalizeLang(req.body?.sourceLang || "original"); + const translations = normalizeTranslations(req.body?.translations); + + if (!original) { + return res.status(400).json({ status: "Original text is required" }); + } + + const sequence = liveCaptionState.latestSequence + 1; + const caption = { + sequence, + createdAt: new Date().toISOString(), + sourceLang, + original, + translations, + }; + + liveCaptionState.latestSequence = sequence; + 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. + liveCaptionState.startedAt = Date.now(); + liveCaptionState.latestSequence = 0; + liveCaptionState.captions = []; + return res.json({ status: "ok" }); + } catch (error) { + console.error("Error resetting live captions state", error); + return res.status(500).json({ status: "Internal server error" }); + } +}); + +module.exports = router; diff --git a/scripts/liveCaptionsTestSender.js b/scripts/liveCaptionsTestSender.js new file mode 100644 index 0000000..c6178b6 --- /dev/null +++ b/scripts/liveCaptionsTestSender.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node + +require("dotenv").config(); +const axios = require("axios"); + +const baseUrl = (process.env.CAPTION_TEST_BASE_URL || process.env.BASE_URL || "http://localhost:3000").replace(/\/+$/, ""); +const ingestUrl = `${baseUrl}/live-captions/ingest`; +const intervalMs = 5000; + +const samples = [ + { + original: "Bienvenidos a nuestro servicio de adoracion.", + sourceLang: "es", + translations: { + en: "Welcome to our worship service.", + fr: "Bienvenue a notre service de louange.", + }, + }, + { + original: "Leamos juntos en el Salmo 23.", + sourceLang: "es", + translations: { + en: "Let us read together in Psalm 23.", + fr: "Lisons ensemble le Psaume 23.", + }, + }, + { + original: "Dios es fiel en todo tiempo.", + sourceLang: "es", + translations: { + en: "God is faithful at all times.", + fr: "Dieu est fidele en tout temps.", + }, + }, + { + original: "Tomemos un momento para orar.", + sourceLang: "es", + translations: { + en: "Let us take a moment to pray.", + fr: "Prenons un moment pour prier.", + }, + }, +]; + +let sampleIndex = 0; +let timer = null; + +const sendNextSample = async () => { + const payload = samples[sampleIndex]; + sampleIndex = (sampleIndex + 1) % samples.length; + + try { + const response = await axios.post(ingestUrl, payload, { + headers: { "Content-Type": "application/json" }, + timeout: 10000, + }); + const seq = response?.data?.caption?.sequence || response?.data?.latestSequence || "?"; + console.log(`[live-captions:test-sender] sent sequence=${seq} original="${payload.original}"`); + } catch (error) { + const status = error?.response?.status; + const body = error?.response?.data; + const message = error?.message || "request failed"; + console.error("[live-captions:test-sender] send failed", { status, body, message }); + } +}; + +const start = async () => { + console.log(`[live-captions:test-sender] posting to ${ingestUrl} every ${intervalMs / 1000}s`); + await sendNextSample(); + timer = setInterval(sendNextSample, intervalMs); +}; + +const shutdown = () => { + if (timer) clearInterval(timer); + console.log("[live-captions:test-sender] stopped"); + process.exit(0); +}; + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); + +start();