fix: restore direct lrclib lyrics search with fuzzy fallback (both platforms)
Navidrome lyrics plugin does not implement getLyricsBySongId for synced lyrics — removed all plugin calls. Both Android and web now query lrclib.net/api/get (exact match) with /api/search fallback for metadata mismatches (e.g. artist tagged differently in Navidrome vs lrclib). Also fixes Android lyrics scroll tween<Int> type inference error. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
7e20062995
commit
8b9bfd3098
7 files changed
+149
-67
No files matched your search
@@ -0,0 +1,89 @@
|
||||
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.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
data class LrcLibResponse(
|
||||
val id: Long = 0,
|
||||
val trackName: String = "",
|
||||
val artistName: String = "",
|
||||
val albumName: String = "",
|
||||
val duration: Double = 0.0,
|
||||
val instrumental: Boolean = false,
|
||||
val plainLyrics: String? = null,
|
||||
val syncedLyrics: String? = null,
|
||||
)
|
||||
|
||||
data class LrcLine(val timeMs: Long, val text: String)
|
||||
|
||||
fun parseLrc(lrc: String): List<LrcLine> {
|
||||
val regex = Regex("""\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)""")
|
||||
return lrc.lines()
|
||||
.mapNotNull { line ->
|
||||
regex.find(line.trim())?.let { m ->
|
||||
val mm = m.groupValues[1].toLong()
|
||||
val ss = m.groupValues[2].toLong()
|
||||
val frac = m.groupValues[3]
|
||||
val ms = mm * 60_000 + ss * 1_000 +
|
||||
if (frac.length == 2) frac.toLong() * 10 else frac.toLong()
|
||||
val text = m.groupValues[4].trim()
|
||||
if (text.isNotEmpty()) LrcLine(ms, text) else null
|
||||
}
|
||||
}
|
||||
.sortedBy { it.timeMs }
|
||||
}
|
||||
|
||||
object LrcLibClient {
|
||||
private val client = HttpClient(Android) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json { ignoreUnknownKeys = true; isLenient = true })
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getLyrics(
|
||||
artist: String,
|
||||
title: String,
|
||||
album: String?,
|
||||
durationSecs: Int?,
|
||||
): LrcLibResponse? {
|
||||
// 1. Exact match — fastest, most accurate when metadata is clean
|
||||
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<LrcLibResponse>()
|
||||
}.getOrNull()
|
||||
if (exact != null) return exact
|
||||
|
||||
// 2. Fuzzy search fallback — handles artist name mismatches (e.g. "Utada" vs "Hikaru Utada")
|
||||
val candidates = runCatching {
|
||||
client.get("https://lrclib.net/api/search") {
|
||||
parameter("track_name", title)
|
||||
if (artist.isNotBlank()) parameter("artist_name", artist)
|
||||
}.body<List<LrcLibResponse>>()
|
||||
}.getOrNull() ?: return null
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,10 +157,6 @@ class SubsonicClient(
|
||||
get("getSongsByGenre.view", mapOf("genre" to genre, "count" to count)).songsByGenre?.song
|
||||
?: emptyList()
|
||||
|
||||
suspend fun getLyrics(songId: String): List<com.novasonic.app.api.model.StructuredLyrics> =
|
||||
get("getLyricsBySongId.view", mapOf("id" to songId)).lyricsList?.structuredLyrics
|
||||
?: emptyList()
|
||||
|
||||
suspend fun addSongToPlaylist(playlistId: String, songId: String) =
|
||||
getList("updatePlaylist.view", listOf("playlistId" to playlistId, "songIdToAdd" to songId))
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ data class SubsonicResponse(
|
||||
val topSongs: SongListResult? = null,
|
||||
val genres: GenresResult? = null,
|
||||
val songsByGenre: SongListResult? = null,
|
||||
val lyricsList: LyricsListResult? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -184,19 +183,3 @@ data class Genre(
|
||||
@Serializable
|
||||
data class GenresResult(val genre: List<Genre> = emptyList())
|
||||
|
||||
// ── Lyrics ────────────────────────────────────────────────────────────────────
|
||||
@Serializable
|
||||
data class LyricsLine(val start: Long? = null, val value: String = "")
|
||||
|
||||
@Serializable
|
||||
data class StructuredLyrics(
|
||||
val lang: String = "",
|
||||
val synced: Boolean = false,
|
||||
val line: List<LyricsLine> = emptyList(),
|
||||
val displayArtist: String? = null,
|
||||
val displayTitle: String? = null,
|
||||
val offset: Long? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LyricsListResult(val structuredLyrics: List<StructuredLyrics> = emptyList())
|
||||
@@ -56,7 +56,6 @@ class MusicRepository(private val context: Context) {
|
||||
suspend fun getTopSongs(artistName: String) = api().getTopSongs(artistName)
|
||||
suspend fun getGenres() = api().getGenres()
|
||||
suspend fun getSongsByGenre(genre: String) = api().getSongsByGenre(genre)
|
||||
suspend fun getLyrics(songId: String) = api().getLyrics(songId)
|
||||
suspend fun addSongToPlaylist(playlistId: String, songId: String) = api().addSongToPlaylist(playlistId, songId)
|
||||
suspend fun createPlaylist(name: String, songIds: List<String>) = api().createPlaylist(name, songIds)
|
||||
|
||||
|
||||
@@ -386,7 +386,7 @@ fun NowPlayingScreen(
|
||||
// Center of active line sits at 30% from top (clearly upper area)
|
||||
val targetY = (lyricsPaddingPx + activeIdx * lineHeightPx + lineHeightPx / 2f
|
||||
- lyricsViewH * 0.30f).coerceAtLeast(0f)
|
||||
lyricsScrollState.animateScrollTo(targetY.toInt(), tween(280, easing = FastOutSlowInEasing))
|
||||
lyricsScrollState.animateScrollTo(targetY.toInt(), tween<Int>(280, easing = FastOutSlowInEasing))
|
||||
}
|
||||
|
||||
when {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.LrcLibClient
|
||||
import com.novasonic.app.api.LrcLine
|
||||
import com.novasonic.app.api.parseLrc
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.data.MusicRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -16,12 +16,11 @@ data class LyricsUiState(
|
||||
val plainText: String = "", // fallback plain lyrics
|
||||
val isLoading: Boolean = false,
|
||||
val notFound: Boolean = false,
|
||||
val songId: String = "", // which song these are for
|
||||
val songId: String = "",
|
||||
)
|
||||
|
||||
class LyricsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = MusicRepository(app.applicationContext)
|
||||
private val _state = MutableStateFlow(LyricsUiState())
|
||||
val state: StateFlow<LyricsUiState> = _state
|
||||
|
||||
@@ -29,30 +28,18 @@ class LyricsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
if (_state.value.songId == song.id && !_state.value.isLoading) return
|
||||
_state.value = LyricsUiState(isLoading = true, songId = song.id)
|
||||
viewModelScope.launch {
|
||||
// Navidrome plugin (getLyricsBySongId) — plugin handles source lookup + caching
|
||||
val ndLyrics = runCatching { repo.getLyrics(song.id) }.getOrNull()
|
||||
val synced = ndLyrics?.firstOrNull { it.synced && it.line.isNotEmpty() }
|
||||
val plain = ndLyrics?.firstOrNull { it.line.isNotEmpty() }
|
||||
|
||||
when {
|
||||
synced != null -> {
|
||||
val lines = synced.line
|
||||
.mapNotNull { l -> l.start?.let { ms -> LrcLine(ms, l.value) } }
|
||||
.filter { it.text.isNotBlank() }
|
||||
.sortedBy { it.timeMs }
|
||||
_state.value = if (lines.isNotEmpty())
|
||||
LyricsUiState(lines = lines, songId = song.id)
|
||||
else
|
||||
LyricsUiState(notFound = true, songId = song.id)
|
||||
}
|
||||
plain != null -> {
|
||||
val text = plain.line.joinToString("\n") { it.value }.trim()
|
||||
_state.value = if (text.isNotBlank())
|
||||
LyricsUiState(plainText = text, songId = song.id)
|
||||
else
|
||||
LyricsUiState(notFound = true, songId = song.id)
|
||||
}
|
||||
else -> _state.value = LyricsUiState(notFound = true, songId = song.id)
|
||||
val result = LrcLibClient.getLyrics(
|
||||
artist = song.artist ?: "",
|
||||
title = song.title,
|
||||
album = song.album,
|
||||
durationSecs = song.duration,
|
||||
)
|
||||
_state.value = when {
|
||||
result == null -> LyricsUiState(notFound = true, songId = song.id)
|
||||
result.instrumental -> LyricsUiState(plainText = "🎵 Instrumentalstück", songId = song.id)
|
||||
!result.syncedLyrics.isNullOrBlank() -> LyricsUiState(lines = parseLrc(result.syncedLyrics), songId = song.id)
|
||||
!result.plainLyrics.isNullOrBlank() -> LyricsUiState(plainText = result.plainLyrics, songId = song.id)
|
||||
else -> LyricsUiState(notFound = true, songId = song.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+43
-15
@@ -147,25 +147,53 @@ async function _loadNpLyrics(songId) {
|
||||
content.innerHTML = '<div style="text-align:center;padding:60px 0;color:rgba(255,255,255,.3);font-size:14px">Lade Lyrics…</div>';
|
||||
|
||||
const token = ++_lyricsLoadToken;
|
||||
const { getLyricsBySongId } = await import('../api.js');
|
||||
const s = S.queue[S.qIdx];
|
||||
const artist = (s?.artist || '').split(/[/,]/)[0].trim();
|
||||
const title = s?.title || '';
|
||||
const album = s?.album || '';
|
||||
const dur = s?.duration || 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) });
|
||||
if(r.ok) result = await r.json();
|
||||
} catch {}
|
||||
|
||||
// 2. Search fallback — handles metadata mismatches (e.g. "Utada" vs "Hikaru Utada")
|
||||
if(token !== _lyricsLoadToken) return;
|
||||
|
||||
// Navidrome plugin (getLyricsBySongId) — plugin handles source lookup + caching
|
||||
const lyrics = await getLyricsBySongId(songId);
|
||||
if(token !== _lyricsLoadToken) return;
|
||||
|
||||
content.innerHTML = '';
|
||||
const synced = lyrics.find(t => t.synced && t.line?.length);
|
||||
const plain = lyrics.find(t => t.line?.length);
|
||||
|
||||
if(synced) {
|
||||
_renderSyncedLines(content, synced.line.map(l => ({ start: l.start, value: l.value || '' })).filter(l => l.value));
|
||||
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];
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if(plain) {
|
||||
|
||||
if(token !== _lyricsLoadToken) return;
|
||||
content.innerHTML = '';
|
||||
|
||||
if(result?.syncedLyrics) {
|
||||
const parsed = _parseLrc(result.syncedLyrics);
|
||||
if(parsed.length) { _renderSyncedLines(content, parsed); return; }
|
||||
}
|
||||
if(result?.plainLyrics) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'lyrics-plain';
|
||||
el.textContent = plain.line.map(l => l.value).join('\n');
|
||||
el.textContent = result.plainLyrics;
|
||||
content.appendChild(el);
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user