- Netease-sync/server.js:新增 search 回退与结果挑选逻辑(type=search)
- 主下载失败后走搜索回退,再用搜索命中的 id 下载 - 保持文件名仍用原歌单 ID(不影响 Navidrome 匹配)
This commit is contained in:
@@ -281,6 +281,145 @@ function getPreferredQualities(types) {
|
|||||||
return available.length > 0 ? available : ['320k', '128k'];
|
return available.length > 0 ? available : ['320k', '128k'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeText(str) {
|
||||||
|
return (str || '')
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\s\-_[\]().·()、]+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBestSearchResult(results, songName, songArtist) {
|
||||||
|
if (!Array.isArray(results) || results.length === 0) return null;
|
||||||
|
|
||||||
|
const targetName = normalizeText(songName);
|
||||||
|
const targetArtist = normalizeText(songArtist);
|
||||||
|
|
||||||
|
let best = null;
|
||||||
|
let bestScore = -1;
|
||||||
|
|
||||||
|
for (const item of results) {
|
||||||
|
const name = normalizeText(item.name);
|
||||||
|
const artist = normalizeText(item.artist);
|
||||||
|
let score = 0;
|
||||||
|
if (name && targetName && name === targetName) score += 100;
|
||||||
|
if (artist && targetArtist && artist === targetArtist) score += 60;
|
||||||
|
if (name && targetName && name.includes(targetName)) score += 30;
|
||||||
|
if (artist && targetArtist && artist.includes(targetArtist)) score += 20;
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
best = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best || results[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchSongOnTuneHub(songName, songArtist, source) {
|
||||||
|
try {
|
||||||
|
const keyword = songArtist ? `${songName} ${songArtist}` : songName;
|
||||||
|
if (!keyword) return null;
|
||||||
|
const url = `${TUNEHUB_API_URL}/api/?source=${source}&type=search&keyword=${encodeURIComponent(keyword)}&limit=20`;
|
||||||
|
const response = await apiClient.get(url, { retry: 2, retryDelay: 1000 });
|
||||||
|
if (response.data?.code !== 200) return null;
|
||||||
|
const data = response.data.data || {};
|
||||||
|
const results = data.results || [];
|
||||||
|
return pickBestSearchResult(results, songName, songArtist);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Sync] Search fallback failed:', error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDownloadCandidates(source, songId, songUrl, types) {
|
||||||
|
const candidates = [];
|
||||||
|
if (songUrl) {
|
||||||
|
candidates.push({ label: 'playlist_url', apiUrl: songUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
const qualities = getPreferredQualities(types);
|
||||||
|
for (const q of qualities) {
|
||||||
|
candidates.push({
|
||||||
|
label: `br=${q}`,
|
||||||
|
apiUrl: `${TUNEHUB_API_URL}/api/?source=${source}&id=${songId}&type=url&br=${q}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.push({
|
||||||
|
label: 'br=default',
|
||||||
|
apiUrl: `${TUNEHUB_API_URL}/api/?source=${source}&id=${songId}&type=url`
|
||||||
|
});
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryDownloadWithCandidates(song, source, baseName, candidates, downloadHeaders) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveTuneHubAudioUrl(apiUrl) {
|
async function resolveTuneHubAudioUrl(apiUrl) {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(apiUrl, {
|
const response = await apiClient.get(apiUrl, {
|
||||||
@@ -399,86 +538,17 @@ async function processSong(song) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const downloadHeaders = buildDownloadHeaders(source);
|
const downloadHeaders = buildDownloadHeaders(source);
|
||||||
|
const candidates = buildDownloadCandidates(source, song.id, song.url, song.types);
|
||||||
|
|
||||||
const candidates = [];
|
const primaryOk = await tryDownloadWithCandidates(song, source, baseName, candidates, downloadHeaders);
|
||||||
if (song.url) {
|
if (primaryOk) return true;
|
||||||
candidates.push({ label: 'playlist_url', apiUrl: song.url });
|
|
||||||
}
|
|
||||||
|
|
||||||
const qualities = getPreferredQualities(song.types);
|
// 回退:搜索拿到可用歌曲 ID 再尝试下载
|
||||||
for (const q of qualities) {
|
const searchHit = await searchSongOnTuneHub(song.name, song.artist, source);
|
||||||
candidates.push({
|
if (searchHit?.id) {
|
||||||
label: `br=${q}`,
|
const fallbackCandidates = buildDownloadCandidates(source, searchHit.id, searchHit.url, searchHit.types);
|
||||||
apiUrl: `${TUNEHUB_API_URL}/api/?source=${source}&id=${song.id}&type=url&br=${q}`
|
const fallbackOk = await tryDownloadWithCandidates(song, source, baseName, fallbackCandidates, downloadHeaders);
|
||||||
});
|
if (fallbackOk) return true;
|
||||||
}
|
|
||||||
|
|
||||||
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
|
return false; // Failed
|
||||||
|
|||||||
Reference in New Issue
Block a user