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:
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 ?: "",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user