267 lines
8.3 KiB
TypeScript
267 lines
8.3 KiB
TypeScript
import { readdirSync, existsSync, unlinkSync, statSync } from 'node:fs';
|
|
import { join, extname, basename } from 'node:path';
|
|
import { spawn, execFile } from 'node:child_process';
|
|
|
|
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<number | null> {
|
|
return new Promise((resolve) => {
|
|
execFile(
|
|
'ffprobe',
|
|
['-v', 'quiet', '-show_entries', 'format=duration', '-of', 'csv=p=0', filePath],
|
|
(err, stdout) => {
|
|
if (err) return resolve(null);
|
|
const n = parseFloat(stdout.trim());
|
|
resolve(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<ConvertResult> {
|
|
const ext = extname(inputFile);
|
|
const outputFile = inputFile.slice(0, -ext.length) + '.mp3';
|
|
|
|
if (existsSync(outputFile)) {
|
|
return { outputFile, success: true, skipped: true };
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
const proc = spawn('ffmpeg', [
|
|
'-i', inputFile,
|
|
'-progress', 'pipe:1',
|
|
'-nostats',
|
|
'-loglevel', 'error',
|
|
'-codec:a', 'libmp3lame',
|
|
'-b:a', '320k',
|
|
outputFile,
|
|
'-y',
|
|
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
|
|
let buf = '';
|
|
let lastPercent = -1;
|
|
let stderrText = '';
|
|
|
|
proc.stdout.on('data', (chunk: Buffer) => {
|
|
buf += chunk.toString();
|
|
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}%`);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
proc.stderr.on('data', (chunk: Buffer) => {
|
|
stderrText += chunk.toString();
|
|
});
|
|
|
|
proc.on('close', (code) => {
|
|
if (code !== 0) {
|
|
if (existsSync(outputFile)) unlinkSync(outputFile);
|
|
resolve({ outputFile, success: false, error: stderrText.trim() || 'ffmpeg failed' });
|
|
} else {
|
|
resolve({ outputFile, success: true });
|
|
}
|
|
});
|
|
|
|
proc.on('error', (err) => {
|
|
resolve({ outputFile, success: false, error: err.message });
|
|
});
|
|
});
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|