import { execFile } from 'child_process'; import { promisify } from 'util'; import { existsSync } from 'fs'; import { mkdir, writeFile } from 'fs/promises'; import { join } from 'path'; import { fetchTranscript, type TranscriptResponse } from 'youtube-transcript'; const execFileAsync = promisify(execFile); const DATA_DIR = process.env.DATA_DIR ?? '/tmp/.whisper-pwa'; const TMP_DIR = join(DATA_DIR, 'downloads'); const UPLOADS_DIR = join(DATA_DIR, 'uploads'); export async function ensureTmpDir() { if (!existsSync(TMP_DIR)) await mkdir(TMP_DIR, { recursive: true }); } async function ensureUploadsDir(jobId: string): Promise { const dir = join(UPLOADS_DIR, jobId); if (!existsSync(dir)) await mkdir(dir, { recursive: true }); return dir; } /** Path to a job's persistent upload file. Survives cleanupJobTmp. */ export function getUploadPath(jobId: string, filename: string): string { return join(UPLOADS_DIR, jobId, filename); } /** Delete a job's persistent upload dir (call on success). */ export async function cleanupUploadDir(jobId: string): Promise { const dir = join(UPLOADS_DIR, jobId); try { const { rm } = await import('fs/promises'); await rm(dir, { recursive: true, force: true }); } catch { /* ignore */ } } export interface CaptionResult { type: 'captions'; segments: Array<{ index: number; start: number; end: number; text: string; words: [] }>; title: string; } export interface AudioResult { type: 'audio'; audioPath: string; title: string; } export type DownloadResult = CaptionResult | AudioResult; /** Try to get auto-generated captions from YouTube. Returns null if unavailable. */ async function tryGetCaptions(url: string, _outDir: string): Promise { try { const transcript = await fetchTranscript(url, { lang: 'en' }); const segments = transcriptEntriesToSegments(transcript); const title = await getYouTubeTitle(url); return { type: 'captions', segments, title }; } catch { return null; } } async function getYouTubeTitle(url: string): Promise { try { const { stdout } = await execFileAsync('yt-dlp', [ '--dump-single-json', '--skip-download', '--no-playlist', url ]); return JSON.parse(stdout).title ?? 'Untitled'; } catch { return 'Untitled'; } } /** Download best audio from YouTube. Returns path to audio file. */ async function downloadAudio(url: string, outDir: string): Promise<{ audioPath: string; title: string }> { await execFileAsync('yt-dlp', [ '-f', 'bestaudio', '--write-info-json', '--no-playlist', '-o', join(outDir, 'audio.%(ext)s'), url ]); const { readdirSync, readFileSync } = await import('fs'); const files = readdirSync(outDir); const audioFile = files.find((f) => f.startsWith('audio.') && !f.endsWith('.json')); if (!audioFile) throw new Error('yt-dlp did not produce an audio file'); let title = 'Untitled'; const jsonFile = files.find((f) => f.endsWith('.info.json')); if (jsonFile) { try { title = JSON.parse(readFileSync(join(outDir, jsonFile), 'utf8')).title ?? title; } catch { /* ignore */ } } return { audioPath: join(outDir, audioFile), title }; } /** Download a YouTube URL: try captions first, fall back to audio. */ export async function downloadYouTube(url: string, jobId: string): Promise { await ensureTmpDir(); const outDir = join(TMP_DIR, jobId); await mkdir(outDir, { recursive: true }); const captions = await tryGetCaptions(url, outDir); if (captions && captions.segments.length > 0) return captions; const { audioPath, title } = await downloadAudio(url, outDir); return { type: 'audio', audioPath, title }; } /** Save an uploaded file to persistent storage (survives cleanupJobTmp). */ export async function saveUploadedFile( buffer: Buffer, filename: string, jobId: string ): Promise { const outDir = await ensureUploadsDir(jobId); const dest = join(outDir, filename); await writeFile(dest, buffer); return dest; } export async function cleanupJobTmp(jobId: string) { const outDir = join(TMP_DIR, jobId); try { const { rm } = await import('fs/promises'); await rm(outDir, { recursive: true, force: true }); } catch { /* ignore */ } } export function transcriptEntriesToSegments( entries: TranscriptResponse[] ): Array<{ index: number; start: number; end: number; text: string; words: [] }> { const useMilliseconds = entries.some((entry) => entry.offset > 1000 || entry.duration > 1000); return entries .map((entry) => { const start = useMilliseconds ? entry.offset / 1000 : entry.offset; const duration = useMilliseconds ? entry.duration / 1000 : entry.duration; return { index: 0, start, end: start + duration, text: entry.text.trim(), words: [] as [] }; }) .filter((entry) => entry.text.length > 0) .map((entry, index) => ({ ...entry, index })); }