const https = require('https'); const http = require('http'); const fs = require('fs'); const path = require('path'); const readline = require('readline'); const { execSync, execFileSync } = require('child_process'); // ========== 配置 ========== const APP_CONFIG_FILE = path.join(__dirname, 'config.json'); const DEFAULT_APP_CONFIG = { api: { music: 'https://api.chksz.top/api/163_music', lyric: 'https://api.chksz.top/api/163_lyric', search: 'https://api.chksz.top/api/163_search', playlist: 'https://api.chksz.top/api/163_playlist', neteaseBaseUrl: 'https://music.163.com', }, paths: { downloadDir: 'downloads', historyFile: 'download_history.json', runtimeConfigFile: 'downloader_config.json', trashDir: '.trash', tempZipDir: '.tmp_zip', }, defaults: { quality: 'jymaster', retries: 3, historyLimit: 500, }, }; function loadAppConfig() { try { if (!fs.existsSync(APP_CONFIG_FILE)) return DEFAULT_APP_CONFIG; const config = JSON.parse(fs.readFileSync(APP_CONFIG_FILE, 'utf-8')); return { api: { ...DEFAULT_APP_CONFIG.api, ...(config.api || {}) }, paths: { ...DEFAULT_APP_CONFIG.paths, ...(config.paths || {}) }, defaults: { ...DEFAULT_APP_CONFIG.defaults, ...(config.defaults || {}) }, }; } catch (e) { console.warn(`⚠️ 配置文件读取失败,将使用默认配置: ${e.message}`); return DEFAULT_APP_CONFIG; } } function resolveConfigPath(configPath, fallback) { const value = typeof configPath === 'string' && configPath.trim() ? configPath.trim() : fallback; return path.isAbsolute(value) ? value : path.join(__dirname, value); } function getConfigString(value, fallback) { return typeof value === 'string' && value.trim() ? value.trim() : fallback; } const APP_CONFIG = loadAppConfig(); const API = { music: getConfigString(APP_CONFIG.api.music, DEFAULT_APP_CONFIG.api.music), lyric: getConfigString(APP_CONFIG.api.lyric, DEFAULT_APP_CONFIG.api.lyric), search: getConfigString(APP_CONFIG.api.search, DEFAULT_APP_CONFIG.api.search), playlist: getConfigString(APP_CONFIG.api.playlist, DEFAULT_APP_CONFIG.api.playlist), neteaseBaseUrl: getConfigString(APP_CONFIG.api.neteaseBaseUrl, DEFAULT_APP_CONFIG.api.neteaseBaseUrl), }; const NETEASE_BASE_URL = API.neteaseBaseUrl.replace(/\/$/, ''); const DOWNLOAD_DIR = resolveConfigPath(APP_CONFIG.paths.downloadDir, DEFAULT_APP_CONFIG.paths.downloadDir); const HISTORY_FILE = resolveConfigPath(APP_CONFIG.paths.historyFile, DEFAULT_APP_CONFIG.paths.historyFile); const CONFIG_FILE = resolveConfigPath(APP_CONFIG.paths.runtimeConfigFile, DEFAULT_APP_CONFIG.paths.runtimeConfigFile); const TRASH_DIR = resolveConfigPath(APP_CONFIG.paths.trashDir, DEFAULT_APP_CONFIG.paths.trashDir); const TEMP_ZIP_DIR = resolveConfigPath(APP_CONFIG.paths.tempZipDir, DEFAULT_APP_CONFIG.paths.tempZipDir); const QUALITY_LEVELS = ['standard', 'exhigh', 'lossless', 'hires', 'jymaster', 'sky', 'jyeffect']; const QUALITY_LABELS = { standard: '标准', exhigh: '极高', lossless: '无损', hires: 'Hi-Res', jymaster: '超清母带', sky: '空间音频', jyeffect: '高清臻品', }; const DEFAULT_LEVEL = QUALITY_LEVELS.includes(APP_CONFIG.defaults.quality) ? APP_CONFIG.defaults.quality : DEFAULT_APP_CONFIG.defaults.quality; const DEFAULT_RETRIES = Number.isInteger(APP_CONFIG.defaults.retries) && APP_CONFIG.defaults.retries > 0 ? APP_CONFIG.defaults.retries : DEFAULT_APP_CONFIG.defaults.retries; const HISTORY_LIMIT = Number.isInteger(APP_CONFIG.defaults.historyLimit) && APP_CONFIG.defaults.historyLimit > 0 ? APP_CONFIG.defaults.historyLimit : DEFAULT_APP_CONFIG.defaults.historyLimit; if (!fs.existsSync(DOWNLOAD_DIR)) fs.mkdirSync(DOWNLOAD_DIR, { recursive: true }); // ========== 网络工具 ========== function fetchJSON(url) { return new Promise((resolve, reject) => { const proto = url.startsWith('https') ? https : http; proto.get(url, (res) => { let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => { try { resolve(JSON.parse(data)); } catch { reject(new Error(`JSON 解析失败: ${data.slice(0, 200)}`)); } }); }).on('error', reject); }); } function downloadFile(url, dest, showProgress = true) { return new Promise((resolve, reject) => { const proto = url.startsWith('https') ? https : http; proto.get(url, (res) => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { return downloadFile(res.headers.location, dest, showProgress).then(resolve).catch(reject); } if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`)); const total = parseInt(res.headers['content-length'], 10); let downloaded = 0; let lastTime = Date.now(); let lastBytes = 0; const file = fs.createWriteStream(dest); res.on('data', (chunk) => { downloaded += chunk.length; if (showProgress) { const now = Date.now(); const elapsed = (now - lastTime) / 1000; // 每 300ms 更新一次 if (elapsed >= 0.3) { const speed = (downloaded - lastBytes) / elapsed; lastTime = now; lastBytes = downloaded; const speedStr = speed >= 1048576 ? `${(speed / 1048576).toFixed(1)} MB/s` : `${(speed / 1024).toFixed(0)} KB/s`; if (total) { const pct = downloaded / total; const pctStr = (pct * 100).toFixed(1); const barWidth = 30; const filled = Math.round(pct * barWidth); const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled); const eta = speed > 0 ? Math.ceil((total - downloaded) / speed) : 0; const etaStr = eta > 60 ? `${Math.floor(eta / 60)}m${eta % 60}s` : `${eta}s`; process.stdout.write(`\r ⬇️ [${bar}] ${pctStr}% ${(downloaded / 1048576).toFixed(1)}/${(total / 1048576).toFixed(1)} MB ${speedStr} ETA ${etaStr} `); } else { process.stdout.write(`\r ⬇️ ${(downloaded / 1048576).toFixed(1)} MB ${speedStr} `); } } } }); res.pipe(file); file.on('finish', () => { file.close(); if (showProgress) console.log(); resolve(); }); file.on('error', (err) => { fs.unlink(dest, () => {}); reject(err); }); }).on('error', reject); }); } function downloadBuffer(url) { return new Promise((resolve, reject) => { https.get(url, (res) => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { return downloadBuffer(res.headers.location).then(resolve).catch(reject); } const chunks = []; res.on('data', chunk => chunks.push(chunk)); res.on('end', () => resolve(Buffer.concat(chunks))); }).on('error', reject); }); } function sanitize(name) { return name.replace(/[\/\\:*?"<>|]/g, '_').trim(); } function sanitizePathSegment(name, fallback) { const value = sanitize(String(name || '')).replace(/[. ]+$/g, ''); return value && value !== '.' && value !== '..' ? value : fallback; } function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } function getDirSize(dirPath) { let size = 0; try { const items = fs.readdirSync(dirPath); for (const item of items) { const p = path.join(dirPath, item); const stat = fs.statSync(p); if (stat.isDirectory()) size += getDirSize(p); else size += stat.size; } } catch {} return size; } // 全局暂停控制 let isPaused = false; let pauseResolve = null; function togglePause() { if (isPaused) { isPaused = false; if (pauseResolve) { pauseResolve(); pauseResolve = null; } console.log('\n ▶️ 已恢复下载'); } else { isPaused = true; console.log('\n ⏸ 已暂停 (再按 p 恢复)'); } } async function waitIfPaused() { if (!isPaused) return; await new Promise(resolve => { pauseResolve = resolve; }); } // ========== 下载历史 ========== function loadHistory() { try { if (fs.existsSync(HISTORY_FILE)) { return JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf-8')); } } catch {} return []; } function saveHistory(history) { fs.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true }); fs.writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2), 'utf-8'); } function addHistory(entry) { const history = loadHistory(); history.unshift({ ...entry, timestamp: new Date().toISOString(), }); // 限制历史记录数量 if (history.length > HISTORY_LIMIT) history.length = HISTORY_LIMIT; saveHistory(history); } function formatTime(isoStr) { const d = new Date(isoStr); const pad = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; } // ========== 配置管理 ========== function loadConfig() { try { if (fs.existsSync(CONFIG_FILE)) { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8')); } } catch {} return {}; } function saveConfig(config) { fs.mkdirSync(path.dirname(CONFIG_FILE), { recursive: true }); fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8'); } function getConfig(key, defaultVal) { const config = loadConfig(); return config[key] !== undefined ? config[key] : defaultVal; } function setConfig(key, val) { const config = loadConfig(); config[key] = val; saveConfig(config); } // ========== 封面嵌入 ========== function hasFFmpeg() { try { execSync('ffmpeg -version', { stdio: 'ignore' }); return true; } catch { return false; } } function hasNodeID3() { try { require.resolve('node-id3'); return true; } catch { return false; } } async function embedCover(audioPath, coverUrl, meta) { const ext = path.extname(audioPath).toLowerCase(); const coverPath = audioPath + '.cover.jpg'; try { const coverBuf = await downloadBuffer(coverUrl); fs.writeFileSync(coverPath, coverBuf); // 优先 node-id3 (仅 MP3) if (ext === '.mp3' && hasNodeID3()) { const NodeID3 = require('node-id3'); const tags = { title: meta.name, artist: meta.artist, album: meta.album, image: coverPath, }; if (meta.albumArtist) tags.performerInfo = meta.albumArtist; if (meta.year) tags.year = String(meta.year); if (meta.date) tags.date = String(meta.date); if (meta.trackNo) tags.trackNumber = String(meta.trackNo); if (meta.disc) tags.partOfSet = String(meta.disc); if (meta.genre) tags.genre = meta.genre; if (meta.composer) tags.composer = meta.composer; // TCOM 作曲 if (meta.lyricist) tags.textWriter = meta.lyricist; // TEXT 作词 if (meta.publisher) tags.publisher = meta.publisher; // TPUB 发行公司 // 编曲/制作人/混音无标准帧,写入自定义 TXXX const userTexts = []; if (meta.arranger) userTexts.push({ description: 'ARRANGER', value: meta.arranger }); if (meta.producer) userTexts.push({ description: 'PRODUCER', value: meta.producer }); if (meta.mixer) userTexts.push({ description: 'MIXER', value: meta.mixer }); if (userTexts.length) tags.userDefinedText = userTexts; if (meta.lyrics) tags.unsynchronisedLyrics = { language: 'chi', text: meta.lyrics }; const ok = NodeID3.write(tags, audioPath); if (ok === true) { console.log(' ✅ 封面+标签+歌词已嵌入 (node-id3)'); return; } } if (hasFFmpeg()) { const tmpPath = audioPath + '.tmp' + ext; // 用 execFileSync 直接传参数组,避免 shell 转义问题 const args = ['-y', '-i', audioPath, '-i', coverPath, '-map', '0:a', '-map', '1:0', '-c', 'copy', // 关键:将第二路输入标记为专辑封面,否则播放器不识别(FLAC/MP3 通用) '-disposition:v:0', 'attached_pic', ]; const addMeta = (k, v) => { if (v !== undefined && v !== null && v !== '') args.push('-metadata', `${k}=${v}`); }; addMeta('title', meta.name); addMeta('artist', meta.artist); addMeta('album', meta.album); addMeta('album_artist', meta.albumArtist || meta.artist); addMeta('date', meta.date || meta.year); // 优先完整日期,回退到年份 addMeta('track', meta.trackNo); addMeta('disc', meta.disc); addMeta('genre', meta.genre); addMeta('composer', meta.composer); // 作曲 (MP3→TCOM / FLAC→COMPOSER) addMeta('publisher', meta.publisher); // 发行公司 (MP3→TPUB / FLAC→ORGANIZATION) // 以下键 MP3 会写入 TXXX,FLAC 直接作为 Vorbis 注释 addMeta('lyricist', meta.lyricist); // 作词 addMeta('arranger', meta.arranger); // 编曲 addMeta('producer', meta.producer); // 制作人 addMeta('mixer', meta.mixer); // 混音 // 歌词过长时不写入标签(仍会另存 .lrc),避免命令行超长 if (meta.lyrics && meta.lyrics.length <= 8000) addMeta('lyrics', meta.lyrics); // 封面流描述 args.push('-metadata:s:v', 'title=Album cover', '-metadata:s:v', 'comment=Cover (front)'); if (ext === '.mp3') args.push('-id3v2_version', '3'); args.push(tmpPath); execFileSync('ffmpeg', args, { stdio: 'ignore' }); fs.renameSync(tmpPath, audioPath); console.log(' ✅ 封面+标签+歌词已嵌入 (ffmpeg)'); } else { console.log(' ⚠️ 跳过封面: 需要 node-id3 (npm i node-id3) 或 ffmpeg'); } } catch (e) { console.error(` ⚠️ 封面嵌入失败: ${e.message}`); } finally { if (fs.existsSync(coverPath)) fs.unlinkSync(coverPath); } } // ========== 歌词处理 ========== async function fetchLyric(songId) { try { const res = await fetchJSON(`${API.lyric}?id=${songId}`); if (res.code !== 200) return null; return res.data; // { lrc, tlyric, romalrc, klyric } } catch { return null; } } function mergeLrc(lrc, tlyric) { if (!lrc) return ''; if (!tlyric) return lrc; // 解析翻译歌词为 { timestamp: text } 映射 const transMap = new Map(); for (const line of tlyric.split('\n')) { const m = line.match(/^\[(\d+:\d+[\.:]\d+)\](.*)/); if (m && m[2].trim()) transMap.set(m[1], m[2].trim()); } // 在原文歌词每行后面插入翻译 const result = []; for (const line of lrc.split('\n')) { result.push(line); const m = line.match(/^\[(\d+:\d+[\.:]\d+)\]/); if (m) { const ts = m[1]; if (transMap.has(ts)) { result.push(`[${ts}]${transMap.get(ts)}`); } } } return result.join('\n'); } // 从歌词头部解析制作信息(网易云 song/detail 不返回作词/作曲,只能从 LRC 头提取) // 典型行: "[00:00.000] 作词 : Cassie Wei" 或无时间戳 "作曲 :xxx/yyy" function parseCredits(lrcText) { const credits = {}; if (!lrcText) return credits; const LABELS = { lyricist: /^(作词|作詞|词|填词|Lyricist|Lyrics? by|Written by)$/i, composer: /^(作曲|曲|Composer|Composed by|Music by)$/i, arranger: /^(编曲|編曲|Arranger|Arranged by)$/i, producer: /^(制作人|製作人|出品人|监制|Producer|Produced by)$/i, mixer: /^(混音|Mix(ing)?|Mixed by)$/i, }; for (const raw of lrcText.split('\n')) { // 去掉行首的 [mm:ss.xx] 时间戳(可能有多个) const line = raw.replace(/^(\s*\[[^\]]*\]\s*)+/, '').trim(); const m = line.match(/^([^::]{1,12})\s*[::]\s*(.+)$/); if (!m) continue; const label = m[1].trim(); const value = m[2].trim().replace(/\s*\/\s*/g, '/'); // 归一化多人分隔符 if (!value) continue; for (const [key, re] of Object.entries(LABELS)) { if (re.test(label) && !credits[key]) { credits[key] = value; break; } } } return credits; } async function saveLyric(songId, fileName) { const data = await fetchLyric(songId); if (!data || !data.lrc) { console.log(' ⚠️ 未获取到歌词'); return; } // 保存纯原文 LRC const lrcPath = path.join(DOWNLOAD_DIR, fileName + '.lrc'); fs.writeFileSync(lrcPath, data.lrc, 'utf-8'); console.log(` 📄 歌词已保存: ${path.basename(lrcPath)}`); // 如果有翻译歌词,保存合并版 if (data.tlyric && data.tlyric.trim()) { const merged = mergeLrc(data.lrc, data.tlyric); const mergedPath = path.join(DOWNLOAD_DIR, fileName + '.合并翻译.lrc'); fs.writeFileSync(mergedPath, merged, 'utf-8'); console.log(` 📄 翻译歌词已保存: ${path.basename(mergedPath)}`); } } // ========== 网易云详细元数据 ========== function fetchNeteaseSongDetail(songId) { return new Promise((resolve, reject) => { const requestUrl = new URL(`/api/v1/song/detail?ids=[${songId}]`, `${NETEASE_BASE_URL}/`); const options = { method: 'GET', headers: { 'Referer': NETEASE_BASE_URL, 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept': 'application/json', 'Cookie': 'os=pc', }, }; const proto = requestUrl.protocol === 'https:' ? https : http; const req = proto.request(requestUrl, options, (res) => { let data = ''; res.on('data', c => data += c); res.on('end', () => { try { const j = JSON.parse(data); const s = j.songs?.[0]; if (!s) return resolve(null); resolve({ id: s.id, name: s.name, artists: s.ar?.map(a => ({ id: a.id, name: a.name })) || [], album: s.al ? { id: s.al.id, name: s.al.name, cover: s.al.picUrl || s.al.cover } : null, duration: s.dt || 0, // ms disc: s.cd || '', trackNo: s.no || 0, aliases: s.alia || [], transNames: s.tns || [], popularity: s.pop || 0, fee: s.fee, quality: { h: s.h ? { br: s.h.br, size: s.h.size, sr: s.h.sr } : null, m: s.m ? { br: s.m.br, size: s.m.size, sr: s.m.sr } : null, l: s.l ? { br: s.l.br, size: s.l.size, sr: s.l.sr } : null, }, }); } catch { resolve(null); } }); }); req.on('error', () => resolve(null)); req.setTimeout(5000, () => { req.destroy(); resolve(null); }); req.end(); }); } function fetchNeteaseAlbumDetail(albumId) { return new Promise((resolve, reject) => { const requestUrl = new URL(`/api/v1/album/${albumId}`, `${NETEASE_BASE_URL}/`); const options = { method: 'GET', headers: { 'Referer': NETEASE_BASE_URL, 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept': 'application/json', 'Cookie': 'os=pc', }, }; const proto = requestUrl.protocol === 'https:' ? https : http; const req = proto.request(requestUrl, options, (res) => { let data = ''; res.on('data', c => data += c); res.on('end', () => { try { const j = JSON.parse(data); const a = j.album || j; resolve({ id: a.id, name: a.name, publishTime: a.publishTime || null, // ms timestamp company: a.company || '', description: a.description || '', tags: a.tags || [], type: a.type || '', size: a.size || 0, artist: a.artist ? { id: a.artist.id, name: a.artist.name, trans: a.artist.trans } : null, picUrl: a.picUrl || '', tracks: (j.songs || a.songs || []).map(s => ({ id: s.id, name: s.name, ar: s.ar || s.artists || [], al: s.al || s.album || null, dt: s.dt || s.duration || 0, disc: s.cd || '', trackNo: s.no || 0, })), }); } catch { resolve(null); } }); }); req.on('error', () => resolve(null)); req.setTimeout(5000, () => { req.destroy(); resolve(null); }); req.end(); }); } function getAlbumDownloadTarget(album, track, index, trackCount = album.tracks?.length || 0) { const trackArtists = track.ar?.map(a => a.name).filter(Boolean).join(' / '); const artistDir = sanitizePathSegment(album.artist?.name || trackArtists, '未知歌手'); const albumDir = sanitizePathSegment(album.name, '未知专辑'); const numberWidth = Math.max(2, String(trackCount).length); const trackNumber = String(index + 1).padStart(numberWidth, '0'); return { outputDir: path.join(DOWNLOAD_DIR, artistDir, albumDir), outputBaseName: `${trackNumber}. ${track.name || '未知歌曲'}`, }; } async function fetchFullMetadata(songId) { const [songDetail, chkszData] = await Promise.all([ fetchNeteaseSongDetail(songId), fetchJSON(`${API.music}?id=${songId}&level=standard`).catch(() => null), ]); if (!songDetail) return null; let albumDetail = null; if (songDetail.album?.id) { albumDetail = await fetchNeteaseAlbumDetail(songDetail.album.id); } const durationSec = Math.round(songDetail.duration / 1000); const durationStr = `${Math.floor(durationSec / 60)}:${String(durationSec % 60).padStart(2, '0')}`; const publishYear = albumDetail?.publishTime ? new Date(albumDetail.publishTime).getFullYear() : null; const publishDate = albumDetail?.publishTime ? new Date(albumDetail.publishTime).toISOString().slice(0, 10) : null; return { // 基本信息 id: songId, name: songDetail.name, artists: songDetail.artists, artistStr: songDetail.artists.map(a => a.name).join(' / '), album: songDetail.album, albumStr: songDetail.album?.name || '', // 详细信息 duration: songDetail.duration, durationStr, disc: songDetail.disc, trackNo: songDetail.trackNo, aliases: songDetail.aliases, transNames: songDetail.transNames, popularity: songDetail.popularity, fee: songDetail.fee, // 专辑详情 publishTime: albumDetail?.publishTime, publishYear, publishDate, company: albumDetail?.company || '', description: albumDetail?.description || '', tags: albumDetail?.tags || [], albumType: albumDetail?.type || '', albumSize: albumDetail?.size || 0, albumArtist: albumDetail?.artist, // 音质信息 quality: songDetail.quality, // ChKSz 数据 chksz: chkszData?.code === 200 ? chkszData.data : null, }; } // ========== 音质选择 ========== // 解析音质输入:支持数字序号(1-7)或音质名称,无效/空返回 null function parseLevel(input) { const v = (input || '').trim().toLowerCase(); if (!v) return null; if (/^\d+$/.test(v)) { const idx = parseInt(v, 10) - 1; return (idx >= 0 && idx < QUALITY_LEVELS.length) ? QUALITY_LEVELS[idx] : null; } return QUALITY_LEVELS.includes(v) ? v : null; } function printQualityMenu(currentLevel) { QUALITY_LEVELS.forEach((lv, i) => { const mark = lv === currentLevel ? ' ←当前' : ''; console.log(` ${i + 1}. ${lv.padEnd(10)} ${(QUALITY_LABELS[lv] || '').padEnd(8)}${mark}`); }); } async function askLevel(ask, currentLevel) { const label = QUALITY_LABELS[currentLevel] ? ` (${QUALITY_LABELS[currentLevel]})` : ''; console.log(`\n 当前音质: ${currentLevel}${label}`); console.log(' 可选音质:'); printQualityMenu(currentLevel); const input = await ask(' 选择音质 (输入序号或名称, 回车使用当前): '); const parsed = parseLevel(input); if (parsed) return parsed; if (input.trim()) console.log(' ⚠️ 无效选择,使用当前设置'); return currentLevel; } // ========== 搜索 ========== async function searchSongs(keyword, limit = 10) { const res = await fetchJSON(`${API.search}?keyword=${encodeURIComponent(keyword)}&limit=${limit}`); if (res.code !== 200) { console.log('❌ 搜索无结果'); return []; } // 兼容两种返回格式:data 直接是数组,或 data.songs 是数组 const songs = Array.isArray(res.data) ? res.data : (res.data?.songs || []); if (!songs.length) { console.log('❌ 搜索无结果'); return []; } return songs; } async function interactiveSearch(rl, ask) { const keyword = await ask('🔍 搜索关键词: '); if (!keyword.trim()) return null; const results = await searchSongs(keyword.trim(), 10); if (!results.length) return null; 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); console.log(` ${num} ${name} ${artist} ${s.album}`); }); const pick = await ask('\n选择序号 (多个用逗号分隔, 0=取消): '); if (!pick.trim() || pick.trim() === '0') return null; return pick.split(/[,,\s]+/) .map(n => parseInt(n, 10)) .filter(n => n >= 1 && n <= results.length) .map(n => results[n - 1]); } // ========== 歌单 ========== async function fetchPlaylist(playlistId) { const res = await fetchJSON(`${API.playlist}?id=${playlistId}`); if (!res.data?.tracks?.length) { console.log('❌ 歌单获取失败或为空'); return null; } return res.data; // { name, trackCount, tracks: [{ id, name, ar, al }] } } // ========== 核心: 下载歌曲 ========== async function downloadSong(songId, level, { downloadLyric = true, embedCoverArt = true, maxRetries = DEFAULT_RETRIES, outputDir = DOWNLOAD_DIR, outputBaseName = null, } = {}) { let lastError = null; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { return await _downloadSong(songId, level, { downloadLyric, embedCoverArt, outputDir, outputBaseName }); } catch (e) { lastError = e; if (attempt < maxRetries) { const waitSec = attempt * 3; console.log(` ⚠️ 第 ${attempt} 次失败: ${e.message},${waitSec}秒后重试...`); await sleep(waitSec * 1000); } } } console.error(`❌ 歌曲 ${songId} 下载失败 (已重试 ${maxRetries} 次): ${lastError?.message}`); return false; } async function _downloadSong(songId, level, { downloadLyric = true, embedCoverArt = true, outputDir = DOWNLOAD_DIR, outputBaseName = null, } = {}) { const url = `${API.music}?id=${songId}&level=${level}`; console.log(`\n🎵 解析歌曲 ID: ${songId} ...`); const result = await fetchJSON(url); if (result.code !== 200 || !result.data?.url) { throw new Error(result.msg || 'API 返回错误'); } const { name, artist, album, url: audioUrl, br, size, level: actualLevel, picUrl } = result.data; const ext = audioUrl.match(/\.(mp3|flac|m4a|wav|aac)(\?|$)/i)?.[1] || 'mp3'; const baseName = sanitize(outputBaseName || `${artist} - ${name}`); const fileName = `${baseName}.${ext}`; fs.mkdirSync(outputDir, { recursive: true }); const dest = path.join(outputDir, fileName); console.log(` 🎶 ${name} - ${artist}`); console.log(` 💿 ${album} | ${actualLevel} (${br}kbps) | ${(size / 1024 / 1024).toFixed(2)} MB`); // 检查是否已存在(精确匹配文件名) if (fs.existsSync(dest)) { const existStat = fs.statSync(dest); // 检查文件大小是否接近(允许 1% 误差) const sizeDiff = Math.abs(existStat.size - size) / size; if (sizeDiff < 0.01) { console.log(` ⏭️ 已存在,跳过: ${fileName}`); // 检查是否缺少歌词/封面,自动补全 const lrcPath = path.join(outputDir, baseName + '.lrc'); if (downloadLyric && !fs.existsSync(lrcPath)) { console.log(` 🔧 检测到缺少歌词,自动补全...`); const lyricData = await fetchLyric(songId); if (lyricData?.lrc) { fs.writeFileSync(lrcPath, lyricData.lrc, 'utf-8'); console.log(` 📄 歌词已补全`); if (lyricData.tlyric && lyricData.tlyric.trim()) { const merged = mergeLrc(lyricData.lrc, lyricData.tlyric); fs.writeFileSync(path.join(outputDir, baseName + '.合并翻译.lrc'), merged, 'utf-8'); } } } } else { // 文件大小差异大,可能是不同版本,询问是否重新下载 console.log(` ⚠️ 已存在同名文件但大小不同 (现有 ${(existStat.size / 1048576).toFixed(2)} MB, 预期 ${(size / 1048576).toFixed(2)} MB)`); console.log(` ⬇️ 覆盖下载...`); await downloadFile(audioUrl, dest); console.log(` ✅ 下载完成: ${fileName}`); } } else { console.log(` ⬇️ 下载中...`); await downloadFile(audioUrl, dest); console.log(` ✅ 下载完成: ${fileName}`); } // 下载歌词 let lyricData = null; if (downloadLyric) { lyricData = await fetchLyric(songId); if (lyricData?.lrc) { const lrcPath = path.join(outputDir, baseName + '.lrc'); fs.writeFileSync(lrcPath, lyricData.lrc, 'utf-8'); console.log(` 📄 歌词已保存: ${path.basename(lrcPath)}`); if (lyricData.tlyric && lyricData.tlyric.trim()) { const merged = mergeLrc(lyricData.lrc, lyricData.tlyric); const mergedPath = path.join(outputDir, baseName + '.合并翻译.lrc'); fs.writeFileSync(mergedPath, merged, 'utf-8'); console.log(` 📄 翻译歌词已保存: ${path.basename(mergedPath)}`); } } else { console.log(' ⚠️ 未获取到歌词'); } } // 嵌入封面 + 歌词 if (embedCoverArt && picUrl) { const lrcText = lyricData?.lrc || null; const tagMeta = { name, artist, album, lyrics: lrcText }; // 从歌词头解析作词/作曲/编曲/制作人等(网易云元数据接口不提供这些字段) Object.assign(tagMeta, parseCredits(lrcText)); // 拉取更丰富的元数据(曲目号/碟号/发行年份/发行公司/流派/专辑歌手)用于写入标签 try { const detail = await fetchNeteaseSongDetail(songId); if (detail) { if (detail.trackNo) tagMeta.trackNo = detail.trackNo; if (detail.disc) tagMeta.disc = detail.disc; const albumArtist = detail.artists?.map(a => a.name).join(' / '); if (albumArtist) tagMeta.albumArtist = albumArtist; if (detail.album?.id) { const albumDetail = await fetchNeteaseAlbumDetail(detail.album.id); if (albumDetail?.publishTime) { const d = new Date(albumDetail.publishTime); tagMeta.year = d.getFullYear(); tagMeta.date = d.toISOString().slice(0, 10); // 完整发行日期 YYYY-MM-DD } if (albumDetail?.tags?.length) tagMeta.genre = albumDetail.tags.join(', '); if (albumDetail?.company) tagMeta.publisher = albumDetail.company; // 发行公司 // 曲目号补全为 "当前/总数" 形式 if (tagMeta.trackNo && albumDetail?.size) tagMeta.trackNo = `${tagMeta.trackNo}/${albumDetail.size}`; } } } catch {} await embedCover(dest, picUrl, tagMeta); } // 记录下载历史 addHistory({ id: songId, name, artist, album, level: actualLevel, bitrate: br, size, file: path.relative(DOWNLOAD_DIR, dest), }); return true; } // ========== 主程序 ========== 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`); 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; 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; 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); 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 ... ║`); console.log(` ║ node 163_music_downloader.js --playlist= ║`); console.log(` ║ node 163_music_downloader.js --album= ║`); 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);