fix: lyrics faster + title-only search fallback for metadata mismatches

- Android: add 6s HttpTimeout to LrcLibClient, wrap each step in withTimeoutOrNull
- Both: add title-only lrclib search (step 3) so "Sanctuary (Opening)" is found
  even when artist tag differs ("Utada" vs "Hikaru Utada")
- Web: reduce all timeouts 8s → 5s, extract bestMatch helper

Search order: lrclib exact → lrclib artist+title → lrclib title-only → NetEase

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-06-15 21:20:47 +02:00
1 parent 71667d1a03
commit d0df01a76d
2 files changed
+84 -51

No files matched your search

@@ -3,9 +3,11 @@ package com.novasonic.app.api
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.call.* import io.ktor.client.call.*
import io.ktor.client.engine.android.* import io.ktor.client.engine.android.*
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.withTimeoutOrNull
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -60,6 +62,11 @@ object LrcLibClient {
install(ContentNegotiation) { install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true; isLenient = true }) json(Json { ignoreUnknownKeys = true; isLenient = true })
} }
install(HttpTimeout) {
requestTimeoutMillis = 6_000
connectTimeoutMillis = 5_000
socketTimeoutMillis = 6_000
}
} }
suspend fun getLyrics( suspend fun getLyrics(
@@ -69,50 +76,65 @@ object LrcLibClient {
durationSecs: Int?, durationSecs: Int?,
): LrcLibResponse? { ): LrcLibResponse? {
// 1. lrclib exact match // 1. lrclib exact match
val exact = runCatching { val exact = withTimeoutOrNull(6_000) {
client.get("https://lrclib.net/api/get") { runCatching {
parameter("artist_name", artist) client.get("https://lrclib.net/api/get") {
parameter("track_name", title) parameter("artist_name", artist)
if (!album.isNullOrBlank()) parameter("album_name", album) parameter("track_name", title)
if (durationSecs != null && durationSecs > 0) parameter("duration", durationSecs) if (!album.isNullOrBlank()) parameter("album_name", album)
}.body<LrcLibResponse>() if (durationSecs != null && durationSecs > 0) parameter("duration", durationSecs)
}.getOrNull() }.body<LrcLibResponse>()
}.getOrNull()
}
if (exact != null) return exact if (exact != null) return exact
// 2. lrclib fuzzy search — handles artist name mismatches // 2. lrclib artist+title search — handles metadata mismatches
val candidates = runCatching { val byArtist = withTimeoutOrNull(6_000) {
client.get("https://lrclib.net/api/search") { runCatching {
parameter("track_name", title) client.get("https://lrclib.net/api/search") {
if (artist.isNotBlank()) parameter("artist_name", artist) parameter("track_name", title)
}.body<List<LrcLibResponse>>() if (artist.isNotBlank()) parameter("artist_name", artist)
}.getOrNull() }.body<List<LrcLibResponse>>()
}.getOrNull()
val lrclibResult = candidates?.let { list ->
if (list.isEmpty()) return@let null
if (durationSecs != null && durationSecs > 0) {
val synced = list.filter { !it.syncedLyrics.isNullOrBlank() }
val pool = synced.ifEmpty { list }
pool.minByOrNull { kotlin.math.abs(it.duration - durationSecs) }
?.takeIf { kotlin.math.abs(it.duration - durationSecs) <= 5.0 }
?: synced.firstOrNull()
?: list.firstOrNull()
} else {
list.firstOrNull { !it.syncedLyrics.isNullOrBlank() } ?: list.firstOrNull()
}
} }
if (lrclibResult != null) return lrclibResult bestMatch(byArtist, durationSecs)?.let { return it }
// 3. NetEase fallback — good coverage for Asian/Japanese music // 3. lrclib title-only search — catches artist name mismatches like "Utada" vs "Hikaru Utada"
val neLrc = runCatching { netEaseLyrics(artist, title, durationSecs) }.getOrNull() val byTitle = withTimeoutOrNull(6_000) {
if (!neLrc.isNullOrBlank()) { runCatching {
return LrcLibResponse(syncedLyrics = neLrc) client.get("https://lrclib.net/api/search") {
parameter("track_name", title)
}.body<List<LrcLibResponse>>()
}.getOrNull()
} }
bestMatch(byTitle, durationSecs)?.let { return it }
// 4. NetEase fallback — good coverage for Asian/Japanese music
val neLrc = withTimeoutOrNull(8_000) {
runCatching { netEaseLyrics(artist, title, durationSecs) }.getOrNull()
}
if (!neLrc.isNullOrBlank()) return LrcLibResponse(syncedLyrics = neLrc)
return null return null
} }
private fun bestMatch(candidates: List<LrcLibResponse>?, durationSecs: Int?): LrcLibResponse? {
if (candidates.isNullOrEmpty()) return null
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()
} else {
candidates.firstOrNull { !it.syncedLyrics.isNullOrBlank() }
?: 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 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", query)
parameter("type", 1) parameter("type", 1)
@@ -121,7 +143,7 @@ object LrcLibClient {
}.body<NetEaseSearchResp>() }.body<NetEaseSearchResp>()
val songs = search.result?.songs ?: return null val songs = search.result?.songs ?: return null
val song = if (durationSecs != null && durationSecs > 0) { val song = if (durationSecs != null && durationSecs > 0) {
val durMs = durationSecs * 1000L val durMs = durationSecs * 1000L
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 }
+27 -16
View File
@@ -153,44 +153,55 @@ async function _loadNpLyrics(songId) {
const album = s?.album || ''; const album = s?.album || '';
const dur = s?.duration || 0; const dur = s?.duration || 0;
const SIG = () => AbortSignal.timeout(5000);
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];
return Math.abs(pick.duration - durSecs) <= 5 ? pick : (synced[0] || candidates[0]);
};
// 1. Exact match (fast path) // 1. Exact match (fast path)
let result = null; let result = null;
try { try {
const p = new URLSearchParams({ artist_name: artist, track_name: title }); const p = new URLSearchParams({ artist_name: artist, track_name: title });
if(album) p.set('album_name', album); if(album) p.set('album_name', album);
if(dur) p.set('duration', dur); if(dur) p.set('duration', dur);
const r = await fetch(`https://lrclib.net/api/get?${p}`, { signal: AbortSignal.timeout(8000) }); const r = await fetch(`https://lrclib.net/api/get?${p}`, { signal: SIG() });
if(r.ok) result = await r.json(); if(r.ok) result = await r.json();
} catch {} } catch {}
// 2. Search fallback — handles metadata mismatches (e.g. "Utada" vs "Hikaru Utada") // 2. lrclib artist+title search
if(token !== _lyricsLoadToken) return; if(token !== _lyricsLoadToken) return;
if(!result?.syncedLyrics && !result?.plainLyrics) { if(!result?.syncedLyrics && !result?.plainLyrics) {
try { try {
const p = new URLSearchParams({ track_name: title }); const p = new URLSearchParams({ track_name: title });
if(artist) p.set('artist_name', artist); if(artist) p.set('artist_name', artist);
const r = await fetch(`https://lrclib.net/api/search?${p}`, { signal: AbortSignal.timeout(8000) }); const r = await fetch(`https://lrclib.net/api/search?${p}`, { signal: SIG() });
if(r.ok) { if(r.ok) result = bestMatch(await r.json(), dur);
const candidates = await r.json();
if(candidates?.length) {
const synced = candidates.filter(c => c.syncedLyrics);
const pool = synced.length ? synced : candidates;
result = dur
? pool.sort((a, b) => Math.abs(a.duration - dur) - Math.abs(b.duration - dur))[0]
: pool[0];
}
}
} catch {} } catch {}
} }
// 3. NetEase fallback — good coverage for Asian/Japanese music // 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 {}
}
// 4. NetEase fallback — good coverage for Asian/Japanese music
if(token !== _lyricsLoadToken) return; if(token !== _lyricsLoadToken) return;
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 sr = await 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: AbortSignal.timeout(8000), signal: SIG(),
}); });
if(sr.ok) { if(sr.ok) {
const sd = await sr.json(); const sd = await sr.json();
@@ -204,7 +215,7 @@ async function _loadNpLyrics(songId) {
if(token !== _lyricsLoadToken) return; if(token !== _lyricsLoadToken) return;
const lr = await 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: AbortSignal.timeout(8000) }, { headers: { Referer: 'https://music.163.com' }, signal: SIG() },
); );
if(lr.ok) { if(lr.ok) {
const ld = await lr.json(); const ld = await lr.json();