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 io.ktor.serialization.kotlinx.json.*
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
@@ -55,9 +57,13 @@ object LrcLibClient {
|
|||||||
connectTimeoutMillis = 5_000
|
connectTimeoutMillis = 5_000
|
||||||
socketTimeoutMillis = 8_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?>()
|
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(
|
suspend fun getLyrics(
|
||||||
artist: String,
|
artist: String,
|
||||||
@@ -67,6 +73,18 @@ object LrcLibClient {
|
|||||||
): LrcLibResponse? {
|
): LrcLibResponse? {
|
||||||
val cacheKey = "$artist|$title"
|
val cacheKey = "$artist|$title"
|
||||||
if (cache.containsKey(cacheKey)) return cache[cacheKey]
|
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
|
// 1a. lrclib exact match with duration
|
||||||
val exact = runCatching {
|
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) {
|
private fun prefetchLyrics(song: Song) {
|
||||||
scope.launch {
|
_lyricsPrefetchJob?.cancel()
|
||||||
|
_lyricsPrefetchJob = scope.launch {
|
||||||
|
delay(1500) // skip quickly without firing a request per track
|
||||||
runCatching {
|
runCatching {
|
||||||
LrcLibClient.getLyrics(
|
LrcLibClient.getLyrics(
|
||||||
artist = song.artist ?: "",
|
artist = song.artist ?: "",
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ let _lyricsSongId = null;
|
|||||||
let _lyricsLoadToken = 0;
|
let _lyricsLoadToken = 0;
|
||||||
let _prefetchTimer = null;
|
let _prefetchTimer = null;
|
||||||
const _lyricsCache = new Map(); // "artist|title" → result object (null = not found)
|
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;
|
const LYRICS_LOOKAHEAD_MS = 200;
|
||||||
|
|
||||||
@@ -176,8 +177,16 @@ async function _loadNpLyrics(songId) {
|
|||||||
async function _fetchLyricsData(artist, title, album, dur) {
|
async function _fetchLyricsData(artist, title, album, dur) {
|
||||||
const cacheKey = `${artist}|${title}`;
|
const cacheKey = `${artist}|${title}`;
|
||||||
if(_lyricsCache.has(cacheKey)) return _lyricsCache.get(cacheKey);
|
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 safeJson = async (promise) => { try { const r = await promise; return r.ok ? r.json() : null; } catch { return null; } };
|
||||||
const bestMatch = (candidates, durSecs) => {
|
const bestMatch = (candidates, durSecs) => {
|
||||||
if(!candidates?.length) return null;
|
if(!candidates?.length) return null;
|
||||||
|
|||||||
Reference in new issue
Block a user