import { readdirSync, existsSync, unlinkSync, statSync } from 'node:fs'; import { join, extname, basename } from 'node:path'; const AUDIO_EXTS = new Set([ 'flac', 'wav', 'ogg', 'wma', 'aac', 'm4a', 'opus', 'aiff', 'aif', 'ape', 'wv', 'alac', 'dsf', 'dff', ]); type OnUpdate = (partial: { content: Array<{ type: string; text: string }> }) => void; function update(onUpdate: OnUpdate | undefined, text: string): void { onUpdate?.({ content: [{ type: 'text', text }] }); } async function getDuration(filePath: string): Promise { const proc = Bun.spawn( ['ffprobe', '-v', 'quiet', '-show_entries', 'format=duration', '-of', 'csv=p=0', filePath], { stdout: 'pipe', stderr: 'pipe' }, ); const text = await new Response(proc.stdout).text(); await proc.exited; const n = parseFloat(text.trim()); return isNaN(n) ? null : n; } function scanAudioFiles(dir: string): string[] { const files: string[] = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, entry.name); if (entry.isDirectory()) { files.push(...scanAudioFiles(full)); } else if (entry.isFile()) { const ext = extname(entry.name).slice(1).toLowerCase(); if (AUDIO_EXTS.has(ext)) files.push(full); } } return files; } type ConvertResult = { outputFile: string; success: boolean; skipped?: boolean; error?: string; }; async function convertFile( inputFile: string, duration: number | null, label: string, onUpdate: OnUpdate | undefined, ): Promise { const ext = extname(inputFile); const outputFile = inputFile.slice(0, -ext.length) + '.mp3'; if (existsSync(outputFile)) { return { outputFile, success: true, skipped: true }; } const proc = Bun.spawn( [ 'ffmpeg', '-i', inputFile, '-progress', 'pipe:1', // progress data → stdout '-nostats', '-loglevel', 'error', // only errors → stderr '-codec:a', 'libmp3lame', '-b:a', '320k', outputFile, '-y', ], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }, ); // Parse -progress output from stdout for real-time percentage const reader = (proc.stdout as ReadableStream).getReader(); const decoder = new TextDecoder(); let buf = ''; let lastPercent = -1; (async () => { try { while (true) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); const lines = buf.split('\n'); buf = lines.pop() ?? ''; for (const line of lines) { const m = line.match(/^out_time_us=(\d+)$/); if (m && duration) { const pct = Math.min(100, Math.round((parseInt(m[1]!, 10) / 1_000_000 / duration) * 100)); if (pct >= lastPercent + 5) { lastPercent = pct; update(onUpdate, `${label} ${pct}%`); } } } } } catch { // stream closed } })(); const stderrText = await new Response(proc.stderr).text(); await proc.exited; if (proc.exitCode !== 0) { if (existsSync(outputFile)) unlinkSync(outputFile); return { outputFile, success: false, error: stderrText.trim() || 'ffmpeg failed' }; } return { outputFile, success: true }; } export async function execute( _toolCallId: string, params: { mode: 'single' | 'batch'; path: string }, _signal: AbortSignal | undefined, onUpdate?: OnUpdate, ) { const { mode, path } = params; // ── Single file ────────────────────────────────────────────────────────── if (mode === 'single') { if (!existsSync(path)) { return { content: [{ type: 'text', text: `File not found: ${path}` }], details: { error: 'file_not_found' }, isError: true, }; } const ext = extname(path).slice(1).toLowerCase(); if (!AUDIO_EXTS.has(ext)) { return { content: [{ type: 'text', text: `Unsupported format: .${ext}\nSupported: ${[...AUDIO_EXTS].join(', ')}` }], details: { error: 'unsupported_format' }, isError: true, }; } update(onUpdate, `Getting duration of ${basename(path)}...`); const duration = await getDuration(path); update(onUpdate, `Converting ${basename(path)}...`); const result = await convertFile(path, duration, 'Progress:', onUpdate); if (result.skipped) { return { content: [{ type: 'text', text: `Skipped: ${result.outputFile} already exists` }], details: { skipped: true, outputFile: result.outputFile }, }; } if (!result.success) { return { content: [{ type: 'text', text: `Failed to convert ${basename(path)}:\n${result.error}` }], details: { error: result.error }, isError: true, }; } unlinkSync(path); return { content: [{ type: 'text', text: `Done.\nConverted: ${basename(result.outputFile)}\nDeleted source: ${basename(path)}` }], details: { outputFile: result.outputFile }, }; } // ── Batch ───────────────────────────────────────────────────────────────── if (!existsSync(path)) { return { content: [{ type: 'text', text: `Directory not found: ${path}` }], details: { error: 'dir_not_found' }, isError: true, }; } if (!statSync(path).isDirectory()) { return { content: [{ type: 'text', text: `Not a directory: ${path}\nUse mode="single" for individual files.` }], details: { error: 'not_a_directory' }, isError: true, }; } update(onUpdate, `Scanning ${basename(path)} for audio files...`); const audioFiles = scanAudioFiles(path); if (audioFiles.length === 0) { return { content: [{ type: 'text', text: `No audio files found in: ${path}` }], details: { found: 0 }, }; } update(onUpdate, `Found ${audioFiles.length} audio files. Starting conversion...`); type BatchResult = ConvertResult & { input: string }; const results: BatchResult[] = []; for (let i = 0; i < audioFiles.length; i++) { const inputFile = audioFiles[i]!; const label = `[${i + 1}/${audioFiles.length}] ${basename(inputFile)}`; update(onUpdate, `Converting ${label}...`); const duration = await getDuration(inputFile); const result = await convertFile(inputFile, duration, label, onUpdate); results.push({ ...result, input: inputFile }); if (result.skipped) { update(onUpdate, `→ Skipped ${label} (MP3 already exists)`); } else if (result.success) { update(onUpdate, `✓ Done ${label}`); } else { update(onUpdate, `✗ Failed ${label}: ${result.error}`); } } const converted = results.filter((r) => r.success && !r.skipped); const failed = results.filter((r) => !r.success); const skipped = results.filter((r) => r.skipped); if (failed.length === 0) { // All succeeded — delete source files update(onUpdate, `All conversions succeeded. Deleting ${converted.length} source files...`); for (const r of converted) unlinkSync(r.input); const lines = [ `Conversion complete.`, `Converted: ${converted.length}`, skipped.length > 0 ? `Skipped (already existed): ${skipped.length}` : null, `Source files deleted: ${converted.length}`, ].filter(Boolean); return { content: [{ type: 'text', text: lines.join('\n') }], details: { converted: converted.length, failed: 0, skipped: skipped.length }, }; } // Some failed — delete created MP3s for a clean retry update(onUpdate, `${failed.length} failure(s). Rolling back ${converted.length} created MP3(s) for clean retry...`); for (const r of converted) { if (existsSync(r.outputFile)) unlinkSync(r.outputFile); } const failLines = failed.map((r) => ` - ${basename(r.input)}: ${r.error}`).join('\n'); return { content: [{ type: 'text', text: [ `Conversion failed. ${failed.length}/${audioFiles.length} file(s) could not be converted.`, `Successfully created MP3s have been removed — the directory is unchanged for a clean retry.`, ``, `Failed files:`, failLines, ].join('\n'), }], details: { converted: 0, failed: failed.length, rolledBack: converted.length }, isError: true, }; }