fix(lyrics): stop hammering lrclib (caused IP throttling → no lyrics)

Rapid skips + lyrics prefetch fired the full lrclib cascade many times over,
so lrclib throttled the IP and requests timed out (canceled) on both web and
Android. Reduce the request volume:

- Web: de-dupe concurrent fetches per song (prefetch + tab-open now share one
  cascade); bump the per-request timeout 6s → 8s.
- Android: per-song Mutex so prefetch + on-open load don't both run the cascade;
  send a proper User-Agent (lrclib asks for one and treats it better); debounce
  the prefetch (1.5s, cancelled on the next skip) instead of firing immediately
  on every track change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-06-28 21:12:01 +02:00
1 parent ddb9418603
commit 63a7260c94
3 files changed
+34 -3

No files matched your search

@@ -10,6 +10,8 @@ import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@@ -55,9 +57,13 @@ object LrcLibClient {
connectTimeoutMillis = 5_000
socketTimeoutMillis = 8_000
}
// lrclib asks clients to identify themselves; a proper UA also avoids being throttled.
install(UserAgent) { agent = "NovaSonic/1.0 (+https://gitea.honeyy.duckdns.org/Docker/NovaSonic)" }
}
private val cache = HashMap<String, LrcLibResponse?>()
// Per-song lock so a prefetch + an on-open load don't both fire the whole cascade (→ throttling).
private val locks = java.util.concurrent.ConcurrentHashMap<String, Mutex>()
suspend fun getLyrics(
artist: String,
@@ -67,6 +73,18 @@ object LrcLibClient {
): LrcLibResponse? {
val cacheKey = "$artist|$title"
if (cache.containsKey(cacheKey)) return cache[cacheKey]
val mutex = locks.getOrPut(cacheKey) { Mutex() }
return mutex.withLock { getLyricsInner(artist, title, album, durationSecs, cacheKey) }
}
private suspend fun getLyricsInner(
artist: String,
title: String,
album: String?,
durationSecs: Int?,
cacheKey: String,
): LrcLibResponse? {
if (cache.containsKey(cacheKey)) return cache[cacheKey] // populated while we waited on the lock
// 1a. lrclib exact match with duration
val exact = runCatching {
@@ -280,9 +280,13 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
)
}
/** Warm the LrcLib cache as soon as a track starts, so opening Lyrics feels instant. */
private var _lyricsPrefetchJob: Job? = null
/** Warm the LrcLib cache shortly after a track settles. Debounced so rapid skips don't
* hammer lrclib (which throttles the IP and then no lyrics load at all). */
private fun prefetchLyrics(song: Song) {
scope.launch {
_lyricsPrefetchJob?.cancel()
_lyricsPrefetchJob = scope.launch {
delay(1500) // skip quickly without firing a request per track
runCatching {
LrcLibClient.getLyrics(
artist = song.artist ?: "",
+10 -1
View File
@@ -102,6 +102,7 @@ let _lyricsSongId = null;
let _lyricsLoadToken = 0;
let _prefetchTimer = null;
const _lyricsCache = new Map(); // "artist|title" → result object (null = not found)
const _lyricsInflight = new Map(); // "artist|title" → Promise — de-dupe concurrent fetches
const LYRICS_LOOKAHEAD_MS = 200;
@@ -176,8 +177,16 @@ async function _loadNpLyrics(songId) {
async function _fetchLyricsData(artist, title, album, dur) {
const cacheKey = `${artist}|${title}`;
if(_lyricsCache.has(cacheKey)) return _lyricsCache.get(cacheKey);
// De-dupe concurrent fetches for the same song (prefetch + tab-open) so we don't fire the
// whole lrclib cascade twice — that doubles the request rate and gets the IP throttled.
if(_lyricsInflight.has(cacheKey)) return _lyricsInflight.get(cacheKey);
const p = _doFetchLyrics(artist, title, album, dur, cacheKey);
_lyricsInflight.set(cacheKey, p);
try { return await p; } finally { _lyricsInflight.delete(cacheKey); }
}
const SIG = () => AbortSignal.timeout(6000);
async function _doFetchLyrics(artist, title, album, dur, cacheKey) {
const SIG = () => AbortSignal.timeout(8000);
const safeJson = async (promise) => { try { const r = await promise; return r.ok ? r.json() : null; } catch { return null; } };
const bestMatch = (candidates, durSecs) => {
if(!candidates?.length) return null;