diff --git a/163_music_downloader.js b/163_music_downloader.js index 2216a45..01f778a 100755 --- a/163_music_downloader.js +++ b/163_music_downloader.js @@ -38,6 +38,27 @@ function createRL() { 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() { console.log(''); console.log('╔══════════════════════════════════════════╗'); @@ -62,7 +83,7 @@ function printMenu() { console.log('│ 9. 下载历史 │'); console.log('│ 0. 设置音质等级 │'); console.log('│ s. 按歌名搜索 │'); - console.log('│ a. 下载完整专辑 │'); + console.log('│ a. 下载专辑/指定曲目 │'); console.log('│ b. 批量ID下载 │'); console.log('│ f. 从文件批量下载 │'); console.log('│ n. 从文件名识别并补全标签 │'); @@ -962,22 +983,34 @@ async function main() { console.log(` ${String(i + 1).padStart(2)}. ${track.name} - ${artists}`); }); + const trackPick = await ask('\n选择歌曲序号 (如 1,3,5 或 2-6,a/回车=全部,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); - const confirm = await ask(`确认下载整张专辑? (y/n): `); + const confirm = await ask(`确认下载选中的 ${selectedTracks.length} 首歌曲? (y/n): `); 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); - for (let i = 0; i < album.tracks.length; i++) { - const track = album.tracks[i]; + for (let i = 0; i < selectedTracks.length; i++) { + const { track, trackIndex } = selectedTracks[i]; 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(); - 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); } catch (e) { console.error(` ❌ 出错: ${e.message}`); } - if (i < album.tracks.length - 1) await sleep(500); + if (i < selectedTracks.length - 1) await sleep(500); } break; } @@ -1050,13 +1083,25 @@ async function main() { } 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-6,a/回车=全部,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); - const confirm = await ask(`确认下载? (y/n): `); + const confirm = await ask(`确认下载选中的 ${songsToDownload.length} 首歌曲? (y/n): `); if (confirm.trim().toLowerCase() !== 'y') break; const savedCoverDirs = new Set(); - for (let i = 0; i < allSongs.length; i++) { - const item = allSongs[i]; + for (let i = 0; i < songsToDownload.length; i++) { + const item = songsToDownload[i]; const s = item.song; const target = getAlbumDownloadTarget({ name: item.albumName, @@ -1068,10 +1113,10 @@ async function main() { savedCoverDirs.add(target.outputDir); } 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); } catch (e) { console.error(` ❌ 出错: ${e.message}`); } - if (i < allSongs.length - 1) await sleep(500); + if (i < songsToDownload.length - 1) await sleep(500); } break; } @@ -2023,7 +2068,7 @@ async function main() { console.log(` ║ 3 输入歌单 ID 下载全部 - 输入歌单ID批量下载 ║`); console.log(` ║ s 按歌名搜索 - 按歌名精确搜索,支持试听/详情/下载 ║`); console.log(` ║ 6 按歌手批量下载 - 搜索歌手并选择下载其歌曲 ║`); - console.log(` ║ a 下载完整专辑 - 输入专辑ID下载全部曲目,也支持名称搜索 ║`); + console.log(` ║ a 下载专辑 - 输入专辑ID或名称,可按序号选择曲目 ║`); console.log(` ║ ║`); console.log(` ║ 【查看与管理】 ║`); console.log(` ║ 4 查看歌单歌曲列表 - 浏览歌单内容,可选择性下载 ║`); diff --git a/normalize_person_separators.js b/normalize_person_separators.js new file mode 100644 index 0000000..97c1e93 --- /dev/null +++ b/normalize_person_separators.js @@ -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();