feat: add NetEase as lyrics fallback after lrclib (Android + web)
Search order: lrclib exact → lrclib fuzzy search → NetEase. NetEase has strong coverage for Asian/Japanese music that lrclib misses. Duration-matched candidate selection on all three steps. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
8b9bfd3098
commit
71667d1a03
2 files changed
+102
-15
No files matched your search
@@ -21,6 +21,21 @@ data class LrcLibResponse(
|
|||||||
val syncedLyrics: String? = null,
|
val syncedLyrics: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class NetEaseSearchResp(
|
||||||
|
val result: NetEaseResult? = null,
|
||||||
|
val code: Int = 0,
|
||||||
|
)
|
||||||
|
@Serializable
|
||||||
|
private data class NetEaseResult(val songs: List<NetEaseSong> = emptyList())
|
||||||
|
@Serializable
|
||||||
|
private data class NetEaseSong(val id: Long = 0, val duration: Long = 0)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class NetEaseLyricsResp(val lrc: NetEaseLrc? = null, val code: Int = 0)
|
||||||
|
@Serializable
|
||||||
|
private data class NetEaseLrc(val lyric: String? = null)
|
||||||
|
|
||||||
data class LrcLine(val timeMs: Long, val text: String)
|
data class LrcLine(val timeMs: Long, val text: String)
|
||||||
|
|
||||||
fun parseLrc(lrc: String): List<LrcLine> {
|
fun parseLrc(lrc: String): List<LrcLine> {
|
||||||
@@ -53,7 +68,7 @@ object LrcLibClient {
|
|||||||
album: String?,
|
album: String?,
|
||||||
durationSecs: Int?,
|
durationSecs: Int?,
|
||||||
): LrcLibResponse? {
|
): LrcLibResponse? {
|
||||||
// 1. Exact match — fastest, most accurate when metadata is clean
|
// 1. lrclib exact match
|
||||||
val exact = runCatching {
|
val exact = runCatching {
|
||||||
client.get("https://lrclib.net/api/get") {
|
client.get("https://lrclib.net/api/get") {
|
||||||
parameter("artist_name", artist)
|
parameter("artist_name", artist)
|
||||||
@@ -64,26 +79,65 @@ object LrcLibClient {
|
|||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
if (exact != null) return exact
|
if (exact != null) return exact
|
||||||
|
|
||||||
// 2. Fuzzy search fallback — handles artist name mismatches (e.g. "Utada" vs "Hikaru Utada")
|
// 2. lrclib fuzzy search — handles artist name mismatches
|
||||||
val candidates = runCatching {
|
val candidates = runCatching {
|
||||||
client.get("https://lrclib.net/api/search") {
|
client.get("https://lrclib.net/api/search") {
|
||||||
parameter("track_name", title)
|
parameter("track_name", title)
|
||||||
if (artist.isNotBlank()) parameter("artist_name", artist)
|
if (artist.isNotBlank()) parameter("artist_name", artist)
|
||||||
}.body<List<LrcLibResponse>>()
|
}.body<List<LrcLibResponse>>()
|
||||||
}.getOrNull() ?: return null
|
}.getOrNull()
|
||||||
|
|
||||||
if (candidates.isEmpty()) return null
|
val lrclibResult = candidates?.let { list ->
|
||||||
|
if (list.isEmpty()) return@let null
|
||||||
return if (durationSecs != null && durationSecs > 0) {
|
if (durationSecs != null && durationSecs > 0) {
|
||||||
val synced = candidates.filter { !it.syncedLyrics.isNullOrBlank() }
|
val synced = list.filter { !it.syncedLyrics.isNullOrBlank() }
|
||||||
val pool = synced.ifEmpty { candidates }
|
val pool = synced.ifEmpty { list }
|
||||||
pool.minByOrNull { kotlin.math.abs(it.duration - durationSecs) }
|
pool.minByOrNull { kotlin.math.abs(it.duration - durationSecs) }
|
||||||
?.takeIf { kotlin.math.abs(it.duration - durationSecs) <= 5.0 }
|
?.takeIf { kotlin.math.abs(it.duration - durationSecs) <= 5.0 }
|
||||||
?: synced.firstOrNull()
|
?: synced.firstOrNull()
|
||||||
?: candidates.firstOrNull()
|
?: list.firstOrNull()
|
||||||
} else {
|
} else {
|
||||||
candidates.firstOrNull { !it.syncedLyrics.isNullOrBlank() }
|
list.firstOrNull { !it.syncedLyrics.isNullOrBlank() } ?: list.firstOrNull()
|
||||||
?: candidates.firstOrNull()
|
}
|
||||||
}
|
}
|
||||||
|
if (lrclibResult != null) return lrclibResult
|
||||||
|
|
||||||
|
// 3. NetEase fallback — good coverage for Asian/Japanese music
|
||||||
|
val neLrc = runCatching { netEaseLyrics(artist, title, durationSecs) }.getOrNull()
|
||||||
|
if (!neLrc.isNullOrBlank()) {
|
||||||
|
return LrcLibResponse(syncedLyrics = neLrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun netEaseLyrics(artist: String, title: String, durationSecs: Int?): String? {
|
||||||
|
val query = "$artist $title".trim()
|
||||||
|
val search = client.get("https://music.163.com/api/search/get") {
|
||||||
|
parameter("s", query)
|
||||||
|
parameter("type", 1)
|
||||||
|
parameter("limit", 5)
|
||||||
|
header("Referer", "https://music.163.com")
|
||||||
|
}.body<NetEaseSearchResp>()
|
||||||
|
|
||||||
|
val songs = search.result?.songs ?: return null
|
||||||
|
val song = if (durationSecs != null && durationSecs > 0) {
|
||||||
|
val durMs = durationSecs * 1000L
|
||||||
|
songs.minByOrNull { kotlin.math.abs(it.duration - durMs) }
|
||||||
|
?.takeIf { kotlin.math.abs(it.duration - durMs) <= 5000L }
|
||||||
|
?: songs.firstOrNull()
|
||||||
|
} else {
|
||||||
|
songs.firstOrNull()
|
||||||
|
} ?: return null
|
||||||
|
|
||||||
|
val lyricsResp = client.get("https://music.163.com/api/song/lyric") {
|
||||||
|
parameter("id", song.id)
|
||||||
|
parameter("lv", 1)
|
||||||
|
parameter("kv", 1)
|
||||||
|
parameter("tv", -1)
|
||||||
|
header("Referer", "https://music.163.com")
|
||||||
|
}.body<NetEaseLyricsResp>()
|
||||||
|
|
||||||
|
return lyricsResp.lrc?.lyric?.takeIf { it.isNotBlank() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,6 +183,39 @@ async function _loadNpLyrics(songId) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. NetEase fallback — good coverage for Asian/Japanese music
|
||||||
|
if(token !== _lyricsLoadToken) return;
|
||||||
|
if(!result?.syncedLyrics && !result?.plainLyrics) {
|
||||||
|
try {
|
||||||
|
const q = encodeURIComponent(`${artist} ${title}`.trim());
|
||||||
|
const sr = await fetch(`https://music.163.com/api/search/get?s=${q}&type=1&limit=5`, {
|
||||||
|
headers: { Referer: 'https://music.163.com' },
|
||||||
|
signal: AbortSignal.timeout(8000),
|
||||||
|
});
|
||||||
|
if(sr.ok) {
|
||||||
|
const sd = await sr.json();
|
||||||
|
const songs = sd?.result?.songs || [];
|
||||||
|
const durMs = dur * 1000;
|
||||||
|
const song = dur
|
||||||
|
? songs.sort((a, b) => Math.abs(a.duration - durMs) - Math.abs(b.duration - durMs))
|
||||||
|
.find(s => Math.abs(s.duration - durMs) <= 5000)
|
||||||
|
: songs[0];
|
||||||
|
if(song) {
|
||||||
|
if(token !== _lyricsLoadToken) return;
|
||||||
|
const lr = await fetch(
|
||||||
|
`https://music.163.com/api/song/lyric?id=${song.id}&lv=1&kv=1&tv=-1`,
|
||||||
|
{ headers: { Referer: 'https://music.163.com' }, signal: AbortSignal.timeout(8000) },
|
||||||
|
);
|
||||||
|
if(lr.ok) {
|
||||||
|
const ld = await lr.json();
|
||||||
|
const lrc = ld?.lrc?.lyric;
|
||||||
|
if(lrc) result = { syncedLyrics: lrc };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
if(token !== _lyricsLoadToken) return;
|
if(token !== _lyricsLoadToken) return;
|
||||||
content.innerHTML = '';
|
content.innerHTML = '';
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user