refactor: split downloader into modules
This commit is contained in:
+43
-866
@@ -1,872 +1,36 @@
|
|||||||
const https = require('https');
|
|
||||||
const http = require('http');
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const readline = require('readline');
|
const readline = require('readline');
|
||||||
const { execSync, execFileSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
// ========== 配置 ==========
|
const {
|
||||||
const APP_CONFIG_FILE = path.join(__dirname, 'config.json');
|
API,
|
||||||
const DEFAULT_APP_CONFIG = {
|
DOWNLOAD_DIR,
|
||||||
api: {
|
CONFIG_FILE,
|
||||||
music: 'https://api.chksz.top/api/163_music',
|
TRASH_DIR,
|
||||||
lyric: 'https://api.chksz.top/api/163_lyric',
|
TEMP_ZIP_DIR,
|
||||||
search: 'https://api.chksz.top/api/163_search',
|
QUALITY_LABELS,
|
||||||
playlist: 'https://api.chksz.top/api/163_playlist',
|
DEFAULT_LEVEL,
|
||||||
neteaseBaseUrl: 'https://music.163.com',
|
DEFAULT_RETRIES,
|
||||||
},
|
getConfig,
|
||||||
paths: {
|
setConfig,
|
||||||
downloadDir: 'downloads',
|
} = require('./lib/config');
|
||||||
historyFile: 'download_history.json',
|
const { fetchJSON, downloadFile } = require('./lib/network');
|
||||||
runtimeConfigFile: 'downloader_config.json',
|
const { sanitize, sleep, getDirSize } = require('./lib/utils');
|
||||||
trashDir: '.trash',
|
const { togglePause, waitIfPaused } = require('./lib/pause');
|
||||||
tempZipDir: '.tmp_zip',
|
const { loadHistory, saveHistory, addHistory, formatTime } = require('./lib/history');
|
||||||
},
|
const { hasFFmpeg, hasNodeID3, embedCover } = require('./lib/tagging');
|
||||||
defaults: {
|
const { fetchLyric, mergeLrc } = require('./lib/lyrics');
|
||||||
quality: 'jymaster',
|
const {
|
||||||
retries: 3,
|
fetchNeteaseSongDetail,
|
||||||
historyLimit: 500,
|
fetchNeteaseAlbumDetail,
|
||||||
},
|
getAlbumDownloadTarget,
|
||||||
};
|
saveAlbumCover,
|
||||||
|
fetchFullMetadata,
|
||||||
function loadAppConfig() {
|
} = require('./lib/metadata');
|
||||||
try {
|
const { parseLevel, printQualityMenu, askLevel } = require('./lib/quality');
|
||||||
if (!fs.existsSync(APP_CONFIG_FILE)) return DEFAULT_APP_CONFIG;
|
const { searchSongs, interactiveSearch, fetchPlaylist } = require('./lib/catalog');
|
||||||
const config = JSON.parse(fs.readFileSync(APP_CONFIG_FILE, 'utf-8'));
|
const { downloadSong } = require('./lib/downloader');
|
||||||
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() {
|
function createRL() {
|
||||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
@@ -949,6 +113,10 @@ async function main() {
|
|||||||
console.log('❌ 专辑获取失败或没有歌曲');
|
console.log('❌ 专辑获取失败或没有歌曲');
|
||||||
} else {
|
} else {
|
||||||
console.log(`💿 专辑: ${album.name} - ${album.artist?.name || '未知歌手'} (${album.tracks.length} 首)\n`);
|
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++) {
|
for (let i = 0; i < album.tracks.length; i++) {
|
||||||
const track = album.tracks[i];
|
const track = album.tracks[i];
|
||||||
const target = getAlbumDownloadTarget(album, track, i);
|
const target = getAlbumDownloadTarget(album, track, i);
|
||||||
@@ -1798,6 +966,9 @@ async function main() {
|
|||||||
const confirm = await ask(`确认下载整张专辑? (y/n): `);
|
const confirm = await ask(`确认下载整张专辑? (y/n): `);
|
||||||
if (confirm.trim().toLowerCase() !== 'y') break;
|
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++) {
|
for (let i = 0; i < album.tracks.length; i++) {
|
||||||
const track = album.tracks[i];
|
const track = album.tracks[i];
|
||||||
const artists = track.ar?.map(a => a.name).join('/') || album.artist?.name || '未知';
|
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): `);
|
const confirm = await ask(`确认下载? (y/n): `);
|
||||||
if (confirm.trim().toLowerCase() !== 'y') break;
|
if (confirm.trim().toLowerCase() !== 'y') break;
|
||||||
|
|
||||||
|
const savedCoverDirs = new Set();
|
||||||
for (let i = 0; i < allSongs.length; i++) {
|
for (let i = 0; i < allSongs.length; i++) {
|
||||||
const item = allSongs[i];
|
const item = allSongs[i];
|
||||||
const s = item.song;
|
const s = item.song;
|
||||||
@@ -1890,6 +1062,11 @@ async function main() {
|
|||||||
name: item.albumName,
|
name: item.albumName,
|
||||||
artist: { name: s.artists || '未知歌手' },
|
artist: { name: s.artists || '未知歌手' },
|
||||||
}, s, item.trackIndex, item.trackCount);
|
}, 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();
|
await waitIfPaused();
|
||||||
console.log(`\n[${i + 1}/${allSongs.length}] ${s.name} - ${s.artists}`);
|
console.log(`\n[${i + 1}/${allSongs.length}] ${s.name} - ${s.artists}`);
|
||||||
try { await downloadSong(s.id, level, target); }
|
try { await downloadSong(s.id, level, target); }
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ This file provides guidance to Codex (Codex.ai/code) when working with code in t
|
|||||||
|
|
||||||
## Overview
|
## 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
|
## Running
|
||||||
|
|
||||||
@@ -37,12 +37,14 @@ If a dependency is missing the relevant feature degrades gracefully (skips embed
|
|||||||
|
|
||||||
## Architecture
|
## 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`.
|
- **`lib/network.js`** — `fetchJSON`, `downloadFile` (streams with a live progress bar, follows redirects), and `downloadBuffer`.
|
||||||
- **`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.
|
- **`lib/downloader.js`** — the core retry/download pipeline: resolves the URL, handles existing files, writes audio and lyrics, 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.
|
- **`lib/lyrics.js`** — lyric fetching, translation merging, credit parsing, and standalone `.lrc` saving.
|
||||||
- **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/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):
|
- **Persistence** (all JSON in the script's `__dirname`, not the download dir):
|
||||||
- `download_history.json` — capped at 500 entries, newest-first (`addHistory`).
|
- `download_history.json` — capped at 500 entries, newest-first (`addHistory`).
|
||||||
- `downloader_config.json` — key/value config, currently only the `level` quality setting (`getConfig`/`setConfig`).
|
- `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.
|
- **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.
|
- **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.
|
- **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.
|
- Batch loops sleep ~300–500ms between songs to avoid hammering the API — preserve this when editing download loops.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Overview
|
## 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
|
## Running
|
||||||
|
|
||||||
@@ -37,12 +37,14 @@ If a dependency is missing the relevant feature degrades gracefully (skips embed
|
|||||||
|
|
||||||
## Architecture
|
## 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`.
|
- **`lib/network.js`** — `fetchJSON`, `downloadFile` (streams with a live progress bar, follows redirects), and `downloadBuffer`.
|
||||||
- **`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.
|
- **`lib/downloader.js`** — the core retry/download pipeline: resolves the URL, handles existing files, writes audio and lyrics, 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.
|
- **`lib/lyrics.js`** — lyric fetching, translation merging, credit parsing, and standalone `.lrc` saving.
|
||||||
- **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/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):
|
- **Persistence** (all JSON in the script's `__dirname`, not the download dir):
|
||||||
- `download_history.json` — capped at 500 entries, newest-first (`addHistory`).
|
- `download_history.json` — capped at 500 entries, newest-first (`addHistory`).
|
||||||
- `downloader_config.json` — key/value config, currently only the `level` quality setting (`getConfig`/`setConfig`).
|
- `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.
|
- **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.
|
- **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.
|
- **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.
|
- Batch loops sleep ~300–500ms between songs to avoid hammering the API — preserve this when editing download loops.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
一个基于 Node.js 的网易云音乐命令行下载工具,支持单曲、歌单、专辑及批量下载,并可同步保存歌词、封面和歌曲标签。
|
一个基于 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:
|
直接下载一个或多个歌曲 ID:
|
||||||
@@ -123,6 +141,7 @@ node 163_music_downloader.js 347230 --level=hires --no-cover
|
|||||||
## 输出文件
|
## 输出文件
|
||||||
|
|
||||||
- 音频和 `.lrc` 歌词保存在 `paths.downloadDir` 指定的目录
|
- 音频和 `.lrc` 歌词保存在 `paths.downloadDir` 指定的目录
|
||||||
|
- 下载专辑时,专辑图片会保存为对应专辑目录下的 `cover.jpg`
|
||||||
- 下载历史保存在 `paths.historyFile` 指定的 JSON 文件中,默认最多保留 500 条
|
- 下载历史保存在 `paths.historyFile` 指定的 JSON 文件中,默认最多保留 500 条
|
||||||
- 交互菜单修改的音质设置保存在 `paths.runtimeConfigFile` 指定的文件中
|
- 交互菜单修改的音质设置保存在 `paths.runtimeConfigFile` 指定的文件中
|
||||||
- 删除操作会先将文件移动到 `paths.trashDir`,而不是永久删除
|
- 删除操作会先将文件移动到 `paths.trashDir`,而不是永久删除
|
||||||
|
|||||||
@@ -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 };
|
||||||
+126
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
+168
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
Reference in New Issue
Block a user