diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..b0a0d10 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,14 @@ +{ + "permissions": { + "allow": [ + "Bash(node --check 163_music_downloader.js)", + "Bash(ffmpeg -version)", + "Bash(ffprobe -version)", + "PowerShell($c = Get-Command ffmpeg -ErrorAction SilentlyContinue; if \\($c\\) { Write-Output $c.Source; & ffmpeg -version | Select-Object -First 1 } else { Write-Output \"ffmpeg NOT FOUND\" })", + "PowerShell($paths = @\\(\"C:\\\\ffmpeg\",\"C:\\\\Program Files\\\\ffmpeg\",\"$env:LOCALAPPDATA\\\\Microsoft\\\\WinGet\",\"$env:USERPROFILE\\\\scoop\\\\apps\\\\ffmpeg\"\\); Get-ChildItem -Path C:\\\\ -Filter ffmpeg.exe -Recurse -ErrorAction SilentlyContinue -Depth 4 2>$null | Select-Object -First 5 -ExpandProperty FullName)", + "Bash(node *)", + "Bash(echo \"exit: $?\")", + "Bash(ffprobe *)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..92ae52c --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Downloaded audio files +*.mp3 +*.flac +*.wav +*.m4a +*.aac +*.ogg +*.wma +*.ape +*.opus + +# Archives +*.zip +*.rar +*.7z +*.tar +*.tar.gz +*.tgz diff --git a/163_music_downloader.js b/163_music_downloader.js new file mode 100644 index 0000000..ba4bd18 --- /dev/null +++ b/163_music_downloader.js @@ -0,0 +1,2896 @@ +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); diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..704e0fd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,57 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Overview + +A single-file Node.js CLI for downloading music from NetEase Cloud Music (网易云音乐), with lyrics, cover art, and ID3 tag embedding. All logic lives in `163_music_downloader.js` (~2600 lines). There is no `package.json` — the script runs on Node built-ins only (`https`, `http`, `fs`, `path`, `readline`, `child_process`). The UI, comments, and console output are entirely in Chinese. + +## Running + +```bash +# Interactive menu (TTY required for pause hotkey) +node 163_music_downloader.js + +# Command-line batch mode (bypasses the menu entirely) +node 163_music_downloader.js ... +node 163_music_downloader.js --playlist= + +# Flags (batch mode only) +--level=lossless # override quality; see QUALITY_LEVELS +--retries=5 # download retry count (default 3) +--no-lyric # skip .lrc download +--no-cover # skip cover/tag embedding +``` + +There are no tests, linter, or build step. Verify changes by running the script. + +## External dependencies (all optional / runtime-detected) + +- **`api.chksz.top`** — third-party API (`API` object at top of file) for song URLs, search, lyrics, and playlists. This is the core download source. +- **`music.163.com`** — queried directly (`fetchNeteaseSongDetail`, `fetchNeteaseAlbumDetail`) for rich metadata (album, publish date, popularity). Requires `Cookie: os=pc` header. +- **`node-id3`** (npm) — preferred for MP3 tag/lyric/cover embedding. `require`d lazily via `hasNodeID3()`; absent by default. +- **`ffmpeg`** (system) — fallback embedder for non-MP3 (FLAC etc.) and when node-id3 is missing. Detected via `hasFFmpeg()`. +- **Audio players** (`mpv`, `ffplay`, `play`, `cvlc`, `aplay`) — for the preview/play features, detected via `which`. Note: `which` is Unix-only, so preview won't find players on Windows even if installed. + +If a dependency is missing the relevant feature degrades gracefully (skips embedding, prints the URL, etc.). + +## Architecture + +Everything is one flat module — functions defined top-to-bottom, then a `main()` at the bottom drives either batch mode or an infinite interactive menu loop (`while (true)` + `switch` on single-char menu choices). Key layers: + +- **Network helpers** — `fetchJSON`, `downloadFile` (streams with a live progress bar, follows redirects), `downloadBuffer`, all Promise-wrapped around `https.get`. +- **`downloadSong` → `_downloadSong`** — the core pipeline: `downloadSong` wraps `_downloadSong` with retry/backoff; `_downloadSong` resolves the URL, skips/overwrites based on existing-file size comparison (1% tolerance), writes audio, then lyrics, then embeds cover+tags, then records history. +- **Lyrics** — `fetchLyric` returns `{ lrc, tlyric, ... }`; `mergeLrc` interleaves original + translation by matching `[mm:ss.xx]` timestamps into a `.合并翻译.lrc` file. +- **Cover/tags** — `embedCover` downloads the image to a temp `.cover.jpg`, tries node-id3 (MP3) then ffmpeg (`-map` audio+cover, `-c copy`), always cleans up the temp file in `finally`. +- **Persistence** (all JSON in the script's `__dirname`, not the download dir): + - `download_history.json` — capped at 500 entries, newest-first (`addHistory`). + - `downloader_config.json` — key/value config, currently only the `level` quality setting (`getConfig`/`setConfig`). +- **Output layout** — audio + `.lrc` files go to `downloads/`. Deletes move files to `.trash/` (not permanent). ZIP packaging uses `.tmp_zip/`. The `c` menu option cleans these up. + +## Conventions & gotchas + +- **Quality levels** — `QUALITY_LEVELS` array is the source of truth; `DEFAULT_LEVEL = 'jymaster'`. The chksz API may return a lower `actualLevel` than requested. +- **Filenames** — always `sanitize(`${artist} - ${name}`)` (strips `/\:*?"<>|`). Lyric/rename logic depends on this exact `"artist - name"` shape when parsing filenames back out. +- **Menu dispatch** — adding a feature means adding a `case` in the `main()` switch AND a line in both `printMenu()` and the `h` help text. Note the switch currently has a duplicated `case 'p'/'P'` block (the second is unreachable) and the keypress listener is registered twice — mirror the existing style rather than assuming it's clean. +- **Pause** — global `isPaused`/`waitIfPaused()` gate batch loops; toggled by the `p` hotkey (only wired up in interactive mode with a TTY). +- Batch loops sleep ~300–500ms between songs to avoid hammering the API — preserve this when editing download loops. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d31d6de --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,57 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +A single-file Node.js CLI for downloading music from NetEase Cloud Music (网易云音乐), with lyrics, cover art, and ID3 tag embedding. All logic lives in `163_music_downloader.js` (~2600 lines). There is no `package.json` — the script runs on Node built-ins only (`https`, `http`, `fs`, `path`, `readline`, `child_process`). The UI, comments, and console output are entirely in Chinese. + +## Running + +```bash +# Interactive menu (TTY required for pause hotkey) +node 163_music_downloader.js + +# Command-line batch mode (bypasses the menu entirely) +node 163_music_downloader.js ... +node 163_music_downloader.js --playlist= + +# Flags (batch mode only) +--level=lossless # override quality; see QUALITY_LEVELS +--retries=5 # download retry count (default 3) +--no-lyric # skip .lrc download +--no-cover # skip cover/tag embedding +``` + +There are no tests, linter, or build step. Verify changes by running the script. + +## External dependencies (all optional / runtime-detected) + +- **`api.chksz.top`** — third-party API (`API` object at top of file) for song URLs, search, lyrics, and playlists. This is the core download source. +- **`music.163.com`** — queried directly (`fetchNeteaseSongDetail`, `fetchNeteaseAlbumDetail`) for rich metadata (album, publish date, popularity). Requires `Cookie: os=pc` header. +- **`node-id3`** (npm) — preferred for MP3 tag/lyric/cover embedding. `require`d lazily via `hasNodeID3()`; absent by default. +- **`ffmpeg`** (system) — fallback embedder for non-MP3 (FLAC etc.) and when node-id3 is missing. Detected via `hasFFmpeg()`. +- **Audio players** (`mpv`, `ffplay`, `play`, `cvlc`, `aplay`) — for the preview/play features, detected via `which`. Note: `which` is Unix-only, so preview won't find players on Windows even if installed. + +If a dependency is missing the relevant feature degrades gracefully (skips embedding, prints the URL, etc.). + +## Architecture + +Everything is one flat module — functions defined top-to-bottom, then a `main()` at the bottom drives either batch mode or an infinite interactive menu loop (`while (true)` + `switch` on single-char menu choices). Key layers: + +- **Network helpers** — `fetchJSON`, `downloadFile` (streams with a live progress bar, follows redirects), `downloadBuffer`, all Promise-wrapped around `https.get`. +- **`downloadSong` → `_downloadSong`** — the core pipeline: `downloadSong` wraps `_downloadSong` with retry/backoff; `_downloadSong` resolves the URL, skips/overwrites based on existing-file size comparison (1% tolerance), writes audio, then lyrics, then embeds cover+tags, then records history. +- **Lyrics** — `fetchLyric` returns `{ lrc, tlyric, ... }`; `mergeLrc` interleaves original + translation by matching `[mm:ss.xx]` timestamps into a `.合并翻译.lrc` file. +- **Cover/tags** — `embedCover` downloads the image to a temp `.cover.jpg`, tries node-id3 (MP3) then ffmpeg (`-map` audio+cover, `-c copy`), always cleans up the temp file in `finally`. +- **Persistence** (all JSON in the script's `__dirname`, not the download dir): + - `download_history.json` — capped at 500 entries, newest-first (`addHistory`). + - `downloader_config.json` — key/value config, currently only the `level` quality setting (`getConfig`/`setConfig`). +- **Output layout** — audio + `.lrc` files go to `downloads/`. Deletes move files to `.trash/` (not permanent). ZIP packaging uses `.tmp_zip/`. The `c` menu option cleans these up. + +## Conventions & gotchas + +- **Quality levels** — `QUALITY_LEVELS` array is the source of truth; `DEFAULT_LEVEL = 'jymaster'`. The chksz API may return a lower `actualLevel` than requested. +- **Filenames** — always `sanitize(`${artist} - ${name}`)` (strips `/\:*?"<>|`). Lyric/rename logic depends on this exact `"artist - name"` shape when parsing filenames back out. +- **Menu dispatch** — adding a feature means adding a `case` in the `main()` switch AND a line in both `printMenu()` and the `h` help text. Note the switch currently has a duplicated `case 'p'/'P'` block (the second is unreachable) and the keypress listener is registered twice — mirror the existing style rather than assuming it's clean. +- **Pause** — global `isPaused`/`waitIfPaused()` gate batch loops; toggled by the `p` hotkey (only wired up in interactive mode with a TTY). +- Batch loops sleep ~300–500ms between songs to avoid hammering the API — preserve this when editing download loops. diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..18ae8be --- /dev/null +++ b/SKILL.md @@ -0,0 +1,253 @@ +--- +name: musicdl +description: Use and configure the musicdl NetEase Cloud Music command-line downloader. Use when Codex needs to explain or operate song, playlist, artist, album, lyric, cover, metadata, history, M3U, archive, cleanup, rename, preview, or batch-download features in `163_music_downloader.js`, including `config.json`, interactive menu choices, command-line flags, quality selection, Linux and Windows paths, output naming, optional tools, and troubleshooting. +--- + +# 使用 Musicdl + +## 快速开始 + +在 `163_music_downloader.js` 所在目录运行 Node.js: + +```bash +# 打开中文交互菜单 +node 163_music_downloader.js + +# 下载一首或多首歌曲 +node 163_music_downloader.js 歌曲ID +node 163_music_downloader.js 歌曲ID1 歌曲ID2 歌曲ID3 + +# 下载整个歌单或专辑 +node 163_music_downloader.js --playlist=歌单ID +node 163_music_downloader.js --album=专辑ID +``` + +从网易云歌曲、歌单或专辑网页地址中取得对应数字 ID。需要精确下载完整专辑时优先使用专辑 ID;按名称搜索只能覆盖接口返回的搜索结果。 + +## 准备运行环境 + +必须准备: + +- Node.js;脚本只使用内置模块,不要求项目存在 `package.json`。 +- 可访问 `config.json` 中音乐接口及网易云接口的网络环境。 +- 下载目录的创建和写入权限。 + +按需准备: + +- 安装可被当前脚本解析到的 `node-id3`,为 MP3 写入封面、歌词和 ID3 标签。 +- 将 `ffmpeg` 加入 `PATH`,为 FLAC 等非 MP3 文件写入封面和标签,并作为 MP3 的后备工具。 +- 将 `mpv`、`ffplay`、`play`、`cvlc` 或 `aplay` 加入 `PATH`,使用试听和本地播放功能。 +- 在 Windows 上注意播放器检测使用 `which`,即使已经安装播放器也可能检测不到;此时直接复制显示的音频 URL 到播放器。 +- 准备系统 `zip` 命令以输出 ZIP;缺少 `zip` 时脚本尝试使用 `tar` 输出 `.tar.gz`。 + +缺少可选工具不会阻止音频下载;相关封面嵌入、标签或播放能力会降级。 + +## 配置 `config.json` + +在脚本同目录编辑 `config.json`: + +```json +{ + "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 + } +} +``` + +按以下规则设置: + +- 使用 `api.music` 获取音频地址和基础歌曲信息。 +- 使用 `api.lyric` 获取原文和翻译歌词。 +- 使用 `api.search` 搜索歌曲、歌手和专辑。 +- 使用 `api.playlist` 获取歌单曲目。 +- 使用 `api.neteaseBaseUrl` 获取歌曲、专辑、发行时间和曲序等详细元数据。 +- 使用 `paths.downloadDir` 设置音频和歌词根目录。 +- 使用 `paths.historyFile` 设置下载历史 JSON。 +- 使用 `paths.runtimeConfigFile` 保存交互菜单选择的音质;不要把它与静态 `config.json` 设为同一文件。 +- 使用 `paths.trashDir` 设置软删除目录。 +- 使用 `paths.tempZipDir` 设置歌单打包临时目录。 +- 使用 `defaults.quality` 设置初始音质。 +- 使用 `defaults.retries` 设置单曲默认尝试次数。 +- 使用 `defaults.historyLimit` 设置历史记录上限。 + +使用相对路径时,以脚本所在目录为基准。也可直接使用绝对路径: + +```json +{ + "paths": { + "downloadDir": "/mnt/storage/music" + } +} +``` + +Linux 使用 `/home/user/Music`、`/mnt/storage/music` 等绝对路径;Windows 在 JSON 中将反斜杠写成双反斜杠,例如 `D:\\Music\\Downloads`。目录不存在时由脚本递归创建。配置缺失、无法解析或字段无效时使用脚本内置默认值。 + +## 选择音质 + +使用以下音质标识: + +- `standard`:标准音质。 +- `exhigh`:极高音质。 +- `lossless`:无损音质。 +- `hires`:Hi-Res。 +- `jymaster`:超清母带,默认值。 +- `sky`:空间音频。 +- `jyeffect`:高清臻品。 + +接口可能按歌曲实际可用资源返回低于请求值的音质。交互模式使用菜单 `0` 保存常用音质;命令行使用 `--level=<音质>` 临时覆盖。 + +## 使用命令行批处理 + +命令行只要出现歌曲 ID、`--playlist` 或 `--album`,就直接进入批处理模式并跳过菜单: + +```bash +# 单曲和多首歌曲 +node 163_music_downloader.js 123456 +node 163_music_downloader.js 123456 234567 345678 + +# 歌单 +node 163_music_downloader.js --playlist=123456789 + +# 完整专辑 +node 163_music_downloader.js --album=123456 + +# 组合选项 +node 163_music_downloader.js --album=123456 --level=lossless --retries=5 +node 163_music_downloader.js 123456 --no-lyric --no-cover +``` + +可用参数: + +- `--playlist=`:下载歌单全部曲目。 +- `--album=`:从网易云专辑接口读取并下载全部曲目。 +- `--level=<音质>`:覆盖本次批处理音质。 +- `--retries=<次数>`:覆盖单曲失败尝试次数。 +- `--no-lyric`:不下载 `.lrc` 文件。 +- `--no-cover`:不嵌入封面和标签。 + +允许在一次命令中同时提供专辑、歌单和单曲 ID;脚本依次处理。批量曲目之间保留约 500ms 间隔。 + +## 使用交互菜单 + +运行 `node 163_music_downloader.js` 后输入菜单键。 + +### 搜索与下载 + +- `1` 搜索歌曲并下载:输入关键词,查看歌曲、歌手和专辑,选择一个或多个结果后选择音质下载。 +- `2` 按歌曲 ID 下载:输入单个 ID,或用逗号分隔多个 ID。 +- `3` 下载整个歌单:输入歌单 ID,确认曲目数量和音质后逐首下载。 +- `4` 查看歌单并选择下载:列出歌单曲目;输入 `a` 下载全部,或输入 `1,3,5`、`5-10` 等序号组合。 +- `6` 按歌手批量下载:输入歌手名和搜索数量;下载全部匹配结果或指定序号、序号范围。 +- `s` 按歌名搜索:列出搜索结果;输入序号下载,输入 `a` 下载全部,输入 `v3` 试听第 3 首,输入 `d3` 查看第 3 首详情。 +- `a` 下载专辑:输入专辑 ID 可获取完整曲目;也可输入专辑名,从有限搜索结果中选择一个或多个专辑分组。 +- `b` 批量 ID 下载:连续输入逗号、空格或换行分隔的 ID,输入空行结束;自动去重、统计成功与失败,并可重试失败项目。 +- `f` 从文件批量下载:读取每行一个歌曲 ID 或 `歌手 - 歌名` 的文本文件;名称条目先搜索匹配,再统一确认下载。 + +### 歌词、试听与详情 + +- `5` 查看歌词:输入歌曲 ID,显示去除时间标签后的歌词;按提示保存原文 `.lrc` 和合并翻译歌词。 +- `7` 试听歌曲:输入歌曲 ID,取得标准音质地址并调用本地播放器;未检测到播放器时显示 URL。 +- `8` 查看歌曲详情:显示歌曲 ID、歌手、专辑、时长、别名、曲号、碟号、热度、发行日期、发行公司、专辑歌手、流派、不同音质信息和部分专辑简介。 + +### 本地文件与标签 + +- `l` 查看已下载歌曲:按修改时间列出下载根目录中的音频文件和大小;输入 `p+序号` 播放,输入 `d+序号` 软删除,输入 `da` 移动全部文件到回收站。当前列表只扫描下载根目录,不递归展示专辑子目录。 +- `n` 从文件名识别并补全标签:识别 `歌手 - 歌名.mp3`、`歌手-歌名.flac` 等名称;选择文件后搜索最佳歌曲,补全标签和歌词,并按标准名称重命名。 +- `g` 自动补全标签:扫描下载根目录中符合 `歌手 - 歌名` 且缺少歌词的音频,搜索匹配项并补全封面、标签、歌词和历史记录。 +- `w` 重命名歌曲:输入序号,分别确认歌手和歌名;同时重命名关联 `.lrc` 文件。此功能存在于脚本中,但当前主菜单没有显示 `w` 提示,可直接输入使用。 + +`l`、`n`、`g`、`w` 目前只扫描 `downloadDir` 顶层,不递归处理按歌手/专辑存放的专辑文件。 + +### 历史、列表与统计 + +- `9` 查看下载历史:显示最近 30 条和总数;输入 `d` 删除指定记录,输入 `c` 清空全部记录。删除历史不会删除音频文件。 +- `e` 导出历史:选择 CSV、JSON 或 TXT;结果保存到脚本目录并带时间戳。 +- `t` 查看统计:显示累计歌曲数、总大小、音质分布、下载最多的歌手和最近 7 天下载量。 +- `m` 保存歌单为 M3U:优先写入已存在的本地歌曲路径,未下载歌曲使用配置的音乐 API 作为网络备用地址;M3U 保存到脚本目录。 + +### 打包、清理与设置 + +- `z` 下载歌单并打包:把歌单歌曲下载到临时目录,优先生成 ZIP,缺少 `zip` 时尝试生成 `.tar.gz`;成功或失败后清理本次临时目录。此流程只打包音频,不执行标准歌词和标签下载流程。 +- `c` 清理缓存:删除配置的临时打包目录和孤立的 `.cover.jpg`、`.tmp.*` 文件;清空回收站前再次询问。 +- `0` 设置音质:输入序号或音质名称,并写入运行时配置文件。 +- `p` 暂停或恢复批量下载:只在交互模式且终端支持 TTY 按键事件时可靠工作;正在执行的单首网络下载不会立即中断,下一次暂停检查点才会生效。 +- `r` 重置设置:删除运行时配置并恢复 `config.json` 指定的默认音质;随后选择是否保留下载历史。不会删除静态 `config.json` 或已下载音频。 +- `h` 显示内置帮助。 +- `q` 退出程序;也可以输入 `exit` 或 `quit`。 + +## 理解输出文件 + +普通单曲、歌单和歌手批量下载默认保存为: + +```text +downloadDir/ +├── 歌手 - 歌名.flac +├── 歌手 - 歌名.lrc +└── 歌手 - 歌名.合并翻译.lrc +``` + +专辑下载保存为: + +```text +downloadDir/ +└── 专辑歌手/ + └── 专辑名/ + ├── 01. 歌曲名.flac + ├── 01. 歌曲名.lrc + ├── 01. 歌曲名.合并翻译.lrc + ├── 02. 歌曲名.flac + └── ... +``` + +按接口返回的专辑曲目顺序编号,至少补齐两位;曲目超过 99 首时使用三位编号。根据实际下载 URL 决定 `.mp3`、`.flac`、`.m4a`、`.wav` 或 `.aac` 扩展名,不强制转换格式。自动清理歌手、专辑和歌曲名中的非法路径字符。 + +歌词处理规则: + +- 将原文保存为同名 `.lrc`。 +- 存在翻译时,按时间戳交错合并原文和译文,另存 `.合并翻译.lrc`。 +- 嵌入标签时使用原文歌词,并尝试从歌词头提取作词、作曲、编曲和制作人信息。 + +封面和标签规则: + +- MP3 优先使用 `node-id3`。 +- FLAC 等格式或缺少 `node-id3` 时尝试使用 `ffmpeg`。 +- 尝试写入标题、歌手、专辑、专辑歌手、曲号、碟号、发行年份、完整日期、流派、发行公司、歌词和封面。 +- 在处理结束后清理临时 `.cover.jpg`。 + +## 理解重复下载与历史 + +下载前检查目标文件名: + +- 已有文件与接口文件大小差异低于 1% 时跳过音频下载,并尝试补齐缺少的歌词或封面标签。 +- 大小差异明显时直接覆盖同名音频。 +- 每次完成流程后将记录写入历史文件,最新记录位于最前,并按 `historyLimit` 截断。 +- 专辑历史中的 `file` 字段保存相对于 `downloadDir` 的歌手、专辑和文件路径。 + +删除本地文件时优先使用菜单 `l` 的软删除功能,将音频和关联歌词移动到 `trashDir`。菜单中“删除全部”的提示文字较强,但实现仍是移动到回收站;使用 `c` 清空回收站后才不可恢复。 + +## 处理常见问题 + +- 出现接口错误或无搜索结果时,检查 `config.json` 接口地址和网络连通性,再确认 ID 类型是否正确。 +- 请求高音质却得到较低音质时,以接口返回的实际音质为准;歌曲可能没有对应资源。 +- 没有封面或标签时,确认 `node-id3` 可被 Node.js 解析,或确认 `ffmpeg` 在 `PATH` 中。 +- 无法试听时,确认播放器在 `PATH` 中;Windows 可直接使用脚本显示的音频 URL。 +- Linux 绝对下载目录无法写入时,检查运行脚本用户对目标目录及其父目录的权限。 +- 专辑名称搜索缺歌时,改用准确的专辑 ID 和 `--album=`。 +- 本地文件列表看不到专辑歌曲时,直接查看 `downloadDir/歌手/专辑`;当前管理菜单不递归扫描。 +- 配置文件损坏时,修复 JSON 语法;脚本读取失败会回退到内置默认配置并显示警告。 diff --git a/config.json b/config.json new file mode 100644 index 0000000..0685cf2 --- /dev/null +++ b/config.json @@ -0,0 +1,21 @@ +{ + "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": "/AstrBot/Music", + "historyFile": "download_history.json", + "runtimeConfigFile": "downloader_config.json", + "trashDir": ".trash", + "tempZipDir": ".tmp_zip" + }, + "defaults": { + "quality": "jymaster", + "retries": 3, + "historyLimit": 500 + } +} diff --git a/download_history.json b/download_history.json new file mode 100644 index 0000000..2afeed8 --- /dev/null +++ b/download_history.json @@ -0,0 +1,68 @@ +[ + { + "id": "3342981041", + "name": "铁花飞", + "artist": "Mili/塞壬唱片-MSR", + "album": "铁花飞", + "level": "lossless", + "bitrate": 904424, + "size": 27432639, + "file": "Mili_塞壬唱片-MSR - 铁花飞.flac", + "timestamp": "2026-07-13T10:25:51.755Z" + }, + { + "id": "3336080601", + "name": "SAIKAI", + "artist": "Mili", + "album": "SAIKAI", + "level": "lossless", + "bitrate": 791784, + "size": 32067914, + "file": "Mili - SAIKAI.flac", + "timestamp": "2026-07-13T10:16:32.596Z" + }, + { + "id": 3336080601, + "name": "SAIKAI", + "artist": "Mili", + "album": "SAIKAI", + "level": "hires", + "bitrate": 2733875, + "size": 110709636, + "file": "Mili - SAIKAI.flac", + "timestamp": "2026-07-13T09:36:41.412Z" + }, + { + "id": 3336080601, + "name": "SAIKAI", + "artist": "Mili", + "album": "SAIKAI", + "level": "lossless", + "bitrate": 791784, + "size": 32067914, + "file": "Mili - SAIKAI.flac", + "timestamp": "2026-07-13T07:41:34.485Z" + }, + { + "id": 3342981041, + "name": "铁花飞", + "artist": "Mili/塞壬唱片-MSR", + "album": "铁花飞", + "level": "jyeffect", + "bitrate": 2872452, + "size": 87120168, + "file": "Mili_塞壬唱片-MSR - 铁花飞.flac", + "timestamp": "2026-07-13T07:31:12.383Z" + }, + { + "id": 3336080601, + "name": "SAIKAI", + "artist": "Mili", + "album": "SAIKAI", + "level": "lossless", + "bitrate": 791784, + "size": 32067914, + "file": "Mili - SAIKAI.flac", + "timestamp": "2026-07-13T07:19:59.547Z" + } +] \ No newline at end of file