feat(下载): 增强音乐下载功能,支持多音质选择和自定义请求头

- 为 downloadFile 函数添加 headers 参数以支持自定义请求头
- 新增 buildDownloadHeaders 函数构建不同来源的请求头
- 新增 getPreferredQualities 函数处理音质偏好
- 新增 resolveTuneHubAudioUrl 函数解析音频URL
- 重构 processSong 为异步函数,支持多音质尝试下载
- 为歌曲对象添加 url 和 types 字段支持直接下载链接和音质选择
This commit is contained in:
史悦
2026-01-13 10:06:35 +08:00
parent b5b093e64b
commit 44ff76d58d

View File

@@ -232,13 +232,13 @@ function sanitizeFilename(str) {
.substring(0, 200); // Limit length to avoid path too long errors
}
function downloadFile(url, dest) {
function downloadFile(url, dest, headers = {}) {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http;
protocol.get(url, (res) => {
protocol.get(url, { headers }, (res) => {
// Handle Redirects
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
downloadFile(res.headers.location, dest).then(resolve).catch(reject);
downloadFile(res.headers.location, dest, headers).then(resolve).catch(reject);
return;
}
if (res.statusCode !== 200) {
@@ -261,6 +261,49 @@ function downloadFile(url, dest) {
});
}
function buildDownloadHeaders(source) {
const headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
};
if (source === 'netease') {
headers['Referer'] = 'https://music.163.com/';
}
return headers;
}
function getPreferredQualities(types) {
const preference = ['flac24bit', 'flac', '320k', '128k'];
if (!Array.isArray(types) || types.length === 0) {
return ['320k', '128k'];
}
const normalized = types.map(t => t?.toString().toLowerCase()).filter(Boolean);
const available = preference.filter(q => normalized.includes(q));
return available.length > 0 ? available : ['320k', '128k'];
}
async function resolveTuneHubAudioUrl(apiUrl) {
try {
const response = await apiClient.get(apiUrl, {
maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 400
});
if (response.status >= 300 && response.status < 400 && response.headers?.location) {
return response.headers.location;
}
if (response.status === 200 && response.data) {
const data = response.data.data || response.data;
if (typeof data === 'object' && data.url) {
return data.url;
}
}
return null;
} catch (error) {
return null;
}
}
function writeMetadata(inputPath, outputPath, metadata) {
return new Promise((resolve, reject) => {
const args = ['-i', inputPath];
@@ -321,8 +364,7 @@ function writeMetadata(inputPath, outputPath, metadata) {
});
}
function processSong(song) {
return new Promise((resolve) => {
async function processSong(song) {
const safeName = sanitizeFilename(song.name);
const safeArtist = sanitizeFilename(song.artist);
const source = song.platform || song.source;
@@ -350,107 +392,97 @@ function processSong(song) {
if (exists) {
// console.log(`[Sync] Skipped (Exists): ${song.name}`);
resolve(true); // Exists
return;
return true; // Exists
}
} catch (e) {
console.error('Error reading music dir:', e);
}
// Get URL (prefer FLAC)
const apiUrl = `${TUNEHUB_API_URL}/api/?source=${source}&id=${song.id}&type=url&br=flac`;
https.get(apiUrl, (res) => {
const handleDownload = (url) => {
let ext = 'mp3';
if (url.includes('.flac')) ext = 'flac';
else if (url.includes('.m4a')) ext = 'm4a';
else if (url.includes('.ogg')) ext = 'ogg';
else if (url.includes('.wav')) ext = 'wav';
const downloadHeaders = buildDownloadHeaders(source);
const tempFile = path.join(MUSIC_DIR, `temp_${Date.now()}_${song.id}.${ext}`);
const finalFile = path.join(MUSIC_DIR, `${baseName}.${ext}`);
const candidates = [];
if (song.url) {
candidates.push({ label: 'playlist_url', apiUrl: song.url });
}
downloadFile(url, tempFile)
.then(async () => {
try {
// Try to download cover image
let coverPath = null;
try {
const coverUrl = `${TUNEHUB_API_URL}/api/?source=${source}&id=${song.id}&type=pic`;
coverPath = path.join(MUSIC_DIR, `temp_cover_${Date.now()}_${song.id}.jpg`);
await downloadFile(coverUrl, coverPath);
} catch (e) {
console.warn(`[Sync] Failed to download cover for ${song.name}:`, e.message);
}
// Write Metadata (Title, Artist, Album, Cover)
await writeMetadata(tempFile, finalFile, {
title: song.name,
artist: song.artist,
album: song.album || song.name,
cover: coverPath
});
console.log(`[Sync] Downloaded & Tagged: ${baseName}.${ext}`);
// Cleanup temp files
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
if (coverPath && fs.existsSync(coverPath)) fs.unlinkSync(coverPath);
resolve(true); // Downloaded
} catch (err) {
console.error(`[Sync] Metadata Error for ${song.name}:`, err.message);
// Fallback: Just rename temp to final
if (!fs.existsSync(finalFile)) {
fs.renameSync(tempFile, finalFile);
}
resolve(true); // Downloaded (fallback)
}
})
.catch(err => {
console.error(`[Sync] Download failed for ${song.name}:`, err.message);
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
resolve(false); // Failed
});
};
// Handle 302 Redirect (Standard API behavior)
if (res.statusCode === 302 && res.headers.location) {
res.resume();
handleDownload(res.headers.location);
return;
}
if (res.statusCode !== 200) {
console.error(`[Sync] API Request Failed for ${song.name}: Status ${res.statusCode}`);
res.resume();
resolve(false);
return;
}
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const apiRes = JSON.parse(data);
const url = apiRes.url;
if (!url) {
console.log(`[Sync] No URL found for ${song.name}`);
resolve(false);
return;
}
handleDownload(url);
} catch (e) {
console.error('[Sync] API Parse Error:', e);
resolve(false);
}
const qualities = getPreferredQualities(song.types);
for (const q of qualities) {
candidates.push({
label: `br=${q}`,
apiUrl: `${TUNEHUB_API_URL}/api/?source=${source}&id=${song.id}&type=url&br=${q}`
});
}).on('error', (e) => {
console.error(`[Sync] API Request Error: ${e.message}`);
resolve(false);
}
candidates.push({
label: 'br=default',
apiUrl: `${TUNEHUB_API_URL}/api/?source=${source}&id=${song.id}&type=url`
});
});
const tried = new Set();
for (const candidate of candidates) {
if (!candidate.apiUrl || tried.has(candidate.apiUrl)) continue;
tried.add(candidate.apiUrl);
const directUrl = await resolveTuneHubAudioUrl(candidate.apiUrl);
if (!directUrl) {
console.warn(`[Sync] Failed to resolve audio url for ${song.name} (${candidate.label})`);
continue;
}
let ext = 'mp3';
if (directUrl.includes('.flac')) ext = 'flac';
else if (directUrl.includes('.m4a')) ext = 'm4a';
else if (directUrl.includes('.ogg')) ext = 'ogg';
else if (directUrl.includes('.wav')) ext = 'wav';
const tempFile = path.join(MUSIC_DIR, `temp_${Date.now()}_${song.id}.${ext}`);
const finalFile = path.join(MUSIC_DIR, `${baseName}.${ext}`);
try {
await downloadFile(directUrl, tempFile, downloadHeaders);
try {
// Try to download cover image
let coverPath = null;
try {
const coverUrl = `${TUNEHUB_API_URL}/api/?source=${source}&id=${song.id}&type=pic`;
coverPath = path.join(MUSIC_DIR, `temp_cover_${Date.now()}_${song.id}.jpg`);
await downloadFile(coverUrl, coverPath, downloadHeaders);
} catch (e) {
console.warn(`[Sync] Failed to download cover for ${song.name}:`, e.message);
}
// Write Metadata (Title, Artist, Album, Cover)
await writeMetadata(tempFile, finalFile, {
title: song.name,
artist: song.artist,
album: song.album || song.name,
cover: coverPath
});
console.log(`[Sync] Downloaded & Tagged: ${baseName}.${ext}`);
// Cleanup temp files
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
if (coverPath && fs.existsSync(coverPath)) fs.unlinkSync(coverPath);
return true; // Downloaded
} catch (err) {
console.error(`[Sync] Metadata Error for ${song.name}:`, err.message);
// Fallback: Just rename temp to final
if (!fs.existsSync(finalFile)) {
fs.renameSync(tempFile, finalFile);
}
return true; // Downloaded (fallback)
}
} catch (err) {
console.error(`[Sync] Download failed for ${song.name} (${candidate.label}):`, err.message);
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
}
}
return false; // Failed
}
}
async function searchSongInNavidrome(song) {
@@ -616,7 +648,9 @@ async function syncPlaylist(playlist, cachedInfo = null) {
artist: track.ar?.[0]?.name || track.artist || 'Unknown',
album: track.al?.name || track.album || 'Unknown',
platform: 'netease',
source: 'netease'
source: 'netease',
url: track.url || '',
types: track.types || []
}));
// --- Integrated Download Logic ---