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:
HoneyandClaude Sonnet 4.6 committed 2026-06-15 20:45:32 +02:00
1 parent 7e20062995
commit 8b9bfd3098
7 files changed
+150 -68

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 get("getSongsByGenre.view", mapOf("genre" to genre, "count" to count)).songsByGenre?.song
?: emptyList() ?: 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) = suspend fun addSongToPlaylist(playlistId: String, songId: String) =
getList("updatePlaylist.view", listOf("playlistId" to playlistId, "songIdToAdd" to songId)) getList("updatePlaylist.view", listOf("playlistId" to playlistId, "songIdToAdd" to songId))
@@ -33,7 +33,6 @@ data class SubsonicResponse(
val topSongs: SongListResult? = null, val topSongs: SongListResult? = null,
val genres: GenresResult? = null, val genres: GenresResult? = null,
val songsByGenre: SongListResult? = null, val songsByGenre: SongListResult? = null,
val lyricsList: LyricsListResult? = null,
) )
@Serializable @Serializable
@@ -184,19 +183,3 @@ data class Genre(
@Serializable @Serializable
data class GenresResult(val genre: List<Genre> = emptyList()) 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 getTopSongs(artistName: String) = api().getTopSongs(artistName)
suspend fun getGenres() = api().getGenres() suspend fun getGenres() = api().getGenres()
suspend fun getSongsByGenre(genre: String) = api().getSongsByGenre(genre) 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 addSongToPlaylist(playlistId: String, songId: String) = api().addSongToPlaylist(playlistId, songId)
suspend fun createPlaylist(name: String, songIds: List<String>) = api().createPlaylist(name, songIds) 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) // Center of active line sits at 30% from top (clearly upper area)
val targetY = (lyricsPaddingPx + activeIdx * lineHeightPx + lineHeightPx / 2f val targetY = (lyricsPaddingPx + activeIdx * lineHeightPx + lineHeightPx / 2f
- lyricsViewH * 0.30f).coerceAtLeast(0f) - lyricsViewH * 0.30f).coerceAtLeast(0f)
lyricsScrollState.animateScrollTo(targetY.toInt(), tween(280, easing = FastOutSlowInEasing)) lyricsScrollState.animateScrollTo(targetY.toInt(), tween<Int>(280, easing = FastOutSlowInEasing))
} }
when { when {
@@ -1,12 +1,12 @@
package com.novasonic.app.viewmodel package com.novasonic.app.viewmodel
import android.app.Application import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.LrcLibClient
import com.novasonic.app.api.LrcLine import com.novasonic.app.api.LrcLine
import com.novasonic.app.api.parseLrc
import com.novasonic.app.api.model.Song 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -16,12 +16,11 @@ data class LyricsUiState(
val plainText: String = "", // fallback plain lyrics val plainText: String = "", // fallback plain lyrics
val isLoading: Boolean = false, val isLoading: Boolean = false,
val notFound: Boolean = false, val notFound: Boolean = false,
val songId: String = "", // which song these are for val songId: String = "",
) )
class LyricsViewModel(app: Application) : AndroidViewModel(app) { class LyricsViewModel(app: Application) : AndroidViewModel(app) {
private val repo = MusicRepository(app.applicationContext)
private val _state = MutableStateFlow(LyricsUiState()) private val _state = MutableStateFlow(LyricsUiState())
val state: StateFlow<LyricsUiState> = _state val state: StateFlow<LyricsUiState> = _state
@@ -29,31 +28,19 @@ class LyricsViewModel(app: Application) : AndroidViewModel(app) {
if (_state.value.songId == song.id && !_state.value.isLoading) return if (_state.value.songId == song.id && !_state.value.isLoading) return
_state.value = LyricsUiState(isLoading = true, songId = song.id) _state.value = LyricsUiState(isLoading = true, songId = song.id)
viewModelScope.launch { viewModelScope.launch {
// Navidrome plugin (getLyricsBySongId) — plugin handles source lookup + caching val result = LrcLibClient.getLyrics(
val ndLyrics = runCatching { repo.getLyrics(song.id) }.getOrNull() artist = song.artist ?: "",
val synced = ndLyrics?.firstOrNull { it.synced && it.line.isNotEmpty() } title = song.title,
val plain = ndLyrics?.firstOrNull { it.line.isNotEmpty() } album = song.album,
durationSecs = song.duration,
when { )
synced != null -> { _state.value = when {
val lines = synced.line result == null -> LyricsUiState(notFound = true, songId = song.id)
.mapNotNull { l -> l.start?.let { ms -> LrcLine(ms, l.value) } } result.instrumental -> LyricsUiState(plainText = "🎵 Instrumentalstück", songId = song.id)
.filter { it.text.isNotBlank() } !result.syncedLyrics.isNullOrBlank() -> LyricsUiState(lines = parseLrc(result.syncedLyrics), songId = song.id)
.sortedBy { it.timeMs } !result.plainLyrics.isNullOrBlank() -> LyricsUiState(plainText = result.plainLyrics, songId = song.id)
_state.value = if (lines.isNotEmpty()) else -> LyricsUiState(notFound = true, songId = song.id)
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)
} }
} }
} }
} }
+43 -15
View File
@@ -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>'; 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 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; if(token !== _lyricsLoadToken) return;
if(!result?.syncedLyrics && !result?.plainLyrics) {
// Navidrome plugin (getLyricsBySongId) — plugin handles source lookup + caching try {
const lyrics = await getLyricsBySongId(songId); const p = new URLSearchParams({ track_name: title });
if(token !== _lyricsLoadToken) return; if(artist) p.set('artist_name', artist);
const r = await fetch(`https://lrclib.net/api/search?${p}`, { signal: AbortSignal.timeout(8000) });
content.innerHTML = ''; if(r.ok) {
const synced = lyrics.find(t => t.synced && t.line?.length); const candidates = await r.json();
const plain = lyrics.find(t => t.line?.length); if(candidates?.length) {
const synced = candidates.filter(c => c.syncedLyrics);
if(synced) { const pool = synced.length ? synced : candidates;
_renderSyncedLines(content, synced.line.map(l => ({ start: l.start, value: l.value || '' })).filter(l => l.value)); result = dur
return; ? 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'); const el = document.createElement('div');
el.className = 'lyrics-plain'; el.className = 'lyrics-plain';
el.textContent = plain.line.map(l => l.value).join('\n'); el.textContent = result.plainLyrics;
content.appendChild(el); content.appendChild(el);
return; return;
} }