mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 08:12:26 +01:00
lyrics: a cached song looks again, once a day, for better lyrics than it has
This commit is contained in:
1 parent
9d629c392b
commit
b2a95adb87
2 files changed
+94
-24
No files matched your search
@@ -394,8 +394,13 @@ internal fun LyricsPanel(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val lyrics by produceState<LyricsState>(LyricsState.Loading, now.title, now.artist, now.album, now.durationMs) {
|
val lyrics by produceState<LyricsState>(LyricsState.Loading, now.title, now.artist, now.album, now.durationMs) {
|
||||||
value = LyricsState.Loading
|
value = LyricsState.Loading
|
||||||
value = Lyrics.find(context, now.title, now.artist, now.album, now.durationMs)
|
val found = Lyrics.find(context, now.title, now.artist, now.album, now.durationMs)
|
||||||
?.let { LyricsState.Found(it) } ?: LyricsState.Missing
|
value = found?.let { LyricsState.Found(it) } ?: LyricsState.Missing
|
||||||
|
// Cached lyrics show at once; if a source now has better ones, they replace them.
|
||||||
|
if (found != null) {
|
||||||
|
Lyrics.upgrade(context, now.title, now.artist, now.album, now.durationMs, found)
|
||||||
|
?.let { value = LyricsState.Found(it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val list = rememberLazyListState()
|
val list = rememberLazyListState()
|
||||||
|
|
||||||
|
|||||||
@@ -55,14 +55,39 @@ object Lyrics {
|
|||||||
|
|
||||||
/** Longest any one source may take, DNS included: a server that never answers can't hold the rest up. */
|
/** 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 const val SOURCE_TIMEOUT_MS = 12_000L
|
||||||
|
/** How long a cached song waits before its sources are asked again for better lyrics. */
|
||||||
|
private const val RECHECK_MS = 24 * 60 * 60 * 1000L
|
||||||
private val io = CoroutineScope(Dispatchers.IO)
|
private val io = CoroutineScope(Dispatchers.IO)
|
||||||
|
|
||||||
|
/** Word-timed beats line-timed, which beats plain text. */
|
||||||
|
private const val BEST = 3
|
||||||
|
private fun quality(result: LyricsResult?): Int = when {
|
||||||
|
result == null -> 0
|
||||||
|
!result.synced -> 1
|
||||||
|
result.source == "BetterLyrics" -> BEST
|
||||||
|
else -> 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One place to look, with the best [quality] it can give. */
|
||||||
|
private class Source(val name: String, val best: Int, val lookup: () -> LyricsResult?)
|
||||||
|
|
||||||
|
private fun sources(title: String, artist: String, album: String, durationMs: Long): List<Source> {
|
||||||
|
val more = CarSettings.moreLyrics.value
|
||||||
|
val seconds = (durationMs / 1000).toInt()
|
||||||
|
return buildList {
|
||||||
|
if (more) add(Source("BetterLyrics", BEST) { betterLyrics(title, artist, album, seconds) })
|
||||||
|
add(Source("LRCLIB", 2) { exact(title, artist, album, durationMs) ?: search(title, artist) })
|
||||||
|
if (more) add(Source("KuGou", 2) { kuGou(title, artist, album, seconds) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun key(title: String, artist: String, album: String, durationMs: Long) = "$title|$artist|$album|$durationMs"
|
||||||
|
|
||||||
suspend fun find(context: Context, title: String, artist: String, album: String, durationMs: Long): LyricsResult? {
|
suspend fun find(context: Context, title: String, artist: String, album: String, durationMs: Long): LyricsResult? {
|
||||||
if (title.isBlank()) return null
|
if (title.isBlank()) return null
|
||||||
val more = CarSettings.moreLyrics.value
|
val key = key(title, artist, album, durationMs)
|
||||||
val key = "$title|$artist|$album|$durationMs"
|
|
||||||
// A miss with only LRCLIB isn't a miss once more sources are on, so the mode is part of it.
|
// 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"
|
val memoryKey = "${CarSettings.moreLyrics.value}|$key"
|
||||||
synchronized(cache) { if (cache.containsKey(memoryKey)) return cache[memoryKey] }
|
synchronized(cache) { if (cache.containsKey(memoryKey)) return cache[memoryKey] }
|
||||||
|
|
||||||
val fromDisk = withContext(Dispatchers.IO) { readDisk(context, key) }
|
val fromDisk = withContext(Dispatchers.IO) { readDisk(context, key) }
|
||||||
@@ -71,32 +96,61 @@ object Lyrics {
|
|||||||
return fromDisk
|
return fromDisk
|
||||||
}
|
}
|
||||||
|
|
||||||
val seconds = (durationMs / 1000).toInt()
|
val result = firstBetter(sources(title, artist, album, durationMs), than = 0)
|
||||||
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) })
|
|
||||||
}
|
|
||||||
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() }?.let { LyricsResult(it.lines, it.synced, name) }
|
|
||||||
if (result != null) {
|
|
||||||
GearslipLog.i("lyrics: found on $name (${if (result.synced) "synced" else "plain"})")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
synchronized(cache) { cache[memoryKey] = result }
|
synchronized(cache) { cache[memoryKey] = result }
|
||||||
if (result != null) withContext(Dispatchers.IO) { writeDisk(context, key, result) }
|
if (result != null) withContext(Dispatchers.IO) { writeDisk(context, key, result, System.currentTimeMillis()) }
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After [find] answered with [current], looks for better lyrics than it: word-timed where it has
|
||||||
|
* line-timed, timed where it has plain. Only the sources that could beat it are asked, and only
|
||||||
|
* once a day per song, so an old cache entry improves when a source gains the song without every
|
||||||
|
* play sending the song out again. Returns the better lyrics, or null to keep [current].
|
||||||
|
*/
|
||||||
|
suspend fun upgrade(
|
||||||
|
context: Context, title: String, artist: String, album: String, durationMs: Long, current: LyricsResult,
|
||||||
|
): LyricsResult? {
|
||||||
|
val have = quality(current)
|
||||||
|
if (have >= BEST || title.isBlank()) return null
|
||||||
|
val key = key(title, artist, album, durationMs)
|
||||||
|
val checked = withContext(Dispatchers.IO) { checkedAt(context, key) }
|
||||||
|
if (System.currentTimeMillis() - checked < RECHECK_MS) return null
|
||||||
|
|
||||||
|
val candidates = sources(title, artist, album, durationMs).filter { it.best > have && it.name != current.source }
|
||||||
|
val better = firstBetter(candidates, than = have)
|
||||||
|
withContext(Dispatchers.IO) { writeDisk(context, key, better ?: current, System.currentTimeMillis()) }
|
||||||
|
if (better == null) return null
|
||||||
|
GearslipLog.i("lyrics: replaced ${current.source.ifEmpty { "cached" }} lyrics with ${better.source}'s")
|
||||||
|
synchronized(cache) { cache["${CarSettings.moreLyrics.value}|$key"] = better }
|
||||||
|
return better
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asks [sources] in order and returns the first answer of a higher [quality] than [than]. */
|
||||||
|
private suspend fun firstBetter(sources: List<Source>, than: Int): LyricsResult? {
|
||||||
|
for (source in sources) {
|
||||||
|
val job = io.async {
|
||||||
|
runCatching(source.lookup).onFailure { GearslipLog.w("lyrics: ${source.name} failed: ${it.message}") }.getOrNull()
|
||||||
|
}
|
||||||
|
val result = withTimeoutOrNull(SOURCE_TIMEOUT_MS) { job.await() }
|
||||||
|
?.let { LyricsResult(it.lines, it.synced, source.name) }
|
||||||
|
if (quality(result) > than) {
|
||||||
|
GearslipLog.i("lyrics: found on ${source.name} (${if (result!!.synced) "synced" else "plain"})")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
private fun cacheFile(context: Context, key: String): File {
|
private fun cacheFile(context: Context, key: String): File {
|
||||||
val digest = MessageDigest.getInstance("SHA-1").digest(key.toByteArray())
|
val digest = MessageDigest.getInstance("SHA-1").digest(key.toByteArray())
|
||||||
val name = digest.joinToString("") { "%02x".format(it) }
|
val name = digest.joinToString("") { "%02x".format(it) }
|
||||||
return File(context.cacheDir, "lyrics").apply { mkdirs() }.resolve("$name.json")
|
return File(context.cacheDir, LYRICS_DIR).apply { mkdirs() }.resolve("$name.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Where the lyrics cache lives under the app's cache directory, for the storage screen too. */
|
||||||
|
const val LYRICS_DIR = "lyrics"
|
||||||
|
|
||||||
private fun readDisk(context: Context, key: String): LyricsResult? = runCatching {
|
private fun readDisk(context: Context, key: String): LyricsResult? = runCatching {
|
||||||
val file = cacheFile(context, key)
|
val file = cacheFile(context, key)
|
||||||
if (!file.exists()) return null
|
if (!file.exists()) return null
|
||||||
@@ -110,17 +164,28 @@ object Lyrics {
|
|||||||
LyricsResult(lines, json.getBoolean("synced"), json.optString("source"))
|
LyricsResult(lines, json.getBoolean("synced"), json.optString("source"))
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
private fun writeDisk(context: Context, key: String, result: LyricsResult) {
|
/** When the song's sources were last asked; 0 for entries from before the date was kept. */
|
||||||
|
private fun checkedAt(context: Context, key: String): Long =
|
||||||
|
runCatching { JSONObject(cacheFile(context, key).readText()).optLong("checked") }.getOrDefault(0L)
|
||||||
|
|
||||||
|
private fun writeDisk(context: Context, key: String, result: LyricsResult, checked: Long) {
|
||||||
runCatching {
|
runCatching {
|
||||||
val lines = JSONArray()
|
val lines = JSONArray()
|
||||||
result.lines.forEach { line ->
|
result.lines.forEach { line ->
|
||||||
lines.put(JSONObject().put("t", line.timeMs).put("x", line.text))
|
lines.put(JSONObject().put("t", line.timeMs).put("x", line.text))
|
||||||
}
|
}
|
||||||
val json = JSONObject().put("synced", result.synced).put("source", result.source).put("lines", lines)
|
val json = JSONObject().put("synced", result.synced).put("source", result.source)
|
||||||
|
.put("checked", checked).put("lines", lines)
|
||||||
cacheFile(context, key).writeText(json.toString())
|
cacheFile(context, key).writeText(json.toString())
|
||||||
}.onFailure { GearslipLog.w("lyrics: could not write disk cache: ${it.message}") }
|
}.onFailure { GearslipLog.w("lyrics: could not write disk cache: ${it.message}") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Forgets every cached song, on disk and in memory. */
|
||||||
|
fun clearCache(context: Context) {
|
||||||
|
synchronized(cache) { cache.clear() }
|
||||||
|
File(context.cacheDir, LYRICS_DIR).deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
// --- BetterLyrics ---
|
// --- BetterLyrics ---
|
||||||
|
|
||||||
private fun betterLyrics(title: String, artist: String, album: String, seconds: Int): LyricsResult? {
|
private fun betterLyrics(title: String, artist: String, album: String, seconds: Int): LyricsResult? {
|
||||||
|
|||||||
Reference in new issue
Block a user