/** * 音频问题扫描与修复脚本 * * 功能: * 1. 扫描所有章节,检测语速异常(音频时长与字数不匹配) * 2. 检测 WAV 文件被错误存为 MP3 的问题 * 3. 检测 FFmpeg concat 合并丢失段的问题 * 4. 检测 level=1 章节合并音频与子节时长总和不匹配 * 5. 检测 audioDuration 为 0 但有 audioUrl 的章节 * 6. 提供自动修复(重新合并分段、上传、更新数据库) * * 用法: * npx tsx scripts/fix-audio-issues.ts # 扫描所有问题 * npx tsx scripts/fix-audio-issues.ts --fix # 扫描并自动修复 * npx tsx scripts/fix-audio-issues.ts --book 5 # 只扫描指定书籍 * npx tsx scripts/fix-audio-issues.ts --chapter 11 # 只扫描指定章节 * npx tsx scripts/fix-audio-issues.ts --fix --chapter 11 # 修复指定章节 * npx tsx scripts/fix-audio-issues.ts --merge-all # 重新合并所有 level=1 章的音频 * * 异常判定规则: * - 语速 > 10字/秒 → 严重异常(可能丢失了大部分音频) * - 语速 6-10字/秒 → 轻微异常(可能丢失了部分音频) * - 语速 3-6字/秒 → 正常 * - 音频时长为0但有 audioUrl → 需要补充时长 * - level=1 章合并音频时长 < 子节时长总和 * 0.8 → 合并不完整 */ import { PrismaClient } from '@prisma/client'; import { exec } from 'child_process'; import { promisify } from 'util'; import fs from 'fs'; import path from 'path'; import axios from 'axios'; const execAsync = promisify(exec); const prisma = new PrismaClient(); // ============ 配置 ============ const SEVERE_RATE = 10; // 严重异常阈值(字/秒) const MILD_RATE = 6; // 轻微异常阈值(字/秒) const CHARS_PER_SECOND = 4; // 估算用的标准语速 const MERGE_TOLERANCE = 0.8; // 合并音频时长容差(低于子节总时长的80%视为异常) // ============ 解析命令行参数 ============ const args = process.argv.slice(2); const shouldFix = args.includes('--fix'); const mergeAll = args.includes('--merge-all'); const bookArg = args.find(a => a.startsWith('--book')); const chapterArg = args.find(a => a.startsWith('--chapter')); const bookId = bookArg ? parseInt(bookArg.split('=')[1] || args[args.indexOf(bookArg) + 1]) : null; const chapterId = chapterArg ? parseInt(chapterArg.split('=')[1] || args[args.indexOf(chapterArg) + 1]) : null; // ============ 类型定义 ============ type IssueSeverity = 'critical' | 'mild' | 'missing_duration' | 'format_mismatch' | 'merge_incomplete'; interface AudioIssue { chapterId: number; bookId: number; bookTitle: string; chapterTitle: string; level: number; wordCount: number; audioDuration: number; audioUrl: string; rate: number; severity: IssueSeverity; description: string; localSegments?: string[]; segmentTotalDuration?: number; expectedDuration?: number; // 预估应有的时长 fuzzyMatchedDir?: string; // 模糊匹配的可能目录(需人工确认) } // ============ 工具函数 ============ async function getLocalAudioDuration(filePath: string): Promise { try { const { stdout } = await execAsync( `ffprobe -i "${filePath}" -show_entries format=duration -v quiet -of csv="p=0"`, { timeout: 10000 } ); return Math.round((parseFloat(stdout.trim()) || 0) * 10) / 10; } catch { return 0; } } async function getRemoteAudioDuration(url: string): Promise { try { const { stdout } = await execAsync( `ffprobe -i "${url}" -show_entries format=duration -v quiet -of csv="p=0"`, { timeout: 15000 } ); const duration = parseFloat(stdout.trim()); if (duration > 0) return Math.round(duration * 10) / 10; } catch {} try { const tempDir = path.join(process.cwd(), 'temp'); if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true }); const tempFile = path.join(tempDir, `check_${Date.now()}.tmp`); const response = await axios.get(url, { responseType: 'arraybuffer', timeout: 30000 }); fs.writeFileSync(tempFile, response.data); const duration = await getLocalAudioDuration(tempFile); try { fs.unlinkSync(tempFile); } catch {} return duration; } catch { return 0; } } function isWavFile(filePath: string): boolean { try { if (!fs.existsSync(filePath)) return false; const buffer = Buffer.alloc(12); const fd = fs.openSync(filePath, 'r'); fs.readSync(fd, buffer, 0, 12, 0); fs.closeSync(fd); return buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WAVE'; } catch { return false; } } function resolveAudioUrlToLocal(audioUrl: string): string | null { if (!audioUrl) return null; if (audioUrl.startsWith('http')) return null; const relativePath = audioUrl.replace(/^\//, ''); const absolutePath = path.join(process.cwd(), relativePath); return fs.existsSync(absolutePath) ? absolutePath : null; } function findLocalSegments(audioDir: string): string[] { if (!fs.existsSync(audioDir)) return []; const segments: string[] = []; const files = fs.readdirSync(audioDir).sort(); for (const file of files) { if (file.match(/^segment_\d+\.(wav|mp3)$/)) { segments.push(path.join(audioDir, file)); } } return segments; } function inferAudioDir(audioUrl: string): string | null { if (!audioUrl) return null; const match = audioUrl.match(/\/uploads\/([^/]+)\//); if (match) { return path.join(process.cwd(), 'uploads', match[1]); } return null; } /** * 扫描 uploads 目录,查找所有包含 segment_*.wav 的目录 * 返回 Map<目录路径, 分段文件列表> */ function scanAllSegmentDirs(): Map { const uploadsDir = path.join(process.cwd(), 'uploads'); if (!fs.existsSync(uploadsDir)) return new Map(); const result = new Map(); try { const dirs = fs.readdirSync(uploadsDir); for (const dir of dirs) { const dirPath = path.join(uploadsDir, dir); try { if (!fs.statSync(dirPath).isDirectory()) continue; } catch { continue; } const segments = findLocalSegments(dirPath); if (segments.length > 0) { result.set(dirPath, segments); } } } catch {} return result; } /** * 通过分段文件总时长与数据库章节字数对比,找到最匹配的目录 * ⚠️ 此方法仅作参考,不自动使用——需要人工确认后再修复 */ function findBestMatchingSegmentDir( segmentDirs: Map, wordCount: number, currentDuration: number ): { dir: string; segments: string[]; totalDuration: number } | null { const expectedDuration = wordCount / CHARS_PER_SECOND; let bestMatch: { dir: string; segments: string[]; totalDuration: number; diff: number } | null = null; for (const [dir, segments] of segmentDirs) { // 计算分段总时长(同步快速估算) let totalSize = 0; for (const seg of segments) { try { totalSize += fs.statSync(seg).size; } catch {} } // WAV 文件大约 176KB/s(16bit 22kHz 立体声)或 88KB/s(单声道) const estimatedDuration = totalSize / 176000; // 粗估 // 寻找估算时长接近预期时长的 const diff = Math.abs(estimatedDuration - expectedDuration); if (!bestMatch || diff < bestMatch.diff) { bestMatch = { dir, segments, totalDuration: estimatedDuration, diff }; } } return bestMatch ? { dir: bestMatch.dir, segments: bestMatch.segments, totalDuration: bestMatch.totalDuration } : null; } async function mergeAndUpload( segmentFiles: string[], outputDir: string, chapterId: number ): Promise<{ url: string; duration: number } | null> { if (segmentFiles.length === 0) return null; try { const listFile = path.join(outputDir, 'fix_concat_list.txt'); const listContent = segmentFiles.map(f => { const relativePath = path.relative(outputDir, f).replace(/\\/g, '/'); return `file '${relativePath}'`; }).join('\n'); fs.writeFileSync(listFile, listContent); const outputFile = path.join(outputDir, `output_fixed_${Date.now()}.mp3`); const cmd = `ffmpeg -f concat -safe 0 -i "${listFile}" -c:a libmp3lame -b:a 192k -y "${outputFile}"`; console.log(` 🔧 执行: ${cmd}`); await execAsync(cmd, { timeout: 300000, cwd: outputDir }); const duration = await getLocalAudioDuration(outputFile); const { storageService } = await import('../src/services/storage.service'); const audioId = `fix_${chapterId}_${Date.now()}`; const finalUrl = await storageService.uploadAudio(outputFile, audioId); try { fs.unlinkSync(listFile); } catch {} try { fs.unlinkSync(outputFile); } catch {} console.log(` ✅ 合并上传成功: ${finalUrl}, 时长: ${duration}s`); return { url: finalUrl, duration }; } catch (error: any) { console.error(` ❌ 合并失败: ${error.message}`); return null; } } async function transcodeAndUpload( localFile: string, chapterId: number ): Promise<{ url: string; duration: number } | null> { try { const outputDir = path.dirname(localFile); const outputFile = path.join(outputDir, `transcoded_${Date.now()}.mp3`); const cmd = `ffmpeg -i "${localFile}" -c:a libmp3lame -b:a 192k -y "${outputFile}"`; console.log(` 🔧 转码: ${cmd}`); await execAsync(cmd, { timeout: 120000 }); const duration = await getLocalAudioDuration(outputFile); const { storageService } = await import('../src/services/storage.service'); const audioId = `fix_${chapterId}_${Date.now()}`; const finalUrl = await storageService.uploadAudio(outputFile, audioId); try { fs.unlinkSync(outputFile); } catch {} console.log(` ✅ 转码上传成功: ${finalUrl}, 时长: ${duration}s`); return { url: finalUrl, duration }; } catch (error: any) { console.error(` ❌ 转码失败: ${error.message}`); return null; } } /** * 合并子节音频为 level=1 章节音频(通过 AudioMerger) */ async function mergeChapterAudio(chapterId: number): Promise<{ url: string; duration: number } | null> { try { // 使用 player.service 的 mergeChapterAudios 函数 const { mergeChapterAudios } = await import('../src/modules/player/player.service'); const mergedUrl = await mergeChapterAudios(chapterId); if (!mergedUrl) { console.log(` ⚠️ mergeChapterAudios 返回 null,可能没有子节音频`); return null; } // 获取合并后时长 let duration = 0; const localPath = resolveAudioUrlToLocal(mergedUrl); if (localPath) { duration = await getLocalAudioDuration(localPath); } else if (mergedUrl.startsWith('http')) { duration = await getRemoteAudioDuration(mergedUrl); } // 更新数据库时长 if (duration > 0) { await prisma.bookChapter.update({ where: { id: chapterId }, data: { audioDuration: duration }, }); } console.log(` ✅ 章节合并完成: ${mergedUrl.substring(0, 60)}..., 时长: ${duration}s`); return { url: mergedUrl, duration }; } catch (error: any) { console.error(` ❌ 章节合并失败: ${error.message}`); return null; } } // ============ 扫描函数 ============ async function scanLeafChapterIssues(): Promise { const issues: AudioIssue[] = []; const whereClause: any = { audioUrl: { not: '' }, }; if (chapterId) { whereClause.id = chapterId; } else if (bookId) { whereClause.bookId = bookId; } const chapters = await prisma.bookChapter.findMany({ where: whereClause, include: { book: { select: { id: true, title: true } } }, orderBy: [{ bookId: 'asc' }, { number: 'asc' }], }); console.log(`📚 找到 ${chapters.length} 个有音频的章节(叶节点检查)\n`); // 预扫描 uploads 目录中的分段文件 let allSegmentDirs: Map | null = null; let currentBookId = -1; for (const chapter of chapters) { if (chapter.bookId !== currentBookId) { currentBookId = chapter.bookId; console.log(`📖 书籍 ${currentBookId}: ${chapter.book?.title || '未知'}`); } const wordCount = chapter.wordCount || chapter.content?.length || 0; const duration = chapter.audioDuration || 0; const rate = duration > 0 ? wordCount / duration : 0; let severity: IssueSeverity | null = null; let description = ''; // 检查1: 语速异常 if (duration > 0 && rate > SEVERE_RATE) { severity = 'critical'; description = `严重异常:语速 ${rate.toFixed(1)}字/秒,音频时长仅${duration}s,应有约${Math.round(wordCount / CHARS_PER_SECOND)}s`; } else if (duration > 0 && rate > MILD_RATE) { severity = 'mild'; description = `轻微异常:语速 ${rate.toFixed(1)}字/秒,偏快,可能丢失部分音频`; } // 检查2: 时长缺失 if (duration === 0 && chapter.audioUrl) { severity = 'missing_duration'; description = `音频时长为0,但audioUrl存在`; } // 检查3: 格式不匹配 const localPath = resolveAudioUrlToLocal(chapter.audioUrl); if (localPath && chapter.audioUrl.endsWith('.mp3') && isWavFile(localPath)) { if (!severity) { severity = 'format_mismatch'; description = `文件格式不匹配:扩展名.mp3但内容为WAV格式`; } else { description += ';且文件格式不匹配(.mp3扩展名但WAV内容)'; } } if (severity) { const issue: AudioIssue = { chapterId: chapter.id, bookId: chapter.bookId, bookTitle: chapter.book?.title || '', chapterTitle: chapter.title, level: chapter.level, wordCount, audioDuration: duration, audioUrl: chapter.audioUrl, rate, severity, description, expectedDuration: Math.round(wordCount / CHARS_PER_SECOND), }; // 查找本地分段文件 let audioDir = inferAudioDir(chapter.audioUrl); // 如果从 URL 无法推断目录(远程 OSS URL),尝试扫描 uploads 目录 // ⚠️ 注意:模糊匹配可能不准确,仅用于提示,不自动修复 let fuzzyMatchedDir: string | null = null; if (!audioDir) { if (!allSegmentDirs) { console.log(' 🔍 扫描本地 uploads 目录查找分段文件...'); allSegmentDirs = scanAllSegmentDirs(); console.log(` 📂 找到 ${allSegmentDirs.size} 个含分段文件的目录`); } // 通过字数匹配找到可能的目录(仅提示,不自动使用) if (allSegmentDirs.size > 0) { const match = findBestMatchingSegmentDir(allSegmentDirs, wordCount, duration); if (match && match.totalDuration > duration * 1.5) { fuzzyMatchedDir = match.dir; console.log(` ⚠️ 章节 ${chapter.id} 可能有本地分段目录: ${fuzzyMatchedDir} (估算时长${Math.round(match.totalDuration)}s) - 需人工确认`); } } } if (audioDir) { const segments = findLocalSegments(audioDir); if (segments.length > 0) { issue.localSegments = segments; let totalSegDuration = 0; for (const seg of segments) { totalSegDuration += await getLocalAudioDuration(seg); } issue.segmentTotalDuration = totalSegDuration; } } // 保存模糊匹配结果 if (fuzzyMatchedDir) { issue.fuzzyMatchedDir = fuzzyMatchedDir; } issues.push(issue); const icon = severity === 'critical' ? '🔴' : severity === 'mild' ? '🟡' : severity === 'format_mismatch' ? '🟠' : '🔵'; console.log(` ${icon} 章节 ${chapter.id} "${chapter.title}" (level=${chapter.level}): ${description}`); if (issue.localSegments?.length) { console.log(` 📎 找到 ${issue.localSegments.length} 个分段文件,总时长 ${issue.segmentTotalDuration}s`); } if (issue.fuzzyMatchedDir) { console.log(` ⚠️ 疑似分段目录: ${issue.fuzzyMatchedDir}(需人工确认)`); } } } return issues; } async function scanMergedChapterIssues(): Promise { const issues: AudioIssue[] = []; // 查找所有 level=1 的章节(有子节) const whereClause: any = { level: 1 }; if (chapterId) { whereClause.id = chapterId; } else if (bookId) { whereClause.bookId = bookId; } const level1Chapters = await prisma.bookChapter.findMany({ where: whereClause, include: { book: { select: { id: true, title: true } } }, orderBy: [{ bookId: 'asc' }, { number: 'asc' }], }); console.log(`\n📚 找到 ${level1Chapters.length} 个一级章节(合并音频检查)\n`); let currentBookId = -1; for (const chapter of level1Chapters) { if (chapter.bookId !== currentBookId) { currentBookId = chapter.bookId; console.log(`📖 书籍 ${currentBookId}: ${chapter.book?.title || '未知'}`); } // 获取子节音频 const children = await prisma.bookChapter.findMany({ where: { parentId: chapter.id, audioUrl: { not: '' } }, orderBy: { number: 'asc' }, }); if (children.length === 0) continue; const childrenTotalDuration = children.reduce((sum, c) => sum + (c.audioDuration || 0), 0); const chapterDuration = chapter.audioDuration || 0; // 检查: 合并音频时长应接近子节时长总和 if (chapter.audioUrl && childrenTotalDuration > 0) { const ratio = chapterDuration / childrenTotalDuration; if (ratio < MERGE_TOLERANCE) { const issue: AudioIssue = { chapterId: chapter.id, bookId: chapter.bookId, bookTitle: chapter.book?.title || '', chapterTitle: chapter.title, level: chapter.level, wordCount: chapter.wordCount || 0, audioDuration: chapterDuration, audioUrl: chapter.audioUrl, rate: 0, severity: 'merge_incomplete', description: `合并不完整:章音频${chapterDuration}s < 子节总时长${childrenTotalDuration}s(比例${(ratio * 100).toFixed(0)}%)`, expectedDuration: childrenTotalDuration, }; issues.push(issue); console.log(` 🟣 章节 ${chapter.id} "${chapter.title}": ${issue.description}`); } else { // 正常 process.stdout.write(` ✅ 章节 ${chapter.id} "${chapter.title}": 合并正常 (${chapterDuration}s ≈ ${childrenTotalDuration}s)\n`); } } else if (!chapter.audioUrl && children.length > 0) { // 章没有合并音频但有子节 console.log(` ⚪ 章节 ${chapter.id} "${chapter.title}": 无合并音频(${children.length}个子节总时长${childrenTotalDuration}s)`); } } return issues; } // ============ 修复函数 ============ async function fixIssue(issue: AudioIssue): Promise { console.log(`\n--- 修复章节 ${issue.chapterId} "${issue.chapterTitle}" ---`); console.log(` 问题: ${issue.description}`); let result: { url: string; duration: number } | null = null; switch (issue.severity) { case 'critical': case 'mild': // 策略1: 本地分段文件重新合并 if (issue.localSegments && issue.localSegments.length > 1) { console.log(` 策略: 重新合并 ${issue.localSegments.length} 个分段文件...`); const audioDir = path.dirname(issue.localSegments[0]); result = await mergeAndUpload(issue.localSegments, audioDir, issue.chapterId); } // 策略2: 单个分段文件(可能格式不匹配) else if (issue.localSegments && issue.localSegments.length === 1) { const localFile = issue.localSegments[0]; if (isWavFile(localFile)) { console.log(` 策略: WAV 分段转码为 MP3...`); result = await transcodeAndUpload(localFile, issue.chapterId); } else { // 尝试重新检测时长 const actualDuration = await getLocalAudioDuration(localFile); if (actualDuration > issue.audioDuration * 1.5) { console.log(` 策略: 本地文件实际时长(${actualDuration}s) > 数据库时长(${issue.audioDuration}s),更新`); result = { url: issue.audioUrl, duration: actualDuration }; } } } // 策略3: 远程文件 else if (issue.audioUrl.startsWith('http')) { const remoteDuration = await getRemoteAudioDuration(issue.audioUrl); if (remoteDuration > issue.audioDuration * 1.5) { console.log(` 策略: 远程时长(${remoteDuration}s) > 数据库时长(${issue.audioDuration}s),更新数据库`); result = { url: issue.audioUrl, duration: remoteDuration }; } else { console.log(` ⚠️ 远程音频时长也异常,无法自动修复(需重新生成音频)`); } } else { console.log(` ⚠️ 没有本地分段文件,无法自动修复(需重新生成音频)`); } break; case 'format_mismatch': const localFile = resolveAudioUrlToLocal(issue.audioUrl); if (localFile) { console.log(` 策略: 转码 WAV→MP3...`); result = await transcodeAndUpload(localFile, issue.chapterId); } break; case 'missing_duration': const localPath = resolveAudioUrlToLocal(issue.audioUrl); if (localPath) { const duration = await getLocalAudioDuration(localPath); if (duration > 0) { console.log(` 策略: 补充本地音频时长 ${duration}s`); result = { url: issue.audioUrl, duration }; } } else if (issue.audioUrl.startsWith('http')) { const duration = await getRemoteAudioDuration(issue.audioUrl); if (duration > 0) { console.log(` 策略: 补充远程音频时长 ${duration}s`); result = { url: issue.audioUrl, duration }; } } break; case 'merge_incomplete': console.log(` 策略: 重新合并子节音频...`); result = await mergeChapterAudio(issue.chapterId); break; } // 更新数据库 if (result) { try { await prisma.bookChapter.update({ where: { id: issue.chapterId }, data: { audioUrl: result.url, audioDuration: result.duration, }, }); const newRate = result.duration > 0 ? (issue.wordCount / result.duration).toFixed(1) : 'N/A'; console.log(` ✅ 数据库已更新: duration=${result.duration}s, 语速=${newRate}字/秒`); return true; } catch (error: any) { console.error(` ❌ 数据库更新失败: ${error.message}`); return false; } } console.log(` ⏭️ 无法自动修复,跳过`); return false; } // ============ 主流程 ============ async function main() { console.log('='.repeat(70)); console.log('🔍 音频问题扫描与修复工具'); console.log('='.repeat(70)); console.log(`模式: ${mergeAll ? '🔄 重新合并所有' : shouldFix ? '🔧 扫描+修复' : '🔍 仅扫描'}`); if (bookId) console.log(`指定书籍 ID: ${bookId}`); if (chapterId) console.log(`指定章节 ID: ${chapterId}`); console.log(''); // --merge-all 模式:重新合并所有 level=1 章节 if (mergeAll) { console.log('🔄 重新合并所有 level=1 章节音频...\n'); const whereClause: any = { level: 1 }; if (bookId) whereClause.bookId = bookId; const chapters = await prisma.bookChapter.findMany({ where: whereClause, include: { book: { select: { id: true, title: true } } }, orderBy: [{ bookId: 'asc' }, { number: 'asc' }], }); let successCount = 0; let failCount = 0; for (const chapter of chapters) { console.log(`\n📖 合并章节 ${chapter.id} "${chapter.title}"...`); try { const result = await mergeChapterAudio(chapter.id); if (result) { successCount++; } else { console.log(` ⚠️ 无子节音频可合并,跳过`); failCount++; } } catch (error: any) { console.error(` ❌ 合并失败: ${error.message}`); failCount++; } } console.log(`\n${'='.repeat(70)}`); console.log(`📊 合并结果: ✅ ${successCount} 成功, ❌ ${failCount} 失败`); await prisma.$disconnect(); return; } // 普通扫描模式 const leafIssues = await scanLeafChapterIssues(); const mergeIssues = await scanMergedChapterIssues(); const allIssues = [...leafIssues, ...mergeIssues]; // 汇总 console.log('\n' + '='.repeat(70)); console.log('📊 扫描结果汇总'); console.log('='.repeat(70)); const critical = allIssues.filter(i => i.severity === 'critical'); const mild = allIssues.filter(i => i.severity === 'mild'); const formatMismatch = allIssues.filter(i => i.severity === 'format_mismatch'); const missingDur = allIssues.filter(i => i.severity === 'missing_duration'); const mergeIncomplete = allIssues.filter(i => i.severity === 'merge_incomplete'); console.log(`🔴 严重异常(语速>${SEVERE_RATE}字/秒): ${critical.length} 个`); console.log(`🟡 轻微异常(语速${MILD_RATE}-${SEVERE_RATE}字/秒): ${mild.length} 个`); console.log(`🟠 格式不匹配(WAV存为.mp3): ${formatMismatch.length} 个`); console.log(`🔵 时长缺失: ${missingDur.length} 个`); console.log(`🟣 合并不完整(章音频<子节总时长80%): ${mergeIncomplete.length} 个`); console.log(`总计: ${allIssues.length} 个问题\n`); if (allIssues.length === 0) { console.log('✅ 没有发现音频问题!'); await prisma.$disconnect(); return; } // 输出详细信息 if (allIssues.length > 0) { console.log('📋 问题详情:'); for (const issue of allIssues) { const icon = issue.severity === 'critical' ? '🔴' : issue.severity === 'mild' ? '🟡' : issue.severity === 'format_mismatch' ? '🟠' : issue.severity === 'merge_incomplete' ? '🟣' : '🔵'; console.log(` ${icon} [${issue.severity}] 章节${issue.chapterId} "${issue.chapterTitle}" (book=${issue.bookId}, level=${issue.level})`); console.log(` ${issue.description}`); if (issue.localSegments?.length) { console.log(` 分段文件: ${issue.localSegments.length}个, 分段总时长: ${issue.segmentTotalDuration}s`); } if (issue.expectedDuration) { console.log(` 预期时长: ~${issue.expectedDuration}s, 实际: ${issue.audioDuration}s`); } if (issue.fuzzyMatchedDir) { console.log(` ⚠️ 疑似分段目录: ${issue.fuzzyMatchedDir}(需人工确认后再修复)`); } } } if (!shouldFix) { console.log('\n💡 使用 --fix 参数自动修复问题:'); console.log(' npx tsx scripts/fix-audio-issues.ts --fix'); console.log(' npx tsx scripts/fix-audio-issues.ts --fix --chapter 11'); console.log('\n💡 使用 --merge-all 重新合并所有 level=1 章节音频:'); console.log(' npx tsx scripts/fix-audio-issues.ts --merge-all'); console.log(' npx tsx scripts/fix-audio-issues.ts --merge-all --book 5'); await prisma.$disconnect(); return; } // 自动修复 console.log('\n' + '='.repeat(70)); console.log('🔧 开始自动修复'); console.log('='.repeat(70) + '\n'); let fixedCount = 0; let failedCount = 0; for (const issue of allIssues) { const success = await fixIssue(issue); if (success) fixedCount++; else failedCount++; } console.log('\n' + '='.repeat(70)); console.log('📊 修复结果'); console.log('='.repeat(70)); console.log(`✅ 修复成功: ${fixedCount} 个`); console.log(`❌ 修复失败/跳过: ${failedCount} 个`); await prisma.$disconnect(); } main().catch(err => { console.error('脚本执行失败:', err); process.exit(1); });