const fs = require('fs'); const path = require('path'); const util = require('util'); const { spawnSync } = require('child_process'); const LOG_FILE = path.join(__dirname, 'log.txt'); let activeLogPath = null; let logFinished = false; let originalConsoleLog = null; let originalConsoleError = null; const AUDIO_EXTENSIONS = new Set(['.mp3', '.flac', '.m4a', '.aac', '.ogg', '.opus', '.wav']); const PERSON_TAGS = new Set([ 'artist', 'artists', 'album_artist', 'albumartist', 'album_artists', 'albumartists', 'composer', 'lyricist', 'text_writer', 'textwriter', 'writer', 'arranger', 'producer', 'mixer', 'performer', 'conductor', 'sort_artist', 'artist_sort', 'artistsort', 'sort_album_artist', 'album_artist_sort', 'albumartistsort', 'sort_composer', 'composer_sort', 'composersort', ]); function printHelp() { console.log(`批量规范音频人物标签中的分隔符 用法: node normalize_person_separators.js [目录] [--apply] [--backup] 参数: 目录 要递归扫描的目录,默认为 Music --apply 实际修改文件;不传时只预览 --backup 修改前创建同名 .bak 备份,仅与 --apply 一起生效 -h, --help 显示帮助 示例: node normalize_person_separators.js node normalize_person_separators.js "D:\\Music" node normalize_person_separators.js Music --apply --backup `); } function appendLog(text) { if (activeLogPath) fs.appendFileSync(activeLogPath, text, 'utf8'); } function startApplyLog(options) { activeLogPath = LOG_FILE; const startedAt = new Date().toISOString(); fs.appendFileSync( activeLogPath, `\n===== apply 开始 ${startedAt} =====\n目标目录: ${options.targetDir}\n创建备份: ${options.backup ? '是' : '否'}\n命令: ${process.argv.map(value => JSON.stringify(value)).join(' ')}\n`, 'utf8', ); originalConsoleLog = console.log; originalConsoleError = console.error; console.log = (...args) => { originalConsoleLog(...args); appendLog(`${util.format(...args)}\n`); }; console.error = (...args) => { originalConsoleError(...args); appendLog(`${util.format(...args)}\n`); }; process.once('exit', finishApplyLog); } function finishApplyLog() { if (!activeLogPath || logFinished) return; logFinished = true; appendLog(`===== apply 结束 ${new Date().toISOString()} =====\n`); console.log = originalConsoleLog; console.error = originalConsoleError; activeLogPath = null; } function mirrorChildOutput(output, isError = false) { if (!output) return; const text = String(output); (isError ? process.stderr : process.stdout).write(text); appendLog(text.endsWith('\n') ? text : `${text}\n`); } function parseArgs(argv) { let targetDir = null; let apply = false; let backup = false; for (const arg of argv) { if (arg === '-h' || arg === '--help') return { help: true }; if (arg === '--apply') apply = true; else if (arg === '--backup') backup = true; else if (arg.startsWith('-')) throw new Error(`未知参数: ${arg}`); else if (targetDir === null) targetDir = arg; else throw new Error(`只能指定一个目录: ${arg}`); } return { help: false, apply, backup, targetDir: path.resolve(targetDir || path.join(__dirname, 'Music')), }; } function normalizeTagKey(key) { return key.toLowerCase().replace(/[\s-]+/g, '_'); } function normalizePersonValue(value) { if (typeof value !== 'string') return value; return value .replace(/\s+&\s+/g, ' / ') .replace(/,\s+/g, ' / '); } function findAudioFiles(dirPath) { const files = []; for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) { const entryPath = path.join(dirPath, entry.name); if (entry.isDirectory()) files.push(...findAudioFiles(entryPath)); else if (entry.isFile() && AUDIO_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(entryPath); } return files; } function readTags(filePath) { const result = spawnSync('ffprobe', [ '-v', 'error', '-show_entries', 'format_tags', '-of', 'json', filePath, ], { encoding: 'utf8', windowsHide: true }); mirrorChildOutput(result.stderr, true); if (result.error) throw result.error; if (result.status !== 0) throw new Error(`ffprobe 退出码: ${result.status}`); return JSON.parse(result.stdout).format?.tags || {}; } function collectChanges(tags) { const changes = []; for (const [key, oldValue] of Object.entries(tags)) { if (!PERSON_TAGS.has(normalizeTagKey(key))) continue; const newValue = normalizePersonValue(oldValue); if (newValue !== oldValue) changes.push({ key, oldValue, newValue }); } return changes; } function replaceFileAtomically(sourcePath, replacementPath, sourceStat) { const displacedPath = `${sourcePath}.normalize-original-${process.pid}`; if (fs.existsSync(displacedPath)) throw new Error(`保护文件已存在: ${displacedPath}`); fs.renameSync(sourcePath, displacedPath); try { fs.renameSync(replacementPath, sourcePath); fs.chmodSync(sourcePath, sourceStat.mode); fs.utimesSync(sourcePath, sourceStat.atime, sourceStat.mtime); fs.unlinkSync(displacedPath); } catch (error) { if (fs.existsSync(sourcePath)) fs.unlinkSync(sourcePath); fs.renameSync(displacedPath, sourcePath); throw error; } } function applyChanges(filePath, changes, createBackup) { const extension = path.extname(filePath); const tempPath = path.join( path.dirname(filePath), `.${path.basename(filePath, extension)}.normalize-${process.pid}-${Date.now()}${extension}`, ); const backupPath = `${filePath}.bak`; const sourceStat = fs.statSync(filePath); if (fs.existsSync(tempPath)) throw new Error(`临时文件已存在: ${tempPath}`); if (createBackup) fs.copyFileSync(filePath, backupPath, fs.constants.COPYFILE_EXCL); const args = [ '-v', 'error', '-i', filePath, '-map', '0', '-map_metadata', '0', '-c', 'copy', ]; for (const change of changes) args.push('-metadata', `${change.key}=${change.newValue}`); args.push(tempPath); try { const result = spawnSync('ffmpeg', args, { encoding: 'utf8', windowsHide: true }); mirrorChildOutput(result.stdout); mirrorChildOutput(result.stderr, true); if (result.error) throw result.error; if (result.status !== 0) throw new Error(`ffmpeg 退出码: ${result.status}`); replaceFileAtomically(filePath, tempPath, sourceStat); } catch (error) { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); throw error; } } function main() { let options; try { options = parseArgs(process.argv.slice(2)); } catch (error) { console.error(`参数错误: ${error.message}`); process.exitCode = 1; return; } if (options.help) { printHelp(); return; } if (!fs.existsSync(options.targetDir) || !fs.statSync(options.targetDir).isDirectory()) { console.error(`目录不存在: ${options.targetDir}`); process.exitCode = 1; return; } if (options.apply) { try { startApplyLog(options); console.log(`完整日志将追加写入: ${LOG_FILE}`); } catch (error) { console.error(`无法创建日志文件: ${error.message}`); process.exitCode = 1; return; } } if (options.backup && !options.apply) { console.log('提示: 当前是预览模式,--backup 不会创建备份。'); } let files; try { files = findAudioFiles(options.targetDir); } catch (error) { console.error(`扫描目录失败: ${error.message}`); process.exitCode = 1; return; } console.log(`${options.apply ? '修改' : '预览'}模式,扫描目录: ${options.targetDir}`); console.log(`找到 ${files.length} 个音频文件。\n`); let matchedFiles = 0; let changedFiles = 0; let failedFiles = 0; for (const filePath of files) { try { const changes = collectChanges(readTags(filePath)); if (!changes.length) continue; matchedFiles++; console.log(path.relative(options.targetDir, filePath) || path.basename(filePath)); for (const change of changes) { console.log(` ${change.key}: ${change.oldValue} -> ${change.newValue}`); } if (options.apply) { applyChanges(filePath, changes, options.backup); changedFiles++; console.log(' 已修改'); } console.log(''); } catch (error) { failedFiles++; console.error(`${filePath}\n 处理失败: ${error.message}\n`); } } if (options.apply) { console.log(`完成:匹配 ${matchedFiles} 个,修改 ${changedFiles} 个,失败 ${failedFiles} 个。`); } else { console.log(`预览完成:${matchedFiles} 个文件需要修改,失败 ${failedFiles} 个。`); if (matchedFiles) console.log('确认无误后加 --apply 执行;如需保留原文件,再加 --backup。'); } if (failedFiles) process.exitCode = 1; finishApplyLog(); } main();