改动点
- searchSongInNavidrome 增加:
- 去括号标题(去掉 (Live) / (十年荣耀版) 等)
- 按歌手进行兜底搜索,再用 path/idToken/模糊标题匹配
- 优先 path 命中(含真实文件名或 netease_id)
This commit is contained in:
@@ -548,25 +548,38 @@ async function processSong(song) {
|
||||
// Check if file exists (fuzzy match for extension)
|
||||
try {
|
||||
const files = fs.readdirSync(MUSIC_DIR);
|
||||
let matchedFile = null;
|
||||
const exists = files.some(f => {
|
||||
// 1. Exact Match: Artist - Name [source_id].ext
|
||||
if (f.startsWith(baseName)) return true;
|
||||
if (f.startsWith(baseName)) {
|
||||
matchedFile = f;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. Simple Format: Artist - Name.ext
|
||||
if (f.startsWith(`${safeArtist} - ${safeName}.`)) return true;
|
||||
if (f.startsWith(`${safeArtist} - ${safeName}.`)) {
|
||||
matchedFile = f;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. Simple Format w/o Artist: Name.ext
|
||||
if (f.startsWith(`${safeName}.`)) return true;
|
||||
if (f.startsWith(`${safeName}.`)) {
|
||||
matchedFile = f;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 4. Match prefix ignoring ID: Artist - Name [
|
||||
if (f.startsWith(`${safeArtist} - ${safeName} [`)) return true;
|
||||
if (f.startsWith(`${safeArtist} - ${safeName} [`)) {
|
||||
matchedFile = f;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (exists) {
|
||||
// console.log(`[Sync] Skipped (Exists): ${song.name}`);
|
||||
return true; // Exists
|
||||
return { ok: true, existed: true, filename: matchedFile || null }; // Exists
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error reading music dir:', e);
|
||||
@@ -576,14 +589,14 @@ async function processSong(song) {
|
||||
const candidates = buildDownloadCandidates(source, song.id, song.url, song.types);
|
||||
|
||||
const primaryOk = await tryDownloadWithCandidates(song, source, baseName, candidates, downloadHeaders);
|
||||
if (primaryOk) return true;
|
||||
if (primaryOk) return { ok: true, existed: false, filename: `${baseName}` };
|
||||
|
||||
// 回退:搜索拿到可用歌曲 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;
|
||||
if (fallbackOk) return { ok: true, existed: false, filename: `${baseName}` };
|
||||
}
|
||||
|
||||
// 换源回退:kuwo -> qq
|
||||
@@ -611,13 +624,59 @@ async function processSong(song) {
|
||||
fallbackCandidates,
|
||||
fallbackHeaders
|
||||
);
|
||||
if (fallbackOk) return true;
|
||||
if (fallbackOk) return { ok: true, existed: false, filename: `${baseName}` };
|
||||
}
|
||||
|
||||
return false; // Failed
|
||||
return { ok: false, existed: false, filename: null }; // Failed
|
||||
}
|
||||
|
||||
async function searchSongInNavidrome(song) {
|
||||
function normalizeMatchText(str) {
|
||||
return (str || '')
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.replace(/[\s\-_[\]().·()、]+/g, '');
|
||||
}
|
||||
|
||||
function stripBracketed(str) {
|
||||
return (str || '')
|
||||
.toString()
|
||||
.replace(/[\(\(\[\【].*?[\)\)\]\】]/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function pickSongByPathOrFuzzy(songs, filenameHint, idToken, rawName, rawArtist) {
|
||||
if (!Array.isArray(songs) || songs.length === 0) return null;
|
||||
|
||||
if (filenameHint) {
|
||||
const byPath = songs.find(s => typeof s.path === 'string' && s.path.includes(filenameHint));
|
||||
if (byPath) return byPath.id;
|
||||
}
|
||||
|
||||
if (idToken) {
|
||||
const byIdToken = songs.find(s => typeof s.path === 'string' && s.path.includes(idToken));
|
||||
if (byIdToken) return byIdToken.id;
|
||||
}
|
||||
|
||||
const targetName = normalizeMatchText(stripBracketed(rawName));
|
||||
const targetArtist = normalizeMatchText(rawArtist);
|
||||
|
||||
const byTitleArtist = songs.find(s =>
|
||||
normalizeMatchText(stripBracketed(s.title)) === targetName &&
|
||||
normalizeMatchText(s.artist) === targetArtist
|
||||
);
|
||||
if (byTitleArtist) return byTitleArtist.id;
|
||||
|
||||
if (songs.length === 1) {
|
||||
const only = songs[0];
|
||||
if (normalizeMatchText(stripBracketed(only.title)) === targetName) {
|
||||
return only.id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function searchSongInNavidrome(song, filenameHint = null) {
|
||||
try {
|
||||
const rawName = (song?.name || '').trim();
|
||||
const rawArtist = (song?.artist || '').trim();
|
||||
@@ -629,8 +688,14 @@ async function searchSongInNavidrome(song) {
|
||||
const safeArtist = sanitizeFilename(rawArtist);
|
||||
|
||||
const queries = [];
|
||||
if (filenameHint) {
|
||||
const base = filenameHint.replace(/\.[^.]+$/, '');
|
||||
queries.push(base);
|
||||
}
|
||||
if (rawName && rawArtist) queries.push(`${rawName} ${rawArtist}`);
|
||||
if (rawName) queries.push(rawName);
|
||||
const strippedName = stripBracketed(rawName);
|
||||
if (strippedName && strippedName !== rawName) queries.push(strippedName);
|
||||
if (rawArtist && rawAlbum) queries.push(`${rawArtist} ${rawAlbum}`);
|
||||
if (safeName) {
|
||||
const fallbackName = safeArtist ? `${safeArtist} - ${safeName}` : safeName;
|
||||
@@ -655,23 +720,8 @@ async function searchSongInNavidrome(song) {
|
||||
? result.searchResult3.song
|
||||
: [result.searchResult3.song];
|
||||
|
||||
if (idToken) {
|
||||
const byPath = songs.find(s => typeof s.path === 'string' && s.path.includes(idToken));
|
||||
if (byPath) return byPath.id;
|
||||
}
|
||||
|
||||
const byTitleArtist = songs.find(s =>
|
||||
normalize(s.title) === normalize(rawName) &&
|
||||
normalize(s.artist) === normalize(rawArtist)
|
||||
);
|
||||
if (byTitleArtist) return byTitleArtist.id;
|
||||
|
||||
if (songs.length === 1) {
|
||||
const only = songs[0];
|
||||
if (normalize(only.title) === normalize(rawName)) {
|
||||
return only.id;
|
||||
}
|
||||
}
|
||||
const picked = pickSongByPathOrFuzzy(songs, filenameHint, idToken, rawName, rawArtist);
|
||||
if (picked) return picked;
|
||||
}
|
||||
|
||||
if (attempt < MAX_ATTEMPTS) {
|
||||
@@ -680,6 +730,18 @@ async function searchSongInNavidrome(song) {
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底:用歌手进行宽松搜索,再按路径或模糊标题匹配
|
||||
if (rawArtist) {
|
||||
const fallbackResult = await callSubsonicAPI('search3', { query: rawArtist });
|
||||
if (fallbackResult.searchResult3?.song) {
|
||||
const songs = Array.isArray(fallbackResult.searchResult3.song)
|
||||
? fallbackResult.searchResult3.song
|
||||
: [fallbackResult.searchResult3.song];
|
||||
const picked = pickSongByPathOrFuzzy(songs, filenameHint, idToken, rawName, rawArtist);
|
||||
if (picked) return picked;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Search song error:', error.message);
|
||||
@@ -772,6 +834,7 @@ async function syncPlaylist(playlist, cachedInfo = null) {
|
||||
let syncedCount = 0;
|
||||
let failedCount = 0;
|
||||
const downloadStatus = {};
|
||||
const downloadInfo = {};
|
||||
const unmatchedSongs = [];
|
||||
|
||||
syncStatus[playlistId] = { status: 'syncing', progress: 10, message: `获取到 ${totalTracks} 首歌曲` };
|
||||
@@ -798,8 +861,12 @@ async function syncPlaylist(playlist, cachedInfo = null) {
|
||||
const batch = songs.slice(i, i + BATCH_SIZE);
|
||||
await Promise.all(batch.map(async (song) => {
|
||||
try {
|
||||
const ok = await processSong(song);
|
||||
const result = await processSong(song);
|
||||
const ok = typeof result === 'object' ? result.ok : !!result;
|
||||
downloadStatus[song.id] = ok;
|
||||
if (typeof result === 'object') {
|
||||
downloadInfo[song.id] = result;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Sync] Failed to process song ${song.name}:`, e);
|
||||
downloadStatus[song.id] = false;
|
||||
@@ -833,7 +900,8 @@ async function syncPlaylist(playlist, cachedInfo = null) {
|
||||
if (playlist.songMapping && playlist.songMapping[neteaseSongId]) {
|
||||
navidromeSongId = playlist.songMapping[neteaseSongId];
|
||||
} else {
|
||||
navidromeSongId = await searchSongInNavidrome(song);
|
||||
const fileHint = downloadInfo[neteaseSongId]?.filename || null;
|
||||
navidromeSongId = await searchSongInNavidrome(song, fileHint);
|
||||
|
||||
if (navidromeSongId) {
|
||||
newSongIds.push(navidromeSongId);
|
||||
@@ -855,7 +923,8 @@ async function syncPlaylist(playlist, cachedInfo = null) {
|
||||
name: song.name,
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
downloaded: downloadStatus[neteaseSongId] === true
|
||||
downloaded: downloadStatus[neteaseSongId] === true,
|
||||
filename: downloadInfo[neteaseSongId]?.filename || null
|
||||
});
|
||||
}
|
||||
|
||||
@@ -872,7 +941,7 @@ async function syncPlaylist(playlist, cachedInfo = null) {
|
||||
if (unmatchedSongs.length > 0) {
|
||||
const maxLog = 20;
|
||||
const preview = unmatchedSongs.slice(0, maxLog)
|
||||
.map(s => `${s.name} - ${s.artist} (id=${s.id}, downloaded=${s.downloaded})`)
|
||||
.map(s => `${s.name} - ${s.artist} (id=${s.id}, downloaded=${s.downloaded}, file=${s.filename || 'n/a'})`)
|
||||
.join(' | ');
|
||||
console.warn(`[Sync] Unmatched songs (${unmatchedSongs.length}): ${preview}${unmatchedSongs.length > maxLog ? ' ...' : ''}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user