Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import kotlinx.coroutines.runBlocking
import pl.lambada.songsync.domain.model.SortOrders
import pl.lambada.songsync.domain.model.SortValues
import pl.lambada.songsync.util.Providers
import pl.lambada.songsync.util.defaultProviderFallbackOrder
import pl.lambada.songsync.util.set

/**
Expand All @@ -23,6 +24,23 @@ import pl.lambada.songsync.util.set
internal fun parseBlacklistedFolders(raw: String): List<String> =
raw.split(",").filter { it.isNotBlank() }

/**
* Parses the persisted provider fallback order (comma-joined enum names) back into a full provider list.
* Robust against renames/removals (unknown names are dropped) and additions (providers missing from the stored
* value — e.g. ones added in an update — are appended in default-order position). Pure + internal so it's
* unit-testable without a DataStore.
*/
internal fun parseProviderOrder(raw: String): List<Providers> {
val stored = raw.split(",").mapNotNull { name ->
Providers.entries.find { it.name == name.trim() }
}.distinct()
return stored + defaultProviderFallbackOrder.filter { it !in stored }
}

/** Parses the persisted disabled-provider set (comma-joined enum names), dropping unknown names. */
internal fun parseDisabledProviders(raw: String): Set<Providers> =
raw.split(",").mapNotNull { name -> Providers.entries.find { it.name == name.trim() } }.toSet()

class UserSettingsController(private val dataStore: DataStore<Preferences>) {
// Read the ENTIRE preferences snapshot once at construction (a single blocking read) instead of one
// runBlocking { data.first() } per field. UserSettingsController is built on the main thread in
Expand Down Expand Up @@ -51,6 +69,18 @@ class UserSettingsController(private val dataStore: DataStore<Preferences>) {
)
private set

// Provider fallback chain (issue #6): the full, user-arrangeable order providers are tried in, plus the set
// the user switched off. The effective chain is [enabledProviderOrder].
var providerOrder by mutableStateOf(parseProviderOrder(prefs[providerOrderKey] ?: ""))
private set

var disabledProviders by mutableStateOf(parseDisabledProviders(prefs[disabledProvidersKey] ?: ""))
private set

/** The providers actually tried, in the user's order. Never empty: falls back to LRCLib if all are off. */
val enabledProviderOrder: List<Providers>
get() = providerOrder.filterNot { it in disabledProviders }.ifEmpty { listOf(Providers.LRCLIB) }

var hideLyrics by mutableStateOf(prefs[hideLyricsKey] ?: false)
private set

Expand Down Expand Up @@ -136,6 +166,17 @@ class UserSettingsController(private val dataStore: DataStore<Preferences>) {
blacklistedFolders = to
}

fun updateProviderOrder(to: List<Providers>) {
dataStore.set(providerOrderKey, to.joinToString(",") { it.name })
providerOrder = to
}

fun updateProviderEnabled(provider: Providers, enabled: Boolean) {
val to = if (enabled) disabledProviders - provider else disabledProviders + provider
dataStore.set(disabledProvidersKey, to.joinToString(",") { it.name })
disabledProviders = to
}

fun updateHideLyrics(to: Boolean) {
dataStore.set(hideLyricsKey, to)
hideLyrics = to
Expand Down Expand Up @@ -231,6 +272,8 @@ private val embedKey = booleanPreferencesKey("embed_lyrics")
private val passedInitKey = booleanPreferencesKey("passed_init")
private val selectedProviderKey = stringPreferencesKey("provider")
private val blacklistedFoldersKey = stringPreferencesKey("blacklist")
private val providerOrderKey = stringPreferencesKey("provider_fallback_order")
private val disabledProvidersKey = stringPreferencesKey("disabled_providers")
private val hideLyricsKey = booleanPreferencesKey("hide_lyrics")
private val includeTranslationKey = booleanPreferencesKey("include_translation")
private val includeRomanizationKey = booleanPreferencesKey("include_romanization")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package pl.lambada.songsync.data.remote.lyrics_providers

import android.util.Log
import pl.lambada.songsync.data.remote.lyrics_providers.apple.AppleAPI
import pl.lambada.songsync.data.remote.lyrics_providers.others.BetterLyricsAPI
import pl.lambada.songsync.data.remote.lyrics_providers.others.LRCLibAPI
import pl.lambada.songsync.data.remote.lyrics_providers.others.LastResortAPI
import pl.lambada.songsync.data.remote.lyrics_providers.others.LyricsPlusAPI
import pl.lambada.songsync.data.remote.lyrics_providers.others.NeteaseAPI
import pl.lambada.songsync.data.remote.lyrics_providers.others.QQMusicAPI
import pl.lambada.songsync.data.remote.lyrics_providers.spotify.SpotifyAPI
Expand Down Expand Up @@ -69,6 +71,11 @@ class LyricsProviderService {
Providers.NETEASE -> NeteaseAPI().getSongInfo(query, offset) ?: throw NoTrackFoundException()
Providers.QQMUSIC -> QQMusicAPI().getSongInfo(query, offset) ?: throw NoTrackFoundException()
Providers.APPLE -> appleAPI.getSongInfo(query, offset) ?: throw NoTrackFoundException()
// Direct-fetch providers: no search endpoint, so there is nothing to paginate — the query itself
// is the lookup token and getSyncedLyrics performs the actual fetch.
Providers.LYRICSPLUS, Providers.BETTERLYRICS ->
if (offset > 0) throw NoTrackFoundException()
else SongInfo(songName = query.songName, artistName = query.artistName)
}
} catch (e: Exception) {
when (e) {
Expand Down Expand Up @@ -219,6 +226,16 @@ class LyricsProviderService {
Providers.APPLE -> appleAPI.getSyncedLyrics(
song.appleID ?: 0L, multiPersonWordByWord
)

Providers.LYRICSPLUS -> LyricsPlusAPI().getSyncedLyrics(
song.songName.orEmpty(), song.artistName.orEmpty(),
multiPersonWordByWord = multiPersonWordByWord,
)

Providers.BETTERLYRICS -> BetterLyricsAPI().getSyncedLyrics(
song.songName.orEmpty(), song.artistName.orEmpty(),
multiPersonWordByWord = multiPersonWordByWord,
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package pl.lambada.songsync.data.remote.lyrics_providers

import android.util.Log
import kotlinx.coroutines.delay
import pl.lambada.songsync.data.remote.lyrics_providers.others.BetterLyricsAPI
import pl.lambada.songsync.data.remote.lyrics_providers.others.LRCLibAPI
import pl.lambada.songsync.data.remote.lyrics_providers.others.LastResortAPI
import pl.lambada.songsync.data.remote.lyrics_providers.others.LyricsPlusAPI
import pl.lambada.songsync.data.remote.lyrics_providers.others.NeteaseAPI
import pl.lambada.songsync.domain.model.SongInfo
import pl.lambada.songsync.util.Providers
Expand Down Expand Up @@ -56,6 +58,9 @@ class SmartLyricsMatcher(
private val providerService: LyricsProviderService? = null,
/** Internal "last resort" canonicalizer (iTunes/Deezer) — not a user-facing provider. */
private val lastResortApi: LastResortAPI = LastResortAPI(),
/** Direct-fetch providers (no search endpoint): word-by-word/TTML sources added for issue #4. */
private val lyricsPlus: LyricsPlusAPI = LyricsPlusAPI(),
private val betterLyrics: BetterLyricsAPI = BetterLyricsAPI(),
) {

/**
Expand Down Expand Up @@ -84,6 +89,7 @@ class SmartLyricsMatcher(
val found = when (provider) {
Providers.LRCLIB -> searchLrcLib(query, local, cand, config, log)
Providers.NETEASE -> searchNetease(query, local, cand, config, log)
Providers.LYRICSPLUS, Providers.BETTERLYRICS -> searchDirect(provider, local, cand, config, log)
else -> searchGeneric(provider, local, cand, config, log)
}

Expand Down Expand Up @@ -231,6 +237,45 @@ class SmartLyricsMatcher(
}?.takeIf { it.isNotBlank() }
}

/**
* Direct-fetch path for LyricsPlus/BetterLyrics: these services take title/artist(/duration/album) and do
* their own matching server-side, returning lyrics or nothing — there are no search results to score. The
* returned hit therefore echoes the candidate we asked for; because that makes a perfect score
* self-fulfilling, the hit is only allowed to AUTO_ACCEPT when the local duration was sent along (the
* service verified the length) — otherwise it is capped at REVIEW like other unverifiable matches.
*/
private suspend fun searchDirect(
provider: Providers, local: LocalTrack, cand: QueryCandidate, config: MatchConfig, log: (String) -> Unit
): List<ScoredHit> {
val artist = cand.artist?.takeIf { it.isNotBlank() } ?: local.artist ?: return emptyList()
val durationSec = local.durationSec?.toInt()?.takeIf { it > 0 }

val lyrics = runCatching {
withRetry(config.maxRetries, onRetry = { a, d, e ->
log(" [${provider.displayName}] retry $a in ${d}ms (${e.message})")
}) {
when (provider) {
Providers.LYRICSPLUS -> lyricsPlus.getSyncedLyrics(cand.title, artist, durationSec, local.album)
else -> betterLyrics.getSyncedLyrics(cand.title, artist, durationSec, local.album)
}
}
}.onFailure { log(" [${provider.displayName}] failed: ${it.message}") }.getOrNull()
if (lyrics.isNullOrBlank()) return emptyList()

val pr = ProviderResult(
title = cand.title,
artist = artist,
durationSec = if (durationSec != null) local.durationSec else null,
album = local.album,
hasSyncedLyrics = true,
)
var conf = scoreFor(local, pr, cand.strategy)
if (durationSec == null && conf.tier == MatchTier.AUTO_ACCEPT) {
conf = conf.copy(score = 0.80, tier = MatchTier.REVIEW)
}
return listOf(ScoredHit(provider, cand.strategy, pr, conf, lyrics, null))
}

/**
* Scored multi-result path for providers that don't expose a full search endpoint here (Apple, Spotify, QQ).
* We walk a few result offsets, score each candidate, and keep the believable ones. This fixes cases where a
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package pl.lambada.songsync.data.remote.lyrics_providers.others

import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.statement.bodyAsText
import kotlinx.serialization.Serializable
import pl.lambada.songsync.util.TtmlLyricsParser
import pl.lambada.songsync.util.networking.Ktor.client
import pl.lambada.songsync.util.networking.Ktor.json

/**
* BetterLyrics provider (lyrics-api.boidu.dev): serves Apple-style TTML with word-level timing behind a direct
* title/artist(/duration/album) query — no search/offset pagination. The TTML body is converted to the app's
* enhanced-LRC dialect by [TtmlLyricsParser].
*/
class BetterLyricsAPI {
private val baseURL = "https://lyrics-api.boidu.dev"

@Serializable
private data class TtmlResponse(val ttml: String? = null)

/** Fetches lyrics and returns them in the app's LRC dialect, or null when nothing matched. */
suspend fun getSyncedLyrics(
title: String,
artist: String,
durationSec: Int? = null,
album: String? = null,
multiPersonWordByWord: Boolean = false,
): String? {
if (title.isBlank() || artist.isBlank()) return null

// Exact title/artist on purpose: the service does its own matching, and normalizing here can land on a
// different edition (radio edit vs original) whose timing won't line up with the local file.
val response = client.get("$baseURL/getLyrics") {
parameter("s", title)
parameter("a", artist)
if (durationSec != null && durationSec > 0) parameter("d", durationSec)
if (!album.isNullOrBlank()) parameter("al", album)
}
val body = response.bodyAsText(Charsets.UTF_8)
if (response.status.value !in 200..299 || body.isBlank()) return null

val ttml = runCatching { json.decodeFromString<TtmlResponse>(body).ttml }.getOrNull()
?: return null
val lines = TtmlLyricsParser.parse(ttml)
return TtmlLyricsParser.toLrc(lines, multiPersonWordByWord)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package pl.lambada.songsync.data.remote.lyrics_providers.others

import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.statement.bodyAsText
import pl.lambada.songsync.domain.model.lyrics_providers.others.LyricsPlusLine
import pl.lambada.songsync.domain.model.lyrics_providers.others.LyricsPlusResponse
import pl.lambada.songsync.domain.model.lyrics_providers.others.LyricsPlusWord
import pl.lambada.songsync.util.ext.toLrcTimestamp
import pl.lambada.songsync.util.networking.Ktor.client
import pl.lambada.songsync.util.networking.Ktor.json

/**
* LyricsPlus provider (github.com/ibratabian17/YouLyPlus backend): word-by-word (enhanced LRC) and line-synced
* lyrics behind a direct title/artist/duration query — no search/offset pagination. Several community mirrors
* serve the same API; we walk them in order and remember the last one that answered, so a dead mirror is only
* paid for once per run.
*/
class LyricsPlusAPI {

companion object {
// Mirror list from the YouLyPlus project (see issue #4). The Cloudflare-workers and old Vercel
// deployments are intentionally last: one is capped at 100k requests/day, the other is often disabled.
private val BASE_URLS = listOf(
"https://lyricsplus.prjktla.my.id", // main server
"https://lyricsplus.binimum.org", // binimum's alternate server
"https://lyricsplus.atomix.one", // meow's mirror
"https://lyricsplus-seven.vercel.app",// jigen's mirror
"https://lyricsplus.prjktla.workers.dev",
"https://lyrics-plus-backend.vercel.app",
)

@Volatile
private var lastWorkingServer: String? = null
}

private fun prioritizedServers(): List<String> {
val last = lastWorkingServer
return if (last != null && last in BASE_URLS) listOf(last) + BASE_URLS.filter { it != last }
else BASE_URLS
}

/**
* Fetches lyrics for [title]/[artist] (optionally narrowed by [durationSec] and [album]) and returns them
* as LRC — enhanced (word-by-word) when the service has a "Word" body, plain line-synced otherwise.
* Returns null when no mirror has a match.
*/
suspend fun getSyncedLyrics(
title: String,
artist: String,
durationSec: Int? = null,
album: String? = null,
multiPersonWordByWord: Boolean = false,
): String? {
if (title.isBlank() || artist.isBlank()) return null

for (baseUrl in prioritizedServers()) {
val response = runCatching {
val res = client.get("$baseUrl/v2/lyrics/get") {
parameter("title", title)
parameter("artist", artist)
if (durationSec != null && durationSec > 0) parameter("duration", durationSec)
if (!album.isNullOrBlank()) parameter("album", album)
}
val body = res.bodyAsText(Charsets.UTF_8)
if (res.status.value !in 200..299 || body.isBlank()) null
else json.decodeFromString<LyricsPlusResponse>(body)
}.getOrNull()

if (response?.lyrics.isNullOrEmpty()) continue
lastWorkingServer = baseUrl
return convertToLrc(response!!, multiPersonWordByWord)
}
return null
}

/**
* Converts a LyricsPlus response into the app's LRC dialect: `[mm:ss.SSS]` line stamps, and for "Word"
* bodies the same `<begin>word <end>` inline syllable timing that the Apple/QQ providers emit (see
* [pl.lambada.songsync.data.remote.PaxMusicHelper]), so the player renders it word-by-word out of the box.
*/
private fun convertToLrc(response: LyricsPlusResponse, multiPersonWordByWord: Boolean): String? {
val lyrics = response.lyrics?.takeIf { it.isNotEmpty() } ?: return null
val wordSync = response.type.equals("Word", ignoreCase = true)

// Only tag voices when the song actually alternates between more than one singer.
val singers = lyrics.mapNotNull { it.element?.singer?.lowercase() }.distinct()
val tagVoices = multiPersonWordByWord && singers.size > 1
val primarySinger = singers.firstOrNull()

val sb = StringBuilder(lyrics.size * 64)
for (line in lyrics) {
val mainWords = line.syllabus?.filter { !it.isBackground }.orEmpty()
val bgWords = line.syllabus?.filter { it.isBackground }.orEmpty()

val hasMain = if (wordSync && line.syllabus != null) mainWords.isNotEmpty() else line.text.isNotBlank()
if (hasMain) {
sb.append("[${line.time.toInt().toLrcTimestamp()}]")
if (tagVoices) {
sb.append(if (line.element?.singer?.lowercase() == primarySinger) "v1:" else "v2:")
}
if (wordSync && mainWords.isNotEmpty()) appendWordByWord(sb, mainWords)
else sb.append(line.text.trim())
sb.append('\n')
}

// Background vocals only make sense in the multi-person word-by-word format (mirrors PaxMusicHelper).
if (bgWords.isNotEmpty() && multiPersonWordByWord && wordSync) {
// Rewrite the trailing newline so the [bg:...] block attaches to its main line.
if (sb.endsWith("\n")) sb.setLength(sb.length - 1)
sb.append("\n[bg:")
appendWordByWord(sb, bgWords)
sb.append("]\n")
}
}
return sb.toString().trimEnd().ifBlank { null }
}

/** Appends `<begin>word <end>` blocks, deduplicating an end stamp that equals the next word's begin. */
private fun appendWordByWord(sb: StringBuilder, words: List<LyricsPlusWord>) {
for (word in words) {
val text = word.text.trim()
if (text.isEmpty()) continue
val begin = "<${word.time.toInt().toLrcTimestamp()}>"
val end = "<${(word.time + word.duration).toInt().toLrcTimestamp()}>"
if (!sb.endsWith(begin)) sb.append(begin)
sb.append(text).append(' ')
sb.append(end)
}
}
}
Loading
Loading