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:
1 parent
d0df01a76d
commit
d7f8b301ab
2 files changed
+86
-85
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,20 +61,27 @@ 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]
|
||||||
|
|
||||||
|
// Fire all three lrclib requests in parallel — total wait = slowest single request
|
||||||
|
val lrclibResult = coroutineScope {
|
||||||
|
val exactDef = async {
|
||||||
runCatching {
|
runCatching {
|
||||||
client.get("https://lrclib.net/api/get") {
|
client.get("https://lrclib.net/api/get") {
|
||||||
parameter("artist_name", artist)
|
parameter("artist_name", artist)
|
||||||
@@ -86,10 +91,7 @@ object LrcLibClient {
|
|||||||
}.body<LrcLibResponse>()
|
}.body<LrcLibResponse>()
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
if (exact != null) return exact
|
val artistDef = async {
|
||||||
|
|
||||||
// 2. lrclib artist+title search — handles metadata mismatches
|
|
||||||
val byArtist = withTimeoutOrNull(6_000) {
|
|
||||||
runCatching {
|
runCatching {
|
||||||
client.get("https://lrclib.net/api/search") {
|
client.get("https://lrclib.net/api/search") {
|
||||||
parameter("track_name", title)
|
parameter("track_name", title)
|
||||||
@@ -97,46 +99,54 @@ object LrcLibClient {
|
|||||||
}.body<List<LrcLibResponse>>()
|
}.body<List<LrcLibResponse>>()
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
bestMatch(byArtist, durationSecs)?.let { return it }
|
val titleDef = async {
|
||||||
|
|
||||||
// 3. lrclib title-only search — catches artist name mismatches like "Utada" vs "Hikaru Utada"
|
|
||||||
val byTitle = withTimeoutOrNull(6_000) {
|
|
||||||
runCatching {
|
runCatching {
|
||||||
client.get("https://lrclib.net/api/search") {
|
client.get("https://lrclib.net/api/search") {
|
||||||
parameter("track_name", title)
|
parameter("track_name", title)
|
||||||
}.body<List<LrcLibResponse>>()
|
}.body<List<LrcLibResponse>>()
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
bestMatch(byTitle, durationSecs)?.let { return it }
|
|
||||||
|
|
||||||
// 4. NetEase fallback — good coverage for Asian/Japanese music
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
return if (durationSecs != null && durationSecs > 0) {
|
|
||||||
val synced = candidates.filter { !it.syncedLyrics.isNullOrBlank() }
|
val synced = candidates.filter { !it.syncedLyrics.isNullOrBlank() }
|
||||||
val pool = synced.ifEmpty { candidates }
|
val pool = synced.ifEmpty { candidates }
|
||||||
pool.minByOrNull { kotlin.math.abs(it.duration - durationSecs) }
|
return if (durationSecs != null && durationSecs > 0) {
|
||||||
?.takeIf { kotlin.math.abs(it.duration - durationSecs) <= 5.0 }
|
val best = pool.minByOrNull { kotlin.math.abs(it.duration - durationSecs) }!!
|
||||||
?: synced.firstOrNull()
|
if (kotlin.math.abs(best.duration - durationSecs) <= 5.0) best
|
||||||
?: candidates.firstOrNull()
|
else 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() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+41
-46
@@ -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;
|
||||||
|
|
||||||
@@ -152,84 +153,78 @@ async function _loadNpLyrics(songId) {
|
|||||||
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(),
|
}));
|
||||||
});
|
|
||||||
if(sr.ok) {
|
|
||||||
const sd = await sr.json();
|
|
||||||
const songs = sd?.result?.songs || [];
|
const songs = sd?.result?.songs || [];
|
||||||
const durMs = dur * 1000;
|
const durMs = dur * 1000;
|
||||||
const song = dur
|
const song = dur
|
||||||
? songs.sort((a, b) => Math.abs(a.duration - durMs) - Math.abs(b.duration - durMs))
|
? [...songs].sort((a, b) => Math.abs(a.duration - durMs) - Math.abs(b.duration - durMs))
|
||||||
.find(s => Math.abs(s.duration - durMs) <= 5000)
|
.find(s => Math.abs(s.duration - durMs) <= 5000)
|
||||||
: songs[0];
|
: songs[0];
|
||||||
if(song) {
|
if(song && token === _lyricsLoadToken) {
|
||||||
if(token !== _lyricsLoadToken) return;
|
const ld = await safeJson(fetch(
|
||||||
const lr = await fetch(
|
|
||||||
`https://music.163.com/api/song/lyric?id=${song.id}&lv=1&kv=1&tv=-1`,
|
`https://music.163.com/api/song/lyric?id=${song.id}&lv=1&kv=1&tv=-1`,
|
||||||
{ headers: { Referer: 'https://music.163.com' }, signal: SIG() },
|
{ headers: { Referer: 'https://music.163.com' }, signal: SIG() },
|
||||||
);
|
));
|
||||||
if(lr.ok) {
|
|
||||||
const ld = await lr.json();
|
|
||||||
const lrc = ld?.lrc?.lyric;
|
const lrc = ld?.lrc?.lyric;
|
||||||
if(lrc) result = { syncedLyrics: lrc };
|
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; }
|
||||||
|
|||||||
Reference in new issue
Block a user