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 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-06-15 21:26:07 +02:00
1 parent d0df01a76d
commit d7f8b301ab
2 files changed
+117 -116

No files matched your search

@@ -7,6 +7,8 @@ import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.* import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.* import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -24,15 +26,11 @@ data class LrcLibResponse(
) )
@Serializable @Serializable
private data class NetEaseSearchResp( private data class NetEaseSearchResp(val result: NetEaseResult? = null, val code: Int = 0)
val result: NetEaseResult? = null,
val code: Int = 0,
)
@Serializable @Serializable
private data class NetEaseResult(val songs: List<NetEaseSong> = emptyList()) private data class NetEaseResult(val songs: List<NetEaseSong> = emptyList())
@Serializable @Serializable
private data class NetEaseSong(val id: Long = 0, val duration: Long = 0) private data class NetEaseSong(val id: Long = 0, val duration: Long = 0)
@Serializable @Serializable
private data class NetEaseLyricsResp(val lrc: NetEaseLrc? = null, val code: Int = 0) private data class NetEaseLyricsResp(val lrc: NetEaseLrc? = null, val code: Int = 0)
@Serializable @Serializable
@@ -63,80 +61,92 @@ object LrcLibClient {
json(Json { ignoreUnknownKeys = true; isLenient = true }) json(Json { ignoreUnknownKeys = true; isLenient = true })
} }
install(HttpTimeout) { install(HttpTimeout) {
requestTimeoutMillis = 6_000 requestTimeoutMillis = 7_000
connectTimeoutMillis = 5_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<String, LrcLibResponse?>()
suspend fun getLyrics( suspend fun getLyrics(
artist: String, artist: String,
title: String, title: String,
album: String?, album: String?,
durationSecs: Int?, durationSecs: Int?,
): LrcLibResponse? { ): LrcLibResponse? {
// 1. lrclib exact match val cacheKey = "$artist|$title"
val exact = withTimeoutOrNull(6_000) { if (cache.containsKey(cacheKey)) return cache[cacheKey]
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<LrcLibResponse>()
}.getOrNull()
}
if (exact != null) return exact
// 2. lrclib artist+title search — handles metadata mismatches // Fire all three lrclib requests in parallel — total wait = slowest single request
val byArtist = withTimeoutOrNull(6_000) { val lrclibResult = coroutineScope {
runCatching { val exactDef = async {
client.get("https://lrclib.net/api/search") { runCatching {
parameter("track_name", title) client.get("https://lrclib.net/api/get") {
if (artist.isNotBlank()) parameter("artist_name", artist) parameter("artist_name", artist)
}.body<List<LrcLibResponse>>() parameter("track_name", title)
}.getOrNull() if (!album.isNullOrBlank()) parameter("album_name", album)
} if (durationSecs != null && durationSecs > 0) parameter("duration", durationSecs)
bestMatch(byArtist, durationSecs)?.let { return it } }.body<LrcLibResponse>()
}.getOrNull()
}
val artistDef = async {
runCatching {
client.get("https://lrclib.net/api/search") {
parameter("track_name", title)
if (artist.isNotBlank()) parameter("artist_name", artist)
}.body<List<LrcLibResponse>>()
}.getOrNull()
}
val titleDef = async {
runCatching {
client.get("https://lrclib.net/api/search") {
parameter("track_name", title)
}.body<List<LrcLibResponse>>()
}.getOrNull()
}
// 3. lrclib title-only search — catches artist name mismatches like "Utada" vs "Hikaru Utada" val exact = exactDef.await()
val byTitle = withTimeoutOrNull(6_000) { if (exact != null) {
runCatching { artistDef.cancel()
client.get("https://lrclib.net/api/search") { titleDef.cancel()
parameter("track_name", title) exact
}.body<List<LrcLibResponse>>() } else {
}.getOrNull() 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) { val neLrc = withTimeoutOrNull(8_000) {
runCatching { netEaseLyrics(artist, title, durationSecs) }.getOrNull() runCatching { netEaseLyrics(artist, title, durationSecs) }.getOrNull()
} }
if (!neLrc.isNullOrBlank()) return LrcLibResponse(syncedLyrics = neLrc) val result = if (!neLrc.isNullOrBlank()) LrcLibResponse(syncedLyrics = neLrc) else null
cache[cacheKey] = result
return null return result
} }
private fun bestMatch(candidates: List<LrcLibResponse>?, durationSecs: Int?): LrcLibResponse? { private fun bestMatch(candidates: List<LrcLibResponse>?, durationSecs: Int?): LrcLibResponse? {
if (candidates.isNullOrEmpty()) return null if (candidates.isNullOrEmpty()) return null
val synced = candidates.filter { !it.syncedLyrics.isNullOrBlank() }
val pool = synced.ifEmpty { candidates }
return if (durationSecs != null && durationSecs > 0) { return if (durationSecs != null && durationSecs > 0) {
val synced = candidates.filter { !it.syncedLyrics.isNullOrBlank() } val best = pool.minByOrNull { kotlin.math.abs(it.duration - durationSecs) }!!
val pool = synced.ifEmpty { candidates } if (kotlin.math.abs(best.duration - durationSecs) <= 5.0) best
pool.minByOrNull { kotlin.math.abs(it.duration - durationSecs) } else synced.firstOrNull() ?: candidates.firstOrNull()
?.takeIf { kotlin.math.abs(it.duration - durationSecs) <= 5.0 }
?: synced.firstOrNull()
?: candidates.firstOrNull()
} else { } else {
candidates.firstOrNull { !it.syncedLyrics.isNullOrBlank() } pool.firstOrNull()
?: candidates.firstOrNull()
} }
} }
private suspend fun netEaseLyrics(artist: String, title: String, durationSecs: Int?): String? { 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") { val search = client.get("https://music.163.com/api/search/get") {
parameter("s", query) parameter("s", "$artist $title".trim())
parameter("type", 1) parameter("type", 1)
parameter("limit", 5) parameter("limit", 5)
header("Referer", "https://music.163.com") header("Referer", "https://music.163.com")
@@ -148,18 +158,14 @@ object LrcLibClient {
songs.minByOrNull { kotlin.math.abs(it.duration - durMs) } songs.minByOrNull { kotlin.math.abs(it.duration - durMs) }
?.takeIf { kotlin.math.abs(it.duration - durMs) <= 5000L } ?.takeIf { kotlin.math.abs(it.duration - durMs) <= 5000L }
?: songs.firstOrNull() ?: songs.firstOrNull()
} else { } else songs.firstOrNull() ?: return null
songs.firstOrNull()
} ?: return null
val lyricsResp = client.get("https://music.163.com/api/song/lyric") { return client.get("https://music.163.com/api/song/lyric") {
parameter("id", song.id) parameter("id", song!!.id)
parameter("lv", 1) parameter("lv", 1)
parameter("kv", 1) parameter("kv", 1)
parameter("tv", -1) parameter("tv", -1)
header("Referer", "https://music.163.com") header("Referer", "https://music.163.com")
}.body<NetEaseLyricsResp>() }.body<NetEaseLyricsResp>().lrc?.lyric?.takeIf { it.isNotBlank() }
return lyricsResp.lrc?.lyric?.takeIf { it.isNotBlank() }
} }
} }
+52 -57
View File
@@ -100,6 +100,7 @@ export function switchNpTab(tab) {
let _lyricsLines = []; let _lyricsLines = [];
let _lyricsSongId = null; let _lyricsSongId = null;
let _lyricsLoadToken = 0; let _lyricsLoadToken = 0;
const _lyricsCache = new Map(); // "artist|title" → result object (null = not found)
const LYRICS_LOOKAHEAD_MS = 200; const LYRICS_LOOKAHEAD_MS = 200;
@@ -147,89 +148,83 @@ async function _loadNpLyrics(songId) {
content.innerHTML = '<div style="text-align:center;padding:60px 0;color:rgba(255,255,255,.3);font-size:14px">Lade Lyrics…</div>'; content.innerHTML = '<div style="text-align:center;padding:60px 0;color:rgba(255,255,255,.3);font-size:14px">Lade Lyrics…</div>';
const token = ++_lyricsLoadToken; const token = ++_lyricsLoadToken;
const s = S.queue[S.qIdx]; const s = S.queue[S.qIdx];
const artist = (s?.artist || '').split(/[/,]/)[0].trim(); const artist = (s?.artist || '').split(/[/,]/)[0].trim();
const title = s?.title || ''; const title = s?.title || '';
const album = s?.album || ''; const album = s?.album || '';
const dur = s?.duration || 0; 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) => { const bestMatch = (candidates, durSecs) => {
if(!candidates?.length) return null; if(!candidates?.length) return null;
const synced = candidates.filter(c => c.syncedLyrics); const synced = candidates.filter(c => c.syncedLyrics);
const pool = synced.length ? synced : candidates; const pool = synced.length ? synced : candidates;
if(!durSecs) return pool[0]; 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]); return Math.abs(pick.duration - durSecs) <= 5 ? pick : (synced[0] || candidates[0]);
}; };
// 1. Exact match (fast path) // Cache hit — instant
let result = null; if(_lyricsCache.has(cacheKey)) {
try { const cached = _lyricsCache.get(cacheKey);
const p = new URLSearchParams({ artist_name: artist, track_name: title }); content.innerHTML = '';
if(album) p.set('album_name', album); _showLyricsResult(content, cached);
if(dur) p.set('duration', dur); return;
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 {}
} }
// 3. lrclib title-only search — catches "Utada" vs "Hikaru Utada" type mismatches // Fire all three lrclib requests in parallel — total wait = slowest single request
if(token !== _lyricsLoadToken) return; const exactP = new URLSearchParams({ artist_name: artist, track_name: title });
if(!result?.syncedLyrics && !result?.plainLyrics) { if(album) exactP.set('album_name', album);
try { if(dur) exactP.set('duration', dur);
const p = new URLSearchParams({ track_name: title }); const artistP = new URLSearchParams({ track_name: title });
const r = await fetch(`https://lrclib.net/api/search?${p}`, { signal: SIG() }); if(artist) artistP.set('artist_name', artist);
if(r.ok) result = bestMatch(await r.json(), dur); const titleP = new URLSearchParams({ track_name: title });
} catch {}
} 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; 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) { if(!result?.syncedLyrics && !result?.plainLyrics) {
try { try {
const q = encodeURIComponent(`${artist} ${title}`.trim()); const q = encodeURIComponent(`${artist} ${title}`.trim());
const sr = await fetch(`https://music.163.com/api/search/get?s=${q}&type=1&limit=5`, { const sd = await safeJson(fetch(`https://music.163.com/api/search/get?s=${q}&type=1&limit=5`, {
headers: { Referer: 'https://music.163.com' }, headers: { Referer: 'https://music.163.com' }, signal: SIG(),
signal: SIG(), }));
}); const songs = sd?.result?.songs || [];
if(sr.ok) { const durMs = dur * 1000;
const sd = await sr.json(); const song = dur
const songs = sd?.result?.songs || []; ? [...songs].sort((a, b) => Math.abs(a.duration - durMs) - Math.abs(b.duration - durMs))
const durMs = dur * 1000; .find(s => Math.abs(s.duration - durMs) <= 5000)
const song = dur : songs[0];
? songs.sort((a, b) => Math.abs(a.duration - durMs) - Math.abs(b.duration - durMs)) if(song && token === _lyricsLoadToken) {
.find(s => Math.abs(s.duration - durMs) <= 5000) const ld = await safeJson(fetch(
: songs[0]; `https://music.163.com/api/song/lyric?id=${song.id}&lv=1&kv=1&tv=-1`,
if(song) { { headers: { Referer: 'https://music.163.com' }, signal: SIG() },
if(token !== _lyricsLoadToken) return; ));
const lr = await fetch( const lrc = ld?.lrc?.lyric;
`https://music.163.com/api/song/lyric?id=${song.id}&lv=1&kv=1&tv=-1`, if(lrc) result = { syncedLyrics: lrc };
{ 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 };
}
}
} }
} catch {} } catch {}
} }
if(token !== _lyricsLoadToken) return; if(token !== _lyricsLoadToken) return;
_lyricsCache.set(cacheKey, result ?? null);
content.innerHTML = ''; content.innerHTML = '';
_showLyricsResult(content, result);
}
function _showLyricsResult(content, result) {
if(result?.syncedLyrics) { if(result?.syncedLyrics) {
const parsed = _parseLrc(result.syncedLyrics); const parsed = _parseLrc(result.syncedLyrics);
if(parsed.length) { _renderSyncedLines(content, parsed); return; } if(parsed.length) { _renderSyncedLines(content, parsed); return; }