- Netease-sync/server.js:新增 search 回退与结果挑选逻辑(type=search)

- 主下载失败后走搜索回退,再用搜索命中的 id 下载
  - 保持文件名仍用原歌单 ID(不影响 Navidrome 匹配)
This commit is contained in:
史悦
2026-01-13 10:52:05 +08:00
parent 1f8d392114
commit ef44218198

View File

@@ -281,6 +281,145 @@ function getPreferredQualities(types) {
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) {
try {
const response = await apiClient.get(apiUrl, {
@@ -399,86 +538,17 @@ async function processSong(song) {
}
const downloadHeaders = buildDownloadHeaders(source);
const candidates = buildDownloadCandidates(source, song.id, song.url, song.types);
const candidates = [];
if (song.url) {
candidates.push({ label: 'playlist_url', apiUrl: song.url });
}
const primaryOk = await tryDownloadWithCandidates(song, source, baseName, candidates, downloadHeaders);
if (primaryOk) return true;
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}`
});
}
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);
}
// 回退:搜索拿到可用歌曲 ID 再尝试下载
const searchHit = await searchSongOnTuneHub(song.name, song.artist, source);
if (searchHit?.id) {
const fallbackCandidates = buildDownloadCandidates(source, searchHit.id, searchHit.url, searchHit.types);
const fallbackOk = await tryDownloadWithCandidates(song, source, baseName, fallbackCandidates, downloadHeaders);
if (fallbackOk) return true;
}
return false; // Failed