diff --git a/server/index.js b/server/index.js index d8bf834..7c90af1 100644 --- a/server/index.js +++ b/server/index.js @@ -10,6 +10,14 @@ import https from "https"; import http from "http"; import { GoogleGenAI } from "@google/genai"; import * as license from "./license.js"; +import { + sendEvent, + extractVideoId, + formatTime, + parseTimestampedTranscript, + safeText, + retryGemini, +} from "./util.js"; const execFileAsync = promisify(execFile); const app = express(); @@ -454,41 +462,7 @@ function calcCost(modelName, usage) { }; } -// ── Safe text extraction from Gemini responses ────────────────────────── - -function safeText(result) { - // The Gemini SDK .text getter can throw or return undefined - try { - if (result.text) return result.text; - } catch {} - // Fallback: dig into candidates manually - try { - const parts = result?.candidates?.[0]?.content?.parts; - if (parts) return parts.map(p => p.text || "").join(""); - } catch {} - return ""; -} - -// ── Retry helper for transient Gemini API errors ────────────────────────── - -async function retryGemini(fn, { retries = 3, delayMs = 3000, label = "Gemini call", log: logFn } = {}) { - for (let attempt = 1; attempt <= retries; attempt++) { - try { - return await fn(); - } catch (err) { - const msg = err?.message || String(err); - const status = err?.status || err?.httpStatusCode || 0; - const isRetryable = status === 503 || status === 429 || /overloaded|unavailable|capacity|high demand|rate limit|fetch failed|ECONNRESET|ETIMEDOUT|socket hang up|network/i.test(msg); - if (isRetryable && attempt < retries) { - const waitSec = (delayMs * attempt / 1000).toFixed(0); - if (logFn) logFn(`⚠ ${label} failed (${status || "error"}), retrying in ${waitSec}s... (attempt ${attempt}/${retries})`); - await new Promise(r => setTimeout(r, delayMs * attempt)); - } else { - throw err; - } - } - } -} +// safeText + retryGemini moved to ./util.js // ── Health check ─────────────────────────────────────────────────────────── @@ -2738,67 +2712,7 @@ async function splitAudioFile(inputPath, outputDir, chunkSeconds = 2700) { return chunks; } -function sendEvent(res, event, data) { - res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); -} - -function extractVideoId(url) { - if (!url) return null; - const patterns = [ - /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/)([a-zA-Z0-9_-]{11})/, - /^([a-zA-Z0-9_-]{11})$/, - ]; - for (const p of patterns) { - const m = url.match(p); - if (m) return m[1]; - } - return null; -} - -function formatTime(seconds) { - const s = Math.floor(seconds); - const h = Math.floor(s / 3600); - const m = Math.floor((s % 3600) / 60); - const sec = s % 60; - if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`; - return `${m}:${String(sec).padStart(2, "0")}`; -} - -function parseTimestampedTranscript(text) { - const lines = text.trim().split("\n").filter(Boolean); - const entries = []; - // Match timestamps in various formats: - // [0:00], [00:00], [0:00:00], (0:00), 0:00 -, **0:00**, etc. - // Optionally preceded by speaker labels, markdown bold, etc. - const tsRegex = /^(?:[*_]*)?(?:\[?\(?)(\d{1,2}):(\d{2})(?::(\d{2}))?[\])]?(?:[*_]*)?\s*[-–—:]?\s*(.*)/; - // Also match lines like "Speaker 1 [0:00]: text" or "**[0:00]** text" - const altRegex = /^(?:.*?)[\[(\s](\d{1,2}):(\d{2})(?::(\d{2}))?[\])]\s*[-–—:]?\s*(.*)/; - - for (const line of lines) { - const trimmed = line.trim(); - let m = trimmed.match(tsRegex); - if (!m) m = trimmed.match(altRegex); - if (m) { - const hours = m[3] !== undefined ? parseInt(m[1]) : 0; - const mins = m[3] !== undefined ? parseInt(m[2]) : parseInt(m[1]); - const secs = m[3] !== undefined ? parseInt(m[3]) : parseInt(m[2]); - const offset = hours * 3600 + mins * 60 + secs; - // Strip any leftover markdown or speaker prefix from the text - const lineText = m[4].replace(/^\*\*\s*/, "").replace(/\s*\*\*$/, "").trim(); - if (lineText) entries.push({ text: lineText, offset, duration: 0 }); - } - } - - // Calculate durations from gaps between entries - for (let i = 0; i < entries.length - 1; i++) { - entries[i].duration = entries[i + 1].offset - entries[i].offset; - } - if (entries.length > 0) { - entries[entries.length - 1].duration = 15; // default last segment - } - - return entries; -} +// sendEvent / extractVideoId / formatTime / parseTimestampedTranscript moved to ./util.js function buildAnalysisPrompt(entries) { const numbered = entries diff --git a/server/util.js b/server/util.js new file mode 100644 index 0000000..4f1a915 --- /dev/null +++ b/server/util.js @@ -0,0 +1,113 @@ +// Pure helpers — no module-scoped state, no Express, no I/O effects. +// Anything in here is safe to import from any other module without +// worrying about ordering or initialization side effects. + +// ── SSE helper ────────────────────────────────────────────────────────────── +// Writes a single Server-Sent Events frame: `event: X\ndata: Y\n\n`. +// Each call flushes one event. Caller is responsible for `res.writeHead` +// and `res.end()`. +export function sendEvent(res, event, data) { + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); +} + +// ── YouTube video-id extraction ───────────────────────────────────────────── +// Accepts watch URLs, youtu.be, /embed/, /v/, or a bare 11-char id. +// Returns null when no id can be extracted. +export function extractVideoId(url) { + if (!url) return null; + const patterns = [ + /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/)([a-zA-Z0-9_-]{11})/, + /^([a-zA-Z0-9_-]{11})$/, + ]; + for (const p of patterns) { + const m = url.match(p); + if (m) return m[1]; + } + return null; +} + +// ── Time formatting ───────────────────────────────────────────────────────── +// Seconds → "M:SS" or "H:MM:SS" (auto-promotes to hours when needed). +export function formatTime(seconds) { + const s = Math.floor(seconds); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`; + return `${m}:${String(sec).padStart(2, "0")}`; +} + +// ── Transcript parsing ────────────────────────────────────────────────────── +// Parses Gemini's timestamped transcript output into structured entries. +// Tolerates several formats: [0:00], (0:00), 0:00, **0:00**, with optional +// speaker prefixes and markdown noise. Each entry has { text, offset, +// duration }, where duration is computed from the gap to the next entry +// (last entry defaults to 15 s). +export function parseTimestampedTranscript(text) { + const lines = text.trim().split("\n").filter(Boolean); + const entries = []; + // Primary: timestamp at the start of the line. + const tsRegex = /^(?:[*_]*)?(?:\[?\(?)(\d{1,2}):(\d{2})(?::(\d{2}))?[\])]?(?:[*_]*)?\s*[-–—:]?\s*(.*)/; + // Secondary: timestamp anywhere on the line, e.g. "Speaker 1 [0:00]: text". + const altRegex = /^(?:.*?)[\[(\s](\d{1,2}):(\d{2})(?::(\d{2}))?[\])]\s*[-–—:]?\s*(.*)/; + + for (const line of lines) { + const trimmed = line.trim(); + let m = trimmed.match(tsRegex); + if (!m) m = trimmed.match(altRegex); + if (m) { + const hours = m[3] !== undefined ? parseInt(m[1]) : 0; + const mins = m[3] !== undefined ? parseInt(m[2]) : parseInt(m[1]); + const secs = m[3] !== undefined ? parseInt(m[3]) : parseInt(m[2]); + const offset = hours * 3600 + mins * 60 + secs; + const lineText = m[4].replace(/^\*\*\s*/, "").replace(/\s*\*\*$/, "").trim(); + if (lineText) entries.push({ text: lineText, offset, duration: 0 }); + } + } + + for (let i = 0; i < entries.length - 1; i++) { + entries[i].duration = entries[i + 1].offset - entries[i].offset; + } + if (entries.length > 0) { + entries[entries.length - 1].duration = 15; + } + + return entries; +} + +// ── Safe text extraction from Gemini responses ────────────────────────────── +// The Gemini SDK's .text getter can throw or return undefined depending on +// response shape — fall back to digging into candidates manually. +export function safeText(result) { + try { + if (result.text) return result.text; + } catch {} + try { + const parts = result?.candidates?.[0]?.content?.parts; + if (parts) return parts.map(p => p.text || "").join(""); + } catch {} + return ""; +} + +// ── Retry helper for transient Gemini API errors ──────────────────────────── +// Retries on 503/429 and on common transient network errors. Linear backoff +// (delayMs * attempt). The optional `log` callback receives a one-line +// status message per retry — useful for streaming progress to a UI. +export async function retryGemini(fn, { retries = 3, delayMs = 3000, label = "Gemini call", log: logFn } = {}) { + for (let attempt = 1; attempt <= retries; attempt++) { + try { + return await fn(); + } catch (err) { + const msg = err?.message || String(err); + const status = err?.status || err?.httpStatusCode || 0; + const isRetryable = status === 503 || status === 429 || /overloaded|unavailable|capacity|high demand|rate limit|fetch failed|ECONNRESET|ETIMEDOUT|socket hang up|network/i.test(msg); + if (isRetryable && attempt < retries) { + const waitSec = (delayMs * attempt / 1000).toFixed(0); + if (logFn) logFn(`⚠ ${label} failed (${status || "error"}), retrying in ${waitSec}s... (attempt ${attempt}/${retries})`); + await new Promise(r => setTimeout(r, delayMs * attempt)); + } else { + throw err; + } + } + } +}