fix: lrclib rate limit — exact first, then 2 parallel searches; check HTTP 200

3 simultaneous requests were hitting lrclib rate limiter.
Now: exact match alone → if no content, 2 parallel searches.
Also check response.status === 200 before parsing to avoid treating
404/429 error bodies as valid empty lyrics responses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-06-15 21:46:09 +02:00
1 parent d7f8b301ab
commit eb5b0ea360
2 files changed
+55 -47

No files matched your search

@@ -6,6 +6,7 @@ import io.ktor.client.engine.android.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
@@ -61,13 +62,12 @@ object LrcLibClient {
json(Json { ignoreUnknownKeys = true; isLenient = true })
}
install(HttpTimeout) {
requestTimeoutMillis = 7_000
requestTimeoutMillis = 8_000
connectTimeoutMillis = 5_000
socketTimeoutMillis = 7_000
socketTimeoutMillis = 8_000
}
}
// Simple in-memory cache — avoids re-fetching when reopening lyrics for the same song
private val cache = HashMap<String, LrcLibResponse?>()
suspend fun getLyrics(
@@ -79,50 +79,45 @@ object LrcLibClient {
val cacheKey = "$artist|$title"
if (cache.containsKey(cacheKey)) return cache[cacheKey]
// 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()
// 1. lrclib exact match — single request, only trust HTTP 200
val exact = runCatching {
val resp = 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)
}
if (resp.status == HttpStatusCode.OK) resp.body<LrcLibResponse>() else null
}.getOrNull()
if (exact.hasContent()) return exact.also { cache[cacheKey] = it }
// 2+3. lrclib search — artist+title and title-only in parallel (2 requests, not 3)
val lrclibResult = coroutineScope {
val artistDef = async {
runCatching {
client.get("https://lrclib.net/api/search") {
val resp = client.get("https://lrclib.net/api/search") {
parameter("track_name", title)
if (artist.isNotBlank()) parameter("artist_name", artist)
}.body<List<LrcLibResponse>>()
}
if (resp.status == HttpStatusCode.OK) resp.body<List<LrcLibResponse>>() else null
}.getOrNull()
}
val titleDef = async {
runCatching {
client.get("https://lrclib.net/api/search") {
val resp = client.get("https://lrclib.net/api/search") {
parameter("track_name", title)
}.body<List<LrcLibResponse>>()
}
if (resp.status == HttpStatusCode.OK) resp.body<List<LrcLibResponse>>() else null
}.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(artistDef.await(), durationSecs)
?: bestMatch(titleDef.await(), durationSecs)
}
// NetEase fallback — good for Asian/Japanese music
if (lrclibResult != null) return lrclibResult.also { cache[cacheKey] = it }
// 4. NetEase fallback
val neLrc = withTimeoutOrNull(8_000) {
runCatching { netEaseLyrics(artist, title, durationSecs) }.getOrNull()
}
@@ -131,6 +126,9 @@ object LrcLibClient {
return result
}
private fun LrcLibResponse?.hasContent() =
this != null && (instrumental || !syncedLyrics.isNullOrBlank() || !plainLyrics.isNullOrBlank())
private fun bestMatch(candidates: List<LrcLibResponse>?, durationSecs: Int?): LrcLibResponse? {
if (candidates.isNullOrEmpty()) return null
val synced = candidates.filter { !it.syncedLyrics.isNullOrBlank() }