145 lines
5.7 KiB
JavaScript
Executable File
145 lines
5.7 KiB
JavaScript
Executable File
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, formatPersonNames, 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: rawArtist, album, url: audioUrl, br, size, level: actualLevel, picUrl } = result.data;
|
||
const artist = formatPersonNames(rawArtist);
|
||
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;
|
||
if (detail.album?.id) {
|
||
const albumDetail = await fetchNeteaseAlbumDetail(detail.album.id);
|
||
if (albumDetail?.albumArtist) tagMeta.albumArtist = albumDetail.albumArtist;
|
||
if (albumDetail?.isCompilation) tagMeta.compilation = true;
|
||
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}`;
|
||
}
|
||
if (!tagMeta.albumArtist) {
|
||
const fallbackAlbumArtist = detail.artists?.map(item => item.name).join(' / ');
|
||
if (fallbackAlbumArtist) tagMeta.albumArtist = fallbackAlbumArtist;
|
||
}
|
||
}
|
||
} 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 };
|