diff --git a/Netease-sync/server.js b/Netease-sync/server.js index afd4b21..908e385 100644 --- a/Netease-sync/server.js +++ b/Netease-sync/server.js @@ -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 ? ' ...' : ''}`); }