// POST /relay/transcribe — forwards an audio payload to the chosen // backend (Gemini first, operator hardware as overflow) and returns // the standard envelope. // // Request shape: multipart/form-data // audio: binary audio file (required) // mime_type: string (default application/octet-stream) // title: string (optional, used by Gemini prompt) // channel: string (optional) // description: string (optional) // chapters: JSON-stringified array (optional) // offset_seconds: number string (optional, for chunked audio) // // Headers: // X-Recap-Install-Id (required) // X-Recap-Job-Id (optional but expected — pairs with /analyze) // Authorization (optional Bearer LIC1-... for licensed tiers) // // Response (standard envelope): // { // result: { text: "[MM:SS] ...", segments: [], duration_seconds: 0 }, // credits_remaining, tier, credit_charged // } import express from "express"; import multer from "multer"; import { resolveLicense } from "../keysat-client.js"; import { getOrCreateRow, planBackend, commitCredit } from "../credits.js"; import { lookupJob, markJobCharged, refundJob } from "../job-credits.js"; import { getConfigSnapshot, getTierQuotas } from "../config.js"; import { createGeminiBackend } from "../backends/gemini.js"; import { createHardwareBackend } from "../backends/hardware.js"; import { envelope, errorEnvelope } from "./envelope.js"; const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 200 * 1024 * 1024 }, // 200 MB per request }); export function transcribeRouter() { const router = express.Router(); router.post("/transcribe", upload.single("audio"), async (req, res) => { const installId = req.header("X-Recap-Install-Id"); const jobId = req.header("X-Recap-Job-Id") || null; const auth = req.header("Authorization"); if (!installId) { const e = await errorEnvelope({ error: "missing X-Recap-Install-Id header", statusHint: 400, }); return res.status(400).json(e.body); } if (!req.file) { const e = await errorEnvelope({ error: "missing audio file", installId, statusHint: 400 }); return res.status(400).json(e.body); } const license = await resolveLicense(auth); const tier = license.tier; // Persist tier on the row so the admin dashboard reflects the // most recently seen tier for this install. const row = await getOrCreateRow(installId); row.tier_snapshot = tier; // Job-id dedup. If we've already charged this job, skip the // credit check entirely — the user is paying once for the whole // summarize job. let reusedJob = false; let chosenBackend = null; const existingJob = lookupJob(installId, jobId); if (existingJob) { reusedJob = true; chosenBackend = existingJob.backend; } else { const cfg = await getConfigSnapshot(); const hasHardware = !!cfg.relay_parakeet_base_url; const quota = await getTierQuotas(); const preference = cfg.relay_transcribe_backend_preference || "gemini_first"; const plan = planBackend(row, quota, { hasHardware, preference }); if (!plan.allowed) { const e = await errorEnvelope({ error: plan.reason, installId, tier, statusHint: 402, }); return res.status(402).json(e.body); } chosenBackend = plan.backend; } // Build the backend client based on chosenBackend. const cfg = await getConfigSnapshot(); let result; try { if (chosenBackend === "gemini") { const backend = createGeminiBackend({ apiKey: cfg.relay_gemini_api_key, transcriptionModel: cfg.relay_gemini_transcription_model, analysisModel: cfg.relay_gemini_analysis_model, }); result = await backend.transcribeAudio({ audio: req.file.buffer, mimeType: req.body?.mime_type || req.file.mimetype || "application/octet-stream", title: req.body?.title || "", channel: req.body?.channel || "", description: req.body?.description || "", chapters: parseChaptersField(req.body?.chapters), offsetSeconds: Number(req.body?.offset_seconds) || 0, }); } else { const backend = createHardwareBackend({ parakeetBaseURL: cfg.relay_parakeet_base_url, gemmaBaseURL: cfg.relay_gemma_base_url, parakeetModel: cfg.relay_parakeet_model, gemmaModel: cfg.relay_gemma_model, }); result = await backend.transcribeAudio({ audio: req.file.buffer, mimeType: req.body?.mime_type || req.file.mimetype || "application/octet-stream", offsetSeconds: Number(req.body?.offset_seconds) || 0, }); } } catch (err) { // If we'd charged this job already (rare — most refundable // failures happen on the FIRST call), refund. if (reusedJob) refundJob(installId, jobId); console.error(`[relay/transcribe] backend error: ${err?.message}`); const e = await errorEnvelope({ error: err?.message || "backend_error", installId, tier, statusHint: err?.status || 502, }); return res.status(e.statusHint).json(e.body); } // Commit the credit on success (unless this was a job-id reuse). let creditCharged = 0; if (!reusedJob) { await commitCredit(installId, { backend: chosenBackend, tier }); markJobCharged(installId, jobId, { backend: chosenBackend, tier }); creditCharged = 1; } const body = await envelope({ result, installId, tier, creditCharged }); res.json(body); }); return router; } function parseChaptersField(raw) { if (!raw) return []; try { const arr = JSON.parse(raw); return Array.isArray(arr) ? arr : []; } catch { return []; } }