301 lines
8.8 KiB
JavaScript
301 lines
8.8 KiB
JavaScript
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();
|