diff --git a/163_music_downloader.js b/163_music_downloader.js index ba4bd18..07a4955 100644 --- a/163_music_downloader.js +++ b/163_music_downloader.js @@ -1,872 +1,36 @@ -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; -} - -// ========== 主程序 ========== +const { execSync } = require('child_process'); + +const { + API, + DOWNLOAD_DIR, + CONFIG_FILE, + TRASH_DIR, + TEMP_ZIP_DIR, + QUALITY_LABELS, + DEFAULT_LEVEL, + DEFAULT_RETRIES, + getConfig, + setConfig, +} = require('./lib/config'); +const { fetchJSON, downloadFile } = require('./lib/network'); +const { sanitize, sleep, getDirSize } = require('./lib/utils'); +const { togglePause, waitIfPaused } = require('./lib/pause'); +const { loadHistory, saveHistory, addHistory, formatTime } = require('./lib/history'); +const { hasFFmpeg, hasNodeID3, embedCover } = require('./lib/tagging'); +const { fetchLyric, mergeLrc } = require('./lib/lyrics'); +const { + fetchNeteaseSongDetail, + fetchNeteaseAlbumDetail, + getAlbumDownloadTarget, + saveAlbumCover, + fetchFullMetadata, +} = require('./lib/metadata'); +const { parseLevel, printQualityMenu, askLevel } = require('./lib/quality'); +const { searchSongs, interactiveSearch, fetchPlaylist } = require('./lib/catalog'); +const { downloadSong } = require('./lib/downloader'); function createRL() { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); @@ -949,6 +113,10 @@ async function main() { console.log('❌ 专辑获取失败或没有歌曲'); } else { console.log(`💿 专辑: ${album.name} - ${album.artist?.name || '未知歌手'} (${album.tracks.length} 首)\n`); + if (batchCover) { + const albumTarget = getAlbumDownloadTarget(album, album.tracks[0], 0); + await saveAlbumCover(album.picUrl, albumTarget.outputDir); + } for (let i = 0; i < album.tracks.length; i++) { const track = album.tracks[i]; const target = getAlbumDownloadTarget(album, track, i); @@ -1798,6 +966,9 @@ async function main() { const confirm = await ask(`确认下载整张专辑? (y/n): `); if (confirm.trim().toLowerCase() !== 'y') break; + const albumTarget = getAlbumDownloadTarget(album, album.tracks[0], 0); + await saveAlbumCover(album.picUrl, albumTarget.outputDir); + for (let i = 0; i < album.tracks.length; i++) { const track = album.tracks[i]; const artists = track.ar?.map(a => a.name).join('/') || album.artist?.name || '未知'; @@ -1883,6 +1054,7 @@ async function main() { const confirm = await ask(`确认下载? (y/n): `); if (confirm.trim().toLowerCase() !== 'y') break; + const savedCoverDirs = new Set(); for (let i = 0; i < allSongs.length; i++) { const item = allSongs[i]; const s = item.song; @@ -1890,6 +1062,11 @@ async function main() { name: item.albumName, artist: { name: s.artists || '未知歌手' }, }, s, item.trackIndex, item.trackCount); + if (!savedCoverDirs.has(target.outputDir)) { + const detail = await fetchNeteaseSongDetail(s.id); + await saveAlbumCover(detail?.album?.cover, target.outputDir); + savedCoverDirs.add(target.outputDir); + } await waitIfPaused(); console.log(`\n[${i + 1}/${allSongs.length}] ${s.name} - ${s.artists}`); try { await downloadSong(s.id, level, target); } diff --git a/AGENTS.md b/AGENTS.md index 704e0fd..47758f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ This file provides guidance to Codex (Codex.ai/code) when working with code in t ## 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. +A modular CommonJS Node.js CLI for downloading music from NetEase Cloud Music (网易云音乐), with lyrics, cover art, and ID3 tag embedding. `163_music_downloader.js` owns CLI argument handling and the interactive menu; reusable logic lives under `lib/`. 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 @@ -37,12 +37,14 @@ If a dependency is missing the relevant feature degrades gracefully (skips embed ## 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: +`163_music_downloader.js` drives batch mode or the interactive menu loop, while `lib/` contains feature modules: -- **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`. +- **`lib/network.js`** — `fetchJSON`, `downloadFile` (streams with a live progress bar, follows redirects), and `downloadBuffer`. +- **`lib/downloader.js`** — the core retry/download pipeline: resolves the URL, handles existing files, writes audio and lyrics, embeds cover/tags, then records history. +- **`lib/lyrics.js`** — lyric fetching, translation merging, credit parsing, and standalone `.lrc` saving. +- **`lib/metadata.js`** — NetEase song/album metadata, album directory naming, and `cover.jpg` saving. +- **`lib/tagging.js`** — node-id3/ffmpeg detection and audio tag embedding. +- **`lib/config.js`, `history.js`, `pause.js`, `quality.js`, `catalog.js`, `utils.js`** — configuration, persistence, batch state, quality selection, search, and shared helpers. - **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`). @@ -53,5 +55,5 @@ Everything is one flat module — functions defined top-to-bottom, then a `main( - **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). +- **Pause** — `lib/pause.js` owns pause state; `waitIfPaused()` gates batch loops and the `p` hotkey toggles it in interactive TTY mode. - 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 index d31d6de..4e0659a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 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. +A modular CommonJS Node.js CLI for downloading music from NetEase Cloud Music (网易云音乐), with lyrics, cover art, and ID3 tag embedding. `163_music_downloader.js` owns CLI argument handling and the interactive menu; reusable logic lives under `lib/`. 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 @@ -37,12 +37,14 @@ If a dependency is missing the relevant feature degrades gracefully (skips embed ## 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: +`163_music_downloader.js` drives batch mode or the interactive menu loop, while `lib/` contains feature modules: -- **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`. +- **`lib/network.js`** — `fetchJSON`, `downloadFile` (streams with a live progress bar, follows redirects), and `downloadBuffer`. +- **`lib/downloader.js`** — the core retry/download pipeline: resolves the URL, handles existing files, writes audio and lyrics, embeds cover/tags, then records history. +- **`lib/lyrics.js`** — lyric fetching, translation merging, credit parsing, and standalone `.lrc` saving. +- **`lib/metadata.js`** — NetEase song/album metadata, album directory naming, and `cover.jpg` saving. +- **`lib/tagging.js`** — node-id3/ffmpeg detection and audio tag embedding. +- **`lib/config.js`, `history.js`, `pause.js`, `quality.js`, `catalog.js`, `utils.js`** — configuration, persistence, batch state, quality selection, search, and shared helpers. - **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`). @@ -53,5 +55,5 @@ Everything is one flat module — functions defined top-to-bottom, then a `main( - **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). +- **Pause** — `lib/pause.js` owns pause state; `waitIfPaused()` gates batch loops and the `p` hotkey toggles it in interactive TTY mode. - Batch loops sleep ~300–500ms between songs to avoid hammering the API — preserve this when editing download loops. diff --git a/README.md b/README.md index f7abe14..e0bcc88 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 一个基于 Node.js 的网易云音乐命令行下载工具,支持单曲、歌单、专辑及批量下载,并可同步保存歌词、封面和歌曲标签。 -项目采用单文件实现,核心功能仅依赖 Node.js 内置模块。`node-id3`、`ffmpeg` 和本地音频播放器均为可选依赖。 +项目采用 CommonJS 模块化结构,核心功能仅依赖 Node.js 内置模块。`node-id3`、`ffmpeg` 和本地音频播放器均为可选依赖。 ## 功能特性 @@ -45,6 +45,24 @@ node 163_music_downloader.js 不带参数运行时会进入中文交互菜单,可搜索、下载和管理歌曲。 +## 项目结构 + +```text +163_music_downloader.js # 命令行入口与交互菜单 +lib/ + catalog.js # 搜索与歌单接口 + config.js # 应用配置、路径和默认值 + downloader.js # 单曲下载主流程与重试 + history.js # 下载历史读写 + lyrics.js # 歌词获取、合并和制作信息解析 + metadata.js # 网易云歌曲/专辑元数据与专辑目录 + network.js # HTTP 请求和文件下载 + pause.js # 批量任务暂停状态 + quality.js # 音质解析与交互选择 + tagging.js # 封面及音频标签写入 + utils.js # 文件名、目录大小等通用工具 +``` + ## 命令行模式 直接下载一个或多个歌曲 ID: @@ -123,6 +141,7 @@ node 163_music_downloader.js 347230 --level=hires --no-cover ## 输出文件 - 音频和 `.lrc` 歌词保存在 `paths.downloadDir` 指定的目录 +- 下载专辑时,专辑图片会保存为对应专辑目录下的 `cover.jpg` - 下载历史保存在 `paths.historyFile` 指定的 JSON 文件中,默认最多保留 500 条 - 交互菜单修改的音质设置保存在 `paths.runtimeConfigFile` 指定的文件中 - 删除操作会先将文件移动到 `paths.trashDir`,而不是永久删除 diff --git a/lib/catalog.js b/lib/catalog.js new file mode 100644 index 0000000..415d094 --- /dev/null +++ b/lib/catalog.js @@ -0,0 +1,47 @@ +const { API } = require('./config'); +const { fetchJSON } = require('./network'); + +async function searchSongs(keyword, limit = 10) { + const res = await fetchJSON(`${API.search}?keyword=${encodeURIComponent(keyword)}&limit=${limit}`); + if (res.code !== 200) { + console.log('❌ 搜索无结果'); + return []; + } + const songs = Array.isArray(res.data) ? res.data : (res.data?.songs || []); + if (!songs.length) console.log('❌ 搜索无结果'); + 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((song, index) => { + const num = String(index + 1).padStart(2); + const name = song.name.slice(0, 20).padEnd(22); + const artist = song.artists.slice(0, 16).padEnd(18); + console.log(` ${num} ${name} ${artist} ${song.album}`); + }); + + const pick = await ask('\n选择序号 (多个用逗号分隔, 0=取消): '); + if (!pick.trim() || pick.trim() === '0') return null; + return pick.split(/[,,\s]+/) + .map(value => parseInt(value, 10)) + .filter(value => value >= 1 && value <= results.length) + .map(value => results[value - 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; +} + +module.exports = { searchSongs, interactiveSearch, fetchPlaylist }; diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 0000000..c371539 --- /dev/null +++ b/lib/config.js @@ -0,0 +1,126 @@ +const fs = require('fs'); +const path = require('path'); + +const ROOT_DIR = path.join(__dirname, '..'); +const APP_CONFIG_FILE = path.join(ROOT_DIR, '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(ROOT_DIR, 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; + +function loadRuntimeConfig() { + try { + if (fs.existsSync(CONFIG_FILE)) return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8')); + } catch {} + return {}; +} + +function saveRuntimeConfig(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 = loadRuntimeConfig(); + return config[key] !== undefined ? config[key] : defaultVal; +} + +function setConfig(key, val) { + const config = loadRuntimeConfig(); + config[key] = val; + saveRuntimeConfig(config); +} + +if (!fs.existsSync(DOWNLOAD_DIR)) fs.mkdirSync(DOWNLOAD_DIR, { recursive: true }); + +module.exports = { + API, + NETEASE_BASE_URL, + DOWNLOAD_DIR, + HISTORY_FILE, + CONFIG_FILE, + TRASH_DIR, + TEMP_ZIP_DIR, + QUALITY_LEVELS, + QUALITY_LABELS, + DEFAULT_LEVEL, + DEFAULT_RETRIES, + HISTORY_LIMIT, + getConfig, + setConfig, +}; diff --git a/lib/downloader.js b/lib/downloader.js new file mode 100644 index 0000000..c498212 --- /dev/null +++ b/lib/downloader.js @@ -0,0 +1,139 @@ +const fs = require('fs'); +const path = require('path'); +const { API, DEFAULT_RETRIES, DOWNLOAD_DIR } = require('./config'); +const { addHistory } = require('./history'); +const { fetchLyric, mergeLrc, parseCredits } = require('./lyrics'); +const { fetchNeteaseAlbumDetail, fetchNeteaseSongDetail } = require('./metadata'); +const { downloadFile, fetchJSON } = require('./network'); +const { embedCover } = require('./tagging'); +const { sanitize, sleep } = require('./utils'); + +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 downloadSongOnce(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 downloadSongOnce(songId, level, { + downloadLyric = true, + embedCoverArt = true, + outputDir = DOWNLOAD_DIR, + outputBaseName = null, +} = {}) { + console.log(`\n🎵 解析歌曲 ID: ${songId} ...`); + const result = await fetchJSON(`${API.music}?id=${songId}&level=${level}`); + 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 / 1048576).toFixed(2)} MB`); + + if (fs.existsSync(dest)) { + const existStat = fs.statSync(dest); + 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?.trim()) { + fs.writeFileSync(path.join(outputDir, baseName + '.合并翻译.lrc'), mergeLrc(lyricData.lrc, lyricData.tlyric), '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) { + fs.writeFileSync(path.join(outputDir, baseName + '.lrc'), lyricData.lrc, 'utf-8'); + console.log(` 📄 歌词已保存: ${baseName}.lrc`); + if (lyricData.tlyric?.trim()) { + const mergedPath = path.join(outputDir, baseName + '.合并翻译.lrc'); + fs.writeFileSync(mergedPath, mergeLrc(lyricData.lrc, lyricData.tlyric), '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, ...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(item => item.name).join(' / '); + if (albumArtist) tagMeta.albumArtist = albumArtist; + if (detail.album?.id) { + const albumDetail = await fetchNeteaseAlbumDetail(detail.album.id); + if (albumDetail?.publishTime) { + const date = new Date(albumDetail.publishTime); + tagMeta.year = date.getFullYear(); + tagMeta.date = date.toISOString().slice(0, 10); + } + 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; +} + +module.exports = { downloadSong }; diff --git a/lib/history.js b/lib/history.js new file mode 100644 index 0000000..7deafbb --- /dev/null +++ b/lib/history.js @@ -0,0 +1,30 @@ +const fs = require('fs'); +const path = require('path'); +const { HISTORY_FILE, HISTORY_LIMIT } = require('./config'); + +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 date = new Date(isoStr); + const pad = value => String(value).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +module.exports = { loadHistory, saveHistory, addHistory, formatTime }; diff --git a/lib/lyrics.js b/lib/lyrics.js new file mode 100644 index 0000000..79a0876 --- /dev/null +++ b/lib/lyrics.js @@ -0,0 +1,79 @@ +const fs = require('fs'); +const path = require('path'); +const { API, DOWNLOAD_DIR } = require('./config'); +const { fetchJSON } = require('./network'); + +async function fetchLyric(songId) { + try { + const res = await fetchJSON(`${API.lyric}?id=${songId}`); + if (res.code !== 200) return null; + return res.data; + } catch { + return null; + } +} + +function mergeLrc(lrc, tlyric) { + if (!lrc) return ''; + if (!tlyric) return lrc; + + const transMap = new Map(); + for (const line of tlyric.split('\n')) { + const match = line.match(/^\[(\d+:\d+[\.:]\d+)\](.*)/); + if (match && match[2].trim()) transMap.set(match[1], match[2].trim()); + } + + const result = []; + for (const line of lrc.split('\n')) { + result.push(line); + const match = line.match(/^\[(\d+:\d+[\.:]\d+)\]/); + if (match && transMap.has(match[1])) result.push(`[${match[1]}]${transMap.get(match[1])}`); + } + return result.join('\n'); +} + +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')) { + const line = raw.replace(/^(\s*\[[^\]]*\]\s*)+/, '').trim(); + const match = line.match(/^([^::]{1,12})\s*[::]\s*(.+)$/); + if (!match) continue; + const label = match[1].trim(); + const value = match[2].trim().replace(/\s*\/\s*/g, '/'); + if (!value) continue; + for (const [key, pattern] of Object.entries(labels)) { + if (pattern.test(label) && !credits[key]) { + credits[key] = value; + break; + } + } + } + return credits; +} + +async function saveLyric(songId, fileName) { + const data = await fetchLyric(songId); + if (!data?.lrc) { + console.log(' ⚠️ 未获取到歌词'); + return; + } + + const lrcPath = path.join(DOWNLOAD_DIR, fileName + '.lrc'); + fs.writeFileSync(lrcPath, data.lrc, 'utf-8'); + console.log(` 📄 歌词已保存: ${path.basename(lrcPath)}`); + if (data.tlyric?.trim()) { + const mergedPath = path.join(DOWNLOAD_DIR, fileName + '.合并翻译.lrc'); + fs.writeFileSync(mergedPath, mergeLrc(data.lrc, data.tlyric), 'utf-8'); + console.log(` 📄 翻译歌词已保存: ${path.basename(mergedPath)}`); + } +} + +module.exports = { fetchLyric, mergeLrc, parseCredits, saveLyric }; diff --git a/lib/metadata.js b/lib/metadata.js new file mode 100644 index 0000000..02676cb --- /dev/null +++ b/lib/metadata.js @@ -0,0 +1,168 @@ +const fs = require('fs'); +const http = require('http'); +const https = require('https'); +const path = require('path'); +const { API, DOWNLOAD_DIR, NETEASE_BASE_URL } = require('./config'); +const { downloadBuffer, fetchJSON } = require('./network'); +const { sanitizePathSegment } = require('./utils'); + +function requestNetease(pathname) { + return new Promise((resolve) => { + const requestUrl = new URL(pathname, `${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 protocol = requestUrl.protocol === 'https:' ? https : http; + const req = protocol.request(requestUrl, options, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + try { resolve(JSON.parse(data)); } + catch { resolve(null); } + }); + }); + req.on('error', () => resolve(null)); + req.setTimeout(5000, () => { req.destroy(); resolve(null); }); + req.end(); + }); +} + +async function fetchNeteaseSongDetail(songId) { + const data = await requestNetease(`/api/v1/song/detail?ids=[${songId}]`); + const song = data?.songs?.[0]; + if (!song) return null; + return { + id: song.id, + name: song.name, + artists: song.ar?.map(artist => ({ id: artist.id, name: artist.name })) || [], + album: song.al ? { id: song.al.id, name: song.al.name, cover: song.al.picUrl || song.al.cover } : null, + duration: song.dt || 0, + disc: song.cd || '', + trackNo: song.no || 0, + aliases: song.alia || [], + transNames: song.tns || [], + popularity: song.pop || 0, + fee: song.fee, + quality: { + h: song.h ? { br: song.h.br, size: song.h.size, sr: song.h.sr } : null, + m: song.m ? { br: song.m.br, size: song.m.size, sr: song.m.sr } : null, + l: song.l ? { br: song.l.br, size: song.l.size, sr: song.l.sr } : null, + }, + }; +} + +async function fetchNeteaseAlbumDetail(albumId) { + const data = await requestNetease(`/api/v1/album/${albumId}`); + if (!data) return null; + const album = data.album || data; + return { + id: album.id, + name: album.name, + publishTime: album.publishTime || null, + company: album.company || '', + description: album.description || '', + tags: album.tags || [], + type: album.type || '', + size: album.size || 0, + artist: album.artist ? { id: album.artist.id, name: album.artist.name, trans: album.artist.trans } : null, + picUrl: album.picUrl || '', + tracks: (data.songs || album.songs || []).map(song => ({ + id: song.id, + name: song.name, + ar: song.ar || song.artists || [], + al: song.al || song.album || null, + dt: song.dt || song.duration || 0, + disc: song.cd || '', + trackNo: song.no || 0, + })), + }; +} + +function getAlbumDownloadTarget(album, track, index, trackCount = album.tracks?.length || 0) { + const trackArtists = track.ar?.map(artist => artist.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 saveAlbumCover(coverUrl, outputDir) { + const coverPath = path.join(outputDir, 'cover.jpg'); + if (fs.existsSync(coverPath) && fs.statSync(coverPath).size > 0) { + console.log(' ⏭️ 专辑封面已存在: cover.jpg'); + return; + } + if (!coverUrl) { + console.log(' ⚠️ 未获取到专辑封面'); + return; + } + try { + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(coverPath, await downloadBuffer(coverUrl)); + console.log(' 🖼️ 专辑封面已保存: cover.jpg'); + } catch (e) { + console.error(` ⚠️ 专辑封面保存失败: ${e.message}`); + } +} + +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; + + const albumDetail = songDetail.album?.id + ? await fetchNeteaseAlbumDetail(songDetail.album.id) + : null; + const durationSec = Math.round(songDetail.duration / 1000); + 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(artist => artist.name).join(' / '), + album: songDetail.album, + albumStr: songDetail.album?.name || '', + duration: songDetail.duration, + durationStr: `${Math.floor(durationSec / 60)}:${String(durationSec % 60).padStart(2, '0')}`, + disc: songDetail.disc, + trackNo: songDetail.trackNo, + aliases: songDetail.aliases, + transNames: songDetail.transNames, + popularity: songDetail.popularity, + fee: songDetail.fee, + publishTime: albumDetail?.publishTime, + publishYear: albumDetail?.publishTime ? new Date(albumDetail.publishTime).getFullYear() : null, + publishDate, + company: albumDetail?.company || '', + description: albumDetail?.description || '', + tags: albumDetail?.tags || [], + albumType: albumDetail?.type || '', + albumSize: albumDetail?.size || 0, + albumArtist: albumDetail?.artist, + quality: songDetail.quality, + chksz: chkszData?.code === 200 ? chkszData.data : null, + }; +} + +module.exports = { + fetchNeteaseSongDetail, + fetchNeteaseAlbumDetail, + getAlbumDownloadTarget, + saveAlbumCover, + fetchFullMetadata, +}; diff --git a/lib/network.js b/lib/network.js new file mode 100644 index 0000000..f562a6b --- /dev/null +++ b/lib/network.js @@ -0,0 +1,84 @@ +const fs = require('fs'); +const http = require('http'); +const https = require('https'); + +function getProtocol(url) { + return url.startsWith('https') ? https : http; +} + +function fetchJSON(url) { + return new Promise((resolve, reject) => { + getProtocol(url).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) => { + getProtocol(url).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) return; + const now = Date.now(); + const elapsed = (now - lastTime) / 1000; + if (elapsed < 0.3) return; + + 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 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}] ${(pct * 100).toFixed(1)}% ${(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) => { + getProtocol(url).get(url, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + return downloadBuffer(res.headers.location).then(resolve).catch(reject); + } + if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`)); + const chunks = []; + res.on('data', chunk => chunks.push(chunk)); + res.on('end', () => resolve(Buffer.concat(chunks))); + }).on('error', reject); + }); +} + +module.exports = { fetchJSON, downloadFile, downloadBuffer }; diff --git a/lib/pause.js b/lib/pause.js new file mode 100644 index 0000000..b59ce6b --- /dev/null +++ b/lib/pause.js @@ -0,0 +1,23 @@ +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; }); +} + +module.exports = { togglePause, waitIfPaused }; diff --git a/lib/quality.js b/lib/quality.js new file mode 100644 index 0000000..0b1faa2 --- /dev/null +++ b/lib/quality.js @@ -0,0 +1,32 @@ +const { QUALITY_LEVELS, QUALITY_LABELS } = require('./config'); + +function parseLevel(input) { + const value = (input || '').trim().toLowerCase(); + if (!value) return null; + if (/^\d+$/.test(value)) { + const index = parseInt(value, 10) - 1; + return index >= 0 && index < QUALITY_LEVELS.length ? QUALITY_LEVELS[index] : null; + } + return QUALITY_LEVELS.includes(value) ? value : null; +} + +function printQualityMenu(currentLevel) { + QUALITY_LEVELS.forEach((level, index) => { + const mark = level === currentLevel ? ' ←当前' : ''; + console.log(` ${index + 1}. ${level.padEnd(10)} ${(QUALITY_LABELS[level] || '').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; +} + +module.exports = { parseLevel, printQualityMenu, askLevel }; diff --git a/lib/tagging.js b/lib/tagging.js new file mode 100644 index 0000000..5baf8cb --- /dev/null +++ b/lib/tagging.js @@ -0,0 +1,97 @@ +const fs = require('fs'); +const path = require('path'); +const { execSync, execFileSync } = require('child_process'); +const { downloadBuffer } = require('./network'); + +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); + + 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; + if (meta.lyricist) tags.textWriter = meta.lyricist; + if (meta.publisher) tags.publisher = meta.publisher; + + 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; + const args = [ + '-y', '-i', audioPath, '-i', coverPath, + '-map', '0:a', '-map', '1:0', '-c', 'copy', + '-disposition:v:0', 'attached_pic', + ]; + const addMeta = (key, value) => { + if (value !== undefined && value !== null && value !== '') { + args.push('-metadata', `${key}=${value}`); + } + }; + 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); + addMeta('publisher', meta.publisher); + addMeta('lyricist', meta.lyricist); + addMeta('arranger', meta.arranger); + addMeta('producer', meta.producer); + addMeta('mixer', meta.mixer); + 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); + } +} + +module.exports = { hasFFmpeg, hasNodeID3, embedCover }; diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 0000000..591428c --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,29 @@ +const fs = require('fs'); +const path = require('path'); + +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(resolve => setTimeout(resolve, ms)); +} + +function getDirSize(dirPath) { + let size = 0; + try { + for (const item of fs.readdirSync(dirPath)) { + const itemPath = path.join(dirPath, item); + const stat = fs.statSync(itemPath); + size += stat.isDirectory() ? getDirSize(itemPath) : stat.size; + } + } catch {} + return size; +} + +module.exports = { sanitize, sanitizePathSegment, sleep, getDirSize };