refactor: split downloader into modules

This commit is contained in:
吴璨
2026-07-22 17:46:35 +08:00
parent 1dfaed4dba
commit 536685e327
15 changed files with 935 additions and 881 deletions
+47
View File
@@ -0,0 +1,47 @@
const { API } = require('./config');
const { fetchJSON } = require('./network');
async function searchSongs(keyword, limit = 10) {
const res = await fetchJSON(`${API.search}?keyword=${encodeURIComponent(keyword)}&limit=${limit}`);
if (res.code !== 200) {
console.log('❌ 搜索无结果');
return [];
}
const songs = Array.isArray(res.data) ? res.data : (res.data?.songs || []);
if (!songs.length) console.log('❌ 搜索无结果');
return songs;
}
async function interactiveSearch(rl, ask) {
const keyword = await ask('🔍 搜索关键词: ');
if (!keyword.trim()) return null;
const results = await searchSongs(keyword.trim(), 10);
if (!results.length) return null;
console.log('\n # 歌曲名 歌手 专辑');
console.log(' ' + '─'.repeat(70));
results.forEach((song, index) => {
const num = String(index + 1).padStart(2);
const name = song.name.slice(0, 20).padEnd(22);
const artist = song.artists.slice(0, 16).padEnd(18);
console.log(` ${num} ${name} ${artist} ${song.album}`);
});
const pick = await ask('\n选择序号 (多个用逗号分隔, 0=取消): ');
if (!pick.trim() || pick.trim() === '0') return null;
return pick.split(/[,\s]+/)
.map(value => parseInt(value, 10))
.filter(value => value >= 1 && value <= results.length)
.map(value => results[value - 1]);
}
async function fetchPlaylist(playlistId) {
const res = await fetchJSON(`${API.playlist}?id=${playlistId}`);
if (!res.data?.tracks?.length) {
console.log('❌ 歌单获取失败或为空');
return null;
}
return res.data;
}
module.exports = { searchSongs, interactiveSearch, fetchPlaylist };
+126
View File
@@ -0,0 +1,126 @@
const fs = require('fs');
const path = require('path');
const ROOT_DIR = path.join(__dirname, '..');
const APP_CONFIG_FILE = path.join(ROOT_DIR, 'config.json');
const DEFAULT_APP_CONFIG = {
api: {
music: 'https://api.chksz.top/api/163_music',
lyric: 'https://api.chksz.top/api/163_lyric',
search: 'https://api.chksz.top/api/163_search',
playlist: 'https://api.chksz.top/api/163_playlist',
neteaseBaseUrl: 'https://music.163.com',
},
paths: {
downloadDir: 'downloads',
historyFile: 'download_history.json',
runtimeConfigFile: 'downloader_config.json',
trashDir: '.trash',
tempZipDir: '.tmp_zip',
},
defaults: {
quality: 'jymaster',
retries: 3,
historyLimit: 500,
},
};
function loadAppConfig() {
try {
if (!fs.existsSync(APP_CONFIG_FILE)) return DEFAULT_APP_CONFIG;
const config = JSON.parse(fs.readFileSync(APP_CONFIG_FILE, 'utf-8'));
return {
api: { ...DEFAULT_APP_CONFIG.api, ...(config.api || {}) },
paths: { ...DEFAULT_APP_CONFIG.paths, ...(config.paths || {}) },
defaults: { ...DEFAULT_APP_CONFIG.defaults, ...(config.defaults || {}) },
};
} catch (e) {
console.warn(`⚠️ 配置文件读取失败,将使用默认配置: ${e.message}`);
return DEFAULT_APP_CONFIG;
}
}
function resolveConfigPath(configPath, fallback) {
const value = typeof configPath === 'string' && configPath.trim() ? configPath.trim() : fallback;
return path.isAbsolute(value) ? value : path.join(ROOT_DIR, value);
}
function getConfigString(value, fallback) {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
const APP_CONFIG = loadAppConfig();
const API = {
music: getConfigString(APP_CONFIG.api.music, DEFAULT_APP_CONFIG.api.music),
lyric: getConfigString(APP_CONFIG.api.lyric, DEFAULT_APP_CONFIG.api.lyric),
search: getConfigString(APP_CONFIG.api.search, DEFAULT_APP_CONFIG.api.search),
playlist: getConfigString(APP_CONFIG.api.playlist, DEFAULT_APP_CONFIG.api.playlist),
neteaseBaseUrl: getConfigString(APP_CONFIG.api.neteaseBaseUrl, DEFAULT_APP_CONFIG.api.neteaseBaseUrl),
};
const NETEASE_BASE_URL = API.neteaseBaseUrl.replace(/\/$/, '');
const DOWNLOAD_DIR = resolveConfigPath(APP_CONFIG.paths.downloadDir, DEFAULT_APP_CONFIG.paths.downloadDir);
const HISTORY_FILE = resolveConfigPath(APP_CONFIG.paths.historyFile, DEFAULT_APP_CONFIG.paths.historyFile);
const CONFIG_FILE = resolveConfigPath(APP_CONFIG.paths.runtimeConfigFile, DEFAULT_APP_CONFIG.paths.runtimeConfigFile);
const TRASH_DIR = resolveConfigPath(APP_CONFIG.paths.trashDir, DEFAULT_APP_CONFIG.paths.trashDir);
const TEMP_ZIP_DIR = resolveConfigPath(APP_CONFIG.paths.tempZipDir, DEFAULT_APP_CONFIG.paths.tempZipDir);
const QUALITY_LEVELS = ['standard', 'exhigh', 'lossless', 'hires', 'jymaster', 'sky', 'jyeffect'];
const QUALITY_LABELS = {
standard: '标准',
exhigh: '极高',
lossless: '无损',
hires: 'Hi-Res',
jymaster: '超清母带',
sky: '空间音频',
jyeffect: '高清臻品',
};
const DEFAULT_LEVEL = QUALITY_LEVELS.includes(APP_CONFIG.defaults.quality)
? APP_CONFIG.defaults.quality
: DEFAULT_APP_CONFIG.defaults.quality;
const DEFAULT_RETRIES = Number.isInteger(APP_CONFIG.defaults.retries) && APP_CONFIG.defaults.retries > 0
? APP_CONFIG.defaults.retries
: DEFAULT_APP_CONFIG.defaults.retries;
const HISTORY_LIMIT = Number.isInteger(APP_CONFIG.defaults.historyLimit) && APP_CONFIG.defaults.historyLimit > 0
? APP_CONFIG.defaults.historyLimit
: DEFAULT_APP_CONFIG.defaults.historyLimit;
function loadRuntimeConfig() {
try {
if (fs.existsSync(CONFIG_FILE)) return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
} catch {}
return {};
}
function saveRuntimeConfig(config) {
fs.mkdirSync(path.dirname(CONFIG_FILE), { recursive: true });
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
}
function getConfig(key, defaultVal) {
const config = loadRuntimeConfig();
return config[key] !== undefined ? config[key] : defaultVal;
}
function setConfig(key, val) {
const config = loadRuntimeConfig();
config[key] = val;
saveRuntimeConfig(config);
}
if (!fs.existsSync(DOWNLOAD_DIR)) fs.mkdirSync(DOWNLOAD_DIR, { recursive: true });
module.exports = {
API,
NETEASE_BASE_URL,
DOWNLOAD_DIR,
HISTORY_FILE,
CONFIG_FILE,
TRASH_DIR,
TEMP_ZIP_DIR,
QUALITY_LEVELS,
QUALITY_LABELS,
DEFAULT_LEVEL,
DEFAULT_RETRIES,
HISTORY_LIMIT,
getConfig,
setConfig,
};
+139
View File
@@ -0,0 +1,139 @@
const fs = require('fs');
const path = require('path');
const { API, DEFAULT_RETRIES, DOWNLOAD_DIR } = require('./config');
const { addHistory } = require('./history');
const { fetchLyric, mergeLrc, parseCredits } = require('./lyrics');
const { fetchNeteaseAlbumDetail, fetchNeteaseSongDetail } = require('./metadata');
const { downloadFile, fetchJSON } = require('./network');
const { embedCover } = require('./tagging');
const { sanitize, sleep } = require('./utils');
async function downloadSong(songId, level, {
downloadLyric = true,
embedCoverArt = true,
maxRetries = DEFAULT_RETRIES,
outputDir = DOWNLOAD_DIR,
outputBaseName = null,
} = {}) {
let lastError = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await downloadSongOnce(songId, level, { downloadLyric, embedCoverArt, outputDir, outputBaseName });
} catch (e) {
lastError = e;
if (attempt < maxRetries) {
const waitSec = attempt * 3;
console.log(` ⚠️ 第 ${attempt} 次失败: ${e.message}${waitSec}秒后重试...`);
await sleep(waitSec * 1000);
}
}
}
console.error(`❌ 歌曲 ${songId} 下载失败 (已重试 ${maxRetries} 次): ${lastError?.message}`);
return false;
}
async function downloadSongOnce(songId, level, {
downloadLyric = true,
embedCoverArt = true,
outputDir = DOWNLOAD_DIR,
outputBaseName = null,
} = {}) {
console.log(`\n🎵 解析歌曲 ID: ${songId} ...`);
const result = await fetchJSON(`${API.music}?id=${songId}&level=${level}`);
if (result.code !== 200 || !result.data?.url) throw new Error(result.msg || 'API 返回错误');
const { name, artist, album, url: audioUrl, br, size, level: actualLevel, picUrl } = result.data;
const ext = audioUrl.match(/\.(mp3|flac|m4a|wav|aac)(\?|$)/i)?.[1] || 'mp3';
const baseName = sanitize(outputBaseName || `${artist} - ${name}`);
const fileName = `${baseName}.${ext}`;
fs.mkdirSync(outputDir, { recursive: true });
const dest = path.join(outputDir, fileName);
console.log(` 🎶 ${name} - ${artist}`);
console.log(` 💿 ${album} | ${actualLevel} (${br}kbps) | ${(size / 1048576).toFixed(2)} MB`);
if (fs.existsSync(dest)) {
const existStat = fs.statSync(dest);
const sizeDiff = Math.abs(existStat.size - size) / size;
if (sizeDiff < 0.01) {
console.log(` ⏭️ 已存在,跳过: ${fileName}`);
const lrcPath = path.join(outputDir, baseName + '.lrc');
if (downloadLyric && !fs.existsSync(lrcPath)) {
console.log(' 🔧 检测到缺少歌词,自动补全...');
const lyricData = await fetchLyric(songId);
if (lyricData?.lrc) {
fs.writeFileSync(lrcPath, lyricData.lrc, 'utf-8');
console.log(' 📄 歌词已补全');
if (lyricData.tlyric?.trim()) {
fs.writeFileSync(path.join(outputDir, baseName + '.合并翻译.lrc'), mergeLrc(lyricData.lrc, lyricData.tlyric), 'utf-8');
}
}
}
} else {
console.log(` ⚠️ 已存在同名文件但大小不同 (现有 ${(existStat.size / 1048576).toFixed(2)} MB, 预期 ${(size / 1048576).toFixed(2)} MB)`);
console.log(' ⬇️ 覆盖下载...');
await downloadFile(audioUrl, dest);
console.log(` ✅ 下载完成: ${fileName}`);
}
} else {
console.log(' ⬇️ 下载中...');
await downloadFile(audioUrl, dest);
console.log(` ✅ 下载完成: ${fileName}`);
}
let lyricData = null;
if (downloadLyric) {
lyricData = await fetchLyric(songId);
if (lyricData?.lrc) {
fs.writeFileSync(path.join(outputDir, baseName + '.lrc'), lyricData.lrc, 'utf-8');
console.log(` 📄 歌词已保存: ${baseName}.lrc`);
if (lyricData.tlyric?.trim()) {
const mergedPath = path.join(outputDir, baseName + '.合并翻译.lrc');
fs.writeFileSync(mergedPath, mergeLrc(lyricData.lrc, lyricData.tlyric), 'utf-8');
console.log(` 📄 翻译歌词已保存: ${path.basename(mergedPath)}`);
}
} else {
console.log(' ⚠️ 未获取到歌词');
}
}
if (embedCoverArt && picUrl) {
const lrcText = lyricData?.lrc || null;
const tagMeta = { name, artist, album, lyrics: lrcText, ...parseCredits(lrcText) };
try {
const detail = await fetchNeteaseSongDetail(songId);
if (detail) {
if (detail.trackNo) tagMeta.trackNo = detail.trackNo;
if (detail.disc) tagMeta.disc = detail.disc;
const albumArtist = detail.artists?.map(item => item.name).join(' / ');
if (albumArtist) tagMeta.albumArtist = albumArtist;
if (detail.album?.id) {
const albumDetail = await fetchNeteaseAlbumDetail(detail.album.id);
if (albumDetail?.publishTime) {
const date = new Date(albumDetail.publishTime);
tagMeta.year = date.getFullYear();
tagMeta.date = date.toISOString().slice(0, 10);
}
if (albumDetail?.tags?.length) tagMeta.genre = albumDetail.tags.join(', ');
if (albumDetail?.company) tagMeta.publisher = albumDetail.company;
if (tagMeta.trackNo && albumDetail?.size) tagMeta.trackNo = `${tagMeta.trackNo}/${albumDetail.size}`;
}
}
} catch {}
await embedCover(dest, picUrl, tagMeta);
}
addHistory({
id: songId,
name,
artist,
album,
level: actualLevel,
bitrate: br,
size,
file: path.relative(DOWNLOAD_DIR, dest),
});
return true;
}
module.exports = { downloadSong };
+30
View File
@@ -0,0 +1,30 @@
const fs = require('fs');
const path = require('path');
const { HISTORY_FILE, HISTORY_LIMIT } = require('./config');
function loadHistory() {
try {
if (fs.existsSync(HISTORY_FILE)) return JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf-8'));
} catch {}
return [];
}
function saveHistory(history) {
fs.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true });
fs.writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2), 'utf-8');
}
function addHistory(entry) {
const history = loadHistory();
history.unshift({ ...entry, timestamp: new Date().toISOString() });
if (history.length > HISTORY_LIMIT) history.length = HISTORY_LIMIT;
saveHistory(history);
}
function formatTime(isoStr) {
const date = new Date(isoStr);
const pad = value => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
module.exports = { loadHistory, saveHistory, addHistory, formatTime };
+79
View File
@@ -0,0 +1,79 @@
const fs = require('fs');
const path = require('path');
const { API, DOWNLOAD_DIR } = require('./config');
const { fetchJSON } = require('./network');
async function fetchLyric(songId) {
try {
const res = await fetchJSON(`${API.lyric}?id=${songId}`);
if (res.code !== 200) return null;
return res.data;
} catch {
return null;
}
}
function mergeLrc(lrc, tlyric) {
if (!lrc) return '';
if (!tlyric) return lrc;
const transMap = new Map();
for (const line of tlyric.split('\n')) {
const match = line.match(/^\[(\d+:\d+[\.:]\d+)\](.*)/);
if (match && match[2].trim()) transMap.set(match[1], match[2].trim());
}
const result = [];
for (const line of lrc.split('\n')) {
result.push(line);
const match = line.match(/^\[(\d+:\d+[\.:]\d+)\]/);
if (match && transMap.has(match[1])) result.push(`[${match[1]}]${transMap.get(match[1])}`);
}
return result.join('\n');
}
function parseCredits(lrcText) {
const credits = {};
if (!lrcText) return credits;
const labels = {
lyricist: /^(作词|作詞|词|填词|Lyricist|Lyrics? by|Written by)$/i,
composer: /^(作曲|曲|Composer|Composed by|Music by)$/i,
arranger: /^(编曲|編曲|Arranger|Arranged by)$/i,
producer: /^(制作人|製作人|出品人|监制|Producer|Produced by)$/i,
mixer: /^(混音|Mix(ing)?|Mixed by)$/i,
};
for (const raw of lrcText.split('\n')) {
const line = raw.replace(/^(\s*\[[^\]]*\]\s*)+/, '').trim();
const match = line.match(/^([^:]{1,12})\s*[:]\s*(.+)$/);
if (!match) continue;
const label = match[1].trim();
const value = match[2].trim().replace(/\s*\/\s*/g, '/');
if (!value) continue;
for (const [key, pattern] of Object.entries(labels)) {
if (pattern.test(label) && !credits[key]) {
credits[key] = value;
break;
}
}
}
return credits;
}
async function saveLyric(songId, fileName) {
const data = await fetchLyric(songId);
if (!data?.lrc) {
console.log(' ⚠️ 未获取到歌词');
return;
}
const lrcPath = path.join(DOWNLOAD_DIR, fileName + '.lrc');
fs.writeFileSync(lrcPath, data.lrc, 'utf-8');
console.log(` 📄 歌词已保存: ${path.basename(lrcPath)}`);
if (data.tlyric?.trim()) {
const mergedPath = path.join(DOWNLOAD_DIR, fileName + '.合并翻译.lrc');
fs.writeFileSync(mergedPath, mergeLrc(data.lrc, data.tlyric), 'utf-8');
console.log(` 📄 翻译歌词已保存: ${path.basename(mergedPath)}`);
}
}
module.exports = { fetchLyric, mergeLrc, parseCredits, saveLyric };
+168
View File
@@ -0,0 +1,168 @@
const fs = require('fs');
const http = require('http');
const https = require('https');
const path = require('path');
const { API, DOWNLOAD_DIR, NETEASE_BASE_URL } = require('./config');
const { downloadBuffer, fetchJSON } = require('./network');
const { sanitizePathSegment } = require('./utils');
function requestNetease(pathname) {
return new Promise((resolve) => {
const requestUrl = new URL(pathname, `${NETEASE_BASE_URL}/`);
const options = {
method: 'GET',
headers: {
Referer: NETEASE_BASE_URL,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
Accept: 'application/json',
Cookie: 'os=pc',
},
};
const protocol = requestUrl.protocol === 'https:' ? https : http;
const req = protocol.request(requestUrl, options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try { resolve(JSON.parse(data)); }
catch { resolve(null); }
});
});
req.on('error', () => resolve(null));
req.setTimeout(5000, () => { req.destroy(); resolve(null); });
req.end();
});
}
async function fetchNeteaseSongDetail(songId) {
const data = await requestNetease(`/api/v1/song/detail?ids=[${songId}]`);
const song = data?.songs?.[0];
if (!song) return null;
return {
id: song.id,
name: song.name,
artists: song.ar?.map(artist => ({ id: artist.id, name: artist.name })) || [],
album: song.al ? { id: song.al.id, name: song.al.name, cover: song.al.picUrl || song.al.cover } : null,
duration: song.dt || 0,
disc: song.cd || '',
trackNo: song.no || 0,
aliases: song.alia || [],
transNames: song.tns || [],
popularity: song.pop || 0,
fee: song.fee,
quality: {
h: song.h ? { br: song.h.br, size: song.h.size, sr: song.h.sr } : null,
m: song.m ? { br: song.m.br, size: song.m.size, sr: song.m.sr } : null,
l: song.l ? { br: song.l.br, size: song.l.size, sr: song.l.sr } : null,
},
};
}
async function fetchNeteaseAlbumDetail(albumId) {
const data = await requestNetease(`/api/v1/album/${albumId}`);
if (!data) return null;
const album = data.album || data;
return {
id: album.id,
name: album.name,
publishTime: album.publishTime || null,
company: album.company || '',
description: album.description || '',
tags: album.tags || [],
type: album.type || '',
size: album.size || 0,
artist: album.artist ? { id: album.artist.id, name: album.artist.name, trans: album.artist.trans } : null,
picUrl: album.picUrl || '',
tracks: (data.songs || album.songs || []).map(song => ({
id: song.id,
name: song.name,
ar: song.ar || song.artists || [],
al: song.al || song.album || null,
dt: song.dt || song.duration || 0,
disc: song.cd || '',
trackNo: song.no || 0,
})),
};
}
function getAlbumDownloadTarget(album, track, index, trackCount = album.tracks?.length || 0) {
const trackArtists = track.ar?.map(artist => artist.name).filter(Boolean).join(' / ');
const artistDir = sanitizePathSegment(album.artist?.name || trackArtists, '未知歌手');
const albumDir = sanitizePathSegment(album.name, '未知专辑');
const numberWidth = Math.max(2, String(trackCount).length);
const trackNumber = String(index + 1).padStart(numberWidth, '0');
return {
outputDir: path.join(DOWNLOAD_DIR, artistDir, albumDir),
outputBaseName: `${trackNumber}. ${track.name || '未知歌曲'}`,
};
}
async function saveAlbumCover(coverUrl, outputDir) {
const coverPath = path.join(outputDir, 'cover.jpg');
if (fs.existsSync(coverPath) && fs.statSync(coverPath).size > 0) {
console.log(' ⏭️ 专辑封面已存在: cover.jpg');
return;
}
if (!coverUrl) {
console.log(' ⚠️ 未获取到专辑封面');
return;
}
try {
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(coverPath, await downloadBuffer(coverUrl));
console.log(' 🖼️ 专辑封面已保存: cover.jpg');
} catch (e) {
console.error(` ⚠️ 专辑封面保存失败: ${e.message}`);
}
}
async function fetchFullMetadata(songId) {
const [songDetail, chkszData] = await Promise.all([
fetchNeteaseSongDetail(songId),
fetchJSON(`${API.music}?id=${songId}&level=standard`).catch(() => null),
]);
if (!songDetail) return null;
const albumDetail = songDetail.album?.id
? await fetchNeteaseAlbumDetail(songDetail.album.id)
: null;
const durationSec = Math.round(songDetail.duration / 1000);
const publishDate = albumDetail?.publishTime
? new Date(albumDetail.publishTime).toISOString().slice(0, 10)
: null;
return {
id: songId,
name: songDetail.name,
artists: songDetail.artists,
artistStr: songDetail.artists.map(artist => artist.name).join(' / '),
album: songDetail.album,
albumStr: songDetail.album?.name || '',
duration: songDetail.duration,
durationStr: `${Math.floor(durationSec / 60)}:${String(durationSec % 60).padStart(2, '0')}`,
disc: songDetail.disc,
trackNo: songDetail.trackNo,
aliases: songDetail.aliases,
transNames: songDetail.transNames,
popularity: songDetail.popularity,
fee: songDetail.fee,
publishTime: albumDetail?.publishTime,
publishYear: albumDetail?.publishTime ? new Date(albumDetail.publishTime).getFullYear() : null,
publishDate,
company: albumDetail?.company || '',
description: albumDetail?.description || '',
tags: albumDetail?.tags || [],
albumType: albumDetail?.type || '',
albumSize: albumDetail?.size || 0,
albumArtist: albumDetail?.artist,
quality: songDetail.quality,
chksz: chkszData?.code === 200 ? chkszData.data : null,
};
}
module.exports = {
fetchNeteaseSongDetail,
fetchNeteaseAlbumDetail,
getAlbumDownloadTarget,
saveAlbumCover,
fetchFullMetadata,
};
+84
View File
@@ -0,0 +1,84 @@
const fs = require('fs');
const http = require('http');
const https = require('https');
function getProtocol(url) {
return url.startsWith('https') ? https : http;
}
function fetchJSON(url) {
return new Promise((resolve, reject) => {
getProtocol(url).get(url, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try { resolve(JSON.parse(data)); }
catch { reject(new Error(`JSON 解析失败: ${data.slice(0, 200)}`)); }
});
}).on('error', reject);
});
}
function downloadFile(url, dest, showProgress = true) {
return new Promise((resolve, reject) => {
getProtocol(url).get(url, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
return downloadFile(res.headers.location, dest, showProgress).then(resolve).catch(reject);
}
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
const total = parseInt(res.headers['content-length'], 10);
let downloaded = 0;
let lastTime = Date.now();
let lastBytes = 0;
const file = fs.createWriteStream(dest);
res.on('data', (chunk) => {
downloaded += chunk.length;
if (!showProgress) return;
const now = Date.now();
const elapsed = (now - lastTime) / 1000;
if (elapsed < 0.3) return;
const speed = (downloaded - lastBytes) / elapsed;
lastTime = now;
lastBytes = downloaded;
const speedStr = speed >= 1048576
? `${(speed / 1048576).toFixed(1)} MB/s`
: `${(speed / 1024).toFixed(0)} KB/s`;
if (total) {
const pct = downloaded / total;
const barWidth = 30;
const filled = Math.round(pct * barWidth);
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
const eta = speed > 0 ? Math.ceil((total - downloaded) / speed) : 0;
const etaStr = eta > 60 ? `${Math.floor(eta / 60)}m${eta % 60}s` : `${eta}s`;
process.stdout.write(`\r ⬇️ [${bar}] ${(pct * 100).toFixed(1)}% ${(downloaded / 1048576).toFixed(1)}/${(total / 1048576).toFixed(1)} MB ${speedStr} ETA ${etaStr} `);
} else {
process.stdout.write(`\r ⬇️ ${(downloaded / 1048576).toFixed(1)} MB ${speedStr} `);
}
});
res.pipe(file);
file.on('finish', () => { file.close(); if (showProgress) console.log(); resolve(); });
file.on('error', (err) => { fs.unlink(dest, () => {}); reject(err); });
}).on('error', reject);
});
}
function downloadBuffer(url) {
return new Promise((resolve, reject) => {
getProtocol(url).get(url, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
return downloadBuffer(res.headers.location).then(resolve).catch(reject);
}
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
const chunks = [];
res.on('data', chunk => chunks.push(chunk));
res.on('end', () => resolve(Buffer.concat(chunks)));
}).on('error', reject);
});
}
module.exports = { fetchJSON, downloadFile, downloadBuffer };
+23
View File
@@ -0,0 +1,23 @@
let isPaused = false;
let pauseResolve = null;
function togglePause() {
if (isPaused) {
isPaused = false;
if (pauseResolve) {
pauseResolve();
pauseResolve = null;
}
console.log('\n ▶️ 已恢复下载');
} else {
isPaused = true;
console.log('\n ⏸ 已暂停 (再按 p 恢复)');
}
}
async function waitIfPaused() {
if (!isPaused) return;
await new Promise(resolve => { pauseResolve = resolve; });
}
module.exports = { togglePause, waitIfPaused };
+32
View File
@@ -0,0 +1,32 @@
const { QUALITY_LEVELS, QUALITY_LABELS } = require('./config');
function parseLevel(input) {
const value = (input || '').trim().toLowerCase();
if (!value) return null;
if (/^\d+$/.test(value)) {
const index = parseInt(value, 10) - 1;
return index >= 0 && index < QUALITY_LEVELS.length ? QUALITY_LEVELS[index] : null;
}
return QUALITY_LEVELS.includes(value) ? value : null;
}
function printQualityMenu(currentLevel) {
QUALITY_LEVELS.forEach((level, index) => {
const mark = level === currentLevel ? ' ←当前' : '';
console.log(` ${index + 1}. ${level.padEnd(10)} ${(QUALITY_LABELS[level] || '').padEnd(8)}${mark}`);
});
}
async function askLevel(ask, currentLevel) {
const label = QUALITY_LABELS[currentLevel] ? ` (${QUALITY_LABELS[currentLevel]})` : '';
console.log(`\n 当前音质: ${currentLevel}${label}`);
console.log(' 可选音质:');
printQualityMenu(currentLevel);
const input = await ask(' 选择音质 (输入序号或名称, 回车使用当前): ');
const parsed = parseLevel(input);
if (parsed) return parsed;
if (input.trim()) console.log(' ⚠️ 无效选择,使用当前设置');
return currentLevel;
}
module.exports = { parseLevel, printQualityMenu, askLevel };
+97
View File
@@ -0,0 +1,97 @@
const fs = require('fs');
const path = require('path');
const { execSync, execFileSync } = require('child_process');
const { downloadBuffer } = require('./network');
function hasFFmpeg() {
try { execSync('ffmpeg -version', { stdio: 'ignore' }); return true; } catch { return false; }
}
function hasNodeID3() {
try { require.resolve('node-id3'); return true; } catch { return false; }
}
async function embedCover(audioPath, coverUrl, meta) {
const ext = path.extname(audioPath).toLowerCase();
const coverPath = audioPath + '.cover.jpg';
try {
const coverBuf = await downloadBuffer(coverUrl);
fs.writeFileSync(coverPath, coverBuf);
if (ext === '.mp3' && hasNodeID3()) {
const NodeID3 = require('node-id3');
const tags = {
title: meta.name,
artist: meta.artist,
album: meta.album,
image: coverPath,
};
if (meta.albumArtist) tags.performerInfo = meta.albumArtist;
if (meta.year) tags.year = String(meta.year);
if (meta.date) tags.date = String(meta.date);
if (meta.trackNo) tags.trackNumber = String(meta.trackNo);
if (meta.disc) tags.partOfSet = String(meta.disc);
if (meta.genre) tags.genre = meta.genre;
if (meta.composer) tags.composer = meta.composer;
if (meta.lyricist) tags.textWriter = meta.lyricist;
if (meta.publisher) tags.publisher = meta.publisher;
const userTexts = [];
if (meta.arranger) userTexts.push({ description: 'ARRANGER', value: meta.arranger });
if (meta.producer) userTexts.push({ description: 'PRODUCER', value: meta.producer });
if (meta.mixer) userTexts.push({ description: 'MIXER', value: meta.mixer });
if (userTexts.length) tags.userDefinedText = userTexts;
if (meta.lyrics) tags.unsynchronisedLyrics = { language: 'chi', text: meta.lyrics };
const ok = NodeID3.write(tags, audioPath);
if (ok === true) {
console.log(' ✅ 封面+标签+歌词已嵌入 (node-id3)');
return;
}
}
if (hasFFmpeg()) {
const tmpPath = audioPath + '.tmp' + ext;
const args = [
'-y', '-i', audioPath, '-i', coverPath,
'-map', '0:a', '-map', '1:0', '-c', 'copy',
'-disposition:v:0', 'attached_pic',
];
const addMeta = (key, value) => {
if (value !== undefined && value !== null && value !== '') {
args.push('-metadata', `${key}=${value}`);
}
};
addMeta('title', meta.name);
addMeta('artist', meta.artist);
addMeta('album', meta.album);
addMeta('album_artist', meta.albumArtist || meta.artist);
addMeta('date', meta.date || meta.year);
addMeta('track', meta.trackNo);
addMeta('disc', meta.disc);
addMeta('genre', meta.genre);
addMeta('composer', meta.composer);
addMeta('publisher', meta.publisher);
addMeta('lyricist', meta.lyricist);
addMeta('arranger', meta.arranger);
addMeta('producer', meta.producer);
addMeta('mixer', meta.mixer);
if (meta.lyrics && meta.lyrics.length <= 8000) addMeta('lyrics', meta.lyrics);
args.push('-metadata:s:v', 'title=Album cover', '-metadata:s:v', 'comment=Cover (front)');
if (ext === '.mp3') args.push('-id3v2_version', '3');
args.push(tmpPath);
execFileSync('ffmpeg', args, { stdio: 'ignore' });
fs.renameSync(tmpPath, audioPath);
console.log(' ✅ 封面+标签+歌词已嵌入 (ffmpeg)');
} else {
console.log(' ⚠️ 跳过封面: 需要 node-id3 (npm i node-id3) 或 ffmpeg');
}
} catch (e) {
console.error(` ⚠️ 封面嵌入失败: ${e.message}`);
} finally {
if (fs.existsSync(coverPath)) fs.unlinkSync(coverPath);
}
}
module.exports = { hasFFmpeg, hasNodeID3, embedCover };
+29
View File
@@ -0,0 +1,29 @@
const fs = require('fs');
const path = require('path');
function sanitize(name) {
return name.replace(/[\/\\:*?"<>|]/g, '_').trim();
}
function sanitizePathSegment(name, fallback) {
const value = sanitize(String(name || '')).replace(/[. ]+$/g, '');
return value && value !== '.' && value !== '..' ? value : fallback;
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function getDirSize(dirPath) {
let size = 0;
try {
for (const item of fs.readdirSync(dirPath)) {
const itemPath = path.join(dirPath, item);
const stat = fs.statSync(itemPath);
size += stat.isDirectory() ? getDirSize(itemPath) : stat.size;
}
} catch {}
return size;
}
module.exports = { sanitize, sanitizePathSegment, sleep, getDirSize };