feat: support selective album downloads and tag cleanup

This commit is contained in:
吴璨
2026-08-04 16:01:33 +08:00
parent 60b2fc0578
commit 480233f01b
2 changed files with 359 additions and 14 deletions
+59 -14
View File
@@ -38,6 +38,27 @@ function createRL() {
return { rl, ask }; return { rl, ask };
} }
function parseNumberSelection(input, total, { emptyMeansAll = false } = {}) {
const value = input.trim().toLowerCase();
if (value === 'a' || (!value && emptyMeansAll)) {
return Array.from({ length: total }, (_, index) => index + 1);
}
if (!value || value === '0') return [];
const indices = new Set();
for (const part of value.split(/[,\s]+/)) {
const rangeMatch = part.match(/^(\d+)-(\d+)$/);
if (rangeMatch) {
const start = parseInt(rangeMatch[1], 10);
const end = parseInt(rangeMatch[2], 10);
for (let number = start; number <= end; number++) indices.add(number);
} else if (/^\d+$/.test(part)) {
indices.add(parseInt(part, 10));
}
}
return [...indices].filter(number => number >= 1 && number <= total).sort((a, b) => a - b);
}
function printBanner() { function printBanner() {
console.log(''); console.log('');
console.log('╔══════════════════════════════════════════╗'); console.log('╔══════════════════════════════════════════╗');
@@ -62,7 +83,7 @@ function printMenu() {
console.log('│ 9. 下载历史 │'); console.log('│ 9. 下载历史 │');
console.log('│ 0. 设置音质等级 │'); console.log('│ 0. 设置音质等级 │');
console.log('│ s. 按歌名搜索 │'); console.log('│ s. 按歌名搜索 │');
console.log('│ a. 下载完整专辑 │'); console.log('│ a. 下载专辑/指定曲目 │');
console.log('│ b. 批量ID下载 │'); console.log('│ b. 批量ID下载 │');
console.log('│ f. 从文件批量下载 │'); console.log('│ f. 从文件批量下载 │');
console.log('│ n. 从文件名识别并补全标签 │'); console.log('│ n. 从文件名识别并补全标签 │');
@@ -962,22 +983,34 @@ async function main() {
console.log(` ${String(i + 1).padStart(2)}. ${track.name} - ${artists}`); console.log(` ${String(i + 1).padStart(2)}. ${track.name} - ${artists}`);
}); });
const trackPick = await ask('\n选择歌曲序号 (如 1,3,5 或 2-6a/回车=全部,0=取消): ');
const selectedIndices = parseNumberSelection(trackPick, album.tracks.length, { emptyMeansAll: true });
if (!selectedIndices.length) {
if (trackPick.trim() !== '0') console.log('⚠️ 没有有效的歌曲序号');
break;
}
const selectedTracks = selectedIndices.map(number => ({
track: album.tracks[number - 1],
trackIndex: number - 1,
}));
level = await askLevel(ask, level); level = await askLevel(ask, level);
const confirm = await ask(`确认下载整张专辑? (y/n): `); const confirm = await ask(`确认下载选中的 ${selectedTracks.length} 首歌曲? (y/n): `);
if (confirm.trim().toLowerCase() !== 'y') break; if (confirm.trim().toLowerCase() !== 'y') break;
const albumTarget = getAlbumDownloadTarget(album, album.tracks[0], 0); const firstItem = selectedTracks[0];
const albumTarget = getAlbumDownloadTarget(album, firstItem.track, firstItem.trackIndex);
await saveAlbumCover(album.picUrl, albumTarget.outputDir); await saveAlbumCover(album.picUrl, albumTarget.outputDir);
for (let i = 0; i < album.tracks.length; i++) { for (let i = 0; i < selectedTracks.length; i++) {
const track = album.tracks[i]; const { track, trackIndex } = selectedTracks[i];
const artists = track.ar?.map(a => a.name).join(' / ') || album.artist?.name || '未知'; const artists = track.ar?.map(a => a.name).join(' / ') || album.artist?.name || '未知';
const target = getAlbumDownloadTarget(album, track, i); const target = getAlbumDownloadTarget(album, track, trackIndex);
await waitIfPaused(); await waitIfPaused();
console.log(`\n[${i + 1}/${album.tracks.length}] ${track.name} - ${artists}`); console.log(`\n[${i + 1}/${selectedTracks.length}] ${track.name} - ${artists}`);
try { await downloadSong(track.id, level, target); } try { await downloadSong(track.id, level, target); }
catch (e) { console.error(` ❌ 出错: ${e.message}`); } catch (e) { console.error(` ❌ 出错: ${e.message}`); }
if (i < album.tracks.length - 1) await sleep(500); if (i < selectedTracks.length - 1) await sleep(500);
} }
break; break;
} }
@@ -1050,13 +1083,25 @@ async function main() {
} }
console.log(`\n💿 共 ${albumsToDownload.length} 张专辑,${allSongs.length} 首歌曲`); console.log(`\n💿 共 ${albumsToDownload.length} 张专辑,${allSongs.length} 首歌曲`);
allSongs.forEach((item, index) => {
const albumSuffix = albumsToDownload.length > 1 ? ` [${item.albumName}]` : '';
console.log(` ${String(index + 1).padStart(2)}. ${item.song.name} - ${item.song.artists}${albumSuffix}`);
});
const songPick = await ask('\n选择歌曲序号 (如 1,3,5 或 2-6a/回车=全部,0=取消): ');
const selectedIndices = parseNumberSelection(songPick, allSongs.length, { emptyMeansAll: true });
if (!selectedIndices.length) {
if (songPick.trim() !== '0') console.log('⚠️ 没有有效的歌曲序号');
break;
}
const songsToDownload = selectedIndices.map(number => allSongs[number - 1]);
level = await askLevel(ask, level); level = await askLevel(ask, level);
const confirm = await ask(`确认下载? (y/n): `); const confirm = await ask(`确认下载选中的 ${songsToDownload.length} 首歌曲? (y/n): `);
if (confirm.trim().toLowerCase() !== 'y') break; if (confirm.trim().toLowerCase() !== 'y') break;
const savedCoverDirs = new Set(); const savedCoverDirs = new Set();
for (let i = 0; i < allSongs.length; i++) { for (let i = 0; i < songsToDownload.length; i++) {
const item = allSongs[i]; const item = songsToDownload[i];
const s = item.song; const s = item.song;
const target = getAlbumDownloadTarget({ const target = getAlbumDownloadTarget({
name: item.albumName, name: item.albumName,
@@ -1068,10 +1113,10 @@ async function main() {
savedCoverDirs.add(target.outputDir); savedCoverDirs.add(target.outputDir);
} }
await waitIfPaused(); await waitIfPaused();
console.log(`\n[${i + 1}/${allSongs.length}] ${s.name} - ${s.artists}`); console.log(`\n[${i + 1}/${songsToDownload.length}] ${s.name} - ${s.artists}`);
try { await downloadSong(s.id, level, target); } try { await downloadSong(s.id, level, target); }
catch (e) { console.error(` ❌ 出错: ${e.message}`); } catch (e) { console.error(` ❌ 出错: ${e.message}`); }
if (i < allSongs.length - 1) await sleep(500); if (i < songsToDownload.length - 1) await sleep(500);
} }
break; break;
} }
@@ -2023,7 +2068,7 @@ async function main() {
console.log(` ║ 3 输入歌单 ID 下载全部 - 输入歌单ID批量下载 ║`); console.log(` ║ 3 输入歌单 ID 下载全部 - 输入歌单ID批量下载 ║`);
console.log(` ║ s 按歌名搜索 - 按歌名精确搜索,支持试听/详情/下载 ║`); console.log(` ║ s 按歌名搜索 - 按歌名精确搜索,支持试听/详情/下载 ║`);
console.log(` ║ 6 按歌手批量下载 - 搜索歌手并选择下载其歌曲 ║`); console.log(` ║ 6 按歌手批量下载 - 搜索歌手并选择下载其歌曲 ║`);
console.log(` ║ a 下载完整专辑 - 输入专辑ID下载全部曲目,也支持名称搜索`); console.log(` ║ a 下载专辑 - 输入专辑ID或名称,可按序号选择曲目 `);
console.log(` ║ ║`); console.log(` ║ ║`);
console.log(` ║ 【查看与管理】 ║`); console.log(` ║ 【查看与管理】 ║`);
console.log(` ║ 4 查看歌单歌曲列表 - 浏览歌单内容,可选择性下载 ║`); console.log(` ║ 4 查看歌单歌曲列表 - 浏览歌单内容,可选择性下载 ║`);
+300
View File
@@ -0,0 +1,300 @@
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();