Files
study-bible-project/server/bibleApi.js
T
nmemmertandClaude Sonnet 5.5 3279dddae5
Build and Push Docker Image / build-and-push (push) Failing after 19s
Reader: ESV chapter audio
The Reader's player follows the translation on screen: ESV plays its own
recording via a server route that resolves the ESV API's audio redirect;
other translations keep the BSB narrators.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 09:20:15 -04:00

148 lines
8.0 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Proxy for licensed translations (NLT, CSB, NASB) served by api.bible.
// The API key stays on the server (API_BIBLE_KEY, loaded from .env if present);
// the browser only ever talks to /api/bible/...
import { existsSync, readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Minimal .env loader so the key doesn't have to be exported in every launcher
// (setup.sh, systemd, docker). Real environment variables win.
const envPath = join(__dirname, '..', '.env');
if (existsSync(envPath)) {
for (const line of readFileSync(envPath, 'utf8').split('\n')) {
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2');
}
}
const API_BASE = 'https://rest.api.bible/v1';
// Canonical book order (index + 1 = the book number the ESV API uses in ranges).
const BOOK_ORDER = ['GEN','EXO','LEV','NUM','DEU','JOS','JDG','RUT','1SA','2SA','1KI','2KI','1CH','2CH','EZR','NEH','EST','JOB','PSA','PRO','ECC','SNG','ISA','JER','LAM','EZK','DAN','HOS','JOL','AMO','OBA','JON','MIC','NAM','HAB','ZEP','HAG','ZEC','MAL','MAT','MRK','LUK','JHN','ACT','ROM','1CO','2CO','GAL','EPH','PHP','COL','1TH','2TH','1TI','2TI','TIT','PHM','HEB','JAS','1PE','2PE','1JN','2JN','3JN','JUD','REV'];
export const LICENSED_TRANSLATIONS = {
ESV: { provider: 'esv', name: 'English Standard Version' },
NLT: { provider: 'api.bible', bibleId: 'd6e14a625393b4da-01', name: 'New Living Translation' },
CSB: { provider: 'api.bible', bibleId: 'a556c5305ee15c3f-01', name: 'Christian Standard Bible' },
NASB: { provider: 'api.bible', bibleId: 'b8ee27bcd1cae43a-01', name: 'New American Standard Bible (1995)' },
};
const CACHE_TTL = 24 * 60 * 60 * 1000;
const CACHE_MAX = 1000;
// Crossway limits how much ESV text an app may store, so ESV chapters are only
// held briefly and in small numbers (enough to absorb repeat loads).
const ESV_CACHE_TTL = 60 * 60 * 1000;
const ESV_CACHE_MAX = 20;
const cache = new Map(); // "NLT/PSA/23" -> { at, payload }
// api.bible returns plain text with inline "[12]" verse markers, poetry line
// breaks, and stray "#" / nbsp characters. Split it back into { number, text }.
export function parseChapterText(content) {
const clean = String(content ?? '')
.replace(/ /g, ' ')
.replace(/#/g, '');
const parts = clean.split(/\[(\d+)\]/);
const verses = [];
for (let i = 1; i < parts.length; i += 2) {
const text = parts[i + 1].replace(/\s+/g, ' ').replace(/\s+,$/, '').trim();
if (text) verses.push({ number: Number(parts[i]), text });
}
return verses;
}
export function registerBibleRoutes(app, requireAuth) {
app.get('/api/bible/translations', requireAuth, (_req, res) => {
res.json(Object.entries(LICENSED_TRANSLATIONS).map(([id, t]) => ({
id, name: t.name, available: Boolean(t.provider === 'esv' ? process.env.ESV_API_KEY : process.env.API_BIBLE_KEY),
})));
});
// ESV chapter audio (read by David Cochran Heath). The ESV API answers with a
// redirect to a public mp3; hand that URL to the browser's <audio> element.
app.get('/api/bible/ESV/audio/:book/:chapter', requireAuth, async (req, res) => {
const { book, chapter } = req.params;
const bookNum = BOOK_ORDER.indexOf(book.toUpperCase()) + 1;
if (!bookNum || !/^\d{1,3}$/.test(chapter)) return res.status(400).json({ error: 'Invalid book or chapter.' });
if (!process.env.ESV_API_KEY) return res.status(503).json({ error: 'ESV_API_KEY is not configured on the server.' });
const prefix = String(bookNum).padStart(2, '0') + String(Number(chapter)).padStart(3, '0');
try {
const upstream = await fetch(`https://api.esv.org/v3/passage/audio/?q=${prefix}001-${prefix}999`, {
headers: { Authorization: `Token ${process.env.ESV_API_KEY}` }, redirect: 'manual',
});
const url = upstream.headers.get('location');
if (upstream.status === 429) return res.status(429).json({ error: 'ESV rate limit reached. Try again in a minute.' });
if (!url) return res.status(502).json({ error: 'ESV audio is not available for this chapter.' });
res.json({ url });
} catch {
res.status(502).json({ error: 'Could not reach the ESV audio service.' });
}
});
app.get('/api/bible/:translation/:book/:chapter', requireAuth, async (req, res) => {
const translation = LICENSED_TRANSLATIONS[req.params.translation.toUpperCase()];
const { book, chapter } = req.params;
if (!translation) return res.status(404).json({ error: 'Unknown translation.' });
if (!/^[1-3A-Z]{3}$/i.test(book) || !/^\d{1,3}$/.test(chapter)) {
return res.status(400).json({ error: 'Invalid book or chapter.' });
}
const id = req.params.translation.toUpperCase();
const isEsv = translation.provider === 'esv';
const apiKey = isEsv ? process.env.ESV_API_KEY : process.env.API_BIBLE_KEY;
if (!apiKey) return res.status(503).json({ error: `${isEsv ? 'ESV_API_KEY' : 'API_BIBLE_KEY'} is not configured on the server.` });
const key = `${id}/${book.toUpperCase()}/${chapter}`;
const ttl = isEsv ? ESV_CACHE_TTL : CACHE_TTL;
const hit = cache.get(key);
if (hit && Date.now() - hit.at < ttl) return res.json(hit.payload);
try {
let payload;
if (isEsv) {
const bookNum = BOOK_ORDER.indexOf(book.toUpperCase()) + 1;
if (!bookNum) return res.status(400).json({ error: 'Invalid book.' });
const prefix = String(bookNum).padStart(2, '0') + String(Number(chapter)).padStart(3, '0');
const qs = new URLSearchParams({
q: `${prefix}001-${prefix}999`,
'include-passage-references': 'false', 'include-verse-numbers': 'true', 'include-first-verse-numbers': 'true',
'include-footnotes': 'false', 'include-headings': 'false', 'include-short-copyright': 'false',
'include-passage-horizontal-lines': 'false', 'include-heading-horizontal-lines': 'false',
});
const upstream = await fetch(`https://api.esv.org/v3/passage/text/?${qs}`, { headers: { Authorization: `Token ${apiKey}` } });
if (upstream.status === 429) return res.status(429).json({ error: 'ESV rate limit reached. Try again in a minute.' });
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
const body = await upstream.json();
const verses = parseChapterText(body.passages?.[0]);
if (verses.length === 0) return res.status(404).json({ error: 'Chapter not found.' });
payload = { translation: id, verses, copyright: '', fumsToken: null };
} else {
const qs = new URLSearchParams({
'content-type': 'text', 'include-notes': 'false', 'include-titles': 'false',
'include-chapter-numbers': 'false', 'include-verse-numbers': 'true', 'include-verse-spans': 'false',
});
const upstream = await fetch(
`${API_BASE}/bibles/${translation.bibleId}/chapters/${book.toUpperCase()}.${chapter}?${qs}`,
{ headers: { 'api-key': apiKey } },
);
if (upstream.status === 404) return res.status(404).json({ error: 'Chapter not found.' });
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
const body = await upstream.json();
const verses = parseChapterText(body.data?.content);
if (verses.length === 0) return res.status(502).json({ error: 'Bible provider returned no verses.' });
payload = { translation: id, verses, copyright: body.data?.copyright ?? '', fumsToken: body.meta?.fumsToken ?? null };
}
const max = isEsv ? ESV_CACHE_MAX : CACHE_MAX;
if (cache.size >= CACHE_MAX + ESV_CACHE_MAX) cache.delete(cache.keys().next().value);
if (isEsv) {
const esvKeys = [...cache.keys()].filter((k) => k.startsWith('ESV/'));
if (esvKeys.length >= max) cache.delete(esvKeys[0]);
}
cache.set(key, { at: Date.now(), payload });
res.json(payload);
} catch {
res.status(502).json({ error: 'Could not reach the Bible provider.' });
}
});
}