0ae59f3550
Introduces RECAP_MODE=multi alongside single-mode self-host: - Tenant auth + accounts (magic-link via System SMTP), per-tenant credit pool, anonymous trial minting with per-IP/-64 caps - Self-serve Pro/Max purchase: inline Lightning (BTCPay) + card (Zaprite), prepaid 30-day periods, expiry-reminder emails - Core-decoupling: relay owns cloud tier/expiry keyed by Recaps user-id - SQLite (better-sqlite3) schema for multi-mode; filesystem unchanged for single - StartOS actions/versions through 0.2.155
98 lines
3.9 KiB
JavaScript
98 lines
3.9 KiB
JavaScript
// Audio I/O helpers — ffprobe for metadata, ffmpeg for splitting, plus
|
|
// HTTP downloading for podcast episodes. Pure module: no state, no
|
|
// Express, only takes paths/URLs and returns data.
|
|
|
|
import { execFile } from "child_process";
|
|
import { promisify } from "util";
|
|
import path from "path";
|
|
import http from "http";
|
|
import https from "https";
|
|
import { createWriteStream } from "fs";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
// ── Audio duration via ffprobe ──────────────────────────────────────────────
|
|
// Returns the duration in seconds, or null if ffprobe can't read the file
|
|
// (corrupt, missing, unsupported format). Caller decides what to do with
|
|
// null — most call sites treat it as "unknown" and skip duration-dependent
|
|
// branches.
|
|
export async function getAudioDuration(filePath) {
|
|
try {
|
|
const { stdout } = await execFileAsync("ffprobe", [
|
|
"-v", "error",
|
|
"-show_entries", "format=duration",
|
|
"-of", "default=noprint_wrappers=1:nokey=1",
|
|
filePath,
|
|
], { timeout: 15000 });
|
|
const dur = parseFloat(stdout.trim());
|
|
return isNaN(dur) ? null : dur;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ── Split a long audio file into chunks ─────────────────────────────────────
|
|
// Used when a video is too long for a single Gemini transcription call.
|
|
// Returns null if no split is needed (audio fits in one chunk), otherwise
|
|
// an array of `{ path, startOffset, index }`. Uses `-acodec copy` so it's
|
|
// fast and lossless — no re-encoding.
|
|
export async function splitAudioFile(inputPath, outputDir, chunkSeconds = 2700) {
|
|
const duration = await getAudioDuration(inputPath);
|
|
if (!duration || duration <= chunkSeconds) return null;
|
|
|
|
const chunks = [];
|
|
let startSec = 0;
|
|
let i = 0;
|
|
while (startSec < duration) {
|
|
const chunkPath = path.join(outputDir, `chunk_${i}.mp3`);
|
|
const segLen = Math.min(chunkSeconds, duration - startSec);
|
|
await execFileAsync("ffmpeg", [
|
|
"-y", "-i", inputPath,
|
|
"-ss", String(startSec),
|
|
"-t", String(segLen),
|
|
"-acodec", "copy",
|
|
chunkPath,
|
|
], { timeout: 120000 });
|
|
chunks.push({
|
|
path: chunkPath,
|
|
startOffset: startSec,
|
|
// Actual seconds in THIS chunk (the last chunk is usually
|
|
// shorter than chunkSeconds). Carried downstream so the
|
|
// transcribe-stitching code can sanity-cap timestamps each
|
|
// chunk's model emits — some models hallucinate offsets
|
|
// way past the chunk's audio (observed: gemini-3.1-flash-lite
|
|
// emitting [10:12:44] on a 45-min chunk).
|
|
durationSec: segLen,
|
|
index: i,
|
|
});
|
|
startSec += chunkSeconds;
|
|
i++;
|
|
}
|
|
return chunks;
|
|
}
|
|
|
|
// ── Download a podcast episode by URL ───────────────────────────────────────
|
|
// Streams the HTTP response straight to disk. Follows redirects. Rejects
|
|
// on any non-200 final status. Used by /api/process when the input URL is
|
|
// a podcast episode rather than a YouTube video.
|
|
export function downloadPodcastAudio(audioUrl, destPath) {
|
|
return new Promise((resolve, reject) => {
|
|
const doFetch = (url) => {
|
|
const getter = url.startsWith("https") ? https : http;
|
|
getter.get(url, (res) => {
|
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
return doFetch(res.headers.location);
|
|
}
|
|
if (res.statusCode !== 200) {
|
|
return reject(new Error(`HTTP ${res.statusCode} downloading podcast audio`));
|
|
}
|
|
const fileStream = createWriteStream(destPath);
|
|
res.pipe(fileStream);
|
|
fileStream.on("finish", () => fileStream.close(resolve));
|
|
fileStream.on("error", reject);
|
|
}).on("error", reject);
|
|
};
|
|
doFetch(audioUrl);
|
|
});
|
|
}
|