perf: parallel lrclib requests + in-memory cache for lyrics

- Android + web: fire exact/artist-search/title-search in parallel so
  total wait = slowest single request instead of sum of all three
- Android: HashMap cache keyed by artist|title, cleared on app restart
- Web: Map cache (session lifetime), cache hit is instant re-open
- Web: extract _showLyricsResult helper, safeJson wrapper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-06-15 21:26:07 +02:00
1 parent d0df01a76d
commit d7f8b301ab
2 files changed
+117 -116

No files matched your search

@@ -7,6 +7,8 @@ import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@@ -24,15 +26,11 @@ data class LrcLibResponse(
)
@Serializable
private data class NetEaseSearchResp(
val result: NetEaseResult? = null,
val code: Int = 0,
)
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
@@ -63,80 +61,92 @@ object LrcLibClient {
json(Json { ignoreUnknownKeys = true; isLenient = true })
}
install(HttpTimeout) {
requestTimeoutMillis = 6_000
requestTimeoutMillis = 7_000
connectTimeoutMillis = 5_000
socketTimeoutMillis = 6_000
socketTimeoutMillis = 7_000
}
}
// Simple in-memory cache — avoids re-fetching when reopening lyrics for the same song
private val cache = HashMap<String, LrcLibResponse?>()
suspend fun getLyrics(
artist: String,
title: String,
album: String?,
durationSecs: Int?,
): LrcLibResponse? {
// 1. lrclib exact match
val exact = withTimeoutOrNull(6_000) {
runCatching {
client.get("https://lrclib.net/api/get") {
parameter("artist_name", artist)
parameter("track_name", title)
if (!album.isNullOrBlank()) parameter("album_name", album)
if (durationSecs != null && durationSecs > 0) parameter("duration", durationSecs)
}.body<LrcLibResponse>()
}.getOrNull()
}
if (exact != null) return exact
val cacheKey = "$artist|$title"
if (cache.containsKey(cacheKey)) return cache[cacheKey]
// 2. lrclib artist+title search — handles metadata mismatches
val byArtist = withTimeoutOrNull(6_000) {
runCatching {
client.get("https://lrclib.net/api/search") {
parameter("track_name", title)
if (artist.isNotBlank()) parameter("artist_name", artist)
}.body<List<LrcLibResponse>>()
}.getOrNull()
}
bestMatch(byArtist, durationSecs)?.let { return it }
// Fire all three lrclib requests in parallel — total wait = slowest single request
val lrclibResult = coroutineScope {
val exactDef = async {
runCatching {
client.get("https://lrclib.net/api/get") {
parameter("artist_name", artist)
parameter("track_name", title)
if (!album.isNullOrBlank()) parameter("album_name", album)
if (durationSecs != null && durationSecs > 0) parameter("duration", durationSecs)
}.body<LrcLibResponse>()
}.getOrNull()
}
val artistDef = async {
runCatching {
client.get("https://lrclib.net/api/search") {
parameter("track_name", title)
if (artist.isNotBlank()) parameter("artist_name", artist)
}.body<List<LrcLibResponse>>()
}.getOrNull()
}
val titleDef = async {
runCatching {
client.get("https://lrclib.net/api/search") {
parameter("track_name", title)
}.body<List<LrcLibResponse>>()
}.getOrNull()
}
// 3. lrclib title-only search — catches artist name mismatches like "Utada" vs "Hikaru Utada"
val byTitle = withTimeoutOrNull(6_000) {
runCatching {
client.get("https://lrclib.net/api/search") {
parameter("track_name", title)
}.body<List<LrcLibResponse>>()
}.getOrNull()
val exact = exactDef.await()
if (exact != null) {
artistDef.cancel()
titleDef.cancel()
exact
} else {
bestMatch(artistDef.await(), durationSecs)
?: bestMatch(titleDef.await(), durationSecs)
}
}
if (lrclibResult != null) {
cache[cacheKey] = lrclibResult
return lrclibResult
}
bestMatch(byTitle, durationSecs)?.let { return it }
// 4. NetEase fallback — good coverage for Asian/Japanese music
// NetEase fallback — good for Asian/Japanese music
val neLrc = withTimeoutOrNull(8_000) {
runCatching { netEaseLyrics(artist, title, durationSecs) }.getOrNull()
}
if (!neLrc.isNullOrBlank()) return LrcLibResponse(syncedLyrics = neLrc)
return null
val result = if (!neLrc.isNullOrBlank()) LrcLibResponse(syncedLyrics = neLrc) else null
cache[cacheKey] = result
return result
}
private fun bestMatch(candidates: List<LrcLibResponse>?, durationSecs: Int?): LrcLibResponse? {
if (candidates.isNullOrEmpty()) return null
val synced = candidates.filter { !it.syncedLyrics.isNullOrBlank() }
val pool = synced.ifEmpty { candidates }
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()
val best = pool.minByOrNull { kotlin.math.abs(it.duration - durationSecs) }!!
if (kotlin.math.abs(best.duration - durationSecs) <= 5.0) best
else synced.firstOrNull() ?: candidates.firstOrNull()
} else {
candidates.firstOrNull { !it.syncedLyrics.isNullOrBlank() }
?: candidates.firstOrNull()
pool.firstOrNull()
}
}
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("s", "$artist $title".trim())
parameter("type", 1)
parameter("limit", 5)
header("Referer", "https://music.163.com")
@@ -148,18 +158,14 @@ object LrcLibClient {
songs.minByOrNull { kotlin.math.abs(it.duration - durMs) }
?.takeIf { kotlin.math.abs(it.duration - durMs) <= 5000L }
?: songs.firstOrNull()
} else {
songs.firstOrNull()
} ?: return null
} else songs.firstOrNull() ?: return null
val lyricsResp = client.get("https://music.163.com/api/song/lyric") {
parameter("id", song.id)
return 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() }
}.body<NetEaseLyricsResp>().lrc?.lyric?.takeIf { it.isNotBlank() }
}
}