From d7f8b301abbd33de64db9336557627af5fea0569 Mon Sep 17 00:00:00 2001 From: Honey Date: Mon, 15 Jun 2026 21:26:07 +0200 Subject: [PATCH] perf: parallel lrclib requests + in-memory cache for lyrics - Android + web: fire exact/artist-search/title-search in parallel so total wait = slowest single request instead of sum of all three - Android: HashMap cache keyed by artist|title, cleared on app restart - Web: Map cache (session lifetime), cache hit is instant re-open - Web: extract _showLyricsResult helper, safeJson wrapper Co-Authored-By: Claude Sonnet 4.6 --- .../com/novasonic/app/api/LrcLibClient.kt | 124 +++++++++--------- src/player/nowplaying.js | 109 ++++++++------- 2 files changed, 117 insertions(+), 116 deletions(-) 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 8551569..d7fca68 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 @@ -7,6 +7,8 @@ import io.ktor.client.plugins.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.request.* import io.ktor.serialization.kotlinx.json.* +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -24,15 +26,11 @@ data class LrcLibResponse( ) @Serializable -private data class NetEaseSearchResp( - val result: NetEaseResult? = null, - val code: Int = 0, -) +private data class NetEaseSearchResp(val result: NetEaseResult? = null, val code: Int = 0) @Serializable private data class NetEaseResult(val songs: List = emptyList()) @Serializable private data class NetEaseSong(val id: Long = 0, val duration: Long = 0) - @Serializable private data class NetEaseLyricsResp(val lrc: NetEaseLrc? = null, val code: Int = 0) @Serializable @@ -63,80 +61,92 @@ object LrcLibClient { json(Json { ignoreUnknownKeys = true; isLenient = true }) } install(HttpTimeout) { - requestTimeoutMillis = 6_000 + requestTimeoutMillis = 7_000 connectTimeoutMillis = 5_000 - socketTimeoutMillis = 6_000 + socketTimeoutMillis = 7_000 } } + // Simple in-memory cache — avoids re-fetching when reopening lyrics for the same song + private val cache = HashMap() + suspend fun getLyrics( artist: String, title: String, album: String?, durationSecs: Int?, ): LrcLibResponse? { - // 1. lrclib exact match - val exact = withTimeoutOrNull(6_000) { - 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() - }.getOrNull() - } - if (exact != null) return exact + val cacheKey = "$artist|$title" + if (cache.containsKey(cacheKey)) return cache[cacheKey] - // 2. lrclib artist+title search — handles metadata mismatches - val byArtist = withTimeoutOrNull(6_000) { - runCatching { - client.get("https://lrclib.net/api/search") { - parameter("track_name", title) - if (artist.isNotBlank()) parameter("artist_name", artist) - }.body>() - }.getOrNull() - } - bestMatch(byArtist, durationSecs)?.let { return it } + // 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() + }.getOrNull() + } + val artistDef = async { + runCatching { + client.get("https://lrclib.net/api/search") { + parameter("track_name", title) + if (artist.isNotBlank()) parameter("artist_name", artist) + }.body>() + }.getOrNull() + } + val titleDef = async { + runCatching { + client.get("https://lrclib.net/api/search") { + parameter("track_name", title) + }.body>() + }.getOrNull() + } - // 3. lrclib title-only search — catches artist name mismatches like "Utada" vs "Hikaru Utada" - val byTitle = withTimeoutOrNull(6_000) { - runCatching { - client.get("https://lrclib.net/api/search") { - parameter("track_name", title) - }.body>() - }.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(byTitle, durationSecs)?.let { return it } - // 4. NetEase fallback — good coverage for Asian/Japanese music + // NetEase fallback — good for Asian/Japanese music val neLrc = withTimeoutOrNull(8_000) { runCatching { netEaseLyrics(artist, title, durationSecs) }.getOrNull() } - if (!neLrc.isNullOrBlank()) return LrcLibResponse(syncedLyrics = neLrc) - - return null + val result = if (!neLrc.isNullOrBlank()) LrcLibResponse(syncedLyrics = neLrc) else null + cache[cacheKey] = result + return result } private fun bestMatch(candidates: List?, durationSecs: Int?): LrcLibResponse? { if (candidates.isNullOrEmpty()) return null + val synced = candidates.filter { !it.syncedLyrics.isNullOrBlank() } + val pool = synced.ifEmpty { candidates } return if (durationSecs != null && durationSecs > 0) { - val synced = candidates.filter { !it.syncedLyrics.isNullOrBlank() } - val pool = synced.ifEmpty { candidates } - pool.minByOrNull { kotlin.math.abs(it.duration - durationSecs) } - ?.takeIf { kotlin.math.abs(it.duration - durationSecs) <= 5.0 } - ?: synced.firstOrNull() - ?: candidates.firstOrNull() + val best = pool.minByOrNull { kotlin.math.abs(it.duration - durationSecs) }!! + if (kotlin.math.abs(best.duration - durationSecs) <= 5.0) best + else synced.firstOrNull() ?: candidates.firstOrNull() } else { - candidates.firstOrNull { !it.syncedLyrics.isNullOrBlank() } - ?: candidates.firstOrNull() + pool.firstOrNull() } } private suspend fun netEaseLyrics(artist: String, title: String, durationSecs: Int?): String? { - val query = "$artist $title".trim() val search = client.get("https://music.163.com/api/search/get") { - parameter("s", query) + parameter("s", "$artist $title".trim()) parameter("type", 1) parameter("limit", 5) header("Referer", "https://music.163.com") @@ -148,18 +158,14 @@ object LrcLibClient { songs.minByOrNull { kotlin.math.abs(it.duration - durMs) } ?.takeIf { kotlin.math.abs(it.duration - durMs) <= 5000L } ?: songs.firstOrNull() - } else { - songs.firstOrNull() - } ?: return null + } else songs.firstOrNull() ?: return null - val lyricsResp = client.get("https://music.163.com/api/song/lyric") { - parameter("id", song.id) + return client.get("https://music.163.com/api/song/lyric") { + parameter("id", song!!.id) parameter("lv", 1) parameter("kv", 1) parameter("tv", -1) header("Referer", "https://music.163.com") - }.body() - - return lyricsResp.lrc?.lyric?.takeIf { it.isNotBlank() } + }.body().lrc?.lyric?.takeIf { it.isNotBlank() } } } diff --git a/src/player/nowplaying.js b/src/player/nowplaying.js index 691492d..2358ea0 100644 --- a/src/player/nowplaying.js +++ b/src/player/nowplaying.js @@ -100,6 +100,7 @@ export function switchNpTab(tab) { let _lyricsLines = []; let _lyricsSongId = null; let _lyricsLoadToken = 0; +const _lyricsCache = new Map(); // "artist|title" → result object (null = not found) const LYRICS_LOOKAHEAD_MS = 200; @@ -147,89 +148,83 @@ async function _loadNpLyrics(songId) { content.innerHTML = '
Lade Lyrics…
'; const token = ++_lyricsLoadToken; - const s = S.queue[S.qIdx]; + const s = S.queue[S.qIdx]; const artist = (s?.artist || '').split(/[/,]/)[0].trim(); const title = s?.title || ''; const album = s?.album || ''; const dur = s?.duration || 0; + const cacheKey = `${artist}|${title}`; - const SIG = () => AbortSignal.timeout(5000); + const SIG = () => AbortSignal.timeout(6000); + 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; const synced = candidates.filter(c => c.syncedLyrics); const pool = synced.length ? synced : candidates; if(!durSecs) return pool[0]; - const pick = pool.sort((a, b) => Math.abs(a.duration - durSecs) - Math.abs(b.duration - durSecs))[0]; + const pick = [...pool].sort((a, b) => Math.abs(a.duration - durSecs) - Math.abs(b.duration - durSecs))[0]; return Math.abs(pick.duration - durSecs) <= 5 ? pick : (synced[0] || candidates[0]); }; - // 1. Exact match (fast path) - let result = null; - try { - const p = new URLSearchParams({ artist_name: artist, track_name: title }); - if(album) p.set('album_name', album); - if(dur) p.set('duration', dur); - const r = await fetch(`https://lrclib.net/api/get?${p}`, { signal: SIG() }); - if(r.ok) result = await r.json(); - } catch {} - - // 2. lrclib artist+title search - if(token !== _lyricsLoadToken) return; - if(!result?.syncedLyrics && !result?.plainLyrics) { - try { - const p = new URLSearchParams({ track_name: title }); - if(artist) p.set('artist_name', artist); - const r = await fetch(`https://lrclib.net/api/search?${p}`, { signal: SIG() }); - if(r.ok) result = bestMatch(await r.json(), dur); - } catch {} + // Cache hit — instant + if(_lyricsCache.has(cacheKey)) { + const cached = _lyricsCache.get(cacheKey); + content.innerHTML = ''; + _showLyricsResult(content, cached); + return; } - // 3. lrclib title-only search — catches "Utada" vs "Hikaru Utada" type mismatches - if(token !== _lyricsLoadToken) return; - if(!result?.syncedLyrics && !result?.plainLyrics) { - try { - const p = new URLSearchParams({ track_name: title }); - const r = await fetch(`https://lrclib.net/api/search?${p}`, { signal: SIG() }); - if(r.ok) result = bestMatch(await r.json(), dur); - } catch {} - } + // Fire all three lrclib requests in parallel — total wait = slowest single request + const exactP = new URLSearchParams({ artist_name: artist, track_name: title }); + if(album) exactP.set('album_name', album); + if(dur) exactP.set('duration', dur); + const artistP = new URLSearchParams({ track_name: title }); + if(artist) artistP.set('artist_name', artist); + const titleP = new URLSearchParams({ track_name: title }); + + const [exactData, artistData, titleData] = await Promise.all([ + safeJson(fetch(`https://lrclib.net/api/get?${exactP}`, { signal: SIG() })), + safeJson(fetch(`https://lrclib.net/api/search?${artistP}`, { signal: SIG() })), + safeJson(fetch(`https://lrclib.net/api/search?${titleP}`, { signal: SIG() })), + ]); - // 4. NetEase fallback — good coverage for Asian/Japanese music if(token !== _lyricsLoadToken) return; + + let result = (exactData?.syncedLyrics || exactData?.plainLyrics ? exactData : null) + ?? bestMatch(artistData, dur) + ?? bestMatch(titleData, dur); + + // NetEase fallback — good for Asian/Japanese music if(!result?.syncedLyrics && !result?.plainLyrics) { try { - const q = encodeURIComponent(`${artist} ${title}`.trim()); - const sr = await fetch(`https://music.163.com/api/search/get?s=${q}&type=1&limit=5`, { - headers: { Referer: 'https://music.163.com' }, - signal: SIG(), - }); - if(sr.ok) { - const sd = await sr.json(); - const songs = sd?.result?.songs || []; - const durMs = dur * 1000; - const song = dur - ? songs.sort((a, b) => Math.abs(a.duration - durMs) - Math.abs(b.duration - durMs)) - .find(s => Math.abs(s.duration - durMs) <= 5000) - : songs[0]; - if(song) { - if(token !== _lyricsLoadToken) return; - const lr = await fetch( - `https://music.163.com/api/song/lyric?id=${song.id}&lv=1&kv=1&tv=-1`, - { headers: { Referer: 'https://music.163.com' }, signal: SIG() }, - ); - if(lr.ok) { - const ld = await lr.json(); - const lrc = ld?.lrc?.lyric; - if(lrc) result = { syncedLyrics: lrc }; - } - } + const q = encodeURIComponent(`${artist} ${title}`.trim()); + const sd = await safeJson(fetch(`https://music.163.com/api/search/get?s=${q}&type=1&limit=5`, { + headers: { Referer: 'https://music.163.com' }, signal: SIG(), + })); + const songs = sd?.result?.songs || []; + const durMs = dur * 1000; + const song = dur + ? [...songs].sort((a, b) => Math.abs(a.duration - durMs) - Math.abs(b.duration - durMs)) + .find(s => Math.abs(s.duration - durMs) <= 5000) + : songs[0]; + if(song && token === _lyricsLoadToken) { + const ld = await safeJson(fetch( + `https://music.163.com/api/song/lyric?id=${song.id}&lv=1&kv=1&tv=-1`, + { headers: { Referer: 'https://music.163.com' }, signal: SIG() }, + )); + const lrc = ld?.lrc?.lyric; + if(lrc) result = { syncedLyrics: lrc }; } } catch {} } if(token !== _lyricsLoadToken) return; + _lyricsCache.set(cacheKey, result ?? null); content.innerHTML = ''; + _showLyricsResult(content, result); +} +function _showLyricsResult(content, result) { if(result?.syncedLyrics) { const parsed = _parseLrc(result.syncedLyrics); if(parsed.length) { _renderSyncedLines(content, parsed); return; }