feat(下载): 增强音乐下载功能,支持多音质选择和自定义请求头
- 为 downloadFile 函数添加 headers 参数以支持自定义请求头 - 新增 buildDownloadHeaders 函数构建不同来源的请求头 - 新增 getPreferredQualities 函数处理音质偏好 - 新增 resolveTuneHubAudioUrl 函数解析音频URL - 重构 processSong 为异步函数,支持多音质尝试下载 - 为歌曲对象添加 url 和 types 字段支持直接下载链接和音质选择
This commit is contained in:
@@ -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 ---
|
||||
|
||||
Reference in New Issue
Block a user