改动点

- searchSongInNavidrome 增加:
      - 去括号标题(去掉 (Live) / (十年荣耀版) 等)
      - 按歌手进行兜底搜索,再用 path/idToken/模糊标题匹配
      - 优先 path 命中(含真实文件名或 netease_id)
This commit is contained in:
史悦
2026-01-13 13:12:09 +08:00
parent ae5e34694e
commit 58ac7ec198

View File

@@ -548,25 +548,38 @@ async function processSong(song) {
// Check if file exists (fuzzy match for extension) // Check if file exists (fuzzy match for extension)
try { try {
const files = fs.readdirSync(MUSIC_DIR); const files = fs.readdirSync(MUSIC_DIR);
let matchedFile = null;
const exists = files.some(f => { const exists = files.some(f => {
// 1. Exact Match: Artist - Name [source_id].ext // 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 // 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 // 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 [ // 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; return false;
}); });
if (exists) { if (exists) {
// console.log(`[Sync] Skipped (Exists): ${song.name}`); // console.log(`[Sync] Skipped (Exists): ${song.name}`);
return true; // Exists return { ok: true, existed: true, filename: matchedFile || null }; // Exists
} }
} catch (e) { } catch (e) {
console.error('Error reading music dir:', 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 candidates = buildDownloadCandidates(source, song.id, song.url, song.types);
const primaryOk = await tryDownloadWithCandidates(song, source, baseName, candidates, downloadHeaders); const primaryOk = await tryDownloadWithCandidates(song, source, baseName, candidates, downloadHeaders);
if (primaryOk) return true; if (primaryOk) return { ok: true, existed: false, filename: `${baseName}` };
// 回退:搜索拿到可用歌曲 ID 再尝试下载 // 回退:搜索拿到可用歌曲 ID 再尝试下载
const searchHit = await searchSongOnTuneHub(song.name, song.artist, source); const searchHit = await searchSongOnTuneHub(song.name, song.artist, source);
if (searchHit?.id) { if (searchHit?.id) {
const fallbackCandidates = buildDownloadCandidates(source, searchHit.id, searchHit.url, searchHit.types); const fallbackCandidates = buildDownloadCandidates(source, searchHit.id, searchHit.url, searchHit.types);
const fallbackOk = await tryDownloadWithCandidates(song, source, baseName, fallbackCandidates, downloadHeaders); const fallbackOk = await tryDownloadWithCandidates(song, source, baseName, fallbackCandidates, downloadHeaders);
if (fallbackOk) return true; if (fallbackOk) return { ok: true, existed: false, filename: `${baseName}` };
} }
// 换源回退kuwo -> qq // 换源回退kuwo -> qq
@@ -611,13 +624,59 @@ async function processSong(song) {
fallbackCandidates, fallbackCandidates,
fallbackHeaders 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 { try {
const rawName = (song?.name || '').trim(); const rawName = (song?.name || '').trim();
const rawArtist = (song?.artist || '').trim(); const rawArtist = (song?.artist || '').trim();
@@ -629,8 +688,14 @@ async function searchSongInNavidrome(song) {
const safeArtist = sanitizeFilename(rawArtist); const safeArtist = sanitizeFilename(rawArtist);
const queries = []; const queries = [];
if (filenameHint) {
const base = filenameHint.replace(/\.[^.]+$/, '');
queries.push(base);
}
if (rawName && rawArtist) queries.push(`${rawName} ${rawArtist}`); if (rawName && rawArtist) queries.push(`${rawName} ${rawArtist}`);
if (rawName) queries.push(rawName); if (rawName) queries.push(rawName);
const strippedName = stripBracketed(rawName);
if (strippedName && strippedName !== rawName) queries.push(strippedName);
if (rawArtist && rawAlbum) queries.push(`${rawArtist} ${rawAlbum}`); if (rawArtist && rawAlbum) queries.push(`${rawArtist} ${rawAlbum}`);
if (safeName) { if (safeName) {
const fallbackName = safeArtist ? `${safeArtist} - ${safeName}` : safeName; const fallbackName = safeArtist ? `${safeArtist} - ${safeName}` : safeName;
@@ -655,23 +720,8 @@ async function searchSongInNavidrome(song) {
? result.searchResult3.song ? result.searchResult3.song
: [result.searchResult3.song]; : [result.searchResult3.song];
if (idToken) { const picked = pickSongByPathOrFuzzy(songs, filenameHint, idToken, rawName, rawArtist);
const byPath = songs.find(s => typeof s.path === 'string' && s.path.includes(idToken)); if (picked) return picked;
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;
}
}
} }
if (attempt < MAX_ATTEMPTS) { 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; return null;
} catch (error) { } catch (error) {
console.error('Search song error:', error.message); console.error('Search song error:', error.message);
@@ -772,6 +834,7 @@ async function syncPlaylist(playlist, cachedInfo = null) {
let syncedCount = 0; let syncedCount = 0;
let failedCount = 0; let failedCount = 0;
const downloadStatus = {}; const downloadStatus = {};
const downloadInfo = {};
const unmatchedSongs = []; const unmatchedSongs = [];
syncStatus[playlistId] = { status: 'syncing', progress: 10, message: `获取到 ${totalTracks} 首歌曲` }; 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); const batch = songs.slice(i, i + BATCH_SIZE);
await Promise.all(batch.map(async (song) => { await Promise.all(batch.map(async (song) => {
try { try {
const ok = await processSong(song); const result = await processSong(song);
const ok = typeof result === 'object' ? result.ok : !!result;
downloadStatus[song.id] = ok; downloadStatus[song.id] = ok;
if (typeof result === 'object') {
downloadInfo[song.id] = result;
}
} catch (e) { } catch (e) {
console.error(`[Sync] Failed to process song ${song.name}:`, e); console.error(`[Sync] Failed to process song ${song.name}:`, e);
downloadStatus[song.id] = false; downloadStatus[song.id] = false;
@@ -833,7 +900,8 @@ async function syncPlaylist(playlist, cachedInfo = null) {
if (playlist.songMapping && playlist.songMapping[neteaseSongId]) { if (playlist.songMapping && playlist.songMapping[neteaseSongId]) {
navidromeSongId = playlist.songMapping[neteaseSongId]; navidromeSongId = playlist.songMapping[neteaseSongId];
} else { } else {
navidromeSongId = await searchSongInNavidrome(song); const fileHint = downloadInfo[neteaseSongId]?.filename || null;
navidromeSongId = await searchSongInNavidrome(song, fileHint);
if (navidromeSongId) { if (navidromeSongId) {
newSongIds.push(navidromeSongId); newSongIds.push(navidromeSongId);
@@ -855,7 +923,8 @@ async function syncPlaylist(playlist, cachedInfo = null) {
name: song.name, name: song.name,
artist: song.artist, artist: song.artist,
album: song.album, 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) { if (unmatchedSongs.length > 0) {
const maxLog = 20; const maxLog = 20;
const preview = unmatchedSongs.slice(0, maxLog) 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(' | '); .join(' | ');
console.warn(`[Sync] Unmatched songs (${unmatchedSongs.length}): ${preview}${unmatchedSongs.length > maxLog ? ' ...' : ''}`); console.warn(`[Sync] Unmatched songs (${unmatchedSongs.length}): ${preview}${unmatchedSongs.length > maxLog ? ' ...' : ''}`);
} }