mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 00:12:24 +01:00
lyrics: try BetterLyrics, then LRCLIB, then KuGou, as Metrolist does, with a setting for LRCLIB only
This commit is contained in:
1 parent
5c72a18f06
commit
0eea09542f
4 files changed
+223
-11
No files matched your search
@@ -64,6 +64,7 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
private const val KEY_AUTOPLAY = "car_autoplay"
|
||||
private const val KEY_PLAYER_TITLE = "car_player_title"
|
||||
private const val KEY_PLAYER_LEFT = "car_player_left"
|
||||
private const val KEY_MORE_LYRICS = "car_more_lyrics"
|
||||
private const val KEY_THEME = "car_app_theme"
|
||||
private const val KEY_UI_THEME = "car_ui_theme"
|
||||
private const val KEY_PINNED = "car_pinned_apps"
|
||||
@@ -104,6 +105,7 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
private val autoplayFlow = MutableStateFlow(false)
|
||||
private val playerTitleFlow = MutableStateFlow(false)
|
||||
private val playerLeftFlow = MutableStateFlow(false)
|
||||
private val moreLyricsFlow = MutableStateFlow(true)
|
||||
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
|
||||
val appTheme: StateFlow<AppTheme> = themeFlow
|
||||
private val uiThemeFlow = MutableStateFlow(GsThemes.default.id)
|
||||
@@ -224,6 +226,9 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
/** The home screen's player sits left of the map instead of right of it. */
|
||||
val playerLeft: StateFlow<Boolean> = playerLeftFlow
|
||||
|
||||
/** Lyrics from BetterLyrics and KuGou as well as LRCLIB; off sends song details to LRCLIB only. */
|
||||
val moreLyrics: StateFlow<Boolean> = moreLyricsFlow
|
||||
|
||||
val scale: StateFlow<Float> = scaleFlow
|
||||
val openOnConnect: StateFlow<String> = openFlow
|
||||
val nightMode: StateFlow<NightMode> = nightFlow
|
||||
@@ -316,6 +321,7 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
|
||||
playerTitleFlow.value = AppSettings.getString(app, KEY_PLAYER_TITLE, "false") == "true"
|
||||
playerLeftFlow.value = AppSettings.getString(app, KEY_PLAYER_LEFT, "false") == "true"
|
||||
moreLyricsFlow.value = AppSettings.getString(app, KEY_MORE_LYRICS, "true") == "true"
|
||||
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true"
|
||||
// Until the driver sets it, on for USB audio and off for Bluetooth, where the map app's
|
||||
// own voice already reaches the car.
|
||||
@@ -393,6 +399,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
AppSettings.putString(app, KEY_PLAYER_LEFT, on.toString())
|
||||
}
|
||||
|
||||
fun setMoreLyrics(on: Boolean) {
|
||||
moreLyricsFlow.value = on
|
||||
AppSettings.putString(app, KEY_MORE_LYRICS, on.toString())
|
||||
}
|
||||
|
||||
fun setAutoplay(on: Boolean) {
|
||||
autoplayFlow.value = on
|
||||
AppSettings.putString(app, KEY_AUTOPLAY, on.toString())
|
||||
|
||||
@@ -65,6 +65,7 @@ fun CarSettingsScreen() {
|
||||
val autoplay by CarSettings.autoplay.collectAsState()
|
||||
val playerTitle by CarSettings.playerTitle.collectAsState()
|
||||
val playerLeft by CarSettings.playerLeft.collectAsState()
|
||||
val moreLyrics by CarSettings.moreLyrics.collectAsState()
|
||||
val theme by CarSettings.appTheme.collectAsState()
|
||||
val uiTheme by CarSettings.uiTheme.collectAsState()
|
||||
val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState()
|
||||
@@ -195,6 +196,14 @@ fun CarSettingsScreen() {
|
||||
) { CarSettings.setAutoplay(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Lyrics from",
|
||||
listOf(false to "LRCLIB", true to "More sources"),
|
||||
moreLyrics,
|
||||
) { CarSettings.setMoreLyrics(it) }
|
||||
}
|
||||
|
||||
item { Group("Sound and voice") }
|
||||
|
||||
item {
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
package app.seb3thehacker.gearslip.media
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import android.util.Xml
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.io.File
|
||||
import java.io.StringReader
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
import java.security.MessageDigest
|
||||
import kotlin.math.abs
|
||||
|
||||
class LyricLine(val timeMs: Long, val text: String)
|
||||
|
||||
@@ -18,11 +27,19 @@ class LyricLine(val timeMs: Long, val text: String)
|
||||
class LyricsResult(val lines: List<LyricLine>, val synced: Boolean)
|
||||
|
||||
/**
|
||||
* Looks lyrics up on LRCLIB (lrclib.net), a free community database that needs no key.
|
||||
* Looks lyrics up online, in the order Metrolist uses: BetterLyrics, then LRCLIB, then KuGou.
|
||||
* Metrolist's other sources match songs by YouTube video id, which a song from another app
|
||||
* doesn't have, and Paxsenix calls Apple Music with a borrowed token, so they're left out.
|
||||
*
|
||||
* Media apps almost never publish lyrics in their session metadata, so this is the only general
|
||||
* source. It sends the track's title, artist, album and length to lrclib.net, which is why the
|
||||
* lookup happens only when the driver opens the lyrics view and never in the background.
|
||||
* - BetterLyrics (lyrics-api.boidu.dev): word-timed lyrics, but only for songs its server has
|
||||
* already fetched; anything else needs an API key and falls through.
|
||||
* - LRCLIB (lrclib.net): a free community database that needs no key.
|
||||
* - KuGou (kugou.com): a large Chinese music service, strong on Asian music.
|
||||
*
|
||||
* Media apps almost never publish lyrics in their session metadata, so these are the only general
|
||||
* source. Each lookup sends the track's title, artist, album and length to these services, which
|
||||
* is why it happens only when the driver opens the lyrics view, never in the background. With
|
||||
* [CarSettings.moreLyrics] off, only LRCLIB is asked.
|
||||
*
|
||||
* Results are cached twice over: in memory for the life of the process, and on disk under the
|
||||
* cache directory so a song looked up on a past drive doesn't need the network again on this one.
|
||||
@@ -33,23 +50,40 @@ object Lyrics {
|
||||
|
||||
private val cache = HashMap<String, LyricsResult?>()
|
||||
|
||||
/** Longest any one source may take, DNS included: a server that never answers can't hold the rest up. */
|
||||
private const val SOURCE_TIMEOUT_MS = 12_000L
|
||||
private val io = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
suspend fun find(context: Context, title: String, artist: String, album: String, durationMs: Long): LyricsResult? {
|
||||
if (title.isBlank()) return null
|
||||
val more = CarSettings.moreLyrics.value
|
||||
val key = "$title|$artist|$album|$durationMs"
|
||||
synchronized(cache) { if (cache.containsKey(key)) return cache[key] }
|
||||
// A miss with only LRCLIB isn't a miss once more sources are on, so the mode is part of it.
|
||||
val memoryKey = "$more|$key"
|
||||
synchronized(cache) { if (cache.containsKey(memoryKey)) return cache[memoryKey] }
|
||||
|
||||
val fromDisk = withContext(Dispatchers.IO) { readDisk(context, key) }
|
||||
if (fromDisk != null) {
|
||||
synchronized(cache) { cache[key] = fromDisk }
|
||||
synchronized(cache) { cache[memoryKey] = fromDisk }
|
||||
return fromDisk
|
||||
}
|
||||
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
exact(title, artist, album, durationMs) ?: search(title, artist)
|
||||
}.onFailure { GearslipLog.w("lyrics: lookup failed: ${it.message}") }.getOrNull()
|
||||
val seconds = (durationMs / 1000).toInt()
|
||||
val sources: List<Pair<String, () -> LyricsResult?>> = buildList {
|
||||
if (more) add("BetterLyrics" to { betterLyrics(title, artist, album, seconds) })
|
||||
add("LRCLIB" to { exact(title, artist, album, durationMs) ?: search(title, artist) })
|
||||
if (more) add("KuGou" to { kuGou(title, artist, album, seconds) })
|
||||
}
|
||||
synchronized(cache) { cache[key] = result }
|
||||
var result: LyricsResult? = null
|
||||
for ((name, lookup) in sources) {
|
||||
val job = io.async { runCatching(lookup).onFailure { GearslipLog.w("lyrics: $name failed: ${it.message}") }.getOrNull() }
|
||||
result = withTimeoutOrNull(SOURCE_TIMEOUT_MS) { job.await() }
|
||||
if (result != null) {
|
||||
GearslipLog.i("lyrics: found on $name (${if (result.synced) "synced" else "plain"})")
|
||||
break
|
||||
}
|
||||
}
|
||||
synchronized(cache) { cache[memoryKey] = result }
|
||||
if (result != null) withContext(Dispatchers.IO) { writeDisk(context, key, result) }
|
||||
return result
|
||||
}
|
||||
@@ -84,6 +118,88 @@ object Lyrics {
|
||||
}.onFailure { GearslipLog.w("lyrics: could not write disk cache: ${it.message}") }
|
||||
}
|
||||
|
||||
// --- BetterLyrics ---
|
||||
|
||||
private fun betterLyrics(title: String, artist: String, album: String, seconds: Int): LyricsResult? {
|
||||
if (artist.isBlank()) return null
|
||||
val query = buildString {
|
||||
append("s=").append(enc(title)).append("&a=").append(enc(artist))
|
||||
if (seconds > 0) append("&d=").append(seconds)
|
||||
if (album.isNotBlank()) append("&al=").append(enc(album))
|
||||
}
|
||||
val body = get("https://lyrics-api.boidu.dev/getLyrics?$query") ?: return null
|
||||
val ttml = JSONObject(body).optString("ttml").takeIf { it.isNotBlank() && it != "null" } ?: return null
|
||||
return parseTtml(ttml)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apple-style TTML: one `<p begin=…>` per line, its words in `<span>`s with spaces between.
|
||||
* Background vocals, translations and romanisations are left out, so a line reads as sung.
|
||||
*/
|
||||
private fun parseTtml(ttml: String): LyricsResult? {
|
||||
val parser = Xml.newPullParser().apply {
|
||||
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
|
||||
setInput(StringReader(ttml))
|
||||
}
|
||||
val lines = mutableListOf<LyricLine>()
|
||||
var inP = false
|
||||
var lineBegin: Long? = null
|
||||
var firstSpanBegin: Long? = null
|
||||
var skipDepth = 0
|
||||
val text = StringBuilder()
|
||||
var event = parser.eventType
|
||||
while (event != XmlPullParser.END_DOCUMENT) {
|
||||
when (event) {
|
||||
XmlPullParser.START_TAG -> when {
|
||||
parser.name == "p" -> {
|
||||
inP = true; text.clear(); firstSpanBegin = null
|
||||
lineBegin = attr(parser, "begin")?.let(::ttmlMs)
|
||||
}
|
||||
inP && skipDepth > 0 -> skipDepth++
|
||||
inP && parser.name == "span" -> {
|
||||
val role = attr(parser, "role")
|
||||
if (role == "x-bg" || role == "x-translation" || role == "x-roman") skipDepth = 1
|
||||
else if (firstSpanBegin == null) firstSpanBegin = attr(parser, "begin")?.let(::ttmlMs)
|
||||
}
|
||||
}
|
||||
XmlPullParser.TEXT -> if (inP && skipDepth == 0) text.append(parser.text)
|
||||
XmlPullParser.END_TAG -> when {
|
||||
inP && skipDepth > 0 -> skipDepth--
|
||||
parser.name == "p" -> {
|
||||
inP = false
|
||||
val words = text.toString().replace(Regex("\\s+"), " ").trim()
|
||||
if (words.isNotEmpty()) lines.add(LyricLine(lineBegin ?: firstSpanBegin ?: -1, words))
|
||||
}
|
||||
}
|
||||
}
|
||||
event = parser.next()
|
||||
}
|
||||
if (lines.isEmpty()) return null
|
||||
val synced = lines.all { it.timeMs >= 0 } && lines.any { it.timeMs > 0 }
|
||||
return if (synced) LyricsResult(lines.sortedBy { it.timeMs }, synced = true)
|
||||
else LyricsResult(lines.map { LyricLine(0, it.text) }, synced = false)
|
||||
}
|
||||
|
||||
private fun attr(parser: XmlPullParser, localName: String): String? =
|
||||
(0 until parser.attributeCount).firstOrNull { parser.getAttributeName(it) == localName }
|
||||
?.let { parser.getAttributeValue(it) }
|
||||
|
||||
/** TTML times: `1:23.456`, `1:02:03.4`, `83.456s`, `250ms` or a bare number of seconds. */
|
||||
private fun ttmlMs(raw: String): Long? {
|
||||
val t = raw.trim()
|
||||
val seconds = when {
|
||||
':' in t -> t.split(':').fold(0.0) { acc, part -> acc * 60 + (part.toDoubleOrNull() ?: return null) }
|
||||
t.endsWith("ms") -> (t.dropLast(2).toDoubleOrNull() ?: return null) / 1000
|
||||
t.endsWith("s") -> t.dropLast(1).toDoubleOrNull() ?: return null
|
||||
t.endsWith("m") -> (t.dropLast(1).toDoubleOrNull() ?: return null) * 60
|
||||
t.endsWith("h") -> (t.dropLast(1).toDoubleOrNull() ?: return null) * 3600
|
||||
else -> t.toDoubleOrNull() ?: return null
|
||||
}
|
||||
return (seconds * 1000).toLong()
|
||||
}
|
||||
|
||||
// --- LRCLIB ---
|
||||
|
||||
private fun exact(title: String, artist: String, album: String, durationMs: Long): LyricsResult? {
|
||||
if (artist.isBlank()) return null
|
||||
val query = buildString {
|
||||
@@ -111,6 +227,73 @@ object Lyrics {
|
||||
return LyricsResult(plain.lines().map { LyricLine(0, it) }, synced = false)
|
||||
}
|
||||
|
||||
// --- KuGou ---
|
||||
|
||||
/** Songs within this many seconds of the playing one count as the same recording. */
|
||||
private const val KUGOU_DURATION_SLACK = 8
|
||||
private val KUGOU_TIMED = Regex("""\[(\d\d):(\d\d)\.(\d{2,3})].*""")
|
||||
/** A credit line such as `[00:01.00]作词:…` or `[00:02.00]Composer: …`. */
|
||||
private val KUGOU_CREDIT = Regex(""".+].+[::].+""")
|
||||
private const val KUGOU_CREDIT_LINES = 30
|
||||
|
||||
private fun kuGou(title: String, artist: String, album: String, seconds: Int): LyricsResult? {
|
||||
val keyword = buildString {
|
||||
append(kuGouTitle(title)).append(" - ").append(kuGouArtist(artist))
|
||||
if (album.isNotBlank()) append(' ').append(album)
|
||||
}
|
||||
val encoded = URLEncoder.encode(keyword, "UTF-8").replace("+", "%20")
|
||||
// First the song itself, matched by length, then its lyrics by the song's hash.
|
||||
val songs = get(
|
||||
"https://mobileservice.kugou.com/api/v3/search/song?version=9108&plat=0&pagesize=8&showtype=0&keyword=$encoded",
|
||||
)?.let { JSONObject(it).optJSONObject("data")?.optJSONArray("info") }
|
||||
if (songs != null) for (i in 0 until songs.length()) {
|
||||
val song = songs.getJSONObject(i)
|
||||
if (seconds > 0 && abs(song.optInt("duration") - seconds) > KUGOU_DURATION_SLACK) continue
|
||||
val hash = song.optString("hash").takeIf { it.isNotBlank() } ?: continue
|
||||
kuGouCandidate("hash=" + enc(hash))?.let { return it }
|
||||
}
|
||||
// Then a plain lyrics search by name.
|
||||
val byName = "keyword=$encoded" + if (seconds > 0) "&duration=${seconds * 1000}" else ""
|
||||
return kuGouCandidate(byName)
|
||||
}
|
||||
|
||||
private fun kuGouCandidate(query: String): LyricsResult? {
|
||||
val body = get("https://lyrics.kugou.com/search?ver=1&man=yes&client=pc&$query") ?: return null
|
||||
val candidate = JSONObject(body).optJSONArray("candidates")?.optJSONObject(0) ?: return null
|
||||
val id = candidate.optLong("id").takeIf { it != 0L } ?: return null
|
||||
val accessKey = candidate.optString("accesskey").takeIf { it.isNotBlank() } ?: return null
|
||||
val download = get(
|
||||
"https://lyrics.kugou.com/download?fmt=lrc&charset=utf8&client=pc&ver=1&id=$id&accesskey=${enc(accessKey)}",
|
||||
) ?: return null
|
||||
val content = JSONObject(download).optString("content").takeIf { it.isNotBlank() } ?: return null
|
||||
val lrc = String(Base64.decode(content, Base64.DEFAULT), Charsets.UTF_8)
|
||||
val lines = parseLrc(kuGouTrim(lrc).joinToString("\n")).filter { it.text.isNotEmpty() }
|
||||
return if (lines.isEmpty()) null else LyricsResult(lines, synced = true)
|
||||
}
|
||||
|
||||
/** Keeps the timed lines and drops the songwriter and producer credits KuGou puts at either end. */
|
||||
private fun kuGouTrim(lrc: String): List<String> {
|
||||
val timed = lrc.lines().filter { it.matches(KUGOU_TIMED) }
|
||||
if (timed.isEmpty()) return timed
|
||||
val headLimit = minOf(KUGOU_CREDIT_LINES, timed.lastIndex)
|
||||
val head = (headLimit downTo 0).firstOrNull { timed[it].matches(KUGOU_CREDIT) }?.plus(1) ?: 0
|
||||
val rest = timed.drop(head)
|
||||
if (rest.isEmpty()) return rest
|
||||
val tailLimit = minOf(KUGOU_CREDIT_LINES, rest.lastIndex)
|
||||
val tail = (tailLimit downTo 0).firstOrNull { rest[rest.lastIndex - it].matches(KUGOU_CREDIT) }?.plus(1) ?: 0
|
||||
return rest.dropLast(tail)
|
||||
}
|
||||
|
||||
/** KuGou's titles leave out bracketed extras such as "(feat. …)" or "(Live)". */
|
||||
private fun kuGouTitle(title: String) = listOf("\\(.*\\)", "(.*)", "「.*」", "『.*』", "<.*>", "《.*》", "〈.*〉", "<.*>")
|
||||
.fold(title) { acc, pattern -> acc.replace(Regex(pattern), "") }.trim()
|
||||
|
||||
/** KuGou joins several artists with "、". */
|
||||
private fun kuGouArtist(artist: String) = artist.replace(", ", "、").replace(" & ", "、").replace(".", "")
|
||||
.replace(Regex("\\(.*\\)"), "").replace(Regex("(.*)"), "").trim()
|
||||
|
||||
// --- shared ---
|
||||
|
||||
/** `[01:23.45] words` - a line may carry several stamps when a chorus repeats. */
|
||||
private fun parseLrc(text: String): List<LyricLine> {
|
||||
val stamp = Regex("""\[(\d+):(\d+(?:\.\d+)?)]""")
|
||||
@@ -129,6 +312,7 @@ object Lyrics {
|
||||
connectTimeout = 5_000
|
||||
readTimeout = 8_000
|
||||
setRequestProperty("User-Agent", "Gearslip (car media host)")
|
||||
setRequestProperty("Accept", "application/json")
|
||||
}
|
||||
return try {
|
||||
if (connection.responseCode != 200) null else connection.inputStream.bufferedReader().readText()
|
||||
|
||||
@@ -152,6 +152,7 @@ private fun GroupRows(group: CarGroup) {
|
||||
val autoplay by CarSettings.autoplay.collectAsState()
|
||||
val playerTitle by CarSettings.playerTitle.collectAsState()
|
||||
val playerLeft by CarSettings.playerLeft.collectAsState()
|
||||
val moreLyrics by CarSettings.moreLyrics.collectAsState()
|
||||
val theme by CarSettings.appTheme.collectAsState()
|
||||
val uiTheme by CarSettings.uiTheme.collectAsState()
|
||||
|
||||
@@ -177,6 +178,13 @@ private fun GroupRows(group: CarGroup) {
|
||||
Choice("Player beside the map", listOf(false to "Right", true to "Left"), playerLeft, CarSettings::setPlayerLeft)
|
||||
SettingsDivider()
|
||||
Toggle("Autoplay on connect", autoplay, CarSettings::setAutoplay)
|
||||
SettingsDivider()
|
||||
Toggle(
|
||||
"More lyrics sources",
|
||||
moreLyrics,
|
||||
CarSettings::setMoreLyrics,
|
||||
note = "Also asks BetterLyrics and KuGou, which then get the song's name and artist. Off asks LRCLIB only.",
|
||||
)
|
||||
}
|
||||
CarGroup.SOUND -> {
|
||||
Choice("Car audio", listOf(false to "Bluetooth", true to "USB"), pipeAudio, CarSettings::setPipeAudio)
|
||||
|
||||
Reference in new issue
Block a user