// Stateless helpers — no module-scoped state, no Express, no // initialization side effects. Anything in here is safe to import from // any other module without worrying about ordering. A few helpers do // I/O (fetchUrl) but only when called. import https from "https"; // ── 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 ""; } // ── HTTP GET with redirect following ──────────────────────────────────────── // Returns the response body as a string. Follows HTTP redirects up to a // reasonable depth (relies on https module's default behavior plus a one- // level recursion). Used for fetching RSS feeds, channel pages, etc. // // For binary downloads (e.g. podcast audio), use audio.downloadPodcastAudio // — it streams to disk instead of buffering in memory. export function fetchUrl(url) { return new Promise((resolve, reject) => { https.get(url, (res) => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { return fetchUrl(res.headers.location).then(resolve, reject); } let data = ""; res.on("data", (chunk) => (data += chunk)); res.on("end", () => resolve(data)); res.on("error", reject); }).on("error", reject); }); } // ── 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; } } } }