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 57a7d34..bfe24fe 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 @@ -21,6 +21,21 @@ data class LrcLibResponse( val syncedLyrics: String? = null, ) +@Serializable +private data class NetEaseSearchResp( + val result: NetEaseResult? = null, + val code: Int = 0, +) +@Serializable +private data class NetEaseResult(val songs: List = emptyList()) +@Serializable +private data class NetEaseSong(val id: Long = 0, val duration: Long = 0) + +@Serializable +private data class NetEaseLyricsResp(val lrc: NetEaseLrc? = null, val code: Int = 0) +@Serializable +private data class NetEaseLrc(val lyric: String? = null) + data class LrcLine(val timeMs: Long, val text: String) fun parseLrc(lrc: String): List { @@ -53,7 +68,7 @@ object LrcLibClient { album: String?, durationSecs: Int?, ): LrcLibResponse? { - // 1. Exact match — fastest, most accurate when metadata is clean + // 1. lrclib exact match val exact = runCatching { client.get("https://lrclib.net/api/get") { parameter("artist_name", artist) @@ -64,26 +79,65 @@ object LrcLibClient { }.getOrNull() if (exact != null) return exact - // 2. Fuzzy search fallback — handles artist name mismatches (e.g. "Utada" vs "Hikaru Utada") + // 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() ?: return null + }.getOrNull() - if (candidates.isEmpty()) 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() + 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 + + // 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? { + val query = "$artist $title".trim() + val search = client.get("https://music.163.com/api/search/get") { + parameter("s", query) + parameter("type", 1) + parameter("limit", 5) + header("Referer", "https://music.163.com") + }.body() + + val songs = search.result?.songs ?: return null + 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 } + ?: songs.firstOrNull() + } else { + songs.firstOrNull() + } ?: return null + + val lyricsResp = client.get("https://music.163.com/api/song/lyric") { + parameter("id", song.id) + parameter("lv", 1) + parameter("kv", 1) + parameter("tv", -1) + header("Referer", "https://music.163.com") + }.body() + + return lyricsResp.lrc?.lyric?.takeIf { it.isNotBlank() } } } diff --git a/src/player/nowplaying.js b/src/player/nowplaying.js index ec28f2d..f5d9074 100644 --- a/src/player/nowplaying.js +++ b/src/player/nowplaying.js @@ -183,6 +183,39 @@ async function _loadNpLyrics(songId) { } catch {} } + // 3. 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), + }); + if(sr.ok) { + const sd = await sr.json(); + const songs = sd?.result?.songs || []; + const durMs = dur * 1000; + const song = dur + ? songs.sort((a, b) => Math.abs(a.duration - durMs) - Math.abs(b.duration - durMs)) + .find(s => Math.abs(s.duration - durMs) <= 5000) + : songs[0]; + if(song) { + 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) }, + ); + if(lr.ok) { + const ld = await lr.json(); + const lrc = ld?.lrc?.lyric; + if(lrc) result = { syncedLyrics: lrc }; + } + } + } + } catch {} + } + if(token !== _lyricsLoadToken) return; content.innerHTML = '';