- 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'];
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user