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
|
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,30 +28,18 @@ 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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user