From d0df01a76da2f489603c3cbf180ec94a3ae891d7 Mon Sep 17 00:00:00 2001 From: Honey Date: Mon, 15 Jun 2026 21:20:47 +0200 Subject: [PATCH] fix: lyrics faster + title-only search fallback for metadata mismatches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../com/novasonic/app/api/LrcLibClient.kt | 92 ++++++++++++------- src/player/nowplaying.js | 43 +++++---- 2 files changed, 84 insertions(+), 51 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 bfe24fe..8551569 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 @@ -3,9 +3,11 @@ package com.novasonic.app.api import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.engine.android.* +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.withTimeoutOrNull import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -60,6 +62,11 @@ object LrcLibClient { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true; isLenient = true }) } + install(HttpTimeout) { + requestTimeoutMillis = 6_000 + connectTimeoutMillis = 5_000 + socketTimeoutMillis = 6_000 + } } suspend fun getLyrics( @@ -69,50 +76,65 @@ object LrcLibClient { durationSecs: Int?, ): LrcLibResponse? { // 1. lrclib exact match - val exact = 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 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 - // 2. lrclib fuzzy search — handles artist name mismatches - val candidates = runCatching { - client.get("https://lrclib.net/api/search") { - parameter("track_name", title) - if (artist.isNotBlank()) parameter("artist_name", artist) - }.body>() - }.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() - } + // 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() } - if (lrclibResult != null) return lrclibResult + bestMatch(byArtist, durationSecs)?.let { return it } - // 3. NetEase fallback — good coverage for Asian/Japanese music - val neLrc = runCatching { netEaseLyrics(artist, title, durationSecs) }.getOrNull() - if (!neLrc.isNullOrBlank()) { - return LrcLibResponse(syncedLyrics = neLrc) + // 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() } + 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 } + private fun bestMatch(candidates: List?, 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? { - val query = "$artist $title".trim() + val query = "$artist $title".trim() val search = client.get("https://music.163.com/api/search/get") { parameter("s", query) parameter("type", 1) @@ -121,7 +143,7 @@ object LrcLibClient { }.body() 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 songs.minByOrNull { kotlin.math.abs(it.duration - durMs) } ?.takeIf { kotlin.math.abs(it.duration - durMs) <= 5000L } diff --git a/src/player/nowplaying.js b/src/player/nowplaying.js index f5d9074..691492d 100644 --- a/src/player/nowplaying.js +++ b/src/player/nowplaying.js @@ -153,44 +153,55 @@ async function _loadNpLyrics(songId) { const album = s?.album || ''; 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) 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: AbortSignal.timeout(8000) }); + const r = await fetch(`https://lrclib.net/api/get?${p}`, { signal: SIG() }); if(r.ok) result = await r.json(); } catch {} - // 2. Search fallback — handles metadata mismatches (e.g. "Utada" vs "Hikaru Utada") + // 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: AbortSignal.timeout(8000) }); - if(r.ok) { - 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]; - } - } + const r = await fetch(`https://lrclib.net/api/search?${p}`, { signal: SIG() }); + if(r.ok) result = bestMatch(await r.json(), dur); } 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(!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: AbortSignal.timeout(8000), + signal: SIG(), }); if(sr.ok) { const sd = await sr.json(); @@ -204,7 +215,7 @@ async function _loadNpLyrics(songId) { 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: AbortSignal.timeout(8000) }, + { headers: { Referer: 'https://music.163.com' }, signal: SIG() }, ); if(lr.ok) { const ld = await lr.json();