Module split: extract pure helpers to server/util.js
First step of breaking up the 2914-line server/index.js. Pulled out the zero-state, no-side-effects helpers: • sendEvent(res, event, data) — writes one SSE frame • extractVideoId(url) — YouTube URL → 11-char id • formatTime(seconds) — seconds → 'M:SS' or 'H:MM:SS' • parseTimestampedTranscript(text) — Gemini transcript text → entries[] • safeText(result) — robust .text getter for Gemini responses • retryGemini(fn, opts) — 503/429/network retry with linear backoff server/index.js: 2914 → 2828 lines. server/util.js : new, 113 lines. Smoke tested: server boots, /api/license-status responds. No behavior change.
This commit is contained in:
+10
-96
@@ -10,6 +10,14 @@ import https from "https";
|
|||||||
import http from "http";
|
import http from "http";
|
||||||
import { GoogleGenAI } from "@google/genai";
|
import { GoogleGenAI } from "@google/genai";
|
||||||
import * as license from "./license.js";
|
import * as license from "./license.js";
|
||||||
|
import {
|
||||||
|
sendEvent,
|
||||||
|
extractVideoId,
|
||||||
|
formatTime,
|
||||||
|
parseTimestampedTranscript,
|
||||||
|
safeText,
|
||||||
|
retryGemini,
|
||||||
|
} from "./util.js";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -454,41 +462,7 @@ function calcCost(modelName, usage) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Safe text extraction from Gemini responses ──────────────────────────
|
// safeText + retryGemini moved to ./util.js
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Health check ───────────────────────────────────────────────────────────
|
// ── Health check ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -2738,67 +2712,7 @@ async function splitAudioFile(inputPath, outputDir, chunkSeconds = 2700) {
|
|||||||
return chunks;
|
return chunks;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendEvent(res, event, data) {
|
// sendEvent / extractVideoId / formatTime / parseTimestampedTranscript moved to ./util.js
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildAnalysisPrompt(entries) {
|
function buildAnalysisPrompt(entries) {
|
||||||
const numbered = entries
|
const numbered = entries
|
||||||
|
|||||||
+113
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user