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
?: 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,31 +28,19 @@ 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)
}
}
}
}
}