When youtube-transcript fetchTranscript() returns empty segments array (parse succeeded but zero captions), treat as fallback trigger instead of returning empty transcript. - tryGetCaptions(): return CaptionResult with empty segments (not null) when segments array is empty. Distinguishes 'no captions available' (null) from 'captions exist but empty' (CaptionResult with []) - downloadYouTube(): check captions.segments.length > 0 before taking fast path. Empty segments fall through to audio download + whisper - Tests: empty array fallback, whitespace-only entries fallback, non-empty segments still use fast path
155 lines
4.7 KiB
TypeScript
155 lines
4.7 KiB
TypeScript
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<string> {
|
|
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<void> {
|
|
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<CaptionResult | null> {
|
|
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<string> {
|
|
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<DownloadResult> {
|
|
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<string> {
|
|
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 }));
|
|
}
|