2897 lines
115 KiB
JavaScript
2897 lines
115 KiB
JavaScript
const https = require('https');
|
||
const http = require('http');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const readline = require('readline');
|
||
const { execSync, execFileSync } = require('child_process');
|
||
|
||
// ========== 配置 ==========
|
||
const APP_CONFIG_FILE = path.join(__dirname, 'config.json');
|
||
const DEFAULT_APP_CONFIG = {
|
||
api: {
|
||
music: 'https://api.chksz.top/api/163_music',
|
||
lyric: 'https://api.chksz.top/api/163_lyric',
|
||
search: 'https://api.chksz.top/api/163_search',
|
||
playlist: 'https://api.chksz.top/api/163_playlist',
|
||
neteaseBaseUrl: 'https://music.163.com',
|
||
},
|
||
paths: {
|
||
downloadDir: 'downloads',
|
||
historyFile: 'download_history.json',
|
||
runtimeConfigFile: 'downloader_config.json',
|
||
trashDir: '.trash',
|
||
tempZipDir: '.tmp_zip',
|
||
},
|
||
defaults: {
|
||
quality: 'jymaster',
|
||
retries: 3,
|
||
historyLimit: 500,
|
||
},
|
||
};
|
||
|
||
function loadAppConfig() {
|
||
try {
|
||
if (!fs.existsSync(APP_CONFIG_FILE)) return DEFAULT_APP_CONFIG;
|
||
const config = JSON.parse(fs.readFileSync(APP_CONFIG_FILE, 'utf-8'));
|
||
return {
|
||
api: { ...DEFAULT_APP_CONFIG.api, ...(config.api || {}) },
|
||
paths: { ...DEFAULT_APP_CONFIG.paths, ...(config.paths || {}) },
|
||
defaults: { ...DEFAULT_APP_CONFIG.defaults, ...(config.defaults || {}) },
|
||
};
|
||
} catch (e) {
|
||
console.warn(`⚠️ 配置文件读取失败,将使用默认配置: ${e.message}`);
|
||
return DEFAULT_APP_CONFIG;
|
||
}
|
||
}
|
||
|
||
function resolveConfigPath(configPath, fallback) {
|
||
const value = typeof configPath === 'string' && configPath.trim() ? configPath.trim() : fallback;
|
||
return path.isAbsolute(value) ? value : path.join(__dirname, value);
|
||
}
|
||
|
||
function getConfigString(value, fallback) {
|
||
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
||
}
|
||
|
||
const APP_CONFIG = loadAppConfig();
|
||
const API = {
|
||
music: getConfigString(APP_CONFIG.api.music, DEFAULT_APP_CONFIG.api.music),
|
||
lyric: getConfigString(APP_CONFIG.api.lyric, DEFAULT_APP_CONFIG.api.lyric),
|
||
search: getConfigString(APP_CONFIG.api.search, DEFAULT_APP_CONFIG.api.search),
|
||
playlist: getConfigString(APP_CONFIG.api.playlist, DEFAULT_APP_CONFIG.api.playlist),
|
||
neteaseBaseUrl: getConfigString(APP_CONFIG.api.neteaseBaseUrl, DEFAULT_APP_CONFIG.api.neteaseBaseUrl),
|
||
};
|
||
const NETEASE_BASE_URL = API.neteaseBaseUrl.replace(/\/$/, '');
|
||
const DOWNLOAD_DIR = resolveConfigPath(APP_CONFIG.paths.downloadDir, DEFAULT_APP_CONFIG.paths.downloadDir);
|
||
const HISTORY_FILE = resolveConfigPath(APP_CONFIG.paths.historyFile, DEFAULT_APP_CONFIG.paths.historyFile);
|
||
const CONFIG_FILE = resolveConfigPath(APP_CONFIG.paths.runtimeConfigFile, DEFAULT_APP_CONFIG.paths.runtimeConfigFile);
|
||
const TRASH_DIR = resolveConfigPath(APP_CONFIG.paths.trashDir, DEFAULT_APP_CONFIG.paths.trashDir);
|
||
const TEMP_ZIP_DIR = resolveConfigPath(APP_CONFIG.paths.tempZipDir, DEFAULT_APP_CONFIG.paths.tempZipDir);
|
||
const QUALITY_LEVELS = ['standard', 'exhigh', 'lossless', 'hires', 'jymaster', 'sky', 'jyeffect'];
|
||
const QUALITY_LABELS = {
|
||
standard: '标准',
|
||
exhigh: '极高',
|
||
lossless: '无损',
|
||
hires: 'Hi-Res',
|
||
jymaster: '超清母带',
|
||
sky: '空间音频',
|
||
jyeffect: '高清臻品',
|
||
};
|
||
const DEFAULT_LEVEL = QUALITY_LEVELS.includes(APP_CONFIG.defaults.quality)
|
||
? APP_CONFIG.defaults.quality
|
||
: DEFAULT_APP_CONFIG.defaults.quality;
|
||
const DEFAULT_RETRIES = Number.isInteger(APP_CONFIG.defaults.retries) && APP_CONFIG.defaults.retries > 0
|
||
? APP_CONFIG.defaults.retries
|
||
: DEFAULT_APP_CONFIG.defaults.retries;
|
||
const HISTORY_LIMIT = Number.isInteger(APP_CONFIG.defaults.historyLimit) && APP_CONFIG.defaults.historyLimit > 0
|
||
? APP_CONFIG.defaults.historyLimit
|
||
: DEFAULT_APP_CONFIG.defaults.historyLimit;
|
||
|
||
if (!fs.existsSync(DOWNLOAD_DIR)) fs.mkdirSync(DOWNLOAD_DIR, { recursive: true });
|
||
|
||
// ========== 网络工具 ==========
|
||
|
||
function fetchJSON(url) {
|
||
return new Promise((resolve, reject) => {
|
||
const proto = url.startsWith('https') ? https : http;
|
||
proto.get(url, (res) => {
|
||
let data = '';
|
||
res.on('data', chunk => data += chunk);
|
||
res.on('end', () => {
|
||
try { resolve(JSON.parse(data)); }
|
||
catch { reject(new Error(`JSON 解析失败: ${data.slice(0, 200)}`)); }
|
||
});
|
||
}).on('error', reject);
|
||
});
|
||
}
|
||
|
||
function downloadFile(url, dest, showProgress = true) {
|
||
return new Promise((resolve, reject) => {
|
||
const proto = url.startsWith('https') ? https : http;
|
||
proto.get(url, (res) => {
|
||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||
return downloadFile(res.headers.location, dest, showProgress).then(resolve).catch(reject);
|
||
}
|
||
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
|
||
|
||
const total = parseInt(res.headers['content-length'], 10);
|
||
let downloaded = 0;
|
||
let lastTime = Date.now();
|
||
let lastBytes = 0;
|
||
const file = fs.createWriteStream(dest);
|
||
|
||
res.on('data', (chunk) => {
|
||
downloaded += chunk.length;
|
||
if (showProgress) {
|
||
const now = Date.now();
|
||
const elapsed = (now - lastTime) / 1000;
|
||
|
||
// 每 300ms 更新一次
|
||
if (elapsed >= 0.3) {
|
||
const speed = (downloaded - lastBytes) / elapsed;
|
||
lastTime = now;
|
||
lastBytes = downloaded;
|
||
|
||
const speedStr = speed >= 1048576
|
||
? `${(speed / 1048576).toFixed(1)} MB/s`
|
||
: `${(speed / 1024).toFixed(0)} KB/s`;
|
||
|
||
if (total) {
|
||
const pct = downloaded / total;
|
||
const pctStr = (pct * 100).toFixed(1);
|
||
const barWidth = 30;
|
||
const filled = Math.round(pct * barWidth);
|
||
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
||
const eta = speed > 0 ? Math.ceil((total - downloaded) / speed) : 0;
|
||
const etaStr = eta > 60 ? `${Math.floor(eta / 60)}m${eta % 60}s` : `${eta}s`;
|
||
process.stdout.write(`\r ⬇️ [${bar}] ${pctStr}% ${(downloaded / 1048576).toFixed(1)}/${(total / 1048576).toFixed(1)} MB ${speedStr} ETA ${etaStr} `);
|
||
} else {
|
||
process.stdout.write(`\r ⬇️ ${(downloaded / 1048576).toFixed(1)} MB ${speedStr} `);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
res.pipe(file);
|
||
file.on('finish', () => { file.close(); if (showProgress) console.log(); resolve(); });
|
||
file.on('error', (err) => { fs.unlink(dest, () => {}); reject(err); });
|
||
}).on('error', reject);
|
||
});
|
||
}
|
||
|
||
function downloadBuffer(url) {
|
||
return new Promise((resolve, reject) => {
|
||
https.get(url, (res) => {
|
||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||
return downloadBuffer(res.headers.location).then(resolve).catch(reject);
|
||
}
|
||
const chunks = [];
|
||
res.on('data', chunk => chunks.push(chunk));
|
||
res.on('end', () => resolve(Buffer.concat(chunks)));
|
||
}).on('error', reject);
|
||
});
|
||
}
|
||
|
||
function sanitize(name) {
|
||
return name.replace(/[\/\\:*?"<>|]/g, '_').trim();
|
||
}
|
||
|
||
function sanitizePathSegment(name, fallback) {
|
||
const value = sanitize(String(name || '')).replace(/[. ]+$/g, '');
|
||
return value && value !== '.' && value !== '..' ? value : fallback;
|
||
}
|
||
|
||
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||
|
||
function getDirSize(dirPath) {
|
||
let size = 0;
|
||
try {
|
||
const items = fs.readdirSync(dirPath);
|
||
for (const item of items) {
|
||
const p = path.join(dirPath, item);
|
||
const stat = fs.statSync(p);
|
||
if (stat.isDirectory()) size += getDirSize(p);
|
||
else size += stat.size;
|
||
}
|
||
} catch {}
|
||
return size;
|
||
}
|
||
|
||
// 全局暂停控制
|
||
let isPaused = false;
|
||
let pauseResolve = null;
|
||
|
||
function togglePause() {
|
||
if (isPaused) {
|
||
isPaused = false;
|
||
if (pauseResolve) { pauseResolve(); pauseResolve = null; }
|
||
console.log('\n ▶️ 已恢复下载');
|
||
} else {
|
||
isPaused = true;
|
||
console.log('\n ⏸ 已暂停 (再按 p 恢复)');
|
||
}
|
||
}
|
||
|
||
async function waitIfPaused() {
|
||
if (!isPaused) return;
|
||
await new Promise(resolve => { pauseResolve = resolve; });
|
||
}
|
||
|
||
// ========== 下载历史 ==========
|
||
|
||
function loadHistory() {
|
||
try {
|
||
if (fs.existsSync(HISTORY_FILE)) {
|
||
return JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf-8'));
|
||
}
|
||
} catch {}
|
||
return [];
|
||
}
|
||
|
||
function saveHistory(history) {
|
||
fs.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true });
|
||
fs.writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2), 'utf-8');
|
||
}
|
||
|
||
function addHistory(entry) {
|
||
const history = loadHistory();
|
||
history.unshift({
|
||
...entry,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
// 限制历史记录数量
|
||
if (history.length > HISTORY_LIMIT) history.length = HISTORY_LIMIT;
|
||
saveHistory(history);
|
||
}
|
||
|
||
function formatTime(isoStr) {
|
||
const d = new Date(isoStr);
|
||
const pad = (n) => String(n).padStart(2, '0');
|
||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||
}
|
||
|
||
// ========== 配置管理 ==========
|
||
|
||
function loadConfig() {
|
||
try {
|
||
if (fs.existsSync(CONFIG_FILE)) {
|
||
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
|
||
}
|
||
} catch {}
|
||
return {};
|
||
}
|
||
|
||
function saveConfig(config) {
|
||
fs.mkdirSync(path.dirname(CONFIG_FILE), { recursive: true });
|
||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
||
}
|
||
|
||
function getConfig(key, defaultVal) {
|
||
const config = loadConfig();
|
||
return config[key] !== undefined ? config[key] : defaultVal;
|
||
}
|
||
|
||
function setConfig(key, val) {
|
||
const config = loadConfig();
|
||
config[key] = val;
|
||
saveConfig(config);
|
||
}
|
||
|
||
// ========== 封面嵌入 ==========
|
||
|
||
function hasFFmpeg() {
|
||
try { execSync('ffmpeg -version', { stdio: 'ignore' }); return true; } catch { return false; }
|
||
}
|
||
function hasNodeID3() {
|
||
try { require.resolve('node-id3'); return true; } catch { return false; }
|
||
}
|
||
|
||
async function embedCover(audioPath, coverUrl, meta) {
|
||
const ext = path.extname(audioPath).toLowerCase();
|
||
const coverPath = audioPath + '.cover.jpg';
|
||
|
||
try {
|
||
const coverBuf = await downloadBuffer(coverUrl);
|
||
fs.writeFileSync(coverPath, coverBuf);
|
||
|
||
// 优先 node-id3 (仅 MP3)
|
||
if (ext === '.mp3' && hasNodeID3()) {
|
||
const NodeID3 = require('node-id3');
|
||
const tags = {
|
||
title: meta.name,
|
||
artist: meta.artist,
|
||
album: meta.album,
|
||
image: coverPath,
|
||
};
|
||
if (meta.albumArtist) tags.performerInfo = meta.albumArtist;
|
||
if (meta.year) tags.year = String(meta.year);
|
||
if (meta.date) tags.date = String(meta.date);
|
||
if (meta.trackNo) tags.trackNumber = String(meta.trackNo);
|
||
if (meta.disc) tags.partOfSet = String(meta.disc);
|
||
if (meta.genre) tags.genre = meta.genre;
|
||
if (meta.composer) tags.composer = meta.composer; // TCOM 作曲
|
||
if (meta.lyricist) tags.textWriter = meta.lyricist; // TEXT 作词
|
||
if (meta.publisher) tags.publisher = meta.publisher; // TPUB 发行公司
|
||
// 编曲/制作人/混音无标准帧,写入自定义 TXXX
|
||
const userTexts = [];
|
||
if (meta.arranger) userTexts.push({ description: 'ARRANGER', value: meta.arranger });
|
||
if (meta.producer) userTexts.push({ description: 'PRODUCER', value: meta.producer });
|
||
if (meta.mixer) userTexts.push({ description: 'MIXER', value: meta.mixer });
|
||
if (userTexts.length) tags.userDefinedText = userTexts;
|
||
if (meta.lyrics) tags.unsynchronisedLyrics = { language: 'chi', text: meta.lyrics };
|
||
const ok = NodeID3.write(tags, audioPath);
|
||
if (ok === true) { console.log(' ✅ 封面+标签+歌词已嵌入 (node-id3)'); return; }
|
||
}
|
||
|
||
if (hasFFmpeg()) {
|
||
const tmpPath = audioPath + '.tmp' + ext;
|
||
|
||
// 用 execFileSync 直接传参数组,避免 shell 转义问题
|
||
const args = ['-y', '-i', audioPath, '-i', coverPath,
|
||
'-map', '0:a', '-map', '1:0', '-c', 'copy',
|
||
// 关键:将第二路输入标记为专辑封面,否则播放器不识别(FLAC/MP3 通用)
|
||
'-disposition:v:0', 'attached_pic',
|
||
];
|
||
|
||
const addMeta = (k, v) => {
|
||
if (v !== undefined && v !== null && v !== '') args.push('-metadata', `${k}=${v}`);
|
||
};
|
||
addMeta('title', meta.name);
|
||
addMeta('artist', meta.artist);
|
||
addMeta('album', meta.album);
|
||
addMeta('album_artist', meta.albumArtist || meta.artist);
|
||
addMeta('date', meta.date || meta.year); // 优先完整日期,回退到年份
|
||
addMeta('track', meta.trackNo);
|
||
addMeta('disc', meta.disc);
|
||
addMeta('genre', meta.genre);
|
||
addMeta('composer', meta.composer); // 作曲 (MP3→TCOM / FLAC→COMPOSER)
|
||
addMeta('publisher', meta.publisher); // 发行公司 (MP3→TPUB / FLAC→ORGANIZATION)
|
||
// 以下键 MP3 会写入 TXXX,FLAC 直接作为 Vorbis 注释
|
||
addMeta('lyricist', meta.lyricist); // 作词
|
||
addMeta('arranger', meta.arranger); // 编曲
|
||
addMeta('producer', meta.producer); // 制作人
|
||
addMeta('mixer', meta.mixer); // 混音
|
||
// 歌词过长时不写入标签(仍会另存 .lrc),避免命令行超长
|
||
if (meta.lyrics && meta.lyrics.length <= 8000) addMeta('lyrics', meta.lyrics);
|
||
// 封面流描述
|
||
args.push('-metadata:s:v', 'title=Album cover', '-metadata:s:v', 'comment=Cover (front)');
|
||
|
||
if (ext === '.mp3') args.push('-id3v2_version', '3');
|
||
args.push(tmpPath);
|
||
|
||
execFileSync('ffmpeg', args, { stdio: 'ignore' });
|
||
fs.renameSync(tmpPath, audioPath);
|
||
console.log(' ✅ 封面+标签+歌词已嵌入 (ffmpeg)');
|
||
} else {
|
||
console.log(' ⚠️ 跳过封面: 需要 node-id3 (npm i node-id3) 或 ffmpeg');
|
||
}
|
||
} catch (e) {
|
||
console.error(` ⚠️ 封面嵌入失败: ${e.message}`);
|
||
} finally {
|
||
if (fs.existsSync(coverPath)) fs.unlinkSync(coverPath);
|
||
}
|
||
}
|
||
|
||
// ========== 歌词处理 ==========
|
||
|
||
async function fetchLyric(songId) {
|
||
try {
|
||
const res = await fetchJSON(`${API.lyric}?id=${songId}`);
|
||
if (res.code !== 200) return null;
|
||
return res.data; // { lrc, tlyric, romalrc, klyric }
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function mergeLrc(lrc, tlyric) {
|
||
if (!lrc) return '';
|
||
if (!tlyric) return lrc;
|
||
|
||
// 解析翻译歌词为 { timestamp: text } 映射
|
||
const transMap = new Map();
|
||
for (const line of tlyric.split('\n')) {
|
||
const m = line.match(/^\[(\d+:\d+[\.:]\d+)\](.*)/);
|
||
if (m && m[2].trim()) transMap.set(m[1], m[2].trim());
|
||
}
|
||
|
||
// 在原文歌词每行后面插入翻译
|
||
const result = [];
|
||
for (const line of lrc.split('\n')) {
|
||
result.push(line);
|
||
const m = line.match(/^\[(\d+:\d+[\.:]\d+)\]/);
|
||
if (m) {
|
||
const ts = m[1];
|
||
if (transMap.has(ts)) {
|
||
result.push(`[${ts}]${transMap.get(ts)}`);
|
||
}
|
||
}
|
||
}
|
||
return result.join('\n');
|
||
}
|
||
|
||
// 从歌词头部解析制作信息(网易云 song/detail 不返回作词/作曲,只能从 LRC 头提取)
|
||
// 典型行: "[00:00.000] 作词 : Cassie Wei" 或无时间戳 "作曲 :xxx/yyy"
|
||
function parseCredits(lrcText) {
|
||
const credits = {};
|
||
if (!lrcText) return credits;
|
||
const LABELS = {
|
||
lyricist: /^(作词|作詞|词|填词|Lyricist|Lyrics? by|Written by)$/i,
|
||
composer: /^(作曲|曲|Composer|Composed by|Music by)$/i,
|
||
arranger: /^(编曲|編曲|Arranger|Arranged by)$/i,
|
||
producer: /^(制作人|製作人|出品人|监制|Producer|Produced by)$/i,
|
||
mixer: /^(混音|Mix(ing)?|Mixed by)$/i,
|
||
};
|
||
for (const raw of lrcText.split('\n')) {
|
||
// 去掉行首的 [mm:ss.xx] 时间戳(可能有多个)
|
||
const line = raw.replace(/^(\s*\[[^\]]*\]\s*)+/, '').trim();
|
||
const m = line.match(/^([^::]{1,12})\s*[::]\s*(.+)$/);
|
||
if (!m) continue;
|
||
const label = m[1].trim();
|
||
const value = m[2].trim().replace(/\s*\/\s*/g, '/'); // 归一化多人分隔符
|
||
if (!value) continue;
|
||
for (const [key, re] of Object.entries(LABELS)) {
|
||
if (re.test(label) && !credits[key]) { credits[key] = value; break; }
|
||
}
|
||
}
|
||
return credits;
|
||
}
|
||
|
||
async function saveLyric(songId, fileName) {
|
||
const data = await fetchLyric(songId);
|
||
if (!data || !data.lrc) {
|
||
console.log(' ⚠️ 未获取到歌词');
|
||
return;
|
||
}
|
||
|
||
// 保存纯原文 LRC
|
||
const lrcPath = path.join(DOWNLOAD_DIR, fileName + '.lrc');
|
||
fs.writeFileSync(lrcPath, data.lrc, 'utf-8');
|
||
console.log(` 📄 歌词已保存: ${path.basename(lrcPath)}`);
|
||
|
||
// 如果有翻译歌词,保存合并版
|
||
if (data.tlyric && data.tlyric.trim()) {
|
||
const merged = mergeLrc(data.lrc, data.tlyric);
|
||
const mergedPath = path.join(DOWNLOAD_DIR, fileName + '.合并翻译.lrc');
|
||
fs.writeFileSync(mergedPath, merged, 'utf-8');
|
||
console.log(` 📄 翻译歌词已保存: ${path.basename(mergedPath)}`);
|
||
}
|
||
}
|
||
|
||
// ========== 网易云详细元数据 ==========
|
||
|
||
function fetchNeteaseSongDetail(songId) {
|
||
return new Promise((resolve, reject) => {
|
||
const requestUrl = new URL(`/api/v1/song/detail?ids=[${songId}]`, `${NETEASE_BASE_URL}/`);
|
||
const options = {
|
||
method: 'GET',
|
||
headers: {
|
||
'Referer': NETEASE_BASE_URL,
|
||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||
'Accept': 'application/json',
|
||
'Cookie': 'os=pc',
|
||
},
|
||
};
|
||
const proto = requestUrl.protocol === 'https:' ? https : http;
|
||
const req = proto.request(requestUrl, options, (res) => {
|
||
let data = '';
|
||
res.on('data', c => data += c);
|
||
res.on('end', () => {
|
||
try {
|
||
const j = JSON.parse(data);
|
||
const s = j.songs?.[0];
|
||
if (!s) return resolve(null);
|
||
resolve({
|
||
id: s.id,
|
||
name: s.name,
|
||
artists: s.ar?.map(a => ({ id: a.id, name: a.name })) || [],
|
||
album: s.al ? { id: s.al.id, name: s.al.name, cover: s.al.picUrl || s.al.cover } : null,
|
||
duration: s.dt || 0, // ms
|
||
disc: s.cd || '',
|
||
trackNo: s.no || 0,
|
||
aliases: s.alia || [],
|
||
transNames: s.tns || [],
|
||
popularity: s.pop || 0,
|
||
fee: s.fee,
|
||
quality: {
|
||
h: s.h ? { br: s.h.br, size: s.h.size, sr: s.h.sr } : null,
|
||
m: s.m ? { br: s.m.br, size: s.m.size, sr: s.m.sr } : null,
|
||
l: s.l ? { br: s.l.br, size: s.l.size, sr: s.l.sr } : null,
|
||
},
|
||
});
|
||
} catch { resolve(null); }
|
||
});
|
||
});
|
||
req.on('error', () => resolve(null));
|
||
req.setTimeout(5000, () => { req.destroy(); resolve(null); });
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
function fetchNeteaseAlbumDetail(albumId) {
|
||
return new Promise((resolve, reject) => {
|
||
const requestUrl = new URL(`/api/v1/album/${albumId}`, `${NETEASE_BASE_URL}/`);
|
||
const options = {
|
||
method: 'GET',
|
||
headers: {
|
||
'Referer': NETEASE_BASE_URL,
|
||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||
'Accept': 'application/json',
|
||
'Cookie': 'os=pc',
|
||
},
|
||
};
|
||
const proto = requestUrl.protocol === 'https:' ? https : http;
|
||
const req = proto.request(requestUrl, options, (res) => {
|
||
let data = '';
|
||
res.on('data', c => data += c);
|
||
res.on('end', () => {
|
||
try {
|
||
const j = JSON.parse(data);
|
||
const a = j.album || j;
|
||
resolve({
|
||
id: a.id,
|
||
name: a.name,
|
||
publishTime: a.publishTime || null, // ms timestamp
|
||
company: a.company || '',
|
||
description: a.description || '',
|
||
tags: a.tags || [],
|
||
type: a.type || '',
|
||
size: a.size || 0,
|
||
artist: a.artist ? { id: a.artist.id, name: a.artist.name, trans: a.artist.trans } : null,
|
||
picUrl: a.picUrl || '',
|
||
tracks: (j.songs || a.songs || []).map(s => ({
|
||
id: s.id,
|
||
name: s.name,
|
||
ar: s.ar || s.artists || [],
|
||
al: s.al || s.album || null,
|
||
dt: s.dt || s.duration || 0,
|
||
disc: s.cd || '',
|
||
trackNo: s.no || 0,
|
||
})),
|
||
});
|
||
} catch { resolve(null); }
|
||
});
|
||
});
|
||
req.on('error', () => resolve(null));
|
||
req.setTimeout(5000, () => { req.destroy(); resolve(null); });
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
function getAlbumDownloadTarget(album, track, index, trackCount = album.tracks?.length || 0) {
|
||
const trackArtists = track.ar?.map(a => a.name).filter(Boolean).join(' / ');
|
||
const artistDir = sanitizePathSegment(album.artist?.name || trackArtists, '未知歌手');
|
||
const albumDir = sanitizePathSegment(album.name, '未知专辑');
|
||
const numberWidth = Math.max(2, String(trackCount).length);
|
||
const trackNumber = String(index + 1).padStart(numberWidth, '0');
|
||
return {
|
||
outputDir: path.join(DOWNLOAD_DIR, artistDir, albumDir),
|
||
outputBaseName: `${trackNumber}. ${track.name || '未知歌曲'}`,
|
||
};
|
||
}
|
||
|
||
async function fetchFullMetadata(songId) {
|
||
const [songDetail, chkszData] = await Promise.all([
|
||
fetchNeteaseSongDetail(songId),
|
||
fetchJSON(`${API.music}?id=${songId}&level=standard`).catch(() => null),
|
||
]);
|
||
|
||
if (!songDetail) return null;
|
||
|
||
let albumDetail = null;
|
||
if (songDetail.album?.id) {
|
||
albumDetail = await fetchNeteaseAlbumDetail(songDetail.album.id);
|
||
}
|
||
|
||
const durationSec = Math.round(songDetail.duration / 1000);
|
||
const durationStr = `${Math.floor(durationSec / 60)}:${String(durationSec % 60).padStart(2, '0')}`;
|
||
const publishYear = albumDetail?.publishTime
|
||
? new Date(albumDetail.publishTime).getFullYear()
|
||
: null;
|
||
const publishDate = albumDetail?.publishTime
|
||
? new Date(albumDetail.publishTime).toISOString().slice(0, 10)
|
||
: null;
|
||
|
||
return {
|
||
// 基本信息
|
||
id: songId,
|
||
name: songDetail.name,
|
||
artists: songDetail.artists,
|
||
artistStr: songDetail.artists.map(a => a.name).join(' / '),
|
||
album: songDetail.album,
|
||
albumStr: songDetail.album?.name || '',
|
||
|
||
// 详细信息
|
||
duration: songDetail.duration,
|
||
durationStr,
|
||
disc: songDetail.disc,
|
||
trackNo: songDetail.trackNo,
|
||
aliases: songDetail.aliases,
|
||
transNames: songDetail.transNames,
|
||
popularity: songDetail.popularity,
|
||
fee: songDetail.fee,
|
||
|
||
// 专辑详情
|
||
publishTime: albumDetail?.publishTime,
|
||
publishYear,
|
||
publishDate,
|
||
company: albumDetail?.company || '',
|
||
description: albumDetail?.description || '',
|
||
tags: albumDetail?.tags || [],
|
||
albumType: albumDetail?.type || '',
|
||
albumSize: albumDetail?.size || 0,
|
||
albumArtist: albumDetail?.artist,
|
||
|
||
// 音质信息
|
||
quality: songDetail.quality,
|
||
|
||
// ChKSz 数据
|
||
chksz: chkszData?.code === 200 ? chkszData.data : null,
|
||
};
|
||
}
|
||
|
||
// ========== 音质选择 ==========
|
||
|
||
// 解析音质输入:支持数字序号(1-7)或音质名称,无效/空返回 null
|
||
function parseLevel(input) {
|
||
const v = (input || '').trim().toLowerCase();
|
||
if (!v) return null;
|
||
if (/^\d+$/.test(v)) {
|
||
const idx = parseInt(v, 10) - 1;
|
||
return (idx >= 0 && idx < QUALITY_LEVELS.length) ? QUALITY_LEVELS[idx] : null;
|
||
}
|
||
return QUALITY_LEVELS.includes(v) ? v : null;
|
||
}
|
||
|
||
function printQualityMenu(currentLevel) {
|
||
QUALITY_LEVELS.forEach((lv, i) => {
|
||
const mark = lv === currentLevel ? ' ←当前' : '';
|
||
console.log(` ${i + 1}. ${lv.padEnd(10)} ${(QUALITY_LABELS[lv] || '').padEnd(8)}${mark}`);
|
||
});
|
||
}
|
||
|
||
async function askLevel(ask, currentLevel) {
|
||
const label = QUALITY_LABELS[currentLevel] ? ` (${QUALITY_LABELS[currentLevel]})` : '';
|
||
console.log(`\n 当前音质: ${currentLevel}${label}`);
|
||
console.log(' 可选音质:');
|
||
printQualityMenu(currentLevel);
|
||
const input = await ask(' 选择音质 (输入序号或名称, 回车使用当前): ');
|
||
const parsed = parseLevel(input);
|
||
if (parsed) return parsed;
|
||
if (input.trim()) console.log(' ⚠️ 无效选择,使用当前设置');
|
||
return currentLevel;
|
||
}
|
||
|
||
// ========== 搜索 ==========
|
||
|
||
async function searchSongs(keyword, limit = 10) {
|
||
const res = await fetchJSON(`${API.search}?keyword=${encodeURIComponent(keyword)}&limit=${limit}`);
|
||
if (res.code !== 200) {
|
||
console.log('❌ 搜索无结果');
|
||
return [];
|
||
}
|
||
// 兼容两种返回格式:data 直接是数组,或 data.songs 是数组
|
||
const songs = Array.isArray(res.data) ? res.data : (res.data?.songs || []);
|
||
if (!songs.length) {
|
||
console.log('❌ 搜索无结果');
|
||
return [];
|
||
}
|
||
return songs;
|
||
}
|
||
|
||
async function interactiveSearch(rl, ask) {
|
||
const keyword = await ask('🔍 搜索关键词: ');
|
||
if (!keyword.trim()) return null;
|
||
|
||
const results = await searchSongs(keyword.trim(), 10);
|
||
if (!results.length) return null;
|
||
|
||
console.log('\n # 歌曲名 歌手 专辑');
|
||
console.log(' ' + '─'.repeat(70));
|
||
results.forEach((s, i) => {
|
||
const num = String(i + 1).padStart(2);
|
||
const name = s.name.slice(0, 20).padEnd(22);
|
||
const artist = s.artists.slice(0, 16).padEnd(18);
|
||
console.log(` ${num} ${name} ${artist} ${s.album}`);
|
||
});
|
||
|
||
const pick = await ask('\n选择序号 (多个用逗号分隔, 0=取消): ');
|
||
if (!pick.trim() || pick.trim() === '0') return null;
|
||
|
||
return pick.split(/[,,\s]+/)
|
||
.map(n => parseInt(n, 10))
|
||
.filter(n => n >= 1 && n <= results.length)
|
||
.map(n => results[n - 1]);
|
||
}
|
||
|
||
// ========== 歌单 ==========
|
||
|
||
async function fetchPlaylist(playlistId) {
|
||
const res = await fetchJSON(`${API.playlist}?id=${playlistId}`);
|
||
if (!res.data?.tracks?.length) {
|
||
console.log('❌ 歌单获取失败或为空');
|
||
return null;
|
||
}
|
||
return res.data; // { name, trackCount, tracks: [{ id, name, ar, al }] }
|
||
}
|
||
|
||
// ========== 核心: 下载歌曲 ==========
|
||
|
||
async function downloadSong(songId, level, {
|
||
downloadLyric = true,
|
||
embedCoverArt = true,
|
||
maxRetries = DEFAULT_RETRIES,
|
||
outputDir = DOWNLOAD_DIR,
|
||
outputBaseName = null,
|
||
} = {}) {
|
||
let lastError = null;
|
||
|
||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||
try {
|
||
return await _downloadSong(songId, level, { downloadLyric, embedCoverArt, outputDir, outputBaseName });
|
||
} catch (e) {
|
||
lastError = e;
|
||
if (attempt < maxRetries) {
|
||
const waitSec = attempt * 3;
|
||
console.log(` ⚠️ 第 ${attempt} 次失败: ${e.message},${waitSec}秒后重试...`);
|
||
await sleep(waitSec * 1000);
|
||
}
|
||
}
|
||
}
|
||
|
||
console.error(`❌ 歌曲 ${songId} 下载失败 (已重试 ${maxRetries} 次): ${lastError?.message}`);
|
||
return false;
|
||
}
|
||
|
||
async function _downloadSong(songId, level, {
|
||
downloadLyric = true,
|
||
embedCoverArt = true,
|
||
outputDir = DOWNLOAD_DIR,
|
||
outputBaseName = null,
|
||
} = {}) {
|
||
const url = `${API.music}?id=${songId}&level=${level}`;
|
||
console.log(`\n🎵 解析歌曲 ID: ${songId} ...`);
|
||
|
||
const result = await fetchJSON(url);
|
||
if (result.code !== 200 || !result.data?.url) {
|
||
throw new Error(result.msg || 'API 返回错误');
|
||
}
|
||
|
||
const { name, artist, album, url: audioUrl, br, size, level: actualLevel, picUrl } = result.data;
|
||
const ext = audioUrl.match(/\.(mp3|flac|m4a|wav|aac)(\?|$)/i)?.[1] || 'mp3';
|
||
const baseName = sanitize(outputBaseName || `${artist} - ${name}`);
|
||
const fileName = `${baseName}.${ext}`;
|
||
fs.mkdirSync(outputDir, { recursive: true });
|
||
const dest = path.join(outputDir, fileName);
|
||
|
||
console.log(` 🎶 ${name} - ${artist}`);
|
||
console.log(` 💿 ${album} | ${actualLevel} (${br}kbps) | ${(size / 1024 / 1024).toFixed(2)} MB`);
|
||
|
||
// 检查是否已存在(精确匹配文件名)
|
||
if (fs.existsSync(dest)) {
|
||
const existStat = fs.statSync(dest);
|
||
// 检查文件大小是否接近(允许 1% 误差)
|
||
const sizeDiff = Math.abs(existStat.size - size) / size;
|
||
if (sizeDiff < 0.01) {
|
||
console.log(` ⏭️ 已存在,跳过: ${fileName}`);
|
||
// 检查是否缺少歌词/封面,自动补全
|
||
const lrcPath = path.join(outputDir, baseName + '.lrc');
|
||
if (downloadLyric && !fs.existsSync(lrcPath)) {
|
||
console.log(` 🔧 检测到缺少歌词,自动补全...`);
|
||
const lyricData = await fetchLyric(songId);
|
||
if (lyricData?.lrc) {
|
||
fs.writeFileSync(lrcPath, lyricData.lrc, 'utf-8');
|
||
console.log(` 📄 歌词已补全`);
|
||
if (lyricData.tlyric && lyricData.tlyric.trim()) {
|
||
const merged = mergeLrc(lyricData.lrc, lyricData.tlyric);
|
||
fs.writeFileSync(path.join(outputDir, baseName + '.合并翻译.lrc'), merged, 'utf-8');
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// 文件大小差异大,可能是不同版本,询问是否重新下载
|
||
console.log(` ⚠️ 已存在同名文件但大小不同 (现有 ${(existStat.size / 1048576).toFixed(2)} MB, 预期 ${(size / 1048576).toFixed(2)} MB)`);
|
||
console.log(` ⬇️ 覆盖下载...`);
|
||
await downloadFile(audioUrl, dest);
|
||
console.log(` ✅ 下载完成: ${fileName}`);
|
||
}
|
||
} else {
|
||
console.log(` ⬇️ 下载中...`);
|
||
await downloadFile(audioUrl, dest);
|
||
console.log(` ✅ 下载完成: ${fileName}`);
|
||
}
|
||
|
||
// 下载歌词
|
||
let lyricData = null;
|
||
if (downloadLyric) {
|
||
lyricData = await fetchLyric(songId);
|
||
if (lyricData?.lrc) {
|
||
const lrcPath = path.join(outputDir, baseName + '.lrc');
|
||
fs.writeFileSync(lrcPath, lyricData.lrc, 'utf-8');
|
||
console.log(` 📄 歌词已保存: ${path.basename(lrcPath)}`);
|
||
if (lyricData.tlyric && lyricData.tlyric.trim()) {
|
||
const merged = mergeLrc(lyricData.lrc, lyricData.tlyric);
|
||
const mergedPath = path.join(outputDir, baseName + '.合并翻译.lrc');
|
||
fs.writeFileSync(mergedPath, merged, 'utf-8');
|
||
console.log(` 📄 翻译歌词已保存: ${path.basename(mergedPath)}`);
|
||
}
|
||
} else {
|
||
console.log(' ⚠️ 未获取到歌词');
|
||
}
|
||
}
|
||
|
||
// 嵌入封面 + 歌词
|
||
if (embedCoverArt && picUrl) {
|
||
const lrcText = lyricData?.lrc || null;
|
||
const tagMeta = { name, artist, album, lyrics: lrcText };
|
||
// 从歌词头解析作词/作曲/编曲/制作人等(网易云元数据接口不提供这些字段)
|
||
Object.assign(tagMeta, parseCredits(lrcText));
|
||
// 拉取更丰富的元数据(曲目号/碟号/发行年份/发行公司/流派/专辑歌手)用于写入标签
|
||
try {
|
||
const detail = await fetchNeteaseSongDetail(songId);
|
||
if (detail) {
|
||
if (detail.trackNo) tagMeta.trackNo = detail.trackNo;
|
||
if (detail.disc) tagMeta.disc = detail.disc;
|
||
const albumArtist = detail.artists?.map(a => a.name).join(' / ');
|
||
if (albumArtist) tagMeta.albumArtist = albumArtist;
|
||
if (detail.album?.id) {
|
||
const albumDetail = await fetchNeteaseAlbumDetail(detail.album.id);
|
||
if (albumDetail?.publishTime) {
|
||
const d = new Date(albumDetail.publishTime);
|
||
tagMeta.year = d.getFullYear();
|
||
tagMeta.date = d.toISOString().slice(0, 10); // 完整发行日期 YYYY-MM-DD
|
||
}
|
||
if (albumDetail?.tags?.length) tagMeta.genre = albumDetail.tags.join(', ');
|
||
if (albumDetail?.company) tagMeta.publisher = albumDetail.company; // 发行公司
|
||
// 曲目号补全为 "当前/总数" 形式
|
||
if (tagMeta.trackNo && albumDetail?.size) tagMeta.trackNo = `${tagMeta.trackNo}/${albumDetail.size}`;
|
||
}
|
||
}
|
||
} catch {}
|
||
await embedCover(dest, picUrl, tagMeta);
|
||
}
|
||
|
||
// 记录下载历史
|
||
addHistory({
|
||
id: songId,
|
||
name,
|
||
artist,
|
||
album,
|
||
level: actualLevel,
|
||
bitrate: br,
|
||
size,
|
||
file: path.relative(DOWNLOAD_DIR, dest),
|
||
});
|
||
|
||
return true;
|
||
}
|
||
|
||
// ========== 主程序 ==========
|
||
|
||
function createRL() {
|
||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||
const ask = (q) => new Promise(r => rl.question(q, r));
|
||
return { rl, ask };
|
||
}
|
||
|
||
function printBanner() {
|
||
console.log('');
|
||
console.log('╔══════════════════════════════════════════╗');
|
||
console.log('║ 🎵 网易云音乐下载器 v2.0 ║');
|
||
console.log('║ 基于 ChKSz API · 免费 · 高音质 ║');
|
||
console.log('╚══════════════════════════════════════════╝');
|
||
console.log(` 封面嵌入: node-id3(${hasNodeID3() ? '✓' : '✗'}) ffmpeg(${hasFFmpeg() ? '✓' : '✗'})`);
|
||
console.log(` 下载目录: ${DOWNLOAD_DIR}`);
|
||
console.log('');
|
||
}
|
||
|
||
function printMenu() {
|
||
console.log('┌────────────────────────────┐');
|
||
console.log('│ 1. 搜索歌曲并下载 │');
|
||
console.log('│ 2. 输入歌曲 ID 下载 │');
|
||
console.log('│ 3. 输入歌单 ID 下载全部 │');
|
||
console.log('│ 4. 查看歌单歌曲列表 │');
|
||
console.log('│ 5. 查看歌曲歌词 │');
|
||
console.log('│ 6. 按歌手批量下载 │');
|
||
console.log('│ 7. 试听歌曲 │');
|
||
console.log('│ 8. 查看歌曲详情 │');
|
||
console.log('│ 9. 下载历史 │');
|
||
console.log('│ 0. 设置音质等级 │');
|
||
console.log('│ s. 按歌名搜索 │');
|
||
console.log('│ a. 下载完整专辑 │');
|
||
console.log('│ b. 批量ID下载 │');
|
||
console.log('│ f. 从文件批量下载 │');
|
||
console.log('│ n. 从文件名识别并补全标签 │');
|
||
console.log('│ e. 导出下载历史 │');
|
||
console.log('│ m. 保存歌单为 M3U │');
|
||
console.log('│ g. 自动识别并补全标签 │');
|
||
console.log('│ z. 歌单下载并打包 ZIP │');
|
||
console.log('│ c. 清理缓存和临时文件 │');
|
||
console.log('│ t. 下载统计 │');
|
||
console.log('│ l. 已下载歌曲列表 │');
|
||
console.log('│ p. 暂停/恢复下载 │');
|
||
console.log('│ r. 重置所有设置 │');
|
||
console.log('│ h. 帮助 │');
|
||
console.log('│ q. 退出 │');
|
||
console.log('└────────────────────────────┘');
|
||
}
|
||
|
||
async function main() {
|
||
const args = process.argv.slice(2);
|
||
|
||
// 解析命令行参数
|
||
let level = getConfig('level', DEFAULT_LEVEL);
|
||
const ids = [];
|
||
let playlistId = null;
|
||
let albumId = null;
|
||
let batchLyric = true;
|
||
let batchCover = true;
|
||
|
||
let maxRetries = DEFAULT_RETRIES;
|
||
for (const arg of args) {
|
||
if (arg.startsWith('--level=')) level = parseLevel(arg.split('=')[1]) || level;
|
||
else if (arg.startsWith('--playlist=')) playlistId = arg.split('=')[1];
|
||
else if (arg.startsWith('--album=')) albumId = arg.split('=')[1];
|
||
else if (arg.startsWith('--retries=')) maxRetries = parseInt(arg.split('=')[1], 10) || DEFAULT_RETRIES;
|
||
else if (arg === '--no-lyric') batchLyric = false;
|
||
else if (arg === '--no-cover') batchCover = false;
|
||
else if (/^\d+$/.test(arg)) ids.push(arg);
|
||
}
|
||
|
||
// 命令行批量模式
|
||
if (ids.length || playlistId || albumId) {
|
||
printBanner();
|
||
console.log('📋 命令行模式\n');
|
||
|
||
if (albumId) {
|
||
const album = await fetchNeteaseAlbumDetail(albumId);
|
||
if (!album?.tracks?.length) {
|
||
console.log('❌ 专辑获取失败或没有歌曲');
|
||
} else {
|
||
console.log(`💿 专辑: ${album.name} - ${album.artist?.name || '未知歌手'} (${album.tracks.length} 首)\n`);
|
||
for (let i = 0; i < album.tracks.length; i++) {
|
||
const track = album.tracks[i];
|
||
const target = getAlbumDownloadTarget(album, track, i);
|
||
await waitIfPaused();
|
||
const artists = track.ar?.map(a => a.name).join('/') || album.artist?.name || '未知';
|
||
console.log(`[${i + 1}/${album.tracks.length}] ${track.name} - ${artists}`);
|
||
try {
|
||
await downloadSong(track.id, level, {
|
||
downloadLyric: batchLyric,
|
||
embedCoverArt: batchCover,
|
||
maxRetries,
|
||
...target,
|
||
});
|
||
}
|
||
catch (e) { console.error(` ❌ 出错: ${e.message}`); }
|
||
if (i < album.tracks.length - 1) await sleep(500);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (playlistId) {
|
||
const pl = await fetchPlaylist(playlistId);
|
||
if (pl) {
|
||
console.log(`📜 歌单: ${pl.name} (${pl.trackCount} 首)\n`);
|
||
for (let i = 0; i < pl.tracks.length; i++) {
|
||
const t = pl.tracks[i];
|
||
await waitIfPaused();
|
||
console.log(`[${i + 1}/${pl.tracks.length}] ${t.name} - ${t.ar?.map(a => a.name).join('/')}`);
|
||
try { await downloadSong(t.id, level, { downloadLyric: batchLyric, embedCoverArt: batchCover, maxRetries }); }
|
||
catch (e) { console.error(` ❌ 出错: ${e.message}`); }
|
||
if (i < pl.tracks.length - 1) await sleep(500);
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const id of ids) {
|
||
await waitIfPaused();
|
||
try { await downloadSong(id, level, { downloadLyric: batchLyric, embedCoverArt: batchCover, maxRetries }); }
|
||
catch (e) { console.error(`❌ ID ${id} 出错: ${e.message}`); }
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 交互模式
|
||
printBanner();
|
||
const { rl, ask } = createRL();
|
||
|
||
// 设置快捷键监听
|
||
if (process.stdin.isTTY) {
|
||
readline.emitKeypressEvents(process.stdin);
|
||
process.stdin.setRawMode(true);
|
||
process.stdin.on('keypress', (str, key) => {
|
||
if (key && key.name === 'p' && !key.ctrl && !key.meta) {
|
||
togglePause();
|
||
}
|
||
if (key && key.ctrl && key.name === 'c') {
|
||
process.exit(0);
|
||
}
|
||
});
|
||
}
|
||
// 启用 keypress 事件用于暂停快捷键
|
||
readline.emitKeypressEvents(process.stdin);
|
||
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
||
process.stdin.on('keypress', (str, key) => {
|
||
if (key && key.name === 'p' && !isPaused) {
|
||
// 只在非输入状态下触发暂停
|
||
}
|
||
if (key && key.ctrl && key.name === 'c') {
|
||
process.exit(0);
|
||
}
|
||
});
|
||
|
||
while (true) {
|
||
printMenu();
|
||
const choice = await ask('\n请选择功能: ');
|
||
|
||
switch (choice.trim()) {
|
||
case '1': {
|
||
// 搜索并下载
|
||
const songs = await interactiveSearch(rl, ask);
|
||
if (songs?.length) {
|
||
level = await askLevel(ask, level);
|
||
for (const s of songs) {
|
||
await waitIfPaused();
|
||
try { await downloadSong(s.id, level); }
|
||
catch (e) { console.error(`❌ ${s.name} 出错: ${e.message}`); }
|
||
await sleep(500);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case '2': {
|
||
// 按 ID 下载
|
||
const input = await ask('请输入歌曲ID (多个用逗号分隔): ');
|
||
const songIds = input.split(/[,,\s]+/).filter(s => /^\d+$/.test(s.trim()));
|
||
if (songIds.length) {
|
||
level = await askLevel(ask, level);
|
||
for (const id of songIds) {
|
||
await waitIfPaused();
|
||
try { await downloadSong(id, level); }
|
||
catch (e) { console.error(`❌ ID ${id} 出错: ${e.message}`); }
|
||
await sleep(500);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case '3': {
|
||
// 歌单下载
|
||
const plInput = await ask('请输入歌单ID: ');
|
||
const plId = plInput.trim();
|
||
if (!/^\d+$/.test(plId)) { console.log('⚠️ 无效歌单ID'); break; }
|
||
|
||
const pl = await fetchPlaylist(plId);
|
||
if (!pl) break;
|
||
|
||
console.log(`\n📜 歌单: ${pl.name} (${pl.trackCount} 首)`);
|
||
level = await askLevel(ask, level);
|
||
const confirm = await ask(`确认下载全部 ${pl.trackCount} 首? (y/n): `);
|
||
if (confirm.trim().toLowerCase() !== 'y') break;
|
||
|
||
for (let i = 0; i < pl.tracks.length; i++) {
|
||
const t = pl.tracks[i];
|
||
await waitIfPaused();
|
||
console.log(`\n[${i + 1}/${pl.tracks.length}] ${t.name} - ${t.ar?.map(a => a.name).join('/')}`);
|
||
try { await downloadSong(t.id, level); }
|
||
catch (e) { console.error(` ❌ 出错: ${e.message}`); }
|
||
if (i < pl.tracks.length - 1) await sleep(500);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case '4': {
|
||
// 查看歌单歌曲列表
|
||
const plInput = await ask('请输入歌单ID: ');
|
||
const plId = plInput.trim();
|
||
if (!/^\d+$/.test(plId)) { console.log('⚠️ 无效歌单ID'); break; }
|
||
|
||
const pl = await fetchPlaylist(plId);
|
||
if (!pl) break;
|
||
|
||
console.log(`\n📜 ${pl.name} (${pl.trackCount} 首)`);
|
||
console.log(` 创建者: ${pl.creator?.nickname || '未知'}`);
|
||
if (pl.description) console.log(` 描述: ${pl.description.slice(0, 100)}`);
|
||
console.log(' ' + '─'.repeat(60));
|
||
console.log(' # 歌曲名 歌手 专辑');
|
||
console.log(' ' + '─'.repeat(60));
|
||
pl.tracks.forEach((t, i) => {
|
||
const num = String(i + 1).padStart(3);
|
||
const name = (t.name || '').slice(0, 22).padEnd(24);
|
||
const artist = (t.ar?.map(a => a.name).join('/') || '').slice(0, 18).padEnd(20);
|
||
const album = (t.al?.name || '').slice(0, 18);
|
||
console.log(` ${num} ${name} ${artist} ${album}`);
|
||
});
|
||
console.log(' ' + '─'.repeat(60));
|
||
console.log(` 共 ${pl.trackCount} 首`);
|
||
|
||
console.log('\n 下载选项:');
|
||
console.log(' a = 下载全部');
|
||
console.log(' 输入序号 = 下载指定歌曲 (如: 1,3,5 或 5-10)');
|
||
console.log(' 回车 = 跳过');
|
||
const doDownload = await ask(' 请选择: ');
|
||
const dlInput = doDownload.trim().toLowerCase();
|
||
|
||
let tracksToDownload = [];
|
||
if (dlInput === 'a') {
|
||
tracksToDownload = pl.tracks;
|
||
} else if (dlInput) {
|
||
// 解析序号: 支持 1,3,5 和 5-10 混合
|
||
const indices = new Set();
|
||
for (const part of dlInput.split(/[,,\s]+/)) {
|
||
const rangeMatch = part.match(/^(\d+)-(\d+)$/);
|
||
if (rangeMatch) {
|
||
const start = parseInt(rangeMatch[1], 10);
|
||
const end = parseInt(rangeMatch[2], 10);
|
||
for (let n = start; n <= end; n++) indices.add(n);
|
||
} else if (/^\d+$/.test(part)) {
|
||
indices.add(parseInt(part, 10));
|
||
}
|
||
}
|
||
tracksToDownload = [...indices]
|
||
.filter(n => n >= 1 && n <= pl.tracks.length)
|
||
.sort((a, b) => a - b)
|
||
.map(n => pl.tracks[n - 1]);
|
||
}
|
||
|
||
if (tracksToDownload.length) {
|
||
level = await askLevel(ask, level);
|
||
console.log(`\n📥 开始下载 ${tracksToDownload.length} 首歌曲...`);
|
||
for (let i = 0; i < tracksToDownload.length; i++) {
|
||
const t = tracksToDownload[i];
|
||
console.log(`\n[${i + 1}/${tracksToDownload.length}] ${t.name} - ${t.ar?.map(a => a.name).join('/')}`);
|
||
try { await downloadSong(t.id, level); }
|
||
catch (e) { console.error(` ❌ 出错: ${e.message}`); }
|
||
if (i < tracksToDownload.length - 1) await sleep(500);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case '5': {
|
||
// 查看歌曲歌词
|
||
const idInput = await ask('请输入歌曲ID: ');
|
||
const songId = idInput.trim();
|
||
if (!/^\d+$/.test(songId)) { console.log('⚠️ 无效歌曲ID'); break; }
|
||
|
||
console.log(`\n🔍 获取歌词 (ID: ${songId}) ...`);
|
||
const data = await fetchLyric(songId);
|
||
if (!data?.lrc) {
|
||
console.log('❌ 未获取到歌词');
|
||
break;
|
||
}
|
||
|
||
// 解析歌词用于显示
|
||
const parseLrc = (lrcStr) => {
|
||
return lrcStr.split('\n')
|
||
.map(line => {
|
||
const m = line.match(/^\[(\d+):(\d+)[.:](\d+)\](.*)/);
|
||
if (!m) return null;
|
||
const time = `${m[1]}:${m[2]}`;
|
||
const text = m[4].trim();
|
||
if (!text) return null;
|
||
return { time, text };
|
||
})
|
||
.filter(Boolean);
|
||
};
|
||
|
||
const lrcLines = parseLrc(data.lrc);
|
||
const transLines = data.tlyric ? parseLrc(data.tlyric) : [];
|
||
const transMap = new Map(transLines.map(l => [l.time, l.text]));
|
||
|
||
console.log('\n 📜 歌词:\n');
|
||
for (const line of lrcLines) {
|
||
console.log(` [${line.time}] ${line.text}`);
|
||
if (transMap.has(line.time)) {
|
||
console.log(` ↳ ${transMap.get(line.time)}`);
|
||
}
|
||
}
|
||
|
||
const saveOpt = await ask('\n是否保存为 .lrc 文件? (y/n): ');
|
||
if (saveOpt.trim().toLowerCase() === 'y') {
|
||
const defaultName = `song_${songId}`;
|
||
const nameInput = await ask(`文件名 (默认 ${defaultName}): `);
|
||
const baseName = sanitize(nameInput.trim() || defaultName);
|
||
const lrcPath = path.join(DOWNLOAD_DIR, baseName + '.lrc');
|
||
fs.writeFileSync(lrcPath, data.lrc, 'utf-8');
|
||
console.log(` ✅ 歌词已保存: ${lrcPath}`);
|
||
|
||
if (data.tlyric && data.tlyric.trim()) {
|
||
const merged = mergeLrc(data.lrc, data.tlyric);
|
||
const mergedPath = path.join(DOWNLOAD_DIR, baseName + '.合并翻译.lrc');
|
||
fs.writeFileSync(mergedPath, merged, 'utf-8');
|
||
console.log(` ✅ 翻译歌词已保存: ${mergedPath}`);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case '6': {
|
||
// 按歌手批量下载
|
||
const artistInput = await ask('请输入歌手名: ');
|
||
const artistName = artistInput.trim();
|
||
if (!artistName) { console.log('⚠️ 请输入歌手名'); break; }
|
||
|
||
const limitInput = await ask('搜索数量 (默认 50): ');
|
||
const searchLimit = parseInt(limitInput, 10) || 50;
|
||
|
||
console.log(`\n🔍 搜索 "${artistName}" 的歌曲 (最多 ${searchLimit} 首) ...`);
|
||
const results = await searchSongs(artistName, searchLimit);
|
||
if (!results.length) break;
|
||
|
||
// 过滤出歌手名匹配的歌曲
|
||
const artistLower = artistName.toLowerCase();
|
||
const matched = results.filter(s => s.artists.toLowerCase().includes(artistLower));
|
||
const pool = matched.length >= 3 ? matched : results; // 匹配太少就用全部
|
||
|
||
if (matched.length >= 3 && matched.length < results.length) {
|
||
console.log(` 找到 ${results.length} 首结果,其中 ${matched.length} 首匹配歌手 "${artistName}"`);
|
||
}
|
||
|
||
console.log(`\n # 歌曲名 歌手 专辑`);
|
||
console.log(' ' + '─'.repeat(70));
|
||
pool.slice(0, 30).forEach((s, i) => {
|
||
const num = String(i + 1).padStart(2);
|
||
const name = s.name.slice(0, 20).padEnd(22);
|
||
const artist = s.artists.slice(0, 16).padEnd(18);
|
||
console.log(` ${num} ${name} ${artist} ${s.album}`);
|
||
});
|
||
if (pool.length > 30) console.log(` ... 共 ${pool.length} 首,仅显示前 30 首`);
|
||
|
||
console.log('\n 下载选项:');
|
||
console.log(' a = 下载全部');
|
||
console.log(' 输入序号 = 下载指定歌曲 (如: 1,3,5 或 1-10)');
|
||
console.log(' 回车 = 跳过');
|
||
const pick = await ask(' 请选择: ');
|
||
const pickInput = pick.trim().toLowerCase();
|
||
|
||
let toDownload = [];
|
||
if (pickInput === 'a') {
|
||
toDownload = pool;
|
||
} else if (pickInput) {
|
||
const indices = new Set();
|
||
for (const part of pickInput.split(/[,,\s]+/)) {
|
||
const rangeMatch = part.match(/^(\d+)-(\d+)$/);
|
||
if (rangeMatch) {
|
||
const start = parseInt(rangeMatch[1], 10);
|
||
const end = parseInt(rangeMatch[2], 10);
|
||
for (let n = start; n <= end; n++) indices.add(n);
|
||
} else if (/^\d+$/.test(part)) {
|
||
indices.add(parseInt(part, 10));
|
||
}
|
||
}
|
||
toDownload = [...indices]
|
||
.filter(n => n >= 1 && n <= pool.length)
|
||
.sort((a, b) => a - b)
|
||
.map(n => pool[n - 1]);
|
||
}
|
||
|
||
if (toDownload.length) {
|
||
level = await askLevel(ask, level);
|
||
console.log(`\n📥 开始下载 ${toDownload.length} 首歌曲...`);
|
||
for (let i = 0; i < toDownload.length; i++) {
|
||
const s = toDownload[i];
|
||
console.log(`\n[${i + 1}/${toDownload.length}] ${s.name} - ${s.artists}`);
|
||
try { await downloadSong(s.id, level); }
|
||
catch (e) { console.error(` ❌ 出错: ${e.message}`); }
|
||
if (i < toDownload.length - 1) await sleep(500);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case '7': {
|
||
// 试听歌曲
|
||
const idInput = await ask('请输入歌曲ID: ');
|
||
const songId = idInput.trim();
|
||
if (!/^\d+$/.test(songId)) { console.log('⚠️ 无效歌曲ID'); break; }
|
||
|
||
console.log(`\n🔍 获取试听链接 (ID: ${songId}) ...`);
|
||
try {
|
||
const result = await fetchJSON(`${API.music}?id=${songId}&level=standard`);
|
||
if (result.code !== 200 || !result.data?.url) {
|
||
console.log(`❌ 获取失败: ${result.msg || '未知错误'}`);
|
||
break;
|
||
}
|
||
const { name, artist, album, url: audioUrl } = result.data;
|
||
console.log(` 🎶 ${name} - ${artist} (${album})`);
|
||
|
||
// 检测可用播放器
|
||
const players = [
|
||
{ cmd: 'mpv', args: (u) => ['--no-video', u] },
|
||
{ cmd: 'ffplay', args: (u) => ['-nodisp', '-autoexit', u] },
|
||
{ cmd: 'play', args: (u) => [u] }, // sox
|
||
{ cmd: 'cvlc', args: (u) => ['--play-and-exit', u] },
|
||
{ cmd: 'aplay', args: (u) => [u] },
|
||
];
|
||
|
||
let player = null;
|
||
for (const p of players) {
|
||
try {
|
||
execSync(`which ${p.cmd}`, { stdio: 'ignore' });
|
||
player = p;
|
||
break;
|
||
} catch {}
|
||
}
|
||
|
||
if (!player) {
|
||
console.log(' ⚠️ 未检测到音频播放器 (mpv/ffplay/play/cvlc/aplay)');
|
||
console.log(` 📋 试听链接: ${audioUrl}`);
|
||
console.log(' 请手动在浏览器或播放器中打开');
|
||
break;
|
||
}
|
||
|
||
console.log(` ▶️ 使用 ${player.cmd} 播放中... (Ctrl+C 停止)`);
|
||
try {
|
||
execSync(`${player.cmd} ${player.args(audioUrl).map(a => `"${a}"`).join(' ')}`, {
|
||
stdio: 'inherit',
|
||
timeout: 300000, // 5分钟超时
|
||
});
|
||
} catch (e) {
|
||
if (e.status !== null) {
|
||
// 用户中断或正常结束
|
||
} else {
|
||
console.error(` ❌ 播放出错: ${e.message}`);
|
||
}
|
||
}
|
||
console.log(' ⏹ 播放结束');
|
||
} catch (e) {
|
||
console.error(` ❌ 出错: ${e.message}`);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case '8': {
|
||
// 查看歌曲详情(完整元数据)
|
||
const idInput = await ask('请输入歌曲ID: ');
|
||
const songId = idInput.trim();
|
||
if (!/^\d+$/.test(songId)) { console.log('⚠️ 无效歌曲ID'); break; }
|
||
|
||
console.log(`\n🔍 获取歌曲详情 (ID: ${songId}) ...`);
|
||
try {
|
||
const meta = await fetchFullMetadata(songId);
|
||
if (!meta) {
|
||
console.log('❌ 获取失败');
|
||
break;
|
||
}
|
||
|
||
const pad = 42;
|
||
console.log('');
|
||
console.log(' ╔══════════════════════════════════════════════════╗');
|
||
console.log(` ║ 🎵 ${meta.name}`);
|
||
if (meta.aliases.length) {
|
||
console.log(` ║ 别名: ${meta.aliases.join(', ')}`);
|
||
}
|
||
if (meta.transNames.length) {
|
||
console.log(` ║ 译名: ${meta.transNames.join(', ')}`);
|
||
}
|
||
console.log(' ╠══════════════════════════════════════════════════╣');
|
||
console.log(` ║ 歌手: ${meta.artistStr}`);
|
||
console.log(` ║ 专辑: ${meta.albumStr}`);
|
||
console.log(` ║ ID: ${meta.id}`);
|
||
if (meta.trackNo) console.log(` ║ 曲目号: ${meta.trackNo}${meta.disc ? ` (碟片 ${meta.disc})` : ''}`);
|
||
console.log(' ╠══════════════════════════════════════════════════╣');
|
||
console.log(` ║ 时长: ${meta.durationStr}`);
|
||
if (meta.publishDate) {
|
||
console.log(` ║ 发行日期: ${meta.publishDate}`);
|
||
} else if (meta.publishYear) {
|
||
console.log(` ║ 发行年份: ${meta.publishYear}`);
|
||
}
|
||
if (meta.company) console.log(` ║ 唱片公司: ${meta.company}`);
|
||
if (meta.albumType) console.log(` ║ 专辑类型: ${meta.albumType}`);
|
||
if (meta.albumSize) console.log(` ║ 专辑曲目: ${meta.albumSize} 首`);
|
||
if (meta.tags.length) console.log(` ║ 标签: ${meta.tags.join(', ')}`);
|
||
console.log(` ║ 热度: ${'★'.repeat(Math.round(meta.popularity / 20))}${'☆'.repeat(5 - Math.round(meta.popularity / 20))} (${meta.popularity}/100)`);
|
||
console.log(' ╠══════════════════════════════════════════════════╣');
|
||
|
||
// 音质信息
|
||
if (meta.quality) {
|
||
console.log(' ║ 音质信息:');
|
||
const q = meta.quality;
|
||
if (q.l) console.log(` ║ 标准 ${q.l.br}kbps ${(q.l.size / 1048576).toFixed(1)} MB ${q.l.sr}Hz`);
|
||
if (q.m) console.log(` ║ 较高 ${q.m.br}kbps ${(q.m.size / 1048576).toFixed(1)} MB ${q.m.sr}Hz`);
|
||
if (q.h) console.log(` ║ 极高 ${q.h.br}kbps ${(q.h.size / 1048576).toFixed(1)} MB ${q.h.sr}Hz`);
|
||
}
|
||
|
||
// ChKSz 最高音质
|
||
if (meta.chksz) {
|
||
console.log(' ╠══════════════════════════════════════════════════╣');
|
||
console.log(` ║ ChKSz 最高可用: ${meta.chksz.level} (${meta.chksz.br}kbps) ${(meta.chksz.size / 1048576).toFixed(1)} MB`);
|
||
}
|
||
|
||
// 专辑描述
|
||
if (meta.description) {
|
||
console.log(' ╠══════════════════════════════════════════════════╣');
|
||
console.log(' ║ 专辑简介:');
|
||
const descLines = meta.description.split(/\n/).slice(0, 3);
|
||
for (const line of descLines) {
|
||
const trimmed = line.trim();
|
||
if (trimmed) {
|
||
// 自动换行
|
||
for (let i = 0; i < trimmed.length; i += 44) {
|
||
console.log(` ║ ${trimmed.slice(i, i + 44)}`);
|
||
}
|
||
}
|
||
}
|
||
if (meta.description.split(/\n/).length > 3) {
|
||
console.log(' ║ ...');
|
||
}
|
||
}
|
||
|
||
console.log(' ╚══════════════════════════════════════════════════╝');
|
||
} catch (e) {
|
||
console.error(` ❌ 出错: ${e.message}`);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case '9': {
|
||
// 下载历史
|
||
const history = loadHistory();
|
||
if (!history.length) {
|
||
console.log('\n 📭 暂无下载历史');
|
||
break;
|
||
}
|
||
|
||
const showCount = Math.min(history.length, 30);
|
||
console.log(`\n 📜 下载历史 (最近 ${showCount} 条,共 ${history.length} 条)`);
|
||
console.log(' ' + '─'.repeat(70));
|
||
console.log(' # 时间 歌曲名 歌手 音质');
|
||
console.log(' ' + '─'.repeat(70));
|
||
history.slice(0, showCount).forEach((h, i) => {
|
||
const num = String(i + 1).padStart(3);
|
||
const time = formatTime(h.timestamp);
|
||
const name = (h.name || '').slice(0, 20).padEnd(22);
|
||
const artist = (h.artist || '').slice(0, 14).padEnd(16);
|
||
const lv = h.level || '?';
|
||
console.log(` ${num} ${time} ${name} ${artist} ${lv}`);
|
||
});
|
||
console.log(' ' + '─'.repeat(70));
|
||
|
||
if (history.length > showCount) {
|
||
console.log(` ... 还有 ${history.length - showCount} 条更早的记录`);
|
||
}
|
||
|
||
const action = await ask('\n 操作: [d=删除指定] [c=清空全部] [回车=返回]: ');
|
||
if (action.trim().toLowerCase() === 'c') {
|
||
const confirm = await ask(' ⚠️ 确认清空全部下载历史? (输入 yes 确认): ');
|
||
if (confirm.trim() === 'yes') {
|
||
saveHistory([]);
|
||
console.log(' ✅ 下载历史已全部清空');
|
||
} else {
|
||
console.log(' 取消操作');
|
||
}
|
||
} else if (action.trim().toLowerCase() === 'd') {
|
||
const delInput = await ask(' 输入要删除的序号 (如: 1,3,5): ');
|
||
const delIndices = delInput.split(/[,,\s]+/)
|
||
.map(n => parseInt(n, 10))
|
||
.filter(n => n >= 1 && n <= showCount)
|
||
.sort((a, b) => b - a); // 从后往前删
|
||
for (const idx of delIndices) {
|
||
history.splice(idx - 1, 1);
|
||
}
|
||
saveHistory(history);
|
||
console.log(` ✅ 已删除 ${delIndices.length} 条记录`);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case '0': {
|
||
// 设置音质
|
||
const label = QUALITY_LABELS[level] ? ` (${QUALITY_LABELS[level]})` : '';
|
||
console.log(`\n当前音质: ${level}${label}`);
|
||
console.log('可选音质:');
|
||
printQualityMenu(level);
|
||
const newLevel = await ask('选择音质等级 (输入序号或名称): ');
|
||
const parsed = parseLevel(newLevel);
|
||
if (parsed) {
|
||
level = parsed;
|
||
setConfig('level', level);
|
||
console.log(`✅ 音质已设为: ${level}${QUALITY_LABELS[level] ? ` (${QUALITY_LABELS[level]})` : ''} (已保存)`);
|
||
} else {
|
||
console.log('⚠️ 无效等级');
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 's':
|
||
case 'S': {
|
||
// 按歌名搜索
|
||
const songName = await ask('请输入歌名: ');
|
||
if (!songName.trim()) { console.log('⚠️ 请输入歌名'); break; }
|
||
|
||
const limitInput = await ask('搜索数量 (默认 20): ');
|
||
const searchLimit = parseInt(limitInput, 10) || 20;
|
||
|
||
console.log(`\n🔍 搜索 "${songName.trim()}" ...`);
|
||
const results = await searchSongs(songName.trim(), searchLimit);
|
||
if (!results.length) break;
|
||
|
||
console.log(`\n # 歌曲名 歌手 专辑`);
|
||
console.log(' ' + '─'.repeat(70));
|
||
results.forEach((s, i) => {
|
||
const num = String(i + 1).padStart(2);
|
||
const name = s.name.slice(0, 20).padEnd(22);
|
||
const artist = s.artists.slice(0, 16).padEnd(18);
|
||
const album = s.album.slice(0, 20);
|
||
console.log(` ${num} ${name} ${artist} ${album}`);
|
||
});
|
||
console.log(' ' + '─'.repeat(70));
|
||
console.log(` 共 ${results.length} 首结果`);
|
||
|
||
console.log('\n 操作选项:');
|
||
console.log(' 输入序号 = 下载指定歌曲 (如: 1,3,5 或 1-5)');
|
||
console.log(' a = 下载全部');
|
||
console.log(' v+序号 = 试听 (如: v3)');
|
||
console.log(' d+序号 = 查看详情 (如: d3)');
|
||
console.log(' 回车 = 返回');
|
||
const action = await ask(' 请选择: ');
|
||
const actionInput = action.trim().toLowerCase();
|
||
|
||
if (!actionInput) break;
|
||
|
||
// 试听
|
||
const listenMatch = actionInput.match(/^v(\d+)$/);
|
||
if (listenMatch) {
|
||
const idx = parseInt(listenMatch[1], 10);
|
||
if (idx >= 1 && idx <= results.length) {
|
||
const s = results[idx - 1];
|
||
console.log(`\n ▶️ 试听: ${s.name} - ${s.artists}`);
|
||
try {
|
||
const r = await fetchJSON(`${API.music}?id=${s.id}&level=standard`);
|
||
if (r.code === 200 && r.data?.url) {
|
||
const players = [
|
||
{ cmd: 'mpv', args: (u) => ['--no-video', u] },
|
||
{ cmd: 'ffplay', args: (u) => ['-nodisp', '-autoexit', u] },
|
||
{ cmd: 'play', args: (u) => [u] },
|
||
{ cmd: 'cvlc', args: (u) => ['--play-and-exit', u] },
|
||
];
|
||
let player = null;
|
||
for (const p of players) {
|
||
try { execSync(`which ${p.cmd}`, { stdio: 'ignore' }); player = p; break; } catch {}
|
||
}
|
||
if (player) {
|
||
console.log(` ▶️ 使用 ${player.cmd} 播放中... (Ctrl+C 停止)`);
|
||
try { execSync(`${player.cmd} ${player.args(r.data.url).map(a => `"${a}"`).join(' ')}`, { stdio: 'inherit', timeout: 300000 }); } catch {}
|
||
console.log(' ⏹ 播放结束');
|
||
} else {
|
||
console.log(` 📋 链接: ${r.data.url}`);
|
||
}
|
||
}
|
||
} catch (e) { console.error(` ❌ ${e.message}`); }
|
||
} else {
|
||
console.log(' ⚠️ 无效序号');
|
||
}
|
||
break;
|
||
}
|
||
|
||
// 详情
|
||
const detailMatch = actionInput.match(/^d(\d+)$/);
|
||
if (detailMatch) {
|
||
const idx = parseInt(detailMatch[1], 10);
|
||
if (idx >= 1 && idx <= results.length) {
|
||
const s = results[idx - 1];
|
||
try {
|
||
const r = await fetchJSON(`${API.music}?id=${s.id}&level=jymaster`);
|
||
if (r.code === 200 && r.data) {
|
||
const d = r.data;
|
||
console.log('\n ┌─────────────────────────────────────────┐');
|
||
console.log(` │ 🎵 ${d.name}`);
|
||
console.log(' ├─────────────────────────────────────────┤');
|
||
console.log(` │ 歌手: ${d.artist}`);
|
||
console.log(` │ 专辑: ${d.album}`);
|
||
console.log(` │ ID: ${d.id}`);
|
||
console.log(` │ 音质: ${d.level} (${d.br}kbps) 大小: ${(d.size / 1048576).toFixed(2)} MB`);
|
||
console.log(' └─────────────────────────────────────────┘');
|
||
}
|
||
} catch (e) { console.error(` ❌ ${e.message}`); }
|
||
} else {
|
||
console.log(' ⚠️ 无效序号');
|
||
}
|
||
break;
|
||
}
|
||
|
||
// 下载
|
||
let toDownload = [];
|
||
if (actionInput === 'a') {
|
||
toDownload = results;
|
||
} else {
|
||
const indices = new Set();
|
||
for (const part of actionInput.split(/[,,\s]+/)) {
|
||
const rangeMatch = part.match(/^(\d+)-(\d+)$/);
|
||
if (rangeMatch) {
|
||
const start = parseInt(rangeMatch[1], 10);
|
||
const end = parseInt(rangeMatch[2], 10);
|
||
for (let n = start; n <= end; n++) indices.add(n);
|
||
} else if (/^\d+$/.test(part)) {
|
||
indices.add(parseInt(part, 10));
|
||
}
|
||
}
|
||
toDownload = [...indices]
|
||
.filter(n => n >= 1 && n <= results.length)
|
||
.sort((a, b) => a - b)
|
||
.map(n => results[n - 1]);
|
||
}
|
||
|
||
if (toDownload.length) {
|
||
level = await askLevel(ask, level);
|
||
console.log(`\n📥 开始下载 ${toDownload.length} 首歌曲...`);
|
||
for (let i = 0; i < toDownload.length; i++) {
|
||
const s = toDownload[i];
|
||
console.log(`\n[${i + 1}/${toDownload.length}] ${s.name} - ${s.artists}`);
|
||
try { await downloadSong(s.id, level); }
|
||
catch (e) { console.error(` ❌ 出错: ${e.message}`); }
|
||
if (i < toDownload.length - 1) await sleep(500);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'l':
|
||
case 'L': {
|
||
// 已下载歌曲列表
|
||
if (!fs.existsSync(DOWNLOAD_DIR)) {
|
||
console.log('\n 📭 下载目录不存在');
|
||
break;
|
||
}
|
||
|
||
const allFiles = fs.readdirSync(DOWNLOAD_DIR);
|
||
const audioExts = ['.mp3', '.flac', '.m4a', '.wav', '.aac', '.ogg', '.wma'];
|
||
const audioFiles = allFiles.filter(f => audioExts.includes(path.extname(f).toLowerCase()));
|
||
|
||
if (!audioFiles.length) {
|
||
console.log('\n 📭 暂无已下载的歌曲');
|
||
break;
|
||
}
|
||
|
||
// 按修改时间排序(最新在前)
|
||
const filesWithTime = audioFiles.map(f => {
|
||
const fp = path.join(DOWNLOAD_DIR, f);
|
||
const stat = fs.statSync(fp);
|
||
return { name: f, size: stat.size, mtime: stat.mtime };
|
||
}).sort((a, b) => b.mtime - a.mtime);
|
||
|
||
console.log(`\n 📁 已下载歌曲 (${filesWithTime.length} 首)`);
|
||
console.log(` 目录: ${DOWNLOAD_DIR}`);
|
||
console.log(' ' + '─'.repeat(75));
|
||
console.log(' # 文件名 大小 修改时间');
|
||
console.log(' ' + '─'.repeat(75));
|
||
|
||
const showCount = Math.min(filesWithTime.length, 50);
|
||
filesWithTime.slice(0, showCount).forEach((f, i) => {
|
||
const num = String(i + 1).padStart(3);
|
||
const name = f.name.length > 38 ? f.name.slice(0, 35) + '...' : f.name;
|
||
const sizeMB = (f.size / 1048576).toFixed(1);
|
||
const time = `${f.mtime.getMonth() + 1}/${f.mtime.getDate()} ${String(f.mtime.getHours()).padStart(2, '0')}:${String(f.mtime.getMinutes()).padStart(2, '0')}`;
|
||
console.log(` ${num} ${name.padEnd(40)} ${(sizeMB + ' MB').padStart(10)} ${time}`);
|
||
});
|
||
|
||
if (filesWithTime.length > showCount) {
|
||
console.log(` ... 还有 ${filesWithTime.length - showCount} 首未显示`);
|
||
}
|
||
|
||
const totalSize = filesWithTime.reduce((s, f) => s + f.size, 0);
|
||
console.log(' ' + '─'.repeat(75));
|
||
console.log(` 共 ${filesWithTime.length} 首,总大小 ${(totalSize / 1073741824).toFixed(2)} GB`);
|
||
|
||
const action = await ask('\n 操作: [p+序号=播放] [d+序号=删除] [da=删除全部] [回车=返回]: ');
|
||
const actLower = action.trim().toLowerCase();
|
||
|
||
// 播放
|
||
const playMatch = actLower.match(/^p(\d+)$/);
|
||
if (playMatch) {
|
||
const idx = parseInt(playMatch[1], 10);
|
||
if (idx >= 1 && idx <= filesWithTime.length) {
|
||
const filePath = path.join(DOWNLOAD_DIR, filesWithTime[idx - 1].name);
|
||
const players = [
|
||
{ cmd: 'mpv', args: (u) => ['--no-video', u] },
|
||
{ cmd: 'ffplay', args: (u) => ['-nodisp', '-autoexit', u] },
|
||
{ cmd: 'play', args: (u) => [u] },
|
||
{ cmd: 'cvlc', args: (u) => ['--play-and-exit', u] },
|
||
];
|
||
let player = null;
|
||
for (const p of players) {
|
||
try { execSync(`which ${p.cmd}`, { stdio: 'ignore' }); player = p; break; } catch {}
|
||
}
|
||
if (player) {
|
||
console.log(` ▶️ 播放: ${filesWithTime[idx - 1].name}`);
|
||
try { execSync(`${player.cmd} ${player.args(filePath).map(a => `"${a}"`).join(' ')}`, { stdio: 'inherit', timeout: 300000 }); } catch {}
|
||
console.log(' ⏹ 播放结束');
|
||
} else {
|
||
console.log(' ⚠️ 未检测到音频播放器');
|
||
}
|
||
} else {
|
||
console.log(' ⚠️ 无效序号');
|
||
}
|
||
}
|
||
|
||
// 删除指定
|
||
const delMatch = actLower.match(/^d(\d+)$/);
|
||
if (delMatch) {
|
||
const idx = parseInt(delMatch[1], 10);
|
||
if (idx >= 1 && idx <= filesWithTime.length) {
|
||
const target = filesWithTime[idx - 1];
|
||
const confirm = await ask(` 确认删除 "${target.name}"? (y/n): `);
|
||
if (confirm.trim().toLowerCase() === 'y') {
|
||
const filePath = path.join(DOWNLOAD_DIR, target.name);
|
||
// 移到回收站目录
|
||
if (!fs.existsSync(TRASH_DIR)) fs.mkdirSync(TRASH_DIR, { recursive: true });
|
||
const trashPath = path.join(TRASH_DIR, `${Date.now()}_${target.name}`);
|
||
try {
|
||
fs.renameSync(filePath, trashPath);
|
||
console.log(` ✅ 已移到回收站: ${target.name}`);
|
||
// 同时移动关联的 .lrc 文件
|
||
const baseName = path.parse(target.name).name;
|
||
for (const f of allFiles) {
|
||
if (f.startsWith(baseName) && f.endsWith('.lrc')) {
|
||
const lrcSrc = path.join(DOWNLOAD_DIR, f);
|
||
const lrcDst = path.join(TRASH_DIR, `${Date.now()}_${f}`);
|
||
try { fs.renameSync(lrcSrc, lrcDst); } catch {}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error(` ❌ 删除失败: ${e.message}`);
|
||
}
|
||
}
|
||
} else {
|
||
console.log(' ⚠️ 无效序号');
|
||
}
|
||
}
|
||
|
||
// 删除全部
|
||
if (actLower === 'da') {
|
||
const confirm = await ask(` ⚠️ 确认删除全部 ${filesWithTime.length} 首歌曲? 此操作不可恢复! (输入 yes 确认): `);
|
||
if (confirm.trim() === 'yes') {
|
||
if (!fs.existsSync(TRASH_DIR)) fs.mkdirSync(TRASH_DIR, { recursive: true });
|
||
let deleted = 0;
|
||
for (const f of filesWithTime) {
|
||
try {
|
||
const src = path.join(DOWNLOAD_DIR, f.name);
|
||
const dst = path.join(TRASH_DIR, `${Date.now()}_${f.name}`);
|
||
fs.renameSync(src, dst);
|
||
deleted++;
|
||
} catch {}
|
||
}
|
||
// 清理关联文件
|
||
for (const f of allFiles) {
|
||
if (!audioExts.includes(path.extname(f).toLowerCase())) {
|
||
try {
|
||
const src = path.join(DOWNLOAD_DIR, f);
|
||
const dst = path.join(TRASH_DIR, `${Date.now()}_${f}`);
|
||
fs.renameSync(src, dst);
|
||
} catch {}
|
||
}
|
||
}
|
||
console.log(` ✅ 已删除 ${deleted} 首歌曲 (已移到 .trash 目录)`);
|
||
} else {
|
||
console.log(' 取消删除');
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'a':
|
||
case 'A': {
|
||
// 按专辑下载
|
||
const albumInput = await ask('请输入专辑 ID(推荐)或专辑名: ');
|
||
const albumQuery = albumInput.trim();
|
||
if (!albumQuery) { console.log('⚠️ 请输入专辑 ID 或名称'); break; }
|
||
|
||
if (/^\d+$/.test(albumQuery)) {
|
||
console.log(`\n🔍 获取专辑 ${albumQuery} ...`);
|
||
const album = await fetchNeteaseAlbumDetail(albumQuery);
|
||
if (!album?.tracks?.length) {
|
||
console.log('❌ 专辑获取失败或没有歌曲');
|
||
break;
|
||
}
|
||
|
||
console.log(`\n💿 ${album.name} - ${album.artist?.name || '未知歌手'}`);
|
||
console.log(` 共 ${album.tracks.length} 首歌曲`);
|
||
album.tracks.forEach((track, i) => {
|
||
const artists = track.ar?.map(a => a.name).join('/') || album.artist?.name || '未知';
|
||
console.log(` ${String(i + 1).padStart(2)}. ${track.name} - ${artists}`);
|
||
});
|
||
|
||
level = await askLevel(ask, level);
|
||
const confirm = await ask(`确认下载整张专辑? (y/n): `);
|
||
if (confirm.trim().toLowerCase() !== 'y') break;
|
||
|
||
for (let i = 0; i < album.tracks.length; i++) {
|
||
const track = album.tracks[i];
|
||
const artists = track.ar?.map(a => a.name).join('/') || album.artist?.name || '未知';
|
||
const target = getAlbumDownloadTarget(album, track, i);
|
||
await waitIfPaused();
|
||
console.log(`\n[${i + 1}/${album.tracks.length}] ${track.name} - ${artists}`);
|
||
try { await downloadSong(track.id, level, target); }
|
||
catch (e) { console.error(` ❌ 出错: ${e.message}`); }
|
||
if (i < album.tracks.length - 1) await sleep(500);
|
||
}
|
||
break;
|
||
}
|
||
|
||
const albumName = albumQuery;
|
||
|
||
const limitInput = await ask('搜索数量 (默认 50): ');
|
||
const searchLimit = parseInt(limitInput, 10) || 50;
|
||
|
||
console.log(`\n🔍 搜索专辑 "${albumName}" ...`);
|
||
const results = await searchSongs(albumName, searchLimit);
|
||
if (!results.length) break;
|
||
|
||
// 按专辑名过滤
|
||
const albumLower = albumName.toLowerCase();
|
||
const matched = results.filter(s => (s.album || '').toLowerCase().includes(albumLower));
|
||
const pool = matched.length >= 2 ? matched : results;
|
||
|
||
if (matched.length >= 2 && matched.length < results.length) {
|
||
console.log(` 找到 ${results.length} 首结果,其中 ${matched.length} 首匹配专辑 "${albumName}"`);
|
||
}
|
||
|
||
// 按专辑分组
|
||
const albumGroups = {};
|
||
for (const s of pool) {
|
||
const key = s.album || '未知专辑';
|
||
if (!albumGroups[key]) albumGroups[key] = [];
|
||
albumGroups[key].push(s);
|
||
}
|
||
|
||
console.log('\n 匹配的专辑:');
|
||
const albumKeys = Object.keys(albumGroups);
|
||
albumKeys.forEach((a, i) => {
|
||
const num = String(i + 1).padStart(2);
|
||
const songs = albumGroups[a];
|
||
const artist = songs[0]?.artists || '未知';
|
||
console.log(` ${num} ${a.slice(0, 30).padEnd(32)} ${artist} (${songs.length}首)`);
|
||
});
|
||
|
||
const albumPick = await ask('\n选择专辑序号 (多个用逗号分隔, a=全部, 0=取消): ');
|
||
const albumPickInput = albumPick.trim().toLowerCase();
|
||
if (!albumPickInput || albumPickInput === '0') break;
|
||
|
||
let albumsToDownload = [];
|
||
if (albumPickInput === 'a') {
|
||
albumsToDownload = albumKeys;
|
||
} else {
|
||
const indices = albumPickInput.split(/[,,\s]+/)
|
||
.map(n => parseInt(n, 10))
|
||
.filter(n => n >= 1 && n <= albumKeys.length);
|
||
albumsToDownload = [...new Set(indices)].sort((a, b) => a - b).map(n => albumKeys[n - 1]);
|
||
}
|
||
|
||
if (!albumsToDownload.length) break;
|
||
|
||
const allSongs = [];
|
||
for (const aName of albumsToDownload) {
|
||
const albumSongs = albumGroups[aName];
|
||
for (let trackIndex = 0; trackIndex < albumSongs.length; trackIndex++) {
|
||
const song = albumSongs[trackIndex];
|
||
if (!allSongs.find(x => x.song.id === song.id)) {
|
||
allSongs.push({
|
||
song,
|
||
albumName: aName,
|
||
trackIndex,
|
||
trackCount: albumSongs.length,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log(`\n💿 共 ${albumsToDownload.length} 张专辑,${allSongs.length} 首歌曲`);
|
||
level = await askLevel(ask, level);
|
||
const confirm = await ask(`确认下载? (y/n): `);
|
||
if (confirm.trim().toLowerCase() !== 'y') break;
|
||
|
||
for (let i = 0; i < allSongs.length; i++) {
|
||
const item = allSongs[i];
|
||
const s = item.song;
|
||
const target = getAlbumDownloadTarget({
|
||
name: item.albumName,
|
||
artist: { name: s.artists || '未知歌手' },
|
||
}, s, item.trackIndex, item.trackCount);
|
||
await waitIfPaused();
|
||
console.log(`\n[${i + 1}/${allSongs.length}] ${s.name} - ${s.artists}`);
|
||
try { await downloadSong(s.id, level, target); }
|
||
catch (e) { console.error(` ❌ 出错: ${e.message}`); }
|
||
if (i < allSongs.length - 1) await sleep(500);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'b':
|
||
case 'B': {
|
||
// 批量ID下载
|
||
console.log('\n 📋 批量输入歌曲ID');
|
||
console.log(' 支持格式:');
|
||
console.log(' - 逗号分隔: 123,456,789');
|
||
console.log(' - 空格分隔: 123 456 789');
|
||
console.log(' - 每行一个 (输入空行结束)');
|
||
|
||
const idLines = [];
|
||
console.log('');
|
||
while (true) {
|
||
const line = await ask(' 输入ID (空行结束): ');
|
||
if (!line.trim()) break;
|
||
idLines.push(line.trim());
|
||
}
|
||
|
||
if (!idLines.length) { console.log(' ⚠️ 未输入任何ID'); break; }
|
||
|
||
// 解析所有ID
|
||
const allIds = [];
|
||
for (const line of idLines) {
|
||
const parts = line.split(/[,,\s]+/).filter(s => /^\d+$/.test(s));
|
||
allIds.push(...parts);
|
||
}
|
||
|
||
const uniqueIds = [...new Set(allIds)];
|
||
if (!uniqueIds.length) { console.log(' ⚠️ 未找到有效ID'); break; }
|
||
|
||
console.log(`\n 📊 解析结果: ${uniqueIds.length} 个有效ID`);
|
||
level = await askLevel(ask, level);
|
||
const confirm = await ask(`确认下载 ${uniqueIds.length} 首歌曲? (y/n): `);
|
||
if (confirm.trim().toLowerCase() !== 'y') break;
|
||
|
||
let success = 0;
|
||
let fail = 0;
|
||
const failedIds = [];
|
||
|
||
for (let i = 0; i < uniqueIds.length; i++) {
|
||
const id = uniqueIds[i];
|
||
console.log(`\n [${i + 1}/${uniqueIds.length}] ID: ${id}`);
|
||
try {
|
||
const ok = await downloadSong(id, level);
|
||
if (ok) success++;
|
||
else { fail++; failedIds.push(id); }
|
||
} catch (e) {
|
||
console.error(` ❌ 出错: ${e.message}`);
|
||
fail++;
|
||
failedIds.push(id);
|
||
}
|
||
if (i < uniqueIds.length - 1) await sleep(500);
|
||
}
|
||
|
||
console.log(`\n 📊 下载完成:`);
|
||
console.log(` ✅ 成功: ${success} 首`);
|
||
console.log(` ❌ 失败: ${fail} 首`);
|
||
if (failedIds.length) {
|
||
console.log(` 失败ID: ${failedIds.join(', ')}`);
|
||
const retry = await ask('\n 是否重试失败的歌曲? (y/n): ');
|
||
if (retry.trim().toLowerCase() === 'y') {
|
||
console.log('\n 🔄 重试中...');
|
||
let retrySuccess = 0;
|
||
for (let i = 0; i < failedIds.length; i++) {
|
||
const id = failedIds[i];
|
||
console.log(` [${i + 1}/${failedIds.length}] ID: ${id}`);
|
||
try {
|
||
const ok = await downloadSong(id, level);
|
||
if (ok) retrySuccess++;
|
||
} catch {}
|
||
if (i < failedIds.length - 1) await sleep(1000);
|
||
}
|
||
console.log(` ✅ 重试完成: 成功 ${retrySuccess} 首`);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'f':
|
||
case 'F': {
|
||
// 从文件批量下载
|
||
const filePath = await ask('请输入文件路径 (每行一个歌曲ID或"歌手 - 歌名"): ');
|
||
const fPath = filePath.trim();
|
||
if (!fPath) { console.log('⚠️ 请输入文件路径'); break; }
|
||
if (!fs.existsSync(fPath)) { console.log('❌ 文件不存在'); break; }
|
||
|
||
const lines = fs.readFileSync(fPath, 'utf-8')
|
||
.split('\n')
|
||
.map(l => l.trim())
|
||
.filter(l => l && !l.startsWith('#'));
|
||
|
||
if (!lines.length) { console.log('⚠️ 文件为空'); break; }
|
||
|
||
// 区分 ID 和 "歌手 - 歌名"
|
||
const idList = [];
|
||
const nameList = [];
|
||
for (const line of lines) {
|
||
if (/^\d+$/.test(line)) {
|
||
idList.push(line);
|
||
} else if (line.includes('-')) {
|
||
nameList.push(line);
|
||
}
|
||
}
|
||
|
||
console.log(`\n📋 解析结果: ${idList.length} 个ID, ${nameList.length} 个歌名`);
|
||
|
||
// 歌名搜索匹配
|
||
const resolvedIds = [];
|
||
if (nameList.length) {
|
||
console.log('\n🔍 搜索歌名匹配...');
|
||
for (const nameLine of nameList) {
|
||
const parts = nameLine.split(/\s*[-—]\s*/);
|
||
const keyword = parts.length >= 2 ? `${parts[0]} ${parts[1]}` : nameLine;
|
||
try {
|
||
const res = await fetchJSON(`${API.search}?keyword=${encodeURIComponent(keyword)}&limit=3`);
|
||
if (res.code === 200 && res.data?.length) {
|
||
const best = res.data[0];
|
||
resolvedIds.push(best.id);
|
||
console.log(` ✅ "${nameLine}" → ${best.name} - ${best.artists} (ID: ${best.id})`);
|
||
} else {
|
||
console.log(` ⚠️ "${nameLine}" 未找到匹配`);
|
||
}
|
||
} catch (e) {
|
||
console.log(` ❌ "${nameLine}" 搜索失败: ${e.message}`);
|
||
}
|
||
await sleep(300);
|
||
}
|
||
}
|
||
|
||
const allIds = [...idList, ...resolvedIds];
|
||
if (!allIds.length) { console.log('❌ 无有效歌曲ID'); break; }
|
||
|
||
console.log(`\n📥 共 ${allIds.length} 首歌曲待下载`);
|
||
level = await askLevel(ask, level);
|
||
const confirm = await ask('确认下载? (y/n): ');
|
||
if (confirm.trim().toLowerCase() !== 'y') break;
|
||
|
||
let success = 0;
|
||
let fail = 0;
|
||
for (let i = 0; i < allIds.length; i++) {
|
||
const id = allIds[i];
|
||
console.log(`\n[${i + 1}/${allIds.length}] ID: ${id}`);
|
||
try {
|
||
const ok = await downloadSong(id, level);
|
||
if (ok) success++; else fail++;
|
||
} catch (e) {
|
||
console.error(` ❌ 出错: ${e.message}`);
|
||
fail++;
|
||
}
|
||
if (i < allIds.length - 1) await sleep(500);
|
||
}
|
||
|
||
console.log(`\n📊 下载完成: 成功 ${success} 首, 失败 ${fail} 首`);
|
||
break;
|
||
}
|
||
|
||
case 'n':
|
||
case 'N': {
|
||
// 从文件名识别并补全标签
|
||
if (!fs.existsSync(DOWNLOAD_DIR)) {
|
||
console.log('\n 📭 下载目录不存在');
|
||
break;
|
||
}
|
||
|
||
const allFiles = fs.readdirSync(DOWNLOAD_DIR);
|
||
const audioExts = ['.mp3', '.flac', '.m4a', '.wav', '.aac'];
|
||
const audioFiles = allFiles.filter(f => audioExts.includes(path.extname(f).toLowerCase()));
|
||
|
||
if (!audioFiles.length) {
|
||
console.log('\n 📭 暂无音频文件');
|
||
break;
|
||
}
|
||
|
||
console.log(`\n 📁 扫描到 ${audioFiles.length} 个音频文件,分析文件名...\n`);
|
||
|
||
const candidates = [];
|
||
for (const f of audioFiles) {
|
||
const baseName = path.parse(f).name;
|
||
// 常见格式: "歌手 - 歌名" 或 "歌手-歌名"
|
||
const match = baseName.match(/^(.+?)\s*[-—]\s*(.+)$/);
|
||
if (match) {
|
||
candidates.push({ file: f, artist: match[1].trim(), name: match[2].trim() });
|
||
}
|
||
}
|
||
|
||
if (!candidates.length) {
|
||
console.log(' ⚠️ 未识别到 "歌手 - 歌名" 格式的文件');
|
||
console.log(' 支持的格式: "周杰伦 - 晴天.mp3" 或 "周杰伦-晴天.flac"');
|
||
break;
|
||
}
|
||
|
||
console.log(` 识别到 ${candidates.length} 首歌曲:`);
|
||
console.log(' ' + '─'.repeat(60));
|
||
console.log(' # 歌手 歌曲名 文件');
|
||
console.log(' ' + '─'.repeat(60));
|
||
candidates.forEach((c, i) => {
|
||
const num = String(i + 1).padStart(3);
|
||
const artist = c.artist.slice(0, 18).padEnd(20);
|
||
const name = c.name.slice(0, 18).padEnd(20);
|
||
const file = c.file.length > 30 ? c.file.slice(0, 27) + '...' : c.file;
|
||
console.log(` ${num} ${artist} ${name} ${file}`);
|
||
});
|
||
console.log(' ' + '─'.repeat(60));
|
||
|
||
console.log('\n 操作选项:');
|
||
console.log(' a = 全部自动匹配并补全标签');
|
||
console.log(' 输入序号 = 选择指定文件 (如: 1,3,5)');
|
||
console.log(' 回车 = 跳过');
|
||
const pick = await ask(' 请选择: ');
|
||
const pickInput = pick.trim().toLowerCase();
|
||
|
||
let targets = [];
|
||
if (pickInput === 'a') {
|
||
targets = candidates;
|
||
} else if (pickInput) {
|
||
const indices = new Set();
|
||
for (const part of pickInput.split(/[,,\s]+/)) {
|
||
if (/^\d+$/.test(part)) indices.add(parseInt(part, 10));
|
||
}
|
||
targets = [...indices]
|
||
.filter(n => n >= 1 && n <= candidates.length)
|
||
.sort((a, b) => a - b)
|
||
.map(n => candidates[n - 1]);
|
||
}
|
||
|
||
if (!targets.length) break;
|
||
|
||
console.log(`\n🏷️ 开始匹配 ${targets.length} 首歌曲...\n`);
|
||
let successCount = 0;
|
||
|
||
for (let i = 0; i < targets.length; i++) {
|
||
const c = targets[i];
|
||
console.log(`[${i + 1}/${targets.length}] ${c.artist} - ${c.name}`);
|
||
|
||
try {
|
||
// 搜索匹配
|
||
const keyword = `${c.artist} ${c.name}`;
|
||
const results = await fetchJSON(`${API.search}?keyword=${encodeURIComponent(keyword)}&limit=5`);
|
||
if (results.code !== 200 || !results.data?.length) {
|
||
console.log(' ⚠️ 未找到匹配');
|
||
continue;
|
||
}
|
||
|
||
// 找最佳匹配
|
||
const best = results.data.find(s => {
|
||
const sName = (s.name || '').toLowerCase();
|
||
const sArtist = (s.artists || '').toLowerCase();
|
||
return sName.includes(c.name.toLowerCase()) && sArtist.includes(c.artist.toLowerCase());
|
||
}) || results.data[0];
|
||
|
||
console.log(` ✅ 匹配: ${best.name} - ${best.artists} (ID: ${best.id})`);
|
||
|
||
// 获取完整歌曲信息并嵌入标签
|
||
const filePath = path.join(DOWNLOAD_DIR, c.file);
|
||
const songInfo = await fetchJSON(`${API.music}?id=${best.id}&level=standard`);
|
||
if (songInfo.code === 200 && songInfo.data) {
|
||
const d = songInfo.data;
|
||
if (d.picUrl) {
|
||
await embedCover(filePath, d.picUrl, { name: d.name, artist: d.artist, album: d.album });
|
||
}
|
||
|
||
// 获取并保存歌词
|
||
const lyricData = await fetchLyric(best.id);
|
||
if (lyricData?.lrc) {
|
||
const baseName = path.parse(c.file).name;
|
||
const lrcPath = path.join(DOWNLOAD_DIR, baseName + '.lrc');
|
||
fs.writeFileSync(lrcPath, lyricData.lrc, 'utf-8');
|
||
console.log(` 📄 歌词已保存`);
|
||
}
|
||
|
||
// 重命名文件
|
||
const ext = path.extname(c.file);
|
||
const newName = sanitize(`${d.artist} - ${d.name}${ext}`);
|
||
if (newName !== c.file) {
|
||
const newPath = path.join(DOWNLOAD_DIR, newName);
|
||
if (!fs.existsSync(newPath)) {
|
||
fs.renameSync(filePath, newPath);
|
||
console.log(` 📝 重命名: ${newName}`);
|
||
}
|
||
}
|
||
|
||
// 记录历史
|
||
addHistory({ id: best.id, name: d.name, artist: d.artist, album: d.album, level: 'standard', bitrate: d.br, size: d.size, file: newName });
|
||
successCount++;
|
||
}
|
||
} catch (e) {
|
||
console.error(` ❌ 出错: ${e.message}`);
|
||
}
|
||
|
||
if (i < targets.length - 1) await sleep(500);
|
||
}
|
||
|
||
console.log(`\n✅ 完成! 成功补全 ${successCount}/${targets.length} 首`);
|
||
break;
|
||
}
|
||
|
||
case 'e':
|
||
case 'E': {
|
||
// 导出下载历史
|
||
const history = loadHistory();
|
||
if (!history.length) {
|
||
console.log('\n 📭 暂无下载历史可导出');
|
||
break;
|
||
}
|
||
|
||
console.log('\n 导出格式:');
|
||
console.log(' 1. CSV (Excel 兼容)');
|
||
console.log(' 2. JSON');
|
||
console.log(' 3. TXT (纯文本列表)');
|
||
const fmt = await ask(' 选择格式 (1/2/3): ');
|
||
|
||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||
let exportPath;
|
||
|
||
switch (fmt.trim()) {
|
||
case '1': {
|
||
// CSV
|
||
exportPath = path.join(__dirname, `download_history_${timestamp}.csv`);
|
||
const header = '歌曲ID,歌曲名,歌手,专辑,音质,码率(kbps),文件大小(bytes),文件名,下载时间\n';
|
||
const rows = history.map(h => {
|
||
return [
|
||
h.id,
|
||
`"${(h.name || '').replace(/"/g, '""')}"`,
|
||
`"${(h.artist || '').replace(/"/g, '""')}"`,
|
||
`"${(h.album || '').replace(/"/g, '""')}"`,
|
||
h.level || '',
|
||
h.bitrate || '',
|
||
h.size || '',
|
||
`"${(h.file || '').replace(/"/g, '""')}"`,
|
||
h.timestamp || '',
|
||
].join(',');
|
||
}).join('\n');
|
||
fs.writeFileSync(exportPath, header + rows + '\n', 'utf-8');
|
||
break;
|
||
}
|
||
|
||
case '2': {
|
||
// JSON
|
||
exportPath = path.join(__dirname, `download_history_${timestamp}.json`);
|
||
fs.writeFileSync(exportPath, JSON.stringify(history, null, 2), 'utf-8');
|
||
break;
|
||
}
|
||
|
||
case '3': {
|
||
// TXT
|
||
exportPath = path.join(__dirname, `download_history_${timestamp}.txt`);
|
||
const lines = history.map((h, i) => {
|
||
const time = h.timestamp ? formatTime(h.timestamp) : '未知';
|
||
return `${i + 1}. ${h.artist || '未知'} - ${h.name || '未知'} (${h.album || '未知专辑'}) [${h.level || '?'}] ${time}`;
|
||
});
|
||
fs.writeFileSync(exportPath, lines.join('\n') + '\n', 'utf-8');
|
||
break;
|
||
}
|
||
|
||
default:
|
||
console.log(' ⚠️ 无效选项');
|
||
break;
|
||
}
|
||
|
||
if (exportPath && fs.existsSync(exportPath)) {
|
||
const stat = fs.statSync(exportPath);
|
||
console.log(`\n ✅ 导出成功!`);
|
||
console.log(` 📄 文件: ${exportPath}`);
|
||
console.log(` 📊 共 ${history.length} 条记录, ${(stat.size / 1024).toFixed(1)} KB`);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'm':
|
||
case 'M': {
|
||
// 保存歌单为 M3U
|
||
const plInput = await ask('请输入歌单ID: ');
|
||
const plId = plInput.trim();
|
||
if (!/^\d+$/.test(plId)) { console.log('⚠️ 无效歌单ID'); break; }
|
||
|
||
console.log(`\n🔍 获取歌单信息 ...`);
|
||
const pl = await fetchPlaylist(plId);
|
||
if (!pl) break;
|
||
|
||
console.log(`📜 歌单: ${pl.name} (${pl.trackCount} 首)`);
|
||
|
||
// 生成 M3U 内容
|
||
const m3uLines = ['#EXTM3U', ''];
|
||
for (const t of pl.tracks) {
|
||
const artist = t.ar?.map(a => a.name).join('/') || '未知';
|
||
const title = t.name || '未知';
|
||
const duration = Math.round((t.dt || 0) / 1000) || -1;
|
||
m3uLines.push(`#EXTINF:${duration},${artist} - ${title}`);
|
||
// 检查本地是否已下载
|
||
const localFile = path.join(DOWNLOAD_DIR, sanitize(`${artist} - ${title}`));
|
||
const possibleExts = ['.mp3', '.flac', '.m4a', '.wav', '.aac'];
|
||
let found = false;
|
||
for (const ext of possibleExts) {
|
||
const fp = localFile + ext;
|
||
if (fs.existsSync(fp)) {
|
||
m3uLines.push(fp);
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!found) {
|
||
// 用 API 链接作为备选
|
||
m3uLines.push(`#EXTVLCOPT:network-caching=1000`);
|
||
m3uLines.push(`${API.music}?id=${t.id}&type=down`);
|
||
}
|
||
m3uLines.push('');
|
||
}
|
||
|
||
const m3uContent = m3uLines.join('\n');
|
||
const defaultName = sanitize(pl.name || `playlist_${plId}`);
|
||
const nameInput = await ask(`保存文件名 (默认 ${defaultName}): `);
|
||
const fileName = sanitize(nameInput.trim() || defaultName) + '.m3u';
|
||
const savePath = path.join(__dirname, fileName);
|
||
|
||
fs.writeFileSync(savePath, m3uContent, 'utf-8');
|
||
console.log(`\n ✅ M3U 播放列表已保存!`);
|
||
console.log(` 📄 文件: ${savePath}`);
|
||
console.log(` 📊 共 ${pl.trackCount} 首歌曲`);
|
||
|
||
// 统计本地已有数量
|
||
const localCount = m3uLines.filter(l => l.startsWith(DOWNLOAD_DIR)).length;
|
||
if (localCount > 0) {
|
||
console.log(` 💾 本地已有: ${localCount} 首可直接播放`);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'g':
|
||
case 'G': {
|
||
// 自动识别并补全标签
|
||
if (!fs.existsSync(DOWNLOAD_DIR)) {
|
||
console.log('\n 📭 下载目录不存在');
|
||
break;
|
||
}
|
||
|
||
const allFiles = fs.readdirSync(DOWNLOAD_DIR);
|
||
const audioExts = ['.mp3', '.flac', '.m4a', '.wav', '.aac'];
|
||
const audioFiles = allFiles.filter(f => audioExts.includes(path.extname(f).toLowerCase()));
|
||
|
||
if (!audioFiles.length) {
|
||
console.log('\n 📭 暂无音频文件');
|
||
break;
|
||
}
|
||
|
||
// 筛选需要补全的文件(没有封面或没有歌词)
|
||
console.log(`\n 🔍 扫描 ${audioFiles.length} 个音频文件,检查标签完整性...\n`);
|
||
|
||
const needTag = [];
|
||
for (const f of audioFiles) {
|
||
const filePath = path.join(DOWNLOAD_DIR, f);
|
||
const baseName = path.parse(f).name;
|
||
const hasLrc = allFiles.some(lf => lf.startsWith(baseName) && lf.endsWith('.lrc'));
|
||
|
||
// 简单判断:文件名包含 " - " 且没有对应 .lrc 文件
|
||
const isNameFormat = /^.+\s*[-—]\s*.+$/.test(baseName);
|
||
if (isNameFormat && !hasLrc) {
|
||
needTag.push(f);
|
||
}
|
||
}
|
||
|
||
if (!needTag.length) {
|
||
console.log(' ✅ 所有文件标签已完整,无需补全');
|
||
break;
|
||
}
|
||
|
||
console.log(` 发现 ${needTag.length} 个文件需要补全标签:`);
|
||
needTag.forEach((f, i) => {
|
||
console.log(` ${i + 1}. ${f}`);
|
||
});
|
||
|
||
const confirm = await ask(`\n 自动补全这 ${needTag.length} 个文件? (y/n): `);
|
||
if (confirm.trim().toLowerCase() !== 'y') break;
|
||
|
||
let success = 0;
|
||
let fail = 0;
|
||
|
||
for (let i = 0; i < needTag.length; i++) {
|
||
const f = needTag[i];
|
||
const baseName = path.parse(f).name;
|
||
console.log(`\n [${i + 1}/${needTag.length}] ${f}`);
|
||
|
||
// 解析文件名
|
||
const match = baseName.match(/^(.+?)\s*[-—]\s*(.+)$/);
|
||
if (!match) {
|
||
console.log(' ⚠️ 无法解析文件名,跳过');
|
||
fail++;
|
||
continue;
|
||
}
|
||
|
||
const artist = match[1].trim();
|
||
const name = match[2].trim();
|
||
|
||
try {
|
||
// 搜索匹配
|
||
const keyword = `${artist} ${name}`;
|
||
const results = await fetchJSON(`${API.search}?keyword=${encodeURIComponent(keyword)}&limit=5`);
|
||
if (results.code !== 200 || !results.data?.length) {
|
||
console.log(' ⚠️ 搜索无结果,跳过');
|
||
fail++;
|
||
continue;
|
||
}
|
||
|
||
// 找最佳匹配
|
||
const best = results.data.find(s => {
|
||
const sName = (s.name || '').toLowerCase();
|
||
const sArtist = (s.artists || '').toLowerCase();
|
||
return sName.includes(name.toLowerCase()) && sArtist.includes(artist.toLowerCase());
|
||
}) || results.data[0];
|
||
|
||
console.log(` ✅ 匹配: ${best.name} - ${best.artists}`);
|
||
|
||
// 获取完整信息
|
||
const songInfo = await fetchJSON(`${API.music}?id=${best.id}&level=standard`);
|
||
if (songInfo.code === 200 && songInfo.data) {
|
||
const d = songInfo.data;
|
||
const filePath = path.join(DOWNLOAD_DIR, f);
|
||
|
||
// 嵌入封面和标签
|
||
if (d.picUrl) {
|
||
await embedCover(filePath, d.picUrl, { name: d.name, artist: d.artist, album: d.album });
|
||
}
|
||
|
||
// 获取并保存歌词
|
||
const lyricData = await fetchLyric(best.id);
|
||
if (lyricData?.lrc) {
|
||
const lrcPath = path.join(DOWNLOAD_DIR, baseName + '.lrc');
|
||
fs.writeFileSync(lrcPath, lyricData.lrc, 'utf-8');
|
||
console.log(' 📄 歌词已保存');
|
||
}
|
||
|
||
// 记录历史
|
||
addHistory({ id: best.id, name: d.name, artist: d.artist, album: d.album, level: 'standard', bitrate: d.br, size: d.size, file: f });
|
||
success++;
|
||
}
|
||
} catch (e) {
|
||
console.error(` ❌ 出错: ${e.message}`);
|
||
fail++;
|
||
}
|
||
|
||
if (i < needTag.length - 1) await sleep(500);
|
||
}
|
||
|
||
console.log(`\n 📊 自动补全完成: 成功 ${success} 首, 失败 ${fail} 首`);
|
||
break;
|
||
}
|
||
|
||
case 'z':
|
||
case 'Z': {
|
||
// 歌单下载并打包 ZIP
|
||
const plInput = await ask('请输入歌单ID: ');
|
||
const plId = plInput.trim();
|
||
if (!/^\d+$/.test(plId)) { console.log('⚠️ 无效歌单ID'); break; }
|
||
|
||
const pl = await fetchPlaylist(plId);
|
||
if (!pl) break;
|
||
|
||
console.log(`\n📜 歌单: ${pl.name} (${pl.trackCount} 首)`);
|
||
level = await askLevel(ask, level);
|
||
const confirm = await ask(`确认下载并打包 ${pl.trackCount} 首歌曲? (y/n): `);
|
||
if (confirm.trim().toLowerCase() !== 'y') break;
|
||
|
||
// 创建临时目录
|
||
const zipName = sanitize(pl.name || `playlist_${plId}`);
|
||
const tempDir = path.join(TEMP_ZIP_DIR, zipName);
|
||
if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true });
|
||
fs.mkdirSync(tempDir, { recursive: true });
|
||
|
||
console.log(`\n📥 开始下载到临时目录...\n`);
|
||
let downloaded = 0;
|
||
let failed = 0;
|
||
|
||
for (let i = 0; i < pl.tracks.length; i++) {
|
||
const t = pl.tracks[i];
|
||
const artist = t.ar?.map(a => a.name).join('/') || '未知';
|
||
const title = t.name || '未知';
|
||
console.log(`[${i + 1}/${pl.tracks.length}] ${title} - ${artist}`);
|
||
|
||
try {
|
||
const result = await fetchJSON(`${API.music}?id=${t.id}&level=${level}`);
|
||
if (result.code !== 200 || !result.data?.url) {
|
||
console.log(' ❌ 解析失败');
|
||
failed++;
|
||
continue;
|
||
}
|
||
|
||
const { url: audioUrl, name: sName, artist: sArtist } = result.data;
|
||
const ext = audioUrl.match(/\.([a-z0-9]+)(\?|$)/i)?.[1] || 'mp3';
|
||
const fileName = sanitize(`${sArtist} - ${sName}.${ext}`);
|
||
const dest = path.join(tempDir, fileName);
|
||
|
||
console.log(` ⬇️ 下载中...`);
|
||
await downloadFile(audioUrl, dest, false);
|
||
console.log(` ✅ ${fileName}`);
|
||
downloaded++;
|
||
} catch (e) {
|
||
console.error(` ❌ 出错: ${e.message}`);
|
||
failed++;
|
||
}
|
||
|
||
if (i < pl.tracks.length - 1) await sleep(300);
|
||
}
|
||
|
||
if (downloaded === 0) {
|
||
console.log('\n❌ 没有歌曲下载成功,取消打包');
|
||
fs.rmSync(tempDir, { recursive: true });
|
||
break;
|
||
}
|
||
|
||
// 打包 ZIP
|
||
const zipPath = path.join(__dirname, `${zipName}.zip`);
|
||
console.log(`\n📦 正在打包 ${downloaded} 首歌曲...`);
|
||
|
||
try {
|
||
// 尝试使用系统 zip 命令
|
||
execSync(`cd "${path.dirname(tempDir)}" && zip -r "${zipPath}" "${zipName}"`, { stdio: 'ignore' });
|
||
console.log(`\n ✅ ZIP 打包完成!`);
|
||
console.log(` 📦 文件: ${zipPath}`);
|
||
const zipStat = fs.statSync(zipPath);
|
||
console.log(` 📊 大小: ${(zipStat.size / 1048576).toFixed(2)} MB`);
|
||
console.log(` 📋 包含: ${downloaded} 首歌曲 (失败 ${failed} 首)`);
|
||
} catch (e) {
|
||
// zip 不可用,尝试 tar.gz
|
||
console.log(' ⚠️ zip 命令不可用,尝试 tar.gz...');
|
||
const tarPath = path.join(__dirname, `${zipName}.tar.gz`);
|
||
try {
|
||
execSync(`tar -czf "${tarPath}" -C "${path.dirname(tempDir)}" "${zipName}"`, { stdio: 'ignore' });
|
||
console.log(`\n ✅ tar.gz 打包完成!`);
|
||
console.log(` 📦 文件: ${tarPath}`);
|
||
const tarStat = fs.statSync(tarPath);
|
||
console.log(` 📊 大小: ${(tarStat.size / 1048576).toFixed(2)} MB`);
|
||
} catch (e2) {
|
||
console.error(` ❌ 打包失败: ${e2.message}`);
|
||
console.log(` 📁 歌曲已下载到: ${tempDir}`);
|
||
console.log(' 请手动打包');
|
||
}
|
||
}
|
||
|
||
// 清理临时目录
|
||
try { fs.rmSync(tempDir, { recursive: true }); } catch {}
|
||
break;
|
||
}
|
||
|
||
case 'c':
|
||
case 'C': {
|
||
// 清理缓存和临时文件
|
||
console.log('\n 🧹 清理缓存和临时文件');
|
||
|
||
let cleanedSize = 0;
|
||
let cleanedCount = 0;
|
||
|
||
// 清理临时 zip 目录
|
||
if (fs.existsSync(TEMP_ZIP_DIR)) {
|
||
const tmpSize = getDirSize(TEMP_ZIP_DIR);
|
||
fs.rmSync(TEMP_ZIP_DIR, { recursive: true });
|
||
cleanedSize += tmpSize;
|
||
cleanedCount++;
|
||
console.log(` ✅ 已清理临时打包目录 (${(tmpSize / 1024).toFixed(1)} KB)`);
|
||
}
|
||
|
||
// 清理回收站
|
||
if (fs.existsSync(TRASH_DIR)) {
|
||
const trashFiles = fs.readdirSync(TRASH_DIR);
|
||
if (trashFiles.length) {
|
||
console.log(`\n 📁 回收站中有 ${trashFiles.length} 个文件`);
|
||
const trashConfirm = await ask(' 是否清空回收站? (y/n): ');
|
||
if (trashConfirm.trim().toLowerCase() === 'y') {
|
||
const trashSize = getDirSize(TRASH_DIR);
|
||
fs.rmSync(TRASH_DIR, { recursive: true });
|
||
cleanedSize += trashSize;
|
||
cleanedCount++;
|
||
console.log(` ✅ 回收站已清空 (${(trashSize / 1048576).toFixed(2)} MB)`);
|
||
} else {
|
||
console.log(' ⏭️ 跳过回收站');
|
||
}
|
||
} else {
|
||
console.log(' 📁 回收站为空');
|
||
}
|
||
}
|
||
|
||
// 清理孤立的 .cover.jpg 临时文件
|
||
if (fs.existsSync(DOWNLOAD_DIR)) {
|
||
const allFiles = fs.readdirSync(DOWNLOAD_DIR);
|
||
const coverTemps = allFiles.filter(f => f.endsWith('.cover.jpg'));
|
||
if (coverTemps.length) {
|
||
let coverSize = 0;
|
||
for (const f of coverTemps) {
|
||
const fp = path.join(DOWNLOAD_DIR, f);
|
||
coverSize += fs.statSync(fp).size;
|
||
fs.unlinkSync(fp);
|
||
}
|
||
cleanedSize += coverSize;
|
||
cleanedCount++;
|
||
console.log(` ✅ 已清理 ${coverTemps.length} 个临时封面文件 (${(coverSize / 1024).toFixed(1)} KB)`);
|
||
}
|
||
}
|
||
|
||
// 清理 .tmp 文件
|
||
if (fs.existsSync(DOWNLOAD_DIR)) {
|
||
const allFiles = fs.readdirSync(DOWNLOAD_DIR);
|
||
const tmpFiles = allFiles.filter(f => f.includes('.tmp.'));
|
||
if (tmpFiles.length) {
|
||
let tmpSize = 0;
|
||
for (const f of tmpFiles) {
|
||
const fp = path.join(DOWNLOAD_DIR, f);
|
||
tmpSize += fs.statSync(fp).size;
|
||
fs.unlinkSync(fp);
|
||
}
|
||
cleanedSize += tmpSize;
|
||
cleanedCount++;
|
||
console.log(` ✅ 已清理 ${tmpFiles.length} 个临时文件 (${(tmpSize / 1024).toFixed(1)} KB)`);
|
||
}
|
||
}
|
||
|
||
if (cleanedCount === 0) {
|
||
console.log(' ✨ 没有需要清理的文件');
|
||
} else {
|
||
console.log(`\n 📊 共释放 ${(cleanedSize / 1048576).toFixed(2)} MB 空间`);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'w':
|
||
case 'W': {
|
||
// 重命名歌曲文件
|
||
if (!fs.existsSync(DOWNLOAD_DIR)) {
|
||
console.log('\n 📭 下载目录不存在');
|
||
break;
|
||
}
|
||
|
||
const allFiles = fs.readdirSync(DOWNLOAD_DIR);
|
||
const audioExts = ['.mp3', '.flac', '.m4a', '.wav', '.aac'];
|
||
const audioFiles = allFiles.filter(f => audioExts.includes(path.extname(f).toLowerCase()));
|
||
|
||
if (!audioFiles.length) {
|
||
console.log('\n 📭 暂无音频文件');
|
||
break;
|
||
}
|
||
|
||
console.log(`\n 📁 已下载的音频文件 (${audioFiles.length} 首):`);
|
||
console.log(' ' + '─'.repeat(60));
|
||
audioFiles.forEach((f, i) => {
|
||
console.log(` ${String(i + 1).padStart(3)} ${f}`);
|
||
});
|
||
console.log(' ' + '─'.repeat(60));
|
||
|
||
const pick = await ask('\n 输入要重命名的序号 (如: 1,3,5): ');
|
||
if (!pick.trim()) break;
|
||
|
||
const indices = pick.split(/[,,\s]+/)
|
||
.map(n => parseInt(n, 10))
|
||
.filter(n => n >= 1 && n <= audioFiles.length);
|
||
|
||
if (!indices.length) { console.log(' ⚠️ 无效序号'); break; }
|
||
|
||
for (const idx of indices) {
|
||
const oldName = audioFiles[idx - 1];
|
||
const ext = path.extname(oldName);
|
||
const oldBase = path.parse(oldName).name;
|
||
|
||
console.log(`\n 📝 重命名: ${oldName}`);
|
||
console.log(' 格式: 歌手 - 歌名');
|
||
|
||
// 智能解析现有文件名
|
||
const match = oldBase.match(/^(.+?)\s*[-—]\s*(.+)$/);
|
||
const defaultArtist = match ? match[1].trim() : '';
|
||
const defaultName = match ? match[2].trim() : oldBase;
|
||
|
||
const newArtist = await ask(` 歌手 [${defaultArtist || '未知'}]: `);
|
||
const newName = await ask(` 歌名 [${defaultName}]: `);
|
||
|
||
const artist = sanitize(newArtist.trim() || defaultArtist || '未知');
|
||
const songName = sanitize(newName.trim() || defaultName);
|
||
const newBase = `${artist} - ${songName}`;
|
||
|
||
if (newBase === oldBase) {
|
||
console.log(' ⏭️ 名称未变,跳过');
|
||
continue;
|
||
}
|
||
|
||
const newFileName = newBase + ext;
|
||
const oldPath = path.join(DOWNLOAD_DIR, oldName);
|
||
const newPath = path.join(DOWNLOAD_DIR, newFileName);
|
||
|
||
if (fs.existsSync(newPath)) {
|
||
console.log(` ⚠️ 目标文件已存在: ${newFileName},跳过`);
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
fs.renameSync(oldPath, newPath);
|
||
console.log(` ✅ 已重命名: ${newFileName}`);
|
||
|
||
// 同时重命名关联的 .lrc 文件
|
||
const oldLrc = path.join(DOWNLOAD_DIR, oldBase + '.lrc');
|
||
const newLrc = path.join(DOWNLOAD_DIR, newBase + '.lrc');
|
||
if (fs.existsSync(oldLrc)) {
|
||
fs.renameSync(oldLrc, newLrc);
|
||
console.log(` ✅ 歌词文件也已重命名`);
|
||
}
|
||
const oldMergedLrc = path.join(DOWNLOAD_DIR, oldBase + '.合并翻译.lrc');
|
||
const newMergedLrc = path.join(DOWNLOAD_DIR, newBase + '.合并翻译.lrc');
|
||
if (fs.existsSync(oldMergedLrc)) {
|
||
fs.renameSync(oldMergedLrc, newMergedLrc);
|
||
console.log(` ✅ 翻译歌词文件也已重命名`);
|
||
}
|
||
} catch (e) {
|
||
console.error(` ❌ 重命名失败: ${e.message}`);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 't':
|
||
case 'T': {
|
||
// 下载统计
|
||
const history = loadHistory();
|
||
if (!history.length) {
|
||
console.log('\n 📭 暂无下载记录');
|
||
break;
|
||
}
|
||
|
||
const totalSize = history.reduce((sum, h) => sum + (h.size || 0), 0);
|
||
const uniqueArtists = new Set(history.map(h => h.artist).filter(Boolean));
|
||
const uniqueAlbums = new Set(history.map(h => h.album).filter(Boolean));
|
||
const uniqueSongs = new Set(history.map(h => h.id));
|
||
|
||
// 音质分布
|
||
const levelCount = {};
|
||
for (const h of history) {
|
||
const lv = h.level || '未知';
|
||
levelCount[lv] = (levelCount[lv] || 0) + 1;
|
||
}
|
||
|
||
// 歌手 Top 10
|
||
const artistCount = {};
|
||
for (const h of history) {
|
||
if (h.artist) artistCount[h.artist] = (artistCount[h.artist] || 0) + 1;
|
||
}
|
||
const topArtists = Object.entries(artistCount)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 10);
|
||
|
||
// 最近 7 天下载量
|
||
const now = Date.now();
|
||
const dayMs = 86400000;
|
||
const last7days = [];
|
||
for (let i = 6; i >= 0; i--) {
|
||
const dayStart = new Date(now - i * dayMs);
|
||
const dayStr = `${dayStart.getMonth() + 1}/${dayStart.getDate()}`;
|
||
const count = history.filter(h => {
|
||
const t = new Date(h.timestamp).getTime();
|
||
return t >= dayStart.setHours(0,0,0,0) && t < dayStart.setHours(0,0,0,0) + dayMs;
|
||
}).length;
|
||
last7days.push({ day: dayStr, count });
|
||
}
|
||
|
||
console.log('\n ╔═══════════════════════════════════════╗');
|
||
console.log(' ║ 📊 下载统计 ║');
|
||
console.log(' ╠═══════════════════════════════════════╣');
|
||
console.log(` ║ 总下载次数: ${String(history.length).padStart(6)} 次`);
|
||
console.log(` ║ 去重歌曲数: ${String(uniqueSongs.size).padStart(6)} 首`);
|
||
console.log(` ║ 涉及歌手: ${String(uniqueArtists.size).padStart(6)} 位`);
|
||
console.log(` ║ 涉及专辑: ${String(uniqueAlbums.size).padStart(6)} 张`);
|
||
console.log(` ║ 总文件大小: ${(totalSize / 1073741824).toFixed(2).padStart(6)} GB`);
|
||
console.log(' ╠═══════════════════════════════════════╣');
|
||
|
||
console.log(' ║ 音质分布:');
|
||
for (const [lv, cnt] of Object.entries(levelCount).sort((a, b) => b[1] - a[1])) {
|
||
const bar = '█'.repeat(Math.min(Math.round(cnt / history.length * 20), 20));
|
||
const pct = ((cnt / history.length) * 100).toFixed(0);
|
||
console.log(` ║ ${lv.padEnd(12)} ${bar} ${cnt}首 (${pct}%)`);
|
||
}
|
||
|
||
if (topArtists.length) {
|
||
console.log(' ╠═══════════════════════════════════════╣');
|
||
console.log(' ║ 下载最多的歌手:');
|
||
for (const [artist, cnt] of topArtists) {
|
||
console.log(` ║ ${artist.slice(0, 16).padEnd(18)} ${cnt} 首`);
|
||
}
|
||
}
|
||
|
||
console.log(' ╠═══════════════════════════════════════╣');
|
||
console.log(' ║ 近 7 天下载量:');
|
||
const maxDayCnt = Math.max(...last7days.map(d => d.count), 1);
|
||
for (const d of last7days) {
|
||
const bar = '▓'.repeat(Math.round(d.count / maxDayCnt * 15));
|
||
console.log(` ║ ${d.day.padEnd(6)} ${bar || '·'} ${d.count}`);
|
||
}
|
||
console.log(' ╚═══════════════════════════════════════╝');
|
||
break;
|
||
}
|
||
|
||
case 'p':
|
||
case 'P': {
|
||
// 暂停/恢复
|
||
togglePause();
|
||
break;
|
||
}
|
||
|
||
case 'r':
|
||
case 'R': {
|
||
// 重置所有设置
|
||
console.log('\n ⚠️ 将重置以下内容:');
|
||
console.log(` - 音质等级恢复默认 (${DEFAULT_LEVEL})`);
|
||
console.log(' - 清空下载历史');
|
||
console.log(' - 删除运行时配置文件');
|
||
const confirm = await ask('\n 确认重置? (输入 yes 确认): ');
|
||
if (confirm.trim() === 'yes') {
|
||
// 重置配置
|
||
if (fs.existsSync(CONFIG_FILE)) {
|
||
fs.unlinkSync(CONFIG_FILE);
|
||
}
|
||
level = DEFAULT_LEVEL;
|
||
// 清空历史
|
||
const keepHistory = await ask(' 是否保留下载历史? (y/n): ');
|
||
if (keepHistory.trim().toLowerCase() !== 'y') {
|
||
saveHistory([]);
|
||
console.log(' ✅ 下载历史已清空');
|
||
}
|
||
console.log(` ✅ 音质已恢复默认: ${DEFAULT_LEVEL}`);
|
||
console.log(' ✅ 配置已重置');
|
||
} else {
|
||
console.log(' 取消重置');
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'p':
|
||
case 'P': {
|
||
// 暂停/恢复
|
||
togglePause();
|
||
break;
|
||
}
|
||
|
||
case 'h':
|
||
case 'H': {
|
||
// 帮助
|
||
console.log(`
|
||
╔══════════════════════════════════════════════════════════╗`);
|
||
console.log(` ║ 📖 使用帮助 ║`);
|
||
console.log(` ╠══════════════════════════════════════════════════════════╣`);
|
||
console.log(` ║ ║`);
|
||
console.log(` ║ 【搜索与下载】 ║`);
|
||
console.log(` ║ 1 搜索歌曲并下载 - 输入关键词搜索,选择后下载 ║`);
|
||
console.log(` ║ 2 输入歌曲 ID 下载 - 直接输入网易云歌曲ID ║`);
|
||
console.log(` ║ 3 输入歌单 ID 下载全部 - 输入歌单ID批量下载 ║`);
|
||
console.log(` ║ s 按歌名搜索 - 按歌名精确搜索,支持试听/详情/下载 ║`);
|
||
console.log(` ║ 6 按歌手批量下载 - 搜索歌手并选择下载其歌曲 ║`);
|
||
console.log(` ║ a 下载完整专辑 - 输入专辑ID下载全部曲目,也支持名称搜索 ║`);
|
||
console.log(` ║ ║`);
|
||
console.log(` ║ 【查看与管理】 ║`);
|
||
console.log(` ║ 4 查看歌单歌曲列表 - 浏览歌单内容,可选择性下载 ║`);
|
||
console.log(` ║ 5 查看歌曲歌词 - 输入ID查看歌词,可保存为.lrc文件 ║`);
|
||
console.log(` ║ 7 试听歌曲 - 输入ID在线试听 (需本地播放器) ║`);
|
||
console.log(` ║ 8 查看歌曲详情 - 显示音质、大小等详细信息 ║`);
|
||
console.log(` ║ l 已下载歌曲列表 - 查看本地已下载的所有歌曲 ║`);
|
||
console.log(` ║ ║`);
|
||
console.log(` ║ 【数据统计】 ║`);
|
||
console.log(` ║ 9 下载历史 - 查看/删除下载记录 ║`);
|
||
console.log(` ║ t 下载统计 - 音质分布、歌手排行、近7天趋势 ║`);
|
||
console.log(` ║ ║`);
|
||
console.log(` ║ 【设置】 ║`);
|
||
console.log(` ║ 0 设置音质等级 - 切换下载音质 ║`);
|
||
console.log(` ║ ║`);
|
||
console.log(` ║ 【命令行模式】 ║`);
|
||
console.log(` ║ node 163_music_downloader.js <id1> <id2> ... ║`);
|
||
console.log(` ║ node 163_music_downloader.js --playlist=<id> ║`);
|
||
console.log(` ║ node 163_music_downloader.js --album=<id> ║`);
|
||
console.log(` ║ 可选参数: --level=lossless --retries=5 ║`);
|
||
console.log(` ║ --no-lyric --no-cover ║`);
|
||
console.log(` ║ ║`);
|
||
console.log(` ║ 【音质等级说明】 ║`);
|
||
console.log(` ║ standard - 标准 exhigh - 极高 ║`);
|
||
console.log(` ║ lossless - 无损 hires - Hi-Res ║`);
|
||
console.log(` ║ jymaster - 超清母带 (默认) sky - 空间音频 ║`);
|
||
console.log(` ║ jyeffect - 高清臻品 ║`);
|
||
console.log(` ║ ║`);
|
||
console.log(` ╚══════════════════════════════════════════════════════════╝`);
|
||
break;
|
||
}
|
||
|
||
case 'q':
|
||
case 'Q':
|
||
case 'exit':
|
||
case 'quit':
|
||
rl.close();
|
||
console.log('\n👋 再见!');
|
||
process.exit(0);
|
||
|
||
default:
|
||
console.log('⚠️ 无效选项');
|
||
}
|
||
}
|
||
}
|
||
|
||
main().catch(console.error);
|