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:
1 parent
71667d1a03
commit
d0df01a76d
2 files changed
+69
-36
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,7 +76,8 @@ object LrcLibClient {
|
|||||||
durationSecs: Int?,
|
durationSecs: Int?,
|
||||||
): LrcLibResponse? {
|
): LrcLibResponse? {
|
||||||
// 1. lrclib exact match
|
// 1. lrclib exact match
|
||||||
val exact = runCatching {
|
val exact = withTimeoutOrNull(6_000) {
|
||||||
|
runCatching {
|
||||||
client.get("https://lrclib.net/api/get") {
|
client.get("https://lrclib.net/api/get") {
|
||||||
parameter("artist_name", artist)
|
parameter("artist_name", artist)
|
||||||
parameter("track_name", title)
|
parameter("track_name", title)
|
||||||
@@ -77,39 +85,53 @@ object LrcLibClient {
|
|||||||
if (durationSecs != null && durationSecs > 0) parameter("duration", durationSecs)
|
if (durationSecs != null && durationSecs > 0) parameter("duration", durationSecs)
|
||||||
}.body<LrcLibResponse>()
|
}.body<LrcLibResponse>()
|
||||||
}.getOrNull()
|
}.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) {
|
||||||
|
runCatching {
|
||||||
client.get("https://lrclib.net/api/search") {
|
client.get("https://lrclib.net/api/search") {
|
||||||
parameter("track_name", title)
|
parameter("track_name", title)
|
||||||
if (artist.isNotBlank()) parameter("artist_name", artist)
|
if (artist.isNotBlank()) parameter("artist_name", artist)
|
||||||
}.body<List<LrcLibResponse>>()
|
}.body<List<LrcLibResponse>>()
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
bestMatch(byArtist, durationSecs)?.let { return it }
|
||||||
|
|
||||||
val lrclibResult = candidates?.let { list ->
|
// 3. lrclib title-only search — catches artist name mismatches like "Utada" vs "Hikaru Utada"
|
||||||
if (list.isEmpty()) return@let null
|
val byTitle = withTimeoutOrNull(6_000) {
|
||||||
if (durationSecs != null && durationSecs > 0) {
|
runCatching {
|
||||||
val synced = list.filter { !it.syncedLyrics.isNullOrBlank() }
|
client.get("https://lrclib.net/api/search") {
|
||||||
val pool = synced.ifEmpty { list }
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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) }
|
pool.minByOrNull { kotlin.math.abs(it.duration - durationSecs) }
|
||||||
?.takeIf { kotlin.math.abs(it.duration - durationSecs) <= 5.0 }
|
?.takeIf { kotlin.math.abs(it.duration - durationSecs) <= 5.0 }
|
||||||
?: synced.firstOrNull()
|
?: synced.firstOrNull()
|
||||||
?: list.firstOrNull()
|
?: candidates.firstOrNull()
|
||||||
} else {
|
} else {
|
||||||
list.firstOrNull { !it.syncedLyrics.isNullOrBlank() } ?: list.firstOrNull()
|
candidates.firstOrNull { !it.syncedLyrics.isNullOrBlank() }
|
||||||
|
?: candidates.firstOrNull()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (lrclibResult != null) return lrclibResult
|
|
||||||
|
|
||||||
// 3. NetEase fallback — good coverage for Asian/Japanese music
|
|
||||||
val neLrc = runCatching { netEaseLyrics(artist, title, durationSecs) }.getOrNull()
|
|
||||||
if (!neLrc.isNullOrBlank()) {
|
|
||||||
return LrcLibResponse(syncedLyrics = neLrc)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
||||||
|
|||||||
+27
-16
@@ -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();
|
||||||
|
|||||||
Reference in new issue
Block a user