diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/LrcLibClient.kt b/android/app/src/main/kotlin/com/novasonic/app/api/LrcLibClient.kt index c0c6424..88399a6 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/api/LrcLibClient.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/api/LrcLibClient.kt @@ -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() + // 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() 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 { diff --git a/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt b/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt index fe51a42..54c3ef1 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt @@ -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 ?: "", diff --git a/src/player/nowplaying.js b/src/player/nowplaying.js index 0e2ee2a..e1e4a86 100644 --- a/src/player/nowplaying.js +++ b/src/player/nowplaying.js @@ -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;