2074 lines
86 KiB
JavaScript
2074 lines
86 KiB
JavaScript
const fs = require('fs');
|
||
const path = require('path');
|
||
const readline = require('readline');
|
||
const { execSync } = require('child_process');
|
||
|
||
const {
|
||
API,
|
||
DOWNLOAD_DIR,
|
||
CONFIG_FILE,
|
||
TRASH_DIR,
|
||
TEMP_ZIP_DIR,
|
||
QUALITY_LABELS,
|
||
DEFAULT_LEVEL,
|
||
DEFAULT_RETRIES,
|
||
getConfig,
|
||
setConfig,
|
||
} = require('./lib/config');
|
||
const { fetchJSON, downloadFile } = require('./lib/network');
|
||
const { sanitize, sleep, getDirSize } = require('./lib/utils');
|
||
const { togglePause, waitIfPaused } = require('./lib/pause');
|
||
const { loadHistory, saveHistory, addHistory, formatTime } = require('./lib/history');
|
||
const { hasFFmpeg, hasNodeID3, embedCover } = require('./lib/tagging');
|
||
const { fetchLyric, mergeLrc } = require('./lib/lyrics');
|
||
const {
|
||
fetchNeteaseSongDetail,
|
||
fetchNeteaseAlbumDetail,
|
||
getAlbumDownloadTarget,
|
||
saveAlbumCover,
|
||
fetchFullMetadata,
|
||
} = require('./lib/metadata');
|
||
const { parseLevel, printQualityMenu, askLevel } = require('./lib/quality');
|
||
const { searchSongs, interactiveSearch, fetchPlaylist } = require('./lib/catalog');
|
||
const { downloadSong } = require('./lib/downloader');
|
||
|
||
function createRL() {
|
||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||
const ask = (q) => new Promise(r => rl.question(q, r));
|
||
return { rl, ask };
|
||
}
|
||
|
||
function printBanner() {
|
||
console.log('');
|
||
console.log('╔══════════════════════════════════════════╗');
|
||
console.log('║ 🎵 网易云音乐下载器 v2.0 ║');
|
||
console.log('║ 基于 ChKSz API · 免费 · 高音质 ║');
|
||
console.log('╚══════════════════════════════════════════╝');
|
||
console.log(` 封面嵌入: node-id3(${hasNodeID3() ? '✓' : '✗'}) ffmpeg(${hasFFmpeg() ? '✓' : '✗'})`);
|
||
console.log(` 下载目录: ${DOWNLOAD_DIR}`);
|
||
console.log('');
|
||
}
|
||
|
||
function printMenu() {
|
||
console.log('┌────────────────────────────┐');
|
||
console.log('│ 1. 搜索歌曲并下载 │');
|
||
console.log('│ 2. 输入歌曲 ID 下载 │');
|
||
console.log('│ 3. 输入歌单 ID 下载全部 │');
|
||
console.log('│ 4. 查看歌单歌曲列表 │');
|
||
console.log('│ 5. 查看歌曲歌词 │');
|
||
console.log('│ 6. 按歌手批量下载 │');
|
||
console.log('│ 7. 试听歌曲 │');
|
||
console.log('│ 8. 查看歌曲详情 │');
|
||
console.log('│ 9. 下载历史 │');
|
||
console.log('│ 0. 设置音质等级 │');
|
||
console.log('│ s. 按歌名搜索 │');
|
||
console.log('│ a. 下载完整专辑 │');
|
||
console.log('│ b. 批量ID下载 │');
|
||
console.log('│ f. 从文件批量下载 │');
|
||
console.log('│ n. 从文件名识别并补全标签 │');
|
||
console.log('│ e. 导出下载历史 │');
|
||
console.log('│ m. 保存歌单为 M3U │');
|
||
console.log('│ g. 自动识别并补全标签 │');
|
||
console.log('│ z. 歌单下载并打包 ZIP │');
|
||
console.log('│ c. 清理缓存和临时文件 │');
|
||
console.log('│ t. 下载统计 │');
|
||
console.log('│ l. 已下载歌曲列表 │');
|
||
console.log('│ p. 暂停/恢复下载 │');
|
||
console.log('│ r. 重置所有设置 │');
|
||
console.log('│ h. 帮助 │');
|
||
console.log('│ q. 退出 │');
|
||
console.log('└────────────────────────────┘');
|
||
}
|
||
|
||
async function main() {
|
||
const args = process.argv.slice(2);
|
||
|
||
// 解析命令行参数
|
||
let level = getConfig('level', DEFAULT_LEVEL);
|
||
const ids = [];
|
||
let playlistId = null;
|
||
let albumId = null;
|
||
let batchLyric = true;
|
||
let batchCover = true;
|
||
|
||
let maxRetries = DEFAULT_RETRIES;
|
||
for (const arg of args) {
|
||
if (arg.startsWith('--level=')) level = parseLevel(arg.split('=')[1]) || level;
|
||
else if (arg.startsWith('--playlist=')) playlistId = arg.split('=')[1];
|
||
else if (arg.startsWith('--album=')) albumId = arg.split('=')[1];
|
||
else if (arg.startsWith('--retries=')) maxRetries = parseInt(arg.split('=')[1], 10) || DEFAULT_RETRIES;
|
||
else if (arg === '--no-lyric') batchLyric = false;
|
||
else if (arg === '--no-cover') batchCover = false;
|
||
else if (/^\d+$/.test(arg)) ids.push(arg);
|
||
}
|
||
|
||
// 命令行批量模式
|
||
if (ids.length || playlistId || albumId) {
|
||
printBanner();
|
||
console.log('📋 命令行模式\n');
|
||
|
||
if (albumId) {
|
||
const album = await fetchNeteaseAlbumDetail(albumId);
|
||
if (!album?.tracks?.length) {
|
||
console.log('❌ 专辑获取失败或没有歌曲');
|
||
} else {
|
||
console.log(`💿 专辑: ${album.name} - ${album.artist?.name || '未知歌手'} (${album.tracks.length} 首)\n`);
|
||
if (batchCover) {
|
||
const albumTarget = getAlbumDownloadTarget(album, album.tracks[0], 0);
|
||
await saveAlbumCover(album.picUrl, albumTarget.outputDir);
|
||
}
|
||
for (let i = 0; i < album.tracks.length; i++) {
|
||
const track = album.tracks[i];
|
||
const target = getAlbumDownloadTarget(album, track, i);
|
||
await waitIfPaused();
|
||
const artists = track.ar?.map(a => a.name).join('/') || album.artist?.name || '未知';
|
||
console.log(`[${i + 1}/${album.tracks.length}] ${track.name} - ${artists}`);
|
||
try {
|
||
await downloadSong(track.id, level, {
|
||
downloadLyric: batchLyric,
|
||
embedCoverArt: batchCover,
|
||
maxRetries,
|
||
...target,
|
||
});
|
||
}
|
||
catch (e) { console.error(` ❌ 出错: ${e.message}`); }
|
||
if (i < album.tracks.length - 1) await sleep(500);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (playlistId) {
|
||
const pl = await fetchPlaylist(playlistId);
|
||
if (pl) {
|
||
console.log(`📜 歌单: ${pl.name} (${pl.trackCount} 首)\n`);
|
||
for (let i = 0; i < pl.tracks.length; i++) {
|
||
const t = pl.tracks[i];
|
||
await waitIfPaused();
|
||
console.log(`[${i + 1}/${pl.tracks.length}] ${t.name} - ${t.ar?.map(a => a.name).join('/')}`);
|
||
try { await downloadSong(t.id, level, { downloadLyric: batchLyric, embedCoverArt: batchCover, maxRetries }); }
|
||
catch (e) { console.error(` ❌ 出错: ${e.message}`); }
|
||
if (i < pl.tracks.length - 1) await sleep(500);
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const id of ids) {
|
||
await waitIfPaused();
|
||
try { await downloadSong(id, level, { downloadLyric: batchLyric, embedCoverArt: batchCover, maxRetries }); }
|
||
catch (e) { console.error(`❌ ID ${id} 出错: ${e.message}`); }
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 交互模式
|
||
printBanner();
|
||
const { rl, ask } = createRL();
|
||
|
||
// 设置快捷键监听
|
||
if (process.stdin.isTTY) {
|
||
readline.emitKeypressEvents(process.stdin);
|
||
process.stdin.setRawMode(true);
|
||
process.stdin.on('keypress', (str, key) => {
|
||
if (key && key.name === 'p' && !key.ctrl && !key.meta) {
|
||
togglePause();
|
||
}
|
||
if (key && key.ctrl && key.name === 'c') {
|
||
process.exit(0);
|
||
}
|
||
});
|
||
}
|
||
// 启用 keypress 事件用于暂停快捷键
|
||
readline.emitKeypressEvents(process.stdin);
|
||
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
||
process.stdin.on('keypress', (str, key) => {
|
||
if (key && key.name === 'p' && !isPaused) {
|
||
// 只在非输入状态下触发暂停
|
||
}
|
||
if (key && key.ctrl && key.name === 'c') {
|
||
process.exit(0);
|
||
}
|
||
});
|
||
|
||
while (true) {
|
||
printMenu();
|
||
const choice = await ask('\n请选择功能: ');
|
||
|
||
switch (choice.trim()) {
|
||
case '1': {
|
||
// 搜索并下载
|
||
const songs = await interactiveSearch(rl, ask);
|
||
if (songs?.length) {
|
||
level = await askLevel(ask, level);
|
||
for (const s of songs) {
|
||
await waitIfPaused();
|
||
try { await downloadSong(s.id, level); }
|
||
catch (e) { console.error(`❌ ${s.name} 出错: ${e.message}`); }
|
||
await sleep(500);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case '2': {
|
||
// 按 ID 下载
|
||
const input = await ask('请输入歌曲ID (多个用逗号分隔): ');
|
||
const songIds = input.split(/[,,\s]+/).filter(s => /^\d+$/.test(s.trim()));
|
||
if (songIds.length) {
|
||
level = await askLevel(ask, level);
|
||
for (const id of songIds) {
|
||
await waitIfPaused();
|
||
try { await downloadSong(id, level); }
|
||
catch (e) { console.error(`❌ ID ${id} 出错: ${e.message}`); }
|
||
await sleep(500);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case '3': {
|
||
// 歌单下载
|
||
const plInput = await ask('请输入歌单ID: ');
|
||
const plId = plInput.trim();
|
||
if (!/^\d+$/.test(plId)) { console.log('⚠️ 无效歌单ID'); break; }
|
||
|
||
const pl = await fetchPlaylist(plId);
|
||
if (!pl) break;
|
||
|
||
console.log(`\n📜 歌单: ${pl.name} (${pl.trackCount} 首)`);
|
||
level = await askLevel(ask, level);
|
||
const confirm = await ask(`确认下载全部 ${pl.trackCount} 首? (y/n): `);
|
||
if (confirm.trim().toLowerCase() !== 'y') break;
|
||
|
||
for (let i = 0; i < pl.tracks.length; i++) {
|
||
const t = pl.tracks[i];
|
||
await waitIfPaused();
|
||
console.log(`\n[${i + 1}/${pl.tracks.length}] ${t.name} - ${t.ar?.map(a => a.name).join('/')}`);
|
||
try { await downloadSong(t.id, level); }
|
||
catch (e) { console.error(` ❌ 出错: ${e.message}`); }
|
||
if (i < pl.tracks.length - 1) await sleep(500);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case '4': {
|
||
// 查看歌单歌曲列表
|
||
const plInput = await ask('请输入歌单ID: ');
|
||
const plId = plInput.trim();
|
||
if (!/^\d+$/.test(plId)) { console.log('⚠️ 无效歌单ID'); break; }
|
||
|
||
const pl = await fetchPlaylist(plId);
|
||
if (!pl) break;
|
||
|
||
console.log(`\n📜 ${pl.name} (${pl.trackCount} 首)`);
|
||
console.log(` 创建者: ${pl.creator?.nickname || '未知'}`);
|
||
if (pl.description) console.log(` 描述: ${pl.description.slice(0, 100)}`);
|
||
console.log(' ' + '─'.repeat(60));
|
||
console.log(' # 歌曲名 歌手 专辑');
|
||
console.log(' ' + '─'.repeat(60));
|
||
pl.tracks.forEach((t, i) => {
|
||
const num = String(i + 1).padStart(3);
|
||
const name = (t.name || '').slice(0, 22).padEnd(24);
|
||
const artist = (t.ar?.map(a => a.name).join('/') || '').slice(0, 18).padEnd(20);
|
||
const album = (t.al?.name || '').slice(0, 18);
|
||
console.log(` ${num} ${name} ${artist} ${album}`);
|
||
});
|
||
console.log(' ' + '─'.repeat(60));
|
||
console.log(` 共 ${pl.trackCount} 首`);
|
||
|
||
console.log('\n 下载选项:');
|
||
console.log(' a = 下载全部');
|
||
console.log(' 输入序号 = 下载指定歌曲 (如: 1,3,5 或 5-10)');
|
||
console.log(' 回车 = 跳过');
|
||
const doDownload = await ask(' 请选择: ');
|
||
const dlInput = doDownload.trim().toLowerCase();
|
||
|
||
let tracksToDownload = [];
|
||
if (dlInput === 'a') {
|
||
tracksToDownload = pl.tracks;
|
||
} else if (dlInput) {
|
||
// 解析序号: 支持 1,3,5 和 5-10 混合
|
||
const indices = new Set();
|
||
for (const part of dlInput.split(/[,,\s]+/)) {
|
||
const rangeMatch = part.match(/^(\d+)-(\d+)$/);
|
||
if (rangeMatch) {
|
||
const start = parseInt(rangeMatch[1], 10);
|
||
const end = parseInt(rangeMatch[2], 10);
|
||
for (let n = start; n <= end; n++) indices.add(n);
|
||
} else if (/^\d+$/.test(part)) {
|
||
indices.add(parseInt(part, 10));
|
||
}
|
||
}
|
||
tracksToDownload = [...indices]
|
||
.filter(n => n >= 1 && n <= pl.tracks.length)
|
||
.sort((a, b) => a - b)
|
||
.map(n => pl.tracks[n - 1]);
|
||
}
|
||
|
||
if (tracksToDownload.length) {
|
||
level = await askLevel(ask, level);
|
||
console.log(`\n📥 开始下载 ${tracksToDownload.length} 首歌曲...`);
|
||
for (let i = 0; i < tracksToDownload.length; i++) {
|
||
const t = tracksToDownload[i];
|
||
console.log(`\n[${i + 1}/${tracksToDownload.length}] ${t.name} - ${t.ar?.map(a => a.name).join('/')}`);
|
||
try { await downloadSong(t.id, level); }
|
||
catch (e) { console.error(` ❌ 出错: ${e.message}`); }
|
||
if (i < tracksToDownload.length - 1) await sleep(500);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case '5': {
|
||
// 查看歌曲歌词
|
||
const idInput = await ask('请输入歌曲ID: ');
|
||
const songId = idInput.trim();
|
||
if (!/^\d+$/.test(songId)) { console.log('⚠️ 无效歌曲ID'); break; }
|
||
|
||
console.log(`\n🔍 获取歌词 (ID: ${songId}) ...`);
|
||
const data = await fetchLyric(songId);
|
||
if (!data?.lrc) {
|
||
console.log('❌ 未获取到歌词');
|
||
break;
|
||
}
|
||
|
||
// 解析歌词用于显示
|
||
const parseLrc = (lrcStr) => {
|
||
return lrcStr.split('\n')
|
||
.map(line => {
|
||
const m = line.match(/^\[(\d+):(\d+)[.:](\d+)\](.*)/);
|
||
if (!m) return null;
|
||
const time = `${m[1]}:${m[2]}`;
|
||
const text = m[4].trim();
|
||
if (!text) return null;
|
||
return { time, text };
|
||
})
|
||
.filter(Boolean);
|
||
};
|
||
|
||
const lrcLines = parseLrc(data.lrc);
|
||
const transLines = data.tlyric ? parseLrc(data.tlyric) : [];
|
||
const transMap = new Map(transLines.map(l => [l.time, l.text]));
|
||
|
||
console.log('\n 📜 歌词:\n');
|
||
for (const line of lrcLines) {
|
||
console.log(` [${line.time}] ${line.text}`);
|
||
if (transMap.has(line.time)) {
|
||
console.log(` ↳ ${transMap.get(line.time)}`);
|
||
}
|
||
}
|
||
|
||
const saveOpt = await ask('\n是否保存为 .lrc 文件? (y/n): ');
|
||
if (saveOpt.trim().toLowerCase() === 'y') {
|
||
const defaultName = `song_${songId}`;
|
||
const nameInput = await ask(`文件名 (默认 ${defaultName}): `);
|
||
const baseName = sanitize(nameInput.trim() || defaultName);
|
||
const lrcPath = path.join(DOWNLOAD_DIR, baseName + '.lrc');
|
||
fs.writeFileSync(lrcPath, data.lrc, 'utf-8');
|
||
console.log(` ✅ 歌词已保存: ${lrcPath}`);
|
||
|
||
if (data.tlyric && data.tlyric.trim()) {
|
||
const merged = mergeLrc(data.lrc, data.tlyric);
|
||
const mergedPath = path.join(DOWNLOAD_DIR, baseName + '.合并翻译.lrc');
|
||
fs.writeFileSync(mergedPath, merged, 'utf-8');
|
||
console.log(` ✅ 翻译歌词已保存: ${mergedPath}`);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case '6': {
|
||
// 按歌手批量下载
|
||
const artistInput = await ask('请输入歌手名: ');
|
||
const artistName = artistInput.trim();
|
||
if (!artistName) { console.log('⚠️ 请输入歌手名'); break; }
|
||
|
||
const limitInput = await ask('搜索数量 (默认 50): ');
|
||
const searchLimit = parseInt(limitInput, 10) || 50;
|
||
|
||
console.log(`\n🔍 搜索 "${artistName}" 的歌曲 (最多 ${searchLimit} 首) ...`);
|
||
const results = await searchSongs(artistName, searchLimit);
|
||
if (!results.length) break;
|
||
|
||
// 过滤出歌手名匹配的歌曲
|
||
const artistLower = artistName.toLowerCase();
|
||
const matched = results.filter(s => s.artists.toLowerCase().includes(artistLower));
|
||
const pool = matched.length >= 3 ? matched : results; // 匹配太少就用全部
|
||
|
||
if (matched.length >= 3 && matched.length < results.length) {
|
||
console.log(` 找到 ${results.length} 首结果,其中 ${matched.length} 首匹配歌手 "${artistName}"`);
|
||
}
|
||
|
||
console.log(`\n # 歌曲名 歌手 专辑`);
|
||
console.log(' ' + '─'.repeat(70));
|
||
pool.slice(0, 30).forEach((s, i) => {
|
||
const num = String(i + 1).padStart(2);
|
||
const name = s.name.slice(0, 20).padEnd(22);
|
||
const artist = s.artists.slice(0, 16).padEnd(18);
|
||
console.log(` ${num} ${name} ${artist} ${s.album}`);
|
||
});
|
||
if (pool.length > 30) console.log(` ... 共 ${pool.length} 首,仅显示前 30 首`);
|
||
|
||
console.log('\n 下载选项:');
|
||
console.log(' a = 下载全部');
|
||
console.log(' 输入序号 = 下载指定歌曲 (如: 1,3,5 或 1-10)');
|
||
console.log(' 回车 = 跳过');
|
||
const pick = await ask(' 请选择: ');
|
||
const pickInput = pick.trim().toLowerCase();
|
||
|
||
let toDownload = [];
|
||
if (pickInput === 'a') {
|
||
toDownload = pool;
|
||
} else if (pickInput) {
|
||
const indices = new Set();
|
||
for (const part of pickInput.split(/[,,\s]+/)) {
|
||
const rangeMatch = part.match(/^(\d+)-(\d+)$/);
|
||
if (rangeMatch) {
|
||
const start = parseInt(rangeMatch[1], 10);
|
||
const end = parseInt(rangeMatch[2], 10);
|
||
for (let n = start; n <= end; n++) indices.add(n);
|
||
} else if (/^\d+$/.test(part)) {
|
||
indices.add(parseInt(part, 10));
|
||
}
|
||
}
|
||
toDownload = [...indices]
|
||
.filter(n => n >= 1 && n <= pool.length)
|
||
.sort((a, b) => a - b)
|
||
.map(n => pool[n - 1]);
|
||
}
|
||
|
||
if (toDownload.length) {
|
||
level = await askLevel(ask, level);
|
||
console.log(`\n📥 开始下载 ${toDownload.length} 首歌曲...`);
|
||
for (let i = 0; i < toDownload.length; i++) {
|
||
const s = toDownload[i];
|
||
console.log(`\n[${i + 1}/${toDownload.length}] ${s.name} - ${s.artists}`);
|
||
try { await downloadSong(s.id, level); }
|
||
catch (e) { console.error(` ❌ 出错: ${e.message}`); }
|
||
if (i < toDownload.length - 1) await sleep(500);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case '7': {
|
||
// 试听歌曲
|
||
const idInput = await ask('请输入歌曲ID: ');
|
||
const songId = idInput.trim();
|
||
if (!/^\d+$/.test(songId)) { console.log('⚠️ 无效歌曲ID'); break; }
|
||
|
||
console.log(`\n🔍 获取试听链接 (ID: ${songId}) ...`);
|
||
try {
|
||
const result = await fetchJSON(`${API.music}?id=${songId}&level=standard`);
|
||
if (result.code !== 200 || !result.data?.url) {
|
||
console.log(`❌ 获取失败: ${result.msg || '未知错误'}`);
|
||
break;
|
||
}
|
||
const { name, artist, album, url: audioUrl } = result.data;
|
||
console.log(` 🎶 ${name} - ${artist} (${album})`);
|
||
|
||
// 检测可用播放器
|
||
const players = [
|
||
{ cmd: 'mpv', args: (u) => ['--no-video', u] },
|
||
{ cmd: 'ffplay', args: (u) => ['-nodisp', '-autoexit', u] },
|
||
{ cmd: 'play', args: (u) => [u] }, // sox
|
||
{ cmd: 'cvlc', args: (u) => ['--play-and-exit', u] },
|
||
{ cmd: 'aplay', args: (u) => [u] },
|
||
];
|
||
|
||
let player = null;
|
||
for (const p of players) {
|
||
try {
|
||
execSync(`which ${p.cmd}`, { stdio: 'ignore' });
|
||
player = p;
|
||
break;
|
||
} catch {}
|
||
}
|
||
|
||
if (!player) {
|
||
console.log(' ⚠️ 未检测到音频播放器 (mpv/ffplay/play/cvlc/aplay)');
|
||
console.log(` 📋 试听链接: ${audioUrl}`);
|
||
console.log(' 请手动在浏览器或播放器中打开');
|
||
break;
|
||
}
|
||
|
||
console.log(` ▶️ 使用 ${player.cmd} 播放中... (Ctrl+C 停止)`);
|
||
try {
|
||
execSync(`${player.cmd} ${player.args(audioUrl).map(a => `"${a}"`).join(' ')}`, {
|
||
stdio: 'inherit',
|
||
timeout: 300000, // 5分钟超时
|
||
});
|
||
} catch (e) {
|
||
if (e.status !== null) {
|
||
// 用户中断或正常结束
|
||
} else {
|
||
console.error(` ❌ 播放出错: ${e.message}`);
|
||
}
|
||
}
|
||
console.log(' ⏹ 播放结束');
|
||
} catch (e) {
|
||
console.error(` ❌ 出错: ${e.message}`);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case '8': {
|
||
// 查看歌曲详情(完整元数据)
|
||
const idInput = await ask('请输入歌曲ID: ');
|
||
const songId = idInput.trim();
|
||
if (!/^\d+$/.test(songId)) { console.log('⚠️ 无效歌曲ID'); break; }
|
||
|
||
console.log(`\n🔍 获取歌曲详情 (ID: ${songId}) ...`);
|
||
try {
|
||
const meta = await fetchFullMetadata(songId);
|
||
if (!meta) {
|
||
console.log('❌ 获取失败');
|
||
break;
|
||
}
|
||
|
||
const pad = 42;
|
||
console.log('');
|
||
console.log(' ╔══════════════════════════════════════════════════╗');
|
||
console.log(` ║ 🎵 ${meta.name}`);
|
||
if (meta.aliases.length) {
|
||
console.log(` ║ 别名: ${meta.aliases.join(', ')}`);
|
||
}
|
||
if (meta.transNames.length) {
|
||
console.log(` ║ 译名: ${meta.transNames.join(', ')}`);
|
||
}
|
||
console.log(' ╠══════════════════════════════════════════════════╣');
|
||
console.log(` ║ 歌手: ${meta.artistStr}`);
|
||
console.log(` ║ 专辑: ${meta.albumStr}`);
|
||
console.log(` ║ ID: ${meta.id}`);
|
||
if (meta.trackNo) console.log(` ║ 曲目号: ${meta.trackNo}${meta.disc ? ` (碟片 ${meta.disc})` : ''}`);
|
||
console.log(' ╠══════════════════════════════════════════════════╣');
|
||
console.log(` ║ 时长: ${meta.durationStr}`);
|
||
if (meta.publishDate) {
|
||
console.log(` ║ 发行日期: ${meta.publishDate}`);
|
||
} else if (meta.publishYear) {
|
||
console.log(` ║ 发行年份: ${meta.publishYear}`);
|
||
}
|
||
if (meta.company) console.log(` ║ 唱片公司: ${meta.company}`);
|
||
if (meta.albumType) console.log(` ║ 专辑类型: ${meta.albumType}`);
|
||
if (meta.albumSize) console.log(` ║ 专辑曲目: ${meta.albumSize} 首`);
|
||
if (meta.tags.length) console.log(` ║ 标签: ${meta.tags.join(', ')}`);
|
||
console.log(` ║ 热度: ${'★'.repeat(Math.round(meta.popularity / 20))}${'☆'.repeat(5 - Math.round(meta.popularity / 20))} (${meta.popularity}/100)`);
|
||
console.log(' ╠══════════════════════════════════════════════════╣');
|
||
|
||
// 音质信息
|
||
if (meta.quality) {
|
||
console.log(' ║ 音质信息:');
|
||
const q = meta.quality;
|
||
if (q.l) console.log(` ║ 标准 ${q.l.br}kbps ${(q.l.size / 1048576).toFixed(1)} MB ${q.l.sr}Hz`);
|
||
if (q.m) console.log(` ║ 较高 ${q.m.br}kbps ${(q.m.size / 1048576).toFixed(1)} MB ${q.m.sr}Hz`);
|
||
if (q.h) console.log(` ║ 极高 ${q.h.br}kbps ${(q.h.size / 1048576).toFixed(1)} MB ${q.h.sr}Hz`);
|
||
}
|
||
|
||
// ChKSz 最高音质
|
||
if (meta.chksz) {
|
||
console.log(' ╠══════════════════════════════════════════════════╣');
|
||
console.log(` ║ ChKSz 最高可用: ${meta.chksz.level} (${meta.chksz.br}kbps) ${(meta.chksz.size / 1048576).toFixed(1)} MB`);
|
||
}
|
||
|
||
// 专辑描述
|
||
if (meta.description) {
|
||
console.log(' ╠══════════════════════════════════════════════════╣');
|
||
console.log(' ║ 专辑简介:');
|
||
const descLines = meta.description.split(/\n/).slice(0, 3);
|
||
for (const line of descLines) {
|
||
const trimmed = line.trim();
|
||
if (trimmed) {
|
||
// 自动换行
|
||
for (let i = 0; i < trimmed.length; i += 44) {
|
||
console.log(` ║ ${trimmed.slice(i, i + 44)}`);
|
||
}
|
||
}
|
||
}
|
||
if (meta.description.split(/\n/).length > 3) {
|
||
console.log(' ║ ...');
|
||
}
|
||
}
|
||
|
||
console.log(' ╚══════════════════════════════════════════════════╝');
|
||
} catch (e) {
|
||
console.error(` ❌ 出错: ${e.message}`);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case '9': {
|
||
// 下载历史
|
||
const history = loadHistory();
|
||
if (!history.length) {
|
||
console.log('\n 📭 暂无下载历史');
|
||
break;
|
||
}
|
||
|
||
const showCount = Math.min(history.length, 30);
|
||
console.log(`\n 📜 下载历史 (最近 ${showCount} 条,共 ${history.length} 条)`);
|
||
console.log(' ' + '─'.repeat(70));
|
||
console.log(' # 时间 歌曲名 歌手 音质');
|
||
console.log(' ' + '─'.repeat(70));
|
||
history.slice(0, showCount).forEach((h, i) => {
|
||
const num = String(i + 1).padStart(3);
|
||
const time = formatTime(h.timestamp);
|
||
const name = (h.name || '').slice(0, 20).padEnd(22);
|
||
const artist = (h.artist || '').slice(0, 14).padEnd(16);
|
||
const lv = h.level || '?';
|
||
console.log(` ${num} ${time} ${name} ${artist} ${lv}`);
|
||
});
|
||
console.log(' ' + '─'.repeat(70));
|
||
|
||
if (history.length > showCount) {
|
||
console.log(` ... 还有 ${history.length - showCount} 条更早的记录`);
|
||
}
|
||
|
||
const action = await ask('\n 操作: [d=删除指定] [c=清空全部] [回车=返回]: ');
|
||
if (action.trim().toLowerCase() === 'c') {
|
||
const confirm = await ask(' ⚠️ 确认清空全部下载历史? (输入 yes 确认): ');
|
||
if (confirm.trim() === 'yes') {
|
||
saveHistory([]);
|
||
console.log(' ✅ 下载历史已全部清空');
|
||
} else {
|
||
console.log(' 取消操作');
|
||
}
|
||
} else if (action.trim().toLowerCase() === 'd') {
|
||
const delInput = await ask(' 输入要删除的序号 (如: 1,3,5): ');
|
||
const delIndices = delInput.split(/[,,\s]+/)
|
||
.map(n => parseInt(n, 10))
|
||
.filter(n => n >= 1 && n <= showCount)
|
||
.sort((a, b) => b - a); // 从后往前删
|
||
for (const idx of delIndices) {
|
||
history.splice(idx - 1, 1);
|
||
}
|
||
saveHistory(history);
|
||
console.log(` ✅ 已删除 ${delIndices.length} 条记录`);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case '0': {
|
||
// 设置音质
|
||
const label = QUALITY_LABELS[level] ? ` (${QUALITY_LABELS[level]})` : '';
|
||
console.log(`\n当前音质: ${level}${label}`);
|
||
console.log('可选音质:');
|
||
printQualityMenu(level);
|
||
const newLevel = await ask('选择音质等级 (输入序号或名称): ');
|
||
const parsed = parseLevel(newLevel);
|
||
if (parsed) {
|
||
level = parsed;
|
||
setConfig('level', level);
|
||
console.log(`✅ 音质已设为: ${level}${QUALITY_LABELS[level] ? ` (${QUALITY_LABELS[level]})` : ''} (已保存)`);
|
||
} else {
|
||
console.log('⚠️ 无效等级');
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 's':
|
||
case 'S': {
|
||
// 按歌名搜索
|
||
const songName = await ask('请输入歌名: ');
|
||
if (!songName.trim()) { console.log('⚠️ 请输入歌名'); break; }
|
||
|
||
const limitInput = await ask('搜索数量 (默认 20): ');
|
||
const searchLimit = parseInt(limitInput, 10) || 20;
|
||
|
||
console.log(`\n🔍 搜索 "${songName.trim()}" ...`);
|
||
const results = await searchSongs(songName.trim(), searchLimit);
|
||
if (!results.length) break;
|
||
|
||
console.log(`\n # 歌曲名 歌手 专辑`);
|
||
console.log(' ' + '─'.repeat(70));
|
||
results.forEach((s, i) => {
|
||
const num = String(i + 1).padStart(2);
|
||
const name = s.name.slice(0, 20).padEnd(22);
|
||
const artist = s.artists.slice(0, 16).padEnd(18);
|
||
const album = s.album.slice(0, 20);
|
||
console.log(` ${num} ${name} ${artist} ${album}`);
|
||
});
|
||
console.log(' ' + '─'.repeat(70));
|
||
console.log(` 共 ${results.length} 首结果`);
|
||
|
||
console.log('\n 操作选项:');
|
||
console.log(' 输入序号 = 下载指定歌曲 (如: 1,3,5 或 1-5)');
|
||
console.log(' a = 下载全部');
|
||
console.log(' v+序号 = 试听 (如: v3)');
|
||
console.log(' d+序号 = 查看详情 (如: d3)');
|
||
console.log(' 回车 = 返回');
|
||
const action = await ask(' 请选择: ');
|
||
const actionInput = action.trim().toLowerCase();
|
||
|
||
if (!actionInput) break;
|
||
|
||
// 试听
|
||
const listenMatch = actionInput.match(/^v(\d+)$/);
|
||
if (listenMatch) {
|
||
const idx = parseInt(listenMatch[1], 10);
|
||
if (idx >= 1 && idx <= results.length) {
|
||
const s = results[idx - 1];
|
||
console.log(`\n ▶️ 试听: ${s.name} - ${s.artists}`);
|
||
try {
|
||
const r = await fetchJSON(`${API.music}?id=${s.id}&level=standard`);
|
||
if (r.code === 200 && r.data?.url) {
|
||
const players = [
|
||
{ cmd: 'mpv', args: (u) => ['--no-video', u] },
|
||
{ cmd: 'ffplay', args: (u) => ['-nodisp', '-autoexit', u] },
|
||
{ cmd: 'play', args: (u) => [u] },
|
||
{ cmd: 'cvlc', args: (u) => ['--play-and-exit', u] },
|
||
];
|
||
let player = null;
|
||
for (const p of players) {
|
||
try { execSync(`which ${p.cmd}`, { stdio: 'ignore' }); player = p; break; } catch {}
|
||
}
|
||
if (player) {
|
||
console.log(` ▶️ 使用 ${player.cmd} 播放中... (Ctrl+C 停止)`);
|
||
try { execSync(`${player.cmd} ${player.args(r.data.url).map(a => `"${a}"`).join(' ')}`, { stdio: 'inherit', timeout: 300000 }); } catch {}
|
||
console.log(' ⏹ 播放结束');
|
||
} else {
|
||
console.log(` 📋 链接: ${r.data.url}`);
|
||
}
|
||
}
|
||
} catch (e) { console.error(` ❌ ${e.message}`); }
|
||
} else {
|
||
console.log(' ⚠️ 无效序号');
|
||
}
|
||
break;
|
||
}
|
||
|
||
// 详情
|
||
const detailMatch = actionInput.match(/^d(\d+)$/);
|
||
if (detailMatch) {
|
||
const idx = parseInt(detailMatch[1], 10);
|
||
if (idx >= 1 && idx <= results.length) {
|
||
const s = results[idx - 1];
|
||
try {
|
||
const r = await fetchJSON(`${API.music}?id=${s.id}&level=jymaster`);
|
||
if (r.code === 200 && r.data) {
|
||
const d = r.data;
|
||
console.log('\n ┌─────────────────────────────────────────┐');
|
||
console.log(` │ 🎵 ${d.name}`);
|
||
console.log(' ├─────────────────────────────────────────┤');
|
||
console.log(` │ 歌手: ${d.artist}`);
|
||
console.log(` │ 专辑: ${d.album}`);
|
||
console.log(` │ ID: ${d.id}`);
|
||
console.log(` │ 音质: ${d.level} (${d.br}kbps) 大小: ${(d.size / 1048576).toFixed(2)} MB`);
|
||
console.log(' └─────────────────────────────────────────┘');
|
||
}
|
||
} catch (e) { console.error(` ❌ ${e.message}`); }
|
||
} else {
|
||
console.log(' ⚠️ 无效序号');
|
||
}
|
||
break;
|
||
}
|
||
|
||
// 下载
|
||
let toDownload = [];
|
||
if (actionInput === 'a') {
|
||
toDownload = results;
|
||
} else {
|
||
const indices = new Set();
|
||
for (const part of actionInput.split(/[,,\s]+/)) {
|
||
const rangeMatch = part.match(/^(\d+)-(\d+)$/);
|
||
if (rangeMatch) {
|
||
const start = parseInt(rangeMatch[1], 10);
|
||
const end = parseInt(rangeMatch[2], 10);
|
||
for (let n = start; n <= end; n++) indices.add(n);
|
||
} else if (/^\d+$/.test(part)) {
|
||
indices.add(parseInt(part, 10));
|
||
}
|
||
}
|
||
toDownload = [...indices]
|
||
.filter(n => n >= 1 && n <= results.length)
|
||
.sort((a, b) => a - b)
|
||
.map(n => results[n - 1]);
|
||
}
|
||
|
||
if (toDownload.length) {
|
||
level = await askLevel(ask, level);
|
||
console.log(`\n📥 开始下载 ${toDownload.length} 首歌曲...`);
|
||
for (let i = 0; i < toDownload.length; i++) {
|
||
const s = toDownload[i];
|
||
console.log(`\n[${i + 1}/${toDownload.length}] ${s.name} - ${s.artists}`);
|
||
try { await downloadSong(s.id, level); }
|
||
catch (e) { console.error(` ❌ 出错: ${e.message}`); }
|
||
if (i < toDownload.length - 1) await sleep(500);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'l':
|
||
case 'L': {
|
||
// 已下载歌曲列表
|
||
if (!fs.existsSync(DOWNLOAD_DIR)) {
|
||
console.log('\n 📭 下载目录不存在');
|
||
break;
|
||
}
|
||
|
||
const allFiles = fs.readdirSync(DOWNLOAD_DIR);
|
||
const audioExts = ['.mp3', '.flac', '.m4a', '.wav', '.aac', '.ogg', '.wma'];
|
||
const audioFiles = allFiles.filter(f => audioExts.includes(path.extname(f).toLowerCase()));
|
||
|
||
if (!audioFiles.length) {
|
||
console.log('\n 📭 暂无已下载的歌曲');
|
||
break;
|
||
}
|
||
|
||
// 按修改时间排序(最新在前)
|
||
const filesWithTime = audioFiles.map(f => {
|
||
const fp = path.join(DOWNLOAD_DIR, f);
|
||
const stat = fs.statSync(fp);
|
||
return { name: f, size: stat.size, mtime: stat.mtime };
|
||
}).sort((a, b) => b.mtime - a.mtime);
|
||
|
||
console.log(`\n 📁 已下载歌曲 (${filesWithTime.length} 首)`);
|
||
console.log(` 目录: ${DOWNLOAD_DIR}`);
|
||
console.log(' ' + '─'.repeat(75));
|
||
console.log(' # 文件名 大小 修改时间');
|
||
console.log(' ' + '─'.repeat(75));
|
||
|
||
const showCount = Math.min(filesWithTime.length, 50);
|
||
filesWithTime.slice(0, showCount).forEach((f, i) => {
|
||
const num = String(i + 1).padStart(3);
|
||
const name = f.name.length > 38 ? f.name.slice(0, 35) + '...' : f.name;
|
||
const sizeMB = (f.size / 1048576).toFixed(1);
|
||
const time = `${f.mtime.getMonth() + 1}/${f.mtime.getDate()} ${String(f.mtime.getHours()).padStart(2, '0')}:${String(f.mtime.getMinutes()).padStart(2, '0')}`;
|
||
console.log(` ${num} ${name.padEnd(40)} ${(sizeMB + ' MB').padStart(10)} ${time}`);
|
||
});
|
||
|
||
if (filesWithTime.length > showCount) {
|
||
console.log(` ... 还有 ${filesWithTime.length - showCount} 首未显示`);
|
||
}
|
||
|
||
const totalSize = filesWithTime.reduce((s, f) => s + f.size, 0);
|
||
console.log(' ' + '─'.repeat(75));
|
||
console.log(` 共 ${filesWithTime.length} 首,总大小 ${(totalSize / 1073741824).toFixed(2)} GB`);
|
||
|
||
const action = await ask('\n 操作: [p+序号=播放] [d+序号=删除] [da=删除全部] [回车=返回]: ');
|
||
const actLower = action.trim().toLowerCase();
|
||
|
||
// 播放
|
||
const playMatch = actLower.match(/^p(\d+)$/);
|
||
if (playMatch) {
|
||
const idx = parseInt(playMatch[1], 10);
|
||
if (idx >= 1 && idx <= filesWithTime.length) {
|
||
const filePath = path.join(DOWNLOAD_DIR, filesWithTime[idx - 1].name);
|
||
const players = [
|
||
{ cmd: 'mpv', args: (u) => ['--no-video', u] },
|
||
{ cmd: 'ffplay', args: (u) => ['-nodisp', '-autoexit', u] },
|
||
{ cmd: 'play', args: (u) => [u] },
|
||
{ cmd: 'cvlc', args: (u) => ['--play-and-exit', u] },
|
||
];
|
||
let player = null;
|
||
for (const p of players) {
|
||
try { execSync(`which ${p.cmd}`, { stdio: 'ignore' }); player = p; break; } catch {}
|
||
}
|
||
if (player) {
|
||
console.log(` ▶️ 播放: ${filesWithTime[idx - 1].name}`);
|
||
try { execSync(`${player.cmd} ${player.args(filePath).map(a => `"${a}"`).join(' ')}`, { stdio: 'inherit', timeout: 300000 }); } catch {}
|
||
console.log(' ⏹ 播放结束');
|
||
} else {
|
||
console.log(' ⚠️ 未检测到音频播放器');
|
||
}
|
||
} else {
|
||
console.log(' ⚠️ 无效序号');
|
||
}
|
||
}
|
||
|
||
// 删除指定
|
||
const delMatch = actLower.match(/^d(\d+)$/);
|
||
if (delMatch) {
|
||
const idx = parseInt(delMatch[1], 10);
|
||
if (idx >= 1 && idx <= filesWithTime.length) {
|
||
const target = filesWithTime[idx - 1];
|
||
const confirm = await ask(` 确认删除 "${target.name}"? (y/n): `);
|
||
if (confirm.trim().toLowerCase() === 'y') {
|
||
const filePath = path.join(DOWNLOAD_DIR, target.name);
|
||
// 移到回收站目录
|
||
if (!fs.existsSync(TRASH_DIR)) fs.mkdirSync(TRASH_DIR, { recursive: true });
|
||
const trashPath = path.join(TRASH_DIR, `${Date.now()}_${target.name}`);
|
||
try {
|
||
fs.renameSync(filePath, trashPath);
|
||
console.log(` ✅ 已移到回收站: ${target.name}`);
|
||
// 同时移动关联的 .lrc 文件
|
||
const baseName = path.parse(target.name).name;
|
||
for (const f of allFiles) {
|
||
if (f.startsWith(baseName) && f.endsWith('.lrc')) {
|
||
const lrcSrc = path.join(DOWNLOAD_DIR, f);
|
||
const lrcDst = path.join(TRASH_DIR, `${Date.now()}_${f}`);
|
||
try { fs.renameSync(lrcSrc, lrcDst); } catch {}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error(` ❌ 删除失败: ${e.message}`);
|
||
}
|
||
}
|
||
} else {
|
||
console.log(' ⚠️ 无效序号');
|
||
}
|
||
}
|
||
|
||
// 删除全部
|
||
if (actLower === 'da') {
|
||
const confirm = await ask(` ⚠️ 确认删除全部 ${filesWithTime.length} 首歌曲? 此操作不可恢复! (输入 yes 确认): `);
|
||
if (confirm.trim() === 'yes') {
|
||
if (!fs.existsSync(TRASH_DIR)) fs.mkdirSync(TRASH_DIR, { recursive: true });
|
||
let deleted = 0;
|
||
for (const f of filesWithTime) {
|
||
try {
|
||
const src = path.join(DOWNLOAD_DIR, f.name);
|
||
const dst = path.join(TRASH_DIR, `${Date.now()}_${f.name}`);
|
||
fs.renameSync(src, dst);
|
||
deleted++;
|
||
} catch {}
|
||
}
|
||
// 清理关联文件
|
||
for (const f of allFiles) {
|
||
if (!audioExts.includes(path.extname(f).toLowerCase())) {
|
||
try {
|
||
const src = path.join(DOWNLOAD_DIR, f);
|
||
const dst = path.join(TRASH_DIR, `${Date.now()}_${f}`);
|
||
fs.renameSync(src, dst);
|
||
} catch {}
|
||
}
|
||
}
|
||
console.log(` ✅ 已删除 ${deleted} 首歌曲 (已移到 .trash 目录)`);
|
||
} else {
|
||
console.log(' 取消删除');
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'a':
|
||
case 'A': {
|
||
// 按专辑下载
|
||
const albumInput = await ask('请输入专辑 ID(推荐)或专辑名: ');
|
||
const albumQuery = albumInput.trim();
|
||
if (!albumQuery) { console.log('⚠️ 请输入专辑 ID 或名称'); break; }
|
||
|
||
if (/^\d+$/.test(albumQuery)) {
|
||
console.log(`\n🔍 获取专辑 ${albumQuery} ...`);
|
||
const album = await fetchNeteaseAlbumDetail(albumQuery);
|
||
if (!album?.tracks?.length) {
|
||
console.log('❌ 专辑获取失败或没有歌曲');
|
||
break;
|
||
}
|
||
|
||
console.log(`\n💿 ${album.name} - ${album.artist?.name || '未知歌手'}`);
|
||
console.log(` 共 ${album.tracks.length} 首歌曲`);
|
||
album.tracks.forEach((track, i) => {
|
||
const artists = track.ar?.map(a => a.name).join('/') || album.artist?.name || '未知';
|
||
console.log(` ${String(i + 1).padStart(2)}. ${track.name} - ${artists}`);
|
||
});
|
||
|
||
level = await askLevel(ask, level);
|
||
const confirm = await ask(`确认下载整张专辑? (y/n): `);
|
||
if (confirm.trim().toLowerCase() !== 'y') break;
|
||
|
||
const albumTarget = getAlbumDownloadTarget(album, album.tracks[0], 0);
|
||
await saveAlbumCover(album.picUrl, albumTarget.outputDir);
|
||
|
||
for (let i = 0; i < album.tracks.length; i++) {
|
||
const track = album.tracks[i];
|
||
const artists = track.ar?.map(a => a.name).join('/') || album.artist?.name || '未知';
|
||
const target = getAlbumDownloadTarget(album, track, i);
|
||
await waitIfPaused();
|
||
console.log(`\n[${i + 1}/${album.tracks.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);
|
||
}
|
||
break;
|
||
}
|
||
|
||
const albumName = albumQuery;
|
||
|
||
const limitInput = await ask('搜索数量 (默认 50): ');
|
||
const searchLimit = parseInt(limitInput, 10) || 50;
|
||
|
||
console.log(`\n🔍 搜索专辑 "${albumName}" ...`);
|
||
const results = await searchSongs(albumName, searchLimit);
|
||
if (!results.length) break;
|
||
|
||
// 按专辑名过滤
|
||
const albumLower = albumName.toLowerCase();
|
||
const matched = results.filter(s => (s.album || '').toLowerCase().includes(albumLower));
|
||
const pool = matched.length >= 2 ? matched : results;
|
||
|
||
if (matched.length >= 2 && matched.length < results.length) {
|
||
console.log(` 找到 ${results.length} 首结果,其中 ${matched.length} 首匹配专辑 "${albumName}"`);
|
||
}
|
||
|
||
// 按专辑分组
|
||
const albumGroups = {};
|
||
for (const s of pool) {
|
||
const key = s.album || '未知专辑';
|
||
if (!albumGroups[key]) albumGroups[key] = [];
|
||
albumGroups[key].push(s);
|
||
}
|
||
|
||
console.log('\n 匹配的专辑:');
|
||
const albumKeys = Object.keys(albumGroups);
|
||
albumKeys.forEach((a, i) => {
|
||
const num = String(i + 1).padStart(2);
|
||
const songs = albumGroups[a];
|
||
const artist = songs[0]?.artists || '未知';
|
||
console.log(` ${num} ${a.slice(0, 30).padEnd(32)} ${artist} (${songs.length}首)`);
|
||
});
|
||
|
||
const albumPick = await ask('\n选择专辑序号 (多个用逗号分隔, a=全部, 0=取消): ');
|
||
const albumPickInput = albumPick.trim().toLowerCase();
|
||
if (!albumPickInput || albumPickInput === '0') break;
|
||
|
||
let albumsToDownload = [];
|
||
if (albumPickInput === 'a') {
|
||
albumsToDownload = albumKeys;
|
||
} else {
|
||
const indices = albumPickInput.split(/[,,\s]+/)
|
||
.map(n => parseInt(n, 10))
|
||
.filter(n => n >= 1 && n <= albumKeys.length);
|
||
albumsToDownload = [...new Set(indices)].sort((a, b) => a - b).map(n => albumKeys[n - 1]);
|
||
}
|
||
|
||
if (!albumsToDownload.length) break;
|
||
|
||
const allSongs = [];
|
||
for (const aName of albumsToDownload) {
|
||
const albumSongs = albumGroups[aName];
|
||
for (let trackIndex = 0; trackIndex < albumSongs.length; trackIndex++) {
|
||
const song = albumSongs[trackIndex];
|
||
if (!allSongs.find(x => x.song.id === song.id)) {
|
||
allSongs.push({
|
||
song,
|
||
albumName: aName,
|
||
trackIndex,
|
||
trackCount: albumSongs.length,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log(`\n💿 共 ${albumsToDownload.length} 张专辑,${allSongs.length} 首歌曲`);
|
||
level = await askLevel(ask, level);
|
||
const confirm = await ask(`确认下载? (y/n): `);
|
||
if (confirm.trim().toLowerCase() !== 'y') break;
|
||
|
||
const savedCoverDirs = new Set();
|
||
for (let i = 0; i < allSongs.length; i++) {
|
||
const item = allSongs[i];
|
||
const s = item.song;
|
||
const target = getAlbumDownloadTarget({
|
||
name: item.albumName,
|
||
artist: { name: s.artists || '未知歌手' },
|
||
}, s, item.trackIndex, item.trackCount);
|
||
if (!savedCoverDirs.has(target.outputDir)) {
|
||
const detail = await fetchNeteaseSongDetail(s.id);
|
||
await saveAlbumCover(detail?.album?.cover, target.outputDir);
|
||
savedCoverDirs.add(target.outputDir);
|
||
}
|
||
await waitIfPaused();
|
||
console.log(`\n[${i + 1}/${allSongs.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);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'b':
|
||
case 'B': {
|
||
// 批量ID下载
|
||
console.log('\n 📋 批量输入歌曲ID');
|
||
console.log(' 支持格式:');
|
||
console.log(' - 逗号分隔: 123,456,789');
|
||
console.log(' - 空格分隔: 123 456 789');
|
||
console.log(' - 每行一个 (输入空行结束)');
|
||
|
||
const idLines = [];
|
||
console.log('');
|
||
while (true) {
|
||
const line = await ask(' 输入ID (空行结束): ');
|
||
if (!line.trim()) break;
|
||
idLines.push(line.trim());
|
||
}
|
||
|
||
if (!idLines.length) { console.log(' ⚠️ 未输入任何ID'); break; }
|
||
|
||
// 解析所有ID
|
||
const allIds = [];
|
||
for (const line of idLines) {
|
||
const parts = line.split(/[,,\s]+/).filter(s => /^\d+$/.test(s));
|
||
allIds.push(...parts);
|
||
}
|
||
|
||
const uniqueIds = [...new Set(allIds)];
|
||
if (!uniqueIds.length) { console.log(' ⚠️ 未找到有效ID'); break; }
|
||
|
||
console.log(`\n 📊 解析结果: ${uniqueIds.length} 个有效ID`);
|
||
level = await askLevel(ask, level);
|
||
const confirm = await ask(`确认下载 ${uniqueIds.length} 首歌曲? (y/n): `);
|
||
if (confirm.trim().toLowerCase() !== 'y') break;
|
||
|
||
let success = 0;
|
||
let fail = 0;
|
||
const failedIds = [];
|
||
|
||
for (let i = 0; i < uniqueIds.length; i++) {
|
||
const id = uniqueIds[i];
|
||
console.log(`\n [${i + 1}/${uniqueIds.length}] ID: ${id}`);
|
||
try {
|
||
const ok = await downloadSong(id, level);
|
||
if (ok) success++;
|
||
else { fail++; failedIds.push(id); }
|
||
} catch (e) {
|
||
console.error(` ❌ 出错: ${e.message}`);
|
||
fail++;
|
||
failedIds.push(id);
|
||
}
|
||
if (i < uniqueIds.length - 1) await sleep(500);
|
||
}
|
||
|
||
console.log(`\n 📊 下载完成:`);
|
||
console.log(` ✅ 成功: ${success} 首`);
|
||
console.log(` ❌ 失败: ${fail} 首`);
|
||
if (failedIds.length) {
|
||
console.log(` 失败ID: ${failedIds.join(', ')}`);
|
||
const retry = await ask('\n 是否重试失败的歌曲? (y/n): ');
|
||
if (retry.trim().toLowerCase() === 'y') {
|
||
console.log('\n 🔄 重试中...');
|
||
let retrySuccess = 0;
|
||
for (let i = 0; i < failedIds.length; i++) {
|
||
const id = failedIds[i];
|
||
console.log(` [${i + 1}/${failedIds.length}] ID: ${id}`);
|
||
try {
|
||
const ok = await downloadSong(id, level);
|
||
if (ok) retrySuccess++;
|
||
} catch {}
|
||
if (i < failedIds.length - 1) await sleep(1000);
|
||
}
|
||
console.log(` ✅ 重试完成: 成功 ${retrySuccess} 首`);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'f':
|
||
case 'F': {
|
||
// 从文件批量下载
|
||
const filePath = await ask('请输入文件路径 (每行一个歌曲ID或"歌手 - 歌名"): ');
|
||
const fPath = filePath.trim();
|
||
if (!fPath) { console.log('⚠️ 请输入文件路径'); break; }
|
||
if (!fs.existsSync(fPath)) { console.log('❌ 文件不存在'); break; }
|
||
|
||
const lines = fs.readFileSync(fPath, 'utf-8')
|
||
.split('\n')
|
||
.map(l => l.trim())
|
||
.filter(l => l && !l.startsWith('#'));
|
||
|
||
if (!lines.length) { console.log('⚠️ 文件为空'); break; }
|
||
|
||
// 区分 ID 和 "歌手 - 歌名"
|
||
const idList = [];
|
||
const nameList = [];
|
||
for (const line of lines) {
|
||
if (/^\d+$/.test(line)) {
|
||
idList.push(line);
|
||
} else if (line.includes('-')) {
|
||
nameList.push(line);
|
||
}
|
||
}
|
||
|
||
console.log(`\n📋 解析结果: ${idList.length} 个ID, ${nameList.length} 个歌名`);
|
||
|
||
// 歌名搜索匹配
|
||
const resolvedIds = [];
|
||
if (nameList.length) {
|
||
console.log('\n🔍 搜索歌名匹配...');
|
||
for (const nameLine of nameList) {
|
||
const parts = nameLine.split(/\s*[-—]\s*/);
|
||
const keyword = parts.length >= 2 ? `${parts[0]} ${parts[1]}` : nameLine;
|
||
try {
|
||
const res = await fetchJSON(`${API.search}?keyword=${encodeURIComponent(keyword)}&limit=3`);
|
||
if (res.code === 200 && res.data?.length) {
|
||
const best = res.data[0];
|
||
resolvedIds.push(best.id);
|
||
console.log(` ✅ "${nameLine}" → ${best.name} - ${best.artists} (ID: ${best.id})`);
|
||
} else {
|
||
console.log(` ⚠️ "${nameLine}" 未找到匹配`);
|
||
}
|
||
} catch (e) {
|
||
console.log(` ❌ "${nameLine}" 搜索失败: ${e.message}`);
|
||
}
|
||
await sleep(300);
|
||
}
|
||
}
|
||
|
||
const allIds = [...idList, ...resolvedIds];
|
||
if (!allIds.length) { console.log('❌ 无有效歌曲ID'); break; }
|
||
|
||
console.log(`\n📥 共 ${allIds.length} 首歌曲待下载`);
|
||
level = await askLevel(ask, level);
|
||
const confirm = await ask('确认下载? (y/n): ');
|
||
if (confirm.trim().toLowerCase() !== 'y') break;
|
||
|
||
let success = 0;
|
||
let fail = 0;
|
||
for (let i = 0; i < allIds.length; i++) {
|
||
const id = allIds[i];
|
||
console.log(`\n[${i + 1}/${allIds.length}] ID: ${id}`);
|
||
try {
|
||
const ok = await downloadSong(id, level);
|
||
if (ok) success++; else fail++;
|
||
} catch (e) {
|
||
console.error(` ❌ 出错: ${e.message}`);
|
||
fail++;
|
||
}
|
||
if (i < allIds.length - 1) await sleep(500);
|
||
}
|
||
|
||
console.log(`\n📊 下载完成: 成功 ${success} 首, 失败 ${fail} 首`);
|
||
break;
|
||
}
|
||
|
||
case 'n':
|
||
case 'N': {
|
||
// 从文件名识别并补全标签
|
||
if (!fs.existsSync(DOWNLOAD_DIR)) {
|
||
console.log('\n 📭 下载目录不存在');
|
||
break;
|
||
}
|
||
|
||
const allFiles = fs.readdirSync(DOWNLOAD_DIR);
|
||
const audioExts = ['.mp3', '.flac', '.m4a', '.wav', '.aac'];
|
||
const audioFiles = allFiles.filter(f => audioExts.includes(path.extname(f).toLowerCase()));
|
||
|
||
if (!audioFiles.length) {
|
||
console.log('\n 📭 暂无音频文件');
|
||
break;
|
||
}
|
||
|
||
console.log(`\n 📁 扫描到 ${audioFiles.length} 个音频文件,分析文件名...\n`);
|
||
|
||
const candidates = [];
|
||
for (const f of audioFiles) {
|
||
const baseName = path.parse(f).name;
|
||
// 常见格式: "歌手 - 歌名" 或 "歌手-歌名"
|
||
const match = baseName.match(/^(.+?)\s*[-—]\s*(.+)$/);
|
||
if (match) {
|
||
candidates.push({ file: f, artist: match[1].trim(), name: match[2].trim() });
|
||
}
|
||
}
|
||
|
||
if (!candidates.length) {
|
||
console.log(' ⚠️ 未识别到 "歌手 - 歌名" 格式的文件');
|
||
console.log(' 支持的格式: "周杰伦 - 晴天.mp3" 或 "周杰伦-晴天.flac"');
|
||
break;
|
||
}
|
||
|
||
console.log(` 识别到 ${candidates.length} 首歌曲:`);
|
||
console.log(' ' + '─'.repeat(60));
|
||
console.log(' # 歌手 歌曲名 文件');
|
||
console.log(' ' + '─'.repeat(60));
|
||
candidates.forEach((c, i) => {
|
||
const num = String(i + 1).padStart(3);
|
||
const artist = c.artist.slice(0, 18).padEnd(20);
|
||
const name = c.name.slice(0, 18).padEnd(20);
|
||
const file = c.file.length > 30 ? c.file.slice(0, 27) + '...' : c.file;
|
||
console.log(` ${num} ${artist} ${name} ${file}`);
|
||
});
|
||
console.log(' ' + '─'.repeat(60));
|
||
|
||
console.log('\n 操作选项:');
|
||
console.log(' a = 全部自动匹配并补全标签');
|
||
console.log(' 输入序号 = 选择指定文件 (如: 1,3,5)');
|
||
console.log(' 回车 = 跳过');
|
||
const pick = await ask(' 请选择: ');
|
||
const pickInput = pick.trim().toLowerCase();
|
||
|
||
let targets = [];
|
||
if (pickInput === 'a') {
|
||
targets = candidates;
|
||
} else if (pickInput) {
|
||
const indices = new Set();
|
||
for (const part of pickInput.split(/[,,\s]+/)) {
|
||
if (/^\d+$/.test(part)) indices.add(parseInt(part, 10));
|
||
}
|
||
targets = [...indices]
|
||
.filter(n => n >= 1 && n <= candidates.length)
|
||
.sort((a, b) => a - b)
|
||
.map(n => candidates[n - 1]);
|
||
}
|
||
|
||
if (!targets.length) break;
|
||
|
||
console.log(`\n🏷️ 开始匹配 ${targets.length} 首歌曲...\n`);
|
||
let successCount = 0;
|
||
|
||
for (let i = 0; i < targets.length; i++) {
|
||
const c = targets[i];
|
||
console.log(`[${i + 1}/${targets.length}] ${c.artist} - ${c.name}`);
|
||
|
||
try {
|
||
// 搜索匹配
|
||
const keyword = `${c.artist} ${c.name}`;
|
||
const results = await fetchJSON(`${API.search}?keyword=${encodeURIComponent(keyword)}&limit=5`);
|
||
if (results.code !== 200 || !results.data?.length) {
|
||
console.log(' ⚠️ 未找到匹配');
|
||
continue;
|
||
}
|
||
|
||
// 找最佳匹配
|
||
const best = results.data.find(s => {
|
||
const sName = (s.name || '').toLowerCase();
|
||
const sArtist = (s.artists || '').toLowerCase();
|
||
return sName.includes(c.name.toLowerCase()) && sArtist.includes(c.artist.toLowerCase());
|
||
}) || results.data[0];
|
||
|
||
console.log(` ✅ 匹配: ${best.name} - ${best.artists} (ID: ${best.id})`);
|
||
|
||
// 获取完整歌曲信息并嵌入标签
|
||
const filePath = path.join(DOWNLOAD_DIR, c.file);
|
||
const songInfo = await fetchJSON(`${API.music}?id=${best.id}&level=standard`);
|
||
if (songInfo.code === 200 && songInfo.data) {
|
||
const d = songInfo.data;
|
||
if (d.picUrl) {
|
||
await embedCover(filePath, d.picUrl, { name: d.name, artist: d.artist, album: d.album });
|
||
}
|
||
|
||
// 获取并保存歌词
|
||
const lyricData = await fetchLyric(best.id);
|
||
if (lyricData?.lrc) {
|
||
const baseName = path.parse(c.file).name;
|
||
const lrcPath = path.join(DOWNLOAD_DIR, baseName + '.lrc');
|
||
fs.writeFileSync(lrcPath, lyricData.lrc, 'utf-8');
|
||
console.log(` 📄 歌词已保存`);
|
||
}
|
||
|
||
// 重命名文件
|
||
const ext = path.extname(c.file);
|
||
const newName = sanitize(`${d.artist} - ${d.name}${ext}`);
|
||
if (newName !== c.file) {
|
||
const newPath = path.join(DOWNLOAD_DIR, newName);
|
||
if (!fs.existsSync(newPath)) {
|
||
fs.renameSync(filePath, newPath);
|
||
console.log(` 📝 重命名: ${newName}`);
|
||
}
|
||
}
|
||
|
||
// 记录历史
|
||
addHistory({ id: best.id, name: d.name, artist: d.artist, album: d.album, level: 'standard', bitrate: d.br, size: d.size, file: newName });
|
||
successCount++;
|
||
}
|
||
} catch (e) {
|
||
console.error(` ❌ 出错: ${e.message}`);
|
||
}
|
||
|
||
if (i < targets.length - 1) await sleep(500);
|
||
}
|
||
|
||
console.log(`\n✅ 完成! 成功补全 ${successCount}/${targets.length} 首`);
|
||
break;
|
||
}
|
||
|
||
case 'e':
|
||
case 'E': {
|
||
// 导出下载历史
|
||
const history = loadHistory();
|
||
if (!history.length) {
|
||
console.log('\n 📭 暂无下载历史可导出');
|
||
break;
|
||
}
|
||
|
||
console.log('\n 导出格式:');
|
||
console.log(' 1. CSV (Excel 兼容)');
|
||
console.log(' 2. JSON');
|
||
console.log(' 3. TXT (纯文本列表)');
|
||
const fmt = await ask(' 选择格式 (1/2/3): ');
|
||
|
||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||
let exportPath;
|
||
|
||
switch (fmt.trim()) {
|
||
case '1': {
|
||
// CSV
|
||
exportPath = path.join(__dirname, `download_history_${timestamp}.csv`);
|
||
const header = '歌曲ID,歌曲名,歌手,专辑,音质,码率(kbps),文件大小(bytes),文件名,下载时间\n';
|
||
const rows = history.map(h => {
|
||
return [
|
||
h.id,
|
||
`"${(h.name || '').replace(/"/g, '""')}"`,
|
||
`"${(h.artist || '').replace(/"/g, '""')}"`,
|
||
`"${(h.album || '').replace(/"/g, '""')}"`,
|
||
h.level || '',
|
||
h.bitrate || '',
|
||
h.size || '',
|
||
`"${(h.file || '').replace(/"/g, '""')}"`,
|
||
h.timestamp || '',
|
||
].join(',');
|
||
}).join('\n');
|
||
fs.writeFileSync(exportPath, header + rows + '\n', 'utf-8');
|
||
break;
|
||
}
|
||
|
||
case '2': {
|
||
// JSON
|
||
exportPath = path.join(__dirname, `download_history_${timestamp}.json`);
|
||
fs.writeFileSync(exportPath, JSON.stringify(history, null, 2), 'utf-8');
|
||
break;
|
||
}
|
||
|
||
case '3': {
|
||
// TXT
|
||
exportPath = path.join(__dirname, `download_history_${timestamp}.txt`);
|
||
const lines = history.map((h, i) => {
|
||
const time = h.timestamp ? formatTime(h.timestamp) : '未知';
|
||
return `${i + 1}. ${h.artist || '未知'} - ${h.name || '未知'} (${h.album || '未知专辑'}) [${h.level || '?'}] ${time}`;
|
||
});
|
||
fs.writeFileSync(exportPath, lines.join('\n') + '\n', 'utf-8');
|
||
break;
|
||
}
|
||
|
||
default:
|
||
console.log(' ⚠️ 无效选项');
|
||
break;
|
||
}
|
||
|
||
if (exportPath && fs.existsSync(exportPath)) {
|
||
const stat = fs.statSync(exportPath);
|
||
console.log(`\n ✅ 导出成功!`);
|
||
console.log(` 📄 文件: ${exportPath}`);
|
||
console.log(` 📊 共 ${history.length} 条记录, ${(stat.size / 1024).toFixed(1)} KB`);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'm':
|
||
case 'M': {
|
||
// 保存歌单为 M3U
|
||
const plInput = await ask('请输入歌单ID: ');
|
||
const plId = plInput.trim();
|
||
if (!/^\d+$/.test(plId)) { console.log('⚠️ 无效歌单ID'); break; }
|
||
|
||
console.log(`\n🔍 获取歌单信息 ...`);
|
||
const pl = await fetchPlaylist(plId);
|
||
if (!pl) break;
|
||
|
||
console.log(`📜 歌单: ${pl.name} (${pl.trackCount} 首)`);
|
||
|
||
// 生成 M3U 内容
|
||
const m3uLines = ['#EXTM3U', ''];
|
||
for (const t of pl.tracks) {
|
||
const artist = t.ar?.map(a => a.name).join('/') || '未知';
|
||
const title = t.name || '未知';
|
||
const duration = Math.round((t.dt || 0) / 1000) || -1;
|
||
m3uLines.push(`#EXTINF:${duration},${artist} - ${title}`);
|
||
// 检查本地是否已下载
|
||
const localFile = path.join(DOWNLOAD_DIR, sanitize(`${artist} - ${title}`));
|
||
const possibleExts = ['.mp3', '.flac', '.m4a', '.wav', '.aac'];
|
||
let found = false;
|
||
for (const ext of possibleExts) {
|
||
const fp = localFile + ext;
|
||
if (fs.existsSync(fp)) {
|
||
m3uLines.push(fp);
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!found) {
|
||
// 用 API 链接作为备选
|
||
m3uLines.push(`#EXTVLCOPT:network-caching=1000`);
|
||
m3uLines.push(`${API.music}?id=${t.id}&type=down`);
|
||
}
|
||
m3uLines.push('');
|
||
}
|
||
|
||
const m3uContent = m3uLines.join('\n');
|
||
const defaultName = sanitize(pl.name || `playlist_${plId}`);
|
||
const nameInput = await ask(`保存文件名 (默认 ${defaultName}): `);
|
||
const fileName = sanitize(nameInput.trim() || defaultName) + '.m3u';
|
||
const savePath = path.join(__dirname, fileName);
|
||
|
||
fs.writeFileSync(savePath, m3uContent, 'utf-8');
|
||
console.log(`\n ✅ M3U 播放列表已保存!`);
|
||
console.log(` 📄 文件: ${savePath}`);
|
||
console.log(` 📊 共 ${pl.trackCount} 首歌曲`);
|
||
|
||
// 统计本地已有数量
|
||
const localCount = m3uLines.filter(l => l.startsWith(DOWNLOAD_DIR)).length;
|
||
if (localCount > 0) {
|
||
console.log(` 💾 本地已有: ${localCount} 首可直接播放`);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'g':
|
||
case 'G': {
|
||
// 自动识别并补全标签
|
||
if (!fs.existsSync(DOWNLOAD_DIR)) {
|
||
console.log('\n 📭 下载目录不存在');
|
||
break;
|
||
}
|
||
|
||
const allFiles = fs.readdirSync(DOWNLOAD_DIR);
|
||
const audioExts = ['.mp3', '.flac', '.m4a', '.wav', '.aac'];
|
||
const audioFiles = allFiles.filter(f => audioExts.includes(path.extname(f).toLowerCase()));
|
||
|
||
if (!audioFiles.length) {
|
||
console.log('\n 📭 暂无音频文件');
|
||
break;
|
||
}
|
||
|
||
// 筛选需要补全的文件(没有封面或没有歌词)
|
||
console.log(`\n 🔍 扫描 ${audioFiles.length} 个音频文件,检查标签完整性...\n`);
|
||
|
||
const needTag = [];
|
||
for (const f of audioFiles) {
|
||
const filePath = path.join(DOWNLOAD_DIR, f);
|
||
const baseName = path.parse(f).name;
|
||
const hasLrc = allFiles.some(lf => lf.startsWith(baseName) && lf.endsWith('.lrc'));
|
||
|
||
// 简单判断:文件名包含 " - " 且没有对应 .lrc 文件
|
||
const isNameFormat = /^.+\s*[-—]\s*.+$/.test(baseName);
|
||
if (isNameFormat && !hasLrc) {
|
||
needTag.push(f);
|
||
}
|
||
}
|
||
|
||
if (!needTag.length) {
|
||
console.log(' ✅ 所有文件标签已完整,无需补全');
|
||
break;
|
||
}
|
||
|
||
console.log(` 发现 ${needTag.length} 个文件需要补全标签:`);
|
||
needTag.forEach((f, i) => {
|
||
console.log(` ${i + 1}. ${f}`);
|
||
});
|
||
|
||
const confirm = await ask(`\n 自动补全这 ${needTag.length} 个文件? (y/n): `);
|
||
if (confirm.trim().toLowerCase() !== 'y') break;
|
||
|
||
let success = 0;
|
||
let fail = 0;
|
||
|
||
for (let i = 0; i < needTag.length; i++) {
|
||
const f = needTag[i];
|
||
const baseName = path.parse(f).name;
|
||
console.log(`\n [${i + 1}/${needTag.length}] ${f}`);
|
||
|
||
// 解析文件名
|
||
const match = baseName.match(/^(.+?)\s*[-—]\s*(.+)$/);
|
||
if (!match) {
|
||
console.log(' ⚠️ 无法解析文件名,跳过');
|
||
fail++;
|
||
continue;
|
||
}
|
||
|
||
const artist = match[1].trim();
|
||
const name = match[2].trim();
|
||
|
||
try {
|
||
// 搜索匹配
|
||
const keyword = `${artist} ${name}`;
|
||
const results = await fetchJSON(`${API.search}?keyword=${encodeURIComponent(keyword)}&limit=5`);
|
||
if (results.code !== 200 || !results.data?.length) {
|
||
console.log(' ⚠️ 搜索无结果,跳过');
|
||
fail++;
|
||
continue;
|
||
}
|
||
|
||
// 找最佳匹配
|
||
const best = results.data.find(s => {
|
||
const sName = (s.name || '').toLowerCase();
|
||
const sArtist = (s.artists || '').toLowerCase();
|
||
return sName.includes(name.toLowerCase()) && sArtist.includes(artist.toLowerCase());
|
||
}) || results.data[0];
|
||
|
||
console.log(` ✅ 匹配: ${best.name} - ${best.artists}`);
|
||
|
||
// 获取完整信息
|
||
const songInfo = await fetchJSON(`${API.music}?id=${best.id}&level=standard`);
|
||
if (songInfo.code === 200 && songInfo.data) {
|
||
const d = songInfo.data;
|
||
const filePath = path.join(DOWNLOAD_DIR, f);
|
||
|
||
// 嵌入封面和标签
|
||
if (d.picUrl) {
|
||
await embedCover(filePath, d.picUrl, { name: d.name, artist: d.artist, album: d.album });
|
||
}
|
||
|
||
// 获取并保存歌词
|
||
const lyricData = await fetchLyric(best.id);
|
||
if (lyricData?.lrc) {
|
||
const lrcPath = path.join(DOWNLOAD_DIR, baseName + '.lrc');
|
||
fs.writeFileSync(lrcPath, lyricData.lrc, 'utf-8');
|
||
console.log(' 📄 歌词已保存');
|
||
}
|
||
|
||
// 记录历史
|
||
addHistory({ id: best.id, name: d.name, artist: d.artist, album: d.album, level: 'standard', bitrate: d.br, size: d.size, file: f });
|
||
success++;
|
||
}
|
||
} catch (e) {
|
||
console.error(` ❌ 出错: ${e.message}`);
|
||
fail++;
|
||
}
|
||
|
||
if (i < needTag.length - 1) await sleep(500);
|
||
}
|
||
|
||
console.log(`\n 📊 自动补全完成: 成功 ${success} 首, 失败 ${fail} 首`);
|
||
break;
|
||
}
|
||
|
||
case 'z':
|
||
case 'Z': {
|
||
// 歌单下载并打包 ZIP
|
||
const plInput = await ask('请输入歌单ID: ');
|
||
const plId = plInput.trim();
|
||
if (!/^\d+$/.test(plId)) { console.log('⚠️ 无效歌单ID'); break; }
|
||
|
||
const pl = await fetchPlaylist(plId);
|
||
if (!pl) break;
|
||
|
||
console.log(`\n📜 歌单: ${pl.name} (${pl.trackCount} 首)`);
|
||
level = await askLevel(ask, level);
|
||
const confirm = await ask(`确认下载并打包 ${pl.trackCount} 首歌曲? (y/n): `);
|
||
if (confirm.trim().toLowerCase() !== 'y') break;
|
||
|
||
// 创建临时目录
|
||
const zipName = sanitize(pl.name || `playlist_${plId}`);
|
||
const tempDir = path.join(TEMP_ZIP_DIR, zipName);
|
||
if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true });
|
||
fs.mkdirSync(tempDir, { recursive: true });
|
||
|
||
console.log(`\n📥 开始下载到临时目录...\n`);
|
||
let downloaded = 0;
|
||
let failed = 0;
|
||
|
||
for (let i = 0; i < pl.tracks.length; i++) {
|
||
const t = pl.tracks[i];
|
||
const artist = t.ar?.map(a => a.name).join('/') || '未知';
|
||
const title = t.name || '未知';
|
||
console.log(`[${i + 1}/${pl.tracks.length}] ${title} - ${artist}`);
|
||
|
||
try {
|
||
const result = await fetchJSON(`${API.music}?id=${t.id}&level=${level}`);
|
||
if (result.code !== 200 || !result.data?.url) {
|
||
console.log(' ❌ 解析失败');
|
||
failed++;
|
||
continue;
|
||
}
|
||
|
||
const { url: audioUrl, name: sName, artist: sArtist } = result.data;
|
||
const ext = audioUrl.match(/\.([a-z0-9]+)(\?|$)/i)?.[1] || 'mp3';
|
||
const fileName = sanitize(`${sArtist} - ${sName}.${ext}`);
|
||
const dest = path.join(tempDir, fileName);
|
||
|
||
console.log(` ⬇️ 下载中...`);
|
||
await downloadFile(audioUrl, dest, false);
|
||
console.log(` ✅ ${fileName}`);
|
||
downloaded++;
|
||
} catch (e) {
|
||
console.error(` ❌ 出错: ${e.message}`);
|
||
failed++;
|
||
}
|
||
|
||
if (i < pl.tracks.length - 1) await sleep(300);
|
||
}
|
||
|
||
if (downloaded === 0) {
|
||
console.log('\n❌ 没有歌曲下载成功,取消打包');
|
||
fs.rmSync(tempDir, { recursive: true });
|
||
break;
|
||
}
|
||
|
||
// 打包 ZIP
|
||
const zipPath = path.join(__dirname, `${zipName}.zip`);
|
||
console.log(`\n📦 正在打包 ${downloaded} 首歌曲...`);
|
||
|
||
try {
|
||
// 尝试使用系统 zip 命令
|
||
execSync(`cd "${path.dirname(tempDir)}" && zip -r "${zipPath}" "${zipName}"`, { stdio: 'ignore' });
|
||
console.log(`\n ✅ ZIP 打包完成!`);
|
||
console.log(` 📦 文件: ${zipPath}`);
|
||
const zipStat = fs.statSync(zipPath);
|
||
console.log(` 📊 大小: ${(zipStat.size / 1048576).toFixed(2)} MB`);
|
||
console.log(` 📋 包含: ${downloaded} 首歌曲 (失败 ${failed} 首)`);
|
||
} catch (e) {
|
||
// zip 不可用,尝试 tar.gz
|
||
console.log(' ⚠️ zip 命令不可用,尝试 tar.gz...');
|
||
const tarPath = path.join(__dirname, `${zipName}.tar.gz`);
|
||
try {
|
||
execSync(`tar -czf "${tarPath}" -C "${path.dirname(tempDir)}" "${zipName}"`, { stdio: 'ignore' });
|
||
console.log(`\n ✅ tar.gz 打包完成!`);
|
||
console.log(` 📦 文件: ${tarPath}`);
|
||
const tarStat = fs.statSync(tarPath);
|
||
console.log(` 📊 大小: ${(tarStat.size / 1048576).toFixed(2)} MB`);
|
||
} catch (e2) {
|
||
console.error(` ❌ 打包失败: ${e2.message}`);
|
||
console.log(` 📁 歌曲已下载到: ${tempDir}`);
|
||
console.log(' 请手动打包');
|
||
}
|
||
}
|
||
|
||
// 清理临时目录
|
||
try { fs.rmSync(tempDir, { recursive: true }); } catch {}
|
||
break;
|
||
}
|
||
|
||
case 'c':
|
||
case 'C': {
|
||
// 清理缓存和临时文件
|
||
console.log('\n 🧹 清理缓存和临时文件');
|
||
|
||
let cleanedSize = 0;
|
||
let cleanedCount = 0;
|
||
|
||
// 清理临时 zip 目录
|
||
if (fs.existsSync(TEMP_ZIP_DIR)) {
|
||
const tmpSize = getDirSize(TEMP_ZIP_DIR);
|
||
fs.rmSync(TEMP_ZIP_DIR, { recursive: true });
|
||
cleanedSize += tmpSize;
|
||
cleanedCount++;
|
||
console.log(` ✅ 已清理临时打包目录 (${(tmpSize / 1024).toFixed(1)} KB)`);
|
||
}
|
||
|
||
// 清理回收站
|
||
if (fs.existsSync(TRASH_DIR)) {
|
||
const trashFiles = fs.readdirSync(TRASH_DIR);
|
||
if (trashFiles.length) {
|
||
console.log(`\n 📁 回收站中有 ${trashFiles.length} 个文件`);
|
||
const trashConfirm = await ask(' 是否清空回收站? (y/n): ');
|
||
if (trashConfirm.trim().toLowerCase() === 'y') {
|
||
const trashSize = getDirSize(TRASH_DIR);
|
||
fs.rmSync(TRASH_DIR, { recursive: true });
|
||
cleanedSize += trashSize;
|
||
cleanedCount++;
|
||
console.log(` ✅ 回收站已清空 (${(trashSize / 1048576).toFixed(2)} MB)`);
|
||
} else {
|
||
console.log(' ⏭️ 跳过回收站');
|
||
}
|
||
} else {
|
||
console.log(' 📁 回收站为空');
|
||
}
|
||
}
|
||
|
||
// 清理孤立的 .cover.jpg 临时文件
|
||
if (fs.existsSync(DOWNLOAD_DIR)) {
|
||
const allFiles = fs.readdirSync(DOWNLOAD_DIR);
|
||
const coverTemps = allFiles.filter(f => f.endsWith('.cover.jpg'));
|
||
if (coverTemps.length) {
|
||
let coverSize = 0;
|
||
for (const f of coverTemps) {
|
||
const fp = path.join(DOWNLOAD_DIR, f);
|
||
coverSize += fs.statSync(fp).size;
|
||
fs.unlinkSync(fp);
|
||
}
|
||
cleanedSize += coverSize;
|
||
cleanedCount++;
|
||
console.log(` ✅ 已清理 ${coverTemps.length} 个临时封面文件 (${(coverSize / 1024).toFixed(1)} KB)`);
|
||
}
|
||
}
|
||
|
||
// 清理 .tmp 文件
|
||
if (fs.existsSync(DOWNLOAD_DIR)) {
|
||
const allFiles = fs.readdirSync(DOWNLOAD_DIR);
|
||
const tmpFiles = allFiles.filter(f => f.includes('.tmp.'));
|
||
if (tmpFiles.length) {
|
||
let tmpSize = 0;
|
||
for (const f of tmpFiles) {
|
||
const fp = path.join(DOWNLOAD_DIR, f);
|
||
tmpSize += fs.statSync(fp).size;
|
||
fs.unlinkSync(fp);
|
||
}
|
||
cleanedSize += tmpSize;
|
||
cleanedCount++;
|
||
console.log(` ✅ 已清理 ${tmpFiles.length} 个临时文件 (${(tmpSize / 1024).toFixed(1)} KB)`);
|
||
}
|
||
}
|
||
|
||
if (cleanedCount === 0) {
|
||
console.log(' ✨ 没有需要清理的文件');
|
||
} else {
|
||
console.log(`\n 📊 共释放 ${(cleanedSize / 1048576).toFixed(2)} MB 空间`);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'w':
|
||
case 'W': {
|
||
// 重命名歌曲文件
|
||
if (!fs.existsSync(DOWNLOAD_DIR)) {
|
||
console.log('\n 📭 下载目录不存在');
|
||
break;
|
||
}
|
||
|
||
const allFiles = fs.readdirSync(DOWNLOAD_DIR);
|
||
const audioExts = ['.mp3', '.flac', '.m4a', '.wav', '.aac'];
|
||
const audioFiles = allFiles.filter(f => audioExts.includes(path.extname(f).toLowerCase()));
|
||
|
||
if (!audioFiles.length) {
|
||
console.log('\n 📭 暂无音频文件');
|
||
break;
|
||
}
|
||
|
||
console.log(`\n 📁 已下载的音频文件 (${audioFiles.length} 首):`);
|
||
console.log(' ' + '─'.repeat(60));
|
||
audioFiles.forEach((f, i) => {
|
||
console.log(` ${String(i + 1).padStart(3)} ${f}`);
|
||
});
|
||
console.log(' ' + '─'.repeat(60));
|
||
|
||
const pick = await ask('\n 输入要重命名的序号 (如: 1,3,5): ');
|
||
if (!pick.trim()) break;
|
||
|
||
const indices = pick.split(/[,,\s]+/)
|
||
.map(n => parseInt(n, 10))
|
||
.filter(n => n >= 1 && n <= audioFiles.length);
|
||
|
||
if (!indices.length) { console.log(' ⚠️ 无效序号'); break; }
|
||
|
||
for (const idx of indices) {
|
||
const oldName = audioFiles[idx - 1];
|
||
const ext = path.extname(oldName);
|
||
const oldBase = path.parse(oldName).name;
|
||
|
||
console.log(`\n 📝 重命名: ${oldName}`);
|
||
console.log(' 格式: 歌手 - 歌名');
|
||
|
||
// 智能解析现有文件名
|
||
const match = oldBase.match(/^(.+?)\s*[-—]\s*(.+)$/);
|
||
const defaultArtist = match ? match[1].trim() : '';
|
||
const defaultName = match ? match[2].trim() : oldBase;
|
||
|
||
const newArtist = await ask(` 歌手 [${defaultArtist || '未知'}]: `);
|
||
const newName = await ask(` 歌名 [${defaultName}]: `);
|
||
|
||
const artist = sanitize(newArtist.trim() || defaultArtist || '未知');
|
||
const songName = sanitize(newName.trim() || defaultName);
|
||
const newBase = `${artist} - ${songName}`;
|
||
|
||
if (newBase === oldBase) {
|
||
console.log(' ⏭️ 名称未变,跳过');
|
||
continue;
|
||
}
|
||
|
||
const newFileName = newBase + ext;
|
||
const oldPath = path.join(DOWNLOAD_DIR, oldName);
|
||
const newPath = path.join(DOWNLOAD_DIR, newFileName);
|
||
|
||
if (fs.existsSync(newPath)) {
|
||
console.log(` ⚠️ 目标文件已存在: ${newFileName},跳过`);
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
fs.renameSync(oldPath, newPath);
|
||
console.log(` ✅ 已重命名: ${newFileName}`);
|
||
|
||
// 同时重命名关联的 .lrc 文件
|
||
const oldLrc = path.join(DOWNLOAD_DIR, oldBase + '.lrc');
|
||
const newLrc = path.join(DOWNLOAD_DIR, newBase + '.lrc');
|
||
if (fs.existsSync(oldLrc)) {
|
||
fs.renameSync(oldLrc, newLrc);
|
||
console.log(` ✅ 歌词文件也已重命名`);
|
||
}
|
||
const oldMergedLrc = path.join(DOWNLOAD_DIR, oldBase + '.合并翻译.lrc');
|
||
const newMergedLrc = path.join(DOWNLOAD_DIR, newBase + '.合并翻译.lrc');
|
||
if (fs.existsSync(oldMergedLrc)) {
|
||
fs.renameSync(oldMergedLrc, newMergedLrc);
|
||
console.log(` ✅ 翻译歌词文件也已重命名`);
|
||
}
|
||
} catch (e) {
|
||
console.error(` ❌ 重命名失败: ${e.message}`);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 't':
|
||
case 'T': {
|
||
// 下载统计
|
||
const history = loadHistory();
|
||
if (!history.length) {
|
||
console.log('\n 📭 暂无下载记录');
|
||
break;
|
||
}
|
||
|
||
const totalSize = history.reduce((sum, h) => sum + (h.size || 0), 0);
|
||
const uniqueArtists = new Set(history.map(h => h.artist).filter(Boolean));
|
||
const uniqueAlbums = new Set(history.map(h => h.album).filter(Boolean));
|
||
const uniqueSongs = new Set(history.map(h => h.id));
|
||
|
||
// 音质分布
|
||
const levelCount = {};
|
||
for (const h of history) {
|
||
const lv = h.level || '未知';
|
||
levelCount[lv] = (levelCount[lv] || 0) + 1;
|
||
}
|
||
|
||
// 歌手 Top 10
|
||
const artistCount = {};
|
||
for (const h of history) {
|
||
if (h.artist) artistCount[h.artist] = (artistCount[h.artist] || 0) + 1;
|
||
}
|
||
const topArtists = Object.entries(artistCount)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 10);
|
||
|
||
// 最近 7 天下载量
|
||
const now = Date.now();
|
||
const dayMs = 86400000;
|
||
const last7days = [];
|
||
for (let i = 6; i >= 0; i--) {
|
||
const dayStart = new Date(now - i * dayMs);
|
||
const dayStr = `${dayStart.getMonth() + 1}/${dayStart.getDate()}`;
|
||
const count = history.filter(h => {
|
||
const t = new Date(h.timestamp).getTime();
|
||
return t >= dayStart.setHours(0,0,0,0) && t < dayStart.setHours(0,0,0,0) + dayMs;
|
||
}).length;
|
||
last7days.push({ day: dayStr, count });
|
||
}
|
||
|
||
console.log('\n ╔═══════════════════════════════════════╗');
|
||
console.log(' ║ 📊 下载统计 ║');
|
||
console.log(' ╠═══════════════════════════════════════╣');
|
||
console.log(` ║ 总下载次数: ${String(history.length).padStart(6)} 次`);
|
||
console.log(` ║ 去重歌曲数: ${String(uniqueSongs.size).padStart(6)} 首`);
|
||
console.log(` ║ 涉及歌手: ${String(uniqueArtists.size).padStart(6)} 位`);
|
||
console.log(` ║ 涉及专辑: ${String(uniqueAlbums.size).padStart(6)} 张`);
|
||
console.log(` ║ 总文件大小: ${(totalSize / 1073741824).toFixed(2).padStart(6)} GB`);
|
||
console.log(' ╠═══════════════════════════════════════╣');
|
||
|
||
console.log(' ║ 音质分布:');
|
||
for (const [lv, cnt] of Object.entries(levelCount).sort((a, b) => b[1] - a[1])) {
|
||
const bar = '█'.repeat(Math.min(Math.round(cnt / history.length * 20), 20));
|
||
const pct = ((cnt / history.length) * 100).toFixed(0);
|
||
console.log(` ║ ${lv.padEnd(12)} ${bar} ${cnt}首 (${pct}%)`);
|
||
}
|
||
|
||
if (topArtists.length) {
|
||
console.log(' ╠═══════════════════════════════════════╣');
|
||
console.log(' ║ 下载最多的歌手:');
|
||
for (const [artist, cnt] of topArtists) {
|
||
console.log(` ║ ${artist.slice(0, 16).padEnd(18)} ${cnt} 首`);
|
||
}
|
||
}
|
||
|
||
console.log(' ╠═══════════════════════════════════════╣');
|
||
console.log(' ║ 近 7 天下载量:');
|
||
const maxDayCnt = Math.max(...last7days.map(d => d.count), 1);
|
||
for (const d of last7days) {
|
||
const bar = '▓'.repeat(Math.round(d.count / maxDayCnt * 15));
|
||
console.log(` ║ ${d.day.padEnd(6)} ${bar || '·'} ${d.count}`);
|
||
}
|
||
console.log(' ╚═══════════════════════════════════════╝');
|
||
break;
|
||
}
|
||
|
||
case 'p':
|
||
case 'P': {
|
||
// 暂停/恢复
|
||
togglePause();
|
||
break;
|
||
}
|
||
|
||
case 'r':
|
||
case 'R': {
|
||
// 重置所有设置
|
||
console.log('\n ⚠️ 将重置以下内容:');
|
||
console.log(` - 音质等级恢复默认 (${DEFAULT_LEVEL})`);
|
||
console.log(' - 清空下载历史');
|
||
console.log(' - 删除运行时配置文件');
|
||
const confirm = await ask('\n 确认重置? (输入 yes 确认): ');
|
||
if (confirm.trim() === 'yes') {
|
||
// 重置配置
|
||
if (fs.existsSync(CONFIG_FILE)) {
|
||
fs.unlinkSync(CONFIG_FILE);
|
||
}
|
||
level = DEFAULT_LEVEL;
|
||
// 清空历史
|
||
const keepHistory = await ask(' 是否保留下载历史? (y/n): ');
|
||
if (keepHistory.trim().toLowerCase() !== 'y') {
|
||
saveHistory([]);
|
||
console.log(' ✅ 下载历史已清空');
|
||
}
|
||
console.log(` ✅ 音质已恢复默认: ${DEFAULT_LEVEL}`);
|
||
console.log(' ✅ 配置已重置');
|
||
} else {
|
||
console.log(' 取消重置');
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'p':
|
||
case 'P': {
|
||
// 暂停/恢复
|
||
togglePause();
|
||
break;
|
||
}
|
||
|
||
case 'h':
|
||
case 'H': {
|
||
// 帮助
|
||
console.log(`
|
||
╔══════════════════════════════════════════════════════════╗`);
|
||
console.log(` ║ 📖 使用帮助 ║`);
|
||
console.log(` ╠══════════════════════════════════════════════════════════╣`);
|
||
console.log(` ║ ║`);
|
||
console.log(` ║ 【搜索与下载】 ║`);
|
||
console.log(` ║ 1 搜索歌曲并下载 - 输入关键词搜索,选择后下载 ║`);
|
||
console.log(` ║ 2 输入歌曲 ID 下载 - 直接输入网易云歌曲ID ║`);
|
||
console.log(` ║ 3 输入歌单 ID 下载全部 - 输入歌单ID批量下载 ║`);
|
||
console.log(` ║ s 按歌名搜索 - 按歌名精确搜索,支持试听/详情/下载 ║`);
|
||
console.log(` ║ 6 按歌手批量下载 - 搜索歌手并选择下载其歌曲 ║`);
|
||
console.log(` ║ a 下载完整专辑 - 输入专辑ID下载全部曲目,也支持名称搜索 ║`);
|
||
console.log(` ║ ║`);
|
||
console.log(` ║ 【查看与管理】 ║`);
|
||
console.log(` ║ 4 查看歌单歌曲列表 - 浏览歌单内容,可选择性下载 ║`);
|
||
console.log(` ║ 5 查看歌曲歌词 - 输入ID查看歌词,可保存为.lrc文件 ║`);
|
||
console.log(` ║ 7 试听歌曲 - 输入ID在线试听 (需本地播放器) ║`);
|
||
console.log(` ║ 8 查看歌曲详情 - 显示音质、大小等详细信息 ║`);
|
||
console.log(` ║ l 已下载歌曲列表 - 查看本地已下载的所有歌曲 ║`);
|
||
console.log(` ║ ║`);
|
||
console.log(` ║ 【数据统计】 ║`);
|
||
console.log(` ║ 9 下载历史 - 查看/删除下载记录 ║`);
|
||
console.log(` ║ t 下载统计 - 音质分布、歌手排行、近7天趋势 ║`);
|
||
console.log(` ║ ║`);
|
||
console.log(` ║ 【设置】 ║`);
|
||
console.log(` ║ 0 设置音质等级 - 切换下载音质 ║`);
|
||
console.log(` ║ ║`);
|
||
console.log(` ║ 【命令行模式】 ║`);
|
||
console.log(` ║ node 163_music_downloader.js <id1> <id2> ... ║`);
|
||
console.log(` ║ node 163_music_downloader.js --playlist=<id> ║`);
|
||
console.log(` ║ node 163_music_downloader.js --album=<id> ║`);
|
||
console.log(` ║ 可选参数: --level=lossless --retries=5 ║`);
|
||
console.log(` ║ --no-lyric --no-cover ║`);
|
||
console.log(` ║ ║`);
|
||
console.log(` ║ 【音质等级说明】 ║`);
|
||
console.log(` ║ standard - 标准 exhigh - 极高 ║`);
|
||
console.log(` ║ lossless - 无损 hires - Hi-Res ║`);
|
||
console.log(` ║ jymaster - 超清母带 (默认) sky - 空间音频 ║`);
|
||
console.log(` ║ jyeffect - 高清臻品 ║`);
|
||
console.log(` ║ ║`);
|
||
console.log(` ╚══════════════════════════════════════════════════════════╝`);
|
||
break;
|
||
}
|
||
|
||
case 'q':
|
||
case 'Q':
|
||
case 'exit':
|
||
case 'quit':
|
||
rl.close();
|
||
console.log('\n👋 再见!');
|
||
process.exit(0);
|
||
|
||
default:
|
||
console.log('⚠️ 无效选项');
|
||
}
|
||
}
|
||
}
|
||
|
||
main().catch(console.error);
|