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:
HoneyandClaude Sonnet 4.6 committed 2026-06-15 20:50:16 +02:00
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,
)
@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)
fun parseLrc(lrc: String): List<LrcLine> {
@@ -53,7 +68,7 @@ object LrcLibClient {
album: String?,
durationSecs: Int?,
): LrcLibResponse? {
// 1. Exact match — fastest, most accurate when metadata is clean
// 1. lrclib exact match
val exact = runCatching {
client.get("https://lrclib.net/api/get") {
parameter("artist_name", artist)
@@ -64,26 +79,65 @@ object LrcLibClient {
}.getOrNull()
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 {
client.get("https://lrclib.net/api/search") {
parameter("track_name", title)
if (artist.isNotBlank()) parameter("artist_name", artist)
}.body<List<LrcLibResponse>>()
}.getOrNull() ?: return null
}.getOrNull()
if (candidates.isEmpty()) return null
return if (durationSecs != null && durationSecs > 0) {
val synced = candidates.filter { !it.syncedLyrics.isNullOrBlank() }
val pool = synced.ifEmpty { candidates }
pool.minByOrNull { kotlin.math.abs(it.duration - durationSecs) }
?.takeIf { kotlin.math.abs(it.duration - durationSecs) <= 5.0 }
?: synced.firstOrNull()
?: candidates.firstOrNull()
} else {
candidates.firstOrNull { !it.syncedLyrics.isNullOrBlank() }
?: candidates.firstOrNull()
val lrclibResult = candidates?.let { list ->
if (list.isEmpty()) return@let null
if (durationSecs != null && durationSecs > 0) {
val synced = list.filter { !it.syncedLyrics.isNullOrBlank() }
val pool = synced.ifEmpty { list }
pool.minByOrNull { kotlin.math.abs(it.duration - durationSecs) }
?.takeIf { kotlin.math.abs(it.duration - durationSecs) <= 5.0 }
?: synced.firstOrNull()
?: list.firstOrNull()
} else {
list.firstOrNull { !it.syncedLyrics.isNullOrBlank() } ?: list.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() }
}
}