EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

LyricsRepository.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.lyrics
2
3import dev.stave.data.StavePaths
4import dev.stave.domain.model.DidlItem
5import dev.stave.net.UrlPolicy
6import kotlinx.coroutines.Dispatchers
7import kotlinx.coroutines.withContext
8import kotlinx.serialization.SerialName
9import kotlinx.serialization.Serializable
10import kotlinx.serialization.json.Json
11import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
12import okhttp3.OkHttpClient
13import okhttp3.Request
14import okhttp3.Response
15import java.io.IOException
16import java.io.File
17import java.security.MessageDigest
18import java.util.concurrent.TimeUnit
19
20/**
21 * Fetches and caches time-synced lyrics from lrclib.net.
22 *
23 * **Lookup order** (cheapest first, as on Android):
24 * 1. The local cache (`$XDG_CACHE_HOME/earhorn/lyrics/<hash>.json`).
25 * 2. The export folder (`$XDG_CACHE_HOME/earhorn/lyrics-export/`): LRCs saved from
26 * earlier lookups, or placed there by hand under the same file name.
27 * 3. A `.lrc` beside the song on the music server. Asked only when the server is on
28 * the local network, or when external calls are allowed — otherwise "external
29 * calls off" would not hold for, say, an internet radio URL from a followed Cast
30 * session.
31 * 4. LRCLIB, only when external calls are allowed (Settings → Lyrics).
32 *
33 * **Cache strategy.** Keyed by an MD5 of the track's resourceUrl (the UPnP stream
34 * URL is the closest thing to a stable identifier we have — titles can clash,
35 * MinimServer itemIds aren't portable across rescans, but the stream URL points at
36 * one specific file on the server).
37 *
38 * Negative results are cached too — nothing more wasteful than pinging LRCLIB every
39 * time the user opens lyrics for an obscure demo recording. Negative cache TTL is
40 * shorter than positive so re-lookups after LRCLIB grows its catalog eventually
41 * succeed.
42 *
43 * **API.** `GET https://lrclib.net/api/get?track_name=…&artist_name=…&album_name=…
44 * &duration=…`. LRCLIB is free, no auth, rate limits are generous. Durations match
45 * with a few seconds of slack server-side. A 404 means "we don't know this track" —
46 * we translate to [LyricsResult.NotFound] and cache briefly. `track_name` must match
47 * exactly, so several cleaned-up title variants are tried (see [titleCandidates]).
48 */
49class LyricsRepository(
50 private val settings: dev.stave.data.SettingsStore,
51 cacheDir: File = StavePaths.lyricsCacheDir,
52) {
53
54 val cacheDir: File = StavePaths.ensurePrivateDir(cacheDir)
55
56 /** Where found lyrics are saved as plain `.lrc` files (and read back from). */
57 private val exportDir: File get() = File(cacheDir.parentFile, "lyrics-export")
58
59 private val http: OkHttpClient = OkHttpClient.Builder()
60 // Generous timeouts — LRCLIB is generally fast but we're hitting it over
61 // whatever WAN the user happens to have, and DNS on cold connections can
62 // be the dominant latency. Previously 5s/10s, which failed often enough
63 // to be annoying on marginal links.
64 .connectTimeout(15, TimeUnit.SECONDS)
65 .readTimeout(20, TimeUnit.SECONDS)
66 .writeTimeout(10, TimeUnit.SECONDS)
67 // Let OkHttp itself retry at the connection layer when a route fails.
68 // This is free recovery for cases like stale-socket or ephemeral DNS.
69 .retryOnConnectionFailure(true)
70 .build()
71
72 /** Sidecar client: the music server is normally on the LAN, so short timeouts
73 * (a dead server must not hold up the LRCLIB lookup), and no redirects — a LAN
74 * server has no reason to bounce a sidecar request somewhere else, possibly
75 * off the LAN. */
76 private val sidecarHttp: OkHttpClient = http.newBuilder()
77 .connectTimeout(5, TimeUnit.SECONDS)
78 .readTimeout(10, TimeUnit.SECONDS)
79 .followRedirects(false)
80 .followSslRedirects(false)
81 .build()
82
83 private val json = Json { ignoreUnknownKeys = true }
84
85 suspend fun fetch(track: DidlItem.Track): LyricsResult = withContext(Dispatchers.IO) {
86 // Can't lookup without at least a title. Artist matters too but LRCLIB does
87 // fuzzy matching so blank artist occasionally still resolves.
88 if (track.title.isBlank()) return@withContext LyricsResult.NotFound
89
90 val cacheFile = File(cacheDir, cacheKey(track) + ".json")
91 readCache(cacheFile)?.let { return@withContext it }
92
93 // An LRC saved earlier (or dropped in by hand) — local disk, no network.
94 fetchLocalExport(track)?.let { r ->
95 writeCache(cacheFile, r)
96 return@withContext r
97 }
98
99 // A .lrc next to the song on the server comes first (the maintainer's
100 // answer 3, 2026-09-04 — as on Android). Gated inside: LAN servers
101 // always, anything else only with external calls allowed.
102 fetchServerSidecar(track)?.let { r ->
103 writeCache(cacheFile, r)
104 return@withContext r
105 }
106
107 // Privacy gate: external calls disabled → never hit LRCLIB. The cache
108 // lookup above already ran (cache is local-disk only, no network), so a
109 // previously-fetched track still works when the user later toggles off.
110 // Toggling back on + LyricsViewModel.refresh() will then fetch on demand.
111 if (!settings.allowExternalCalls.value) {
112 return@withContext LyricsResult.PrivacyDisabled
113 }
114
115 val result = fetchFromApi(track)
116 writeCache(cacheFile, result)
117 if (result is LyricsResult.Loaded) exportLrc(track, result)
118 result
119 }
120
121 /** Refresh: ask again, ignoring the cache; replace the cached answer only
122 * when a real one comes back (as ruled for Android). The old refresh
123 * re-read the cache and never asked.
124 *
125 * [skipDuration] leaves the duration out of the LRCLIB request: the user
126 * pressed Refresh because the match was bad, and the duration filter is
127 * often what drops the better variant (as on Android). */
128 suspend fun fetchFresh(
129 track: DidlItem.Track,
130 skipDuration: Boolean = false,
131 ): LyricsResult = withContext(Dispatchers.IO) {
132 if (track.title.isBlank()) return@withContext LyricsResult.NotFound
133 val cacheFile = File(cacheDir, cacheKey(track) + ".json")
134 if (!settings.allowExternalCalls.value) return@withContext LyricsResult.PrivacyDisabled
135 val result = fetchFromApi(track, skipDuration = skipDuration)
136 when (result) {
137 is LyricsResult.Loaded, LyricsResult.Instrumental, LyricsResult.NotFound -> {
138 writeCache(cacheFile, result)
139 if (result is LyricsResult.Loaded) {
140 exportLrc(track, result)
141 } else {
142 // The saved LRC was the bad match the user is replacing; without
143 // this, the next normal lookup would read it straight back.
144 File(exportDir, buildExportFilename(track)).delete()
145 }
146 }
147 else -> Unit
148 }
149 result
150 }
151
152 // ---- Local export folder --------------------------------------------------
153
154 /** Read a previously exported LRC (or a hand-placed one with the same name).
155 * Ported from Android's `fetchLocalExport`. */
156 private fun fetchLocalExport(track: DidlItem.Track): LyricsResult? {
157 val file = File(exportDir, buildExportFilename(track))
158 if (!file.isFile || file.length() == 0L || file.length() > MAX_LRC_BYTES) return null
159 val body = try {
160 file.readText(Charsets.UTF_8)
161 } catch (_: IOException) {
162 return null
163 }
164 return lyricsFromLrcText(body)
165 }
166
167 // ---- Server-side sidecar ----------------------------------------------------
168
169 /** `<song>.lrc` beside the song on the music server, if the server serves
170 * it. Stations and non-http resources are skipped, and so is any server off
171 * the local network unless external calls are allowed. */
172 private fun fetchServerSidecar(track: DidlItem.Track): LyricsResult? {
173 val base = track.resourceUrl
174 if (!base.startsWith("http://", true) && !base.startsWith("https://", true)) return null
175 // "External calls off" only holds if the server is on the LAN.
176 if (!UrlPolicy.isLanUrl(base) && !settings.allowExternalCalls.value) return null
177 val dot = base.lastIndexOf('.'); val slash = base.lastIndexOf('/')
178 val lrcUrl = if (dot > slash) base.substring(0, dot) + ".lrc" else return null
179 val request = try {
180 Request.Builder().url(lrcUrl).build()
181 } catch (_: IllegalArgumentException) {
182 return null
183 }
184 return try {
185 sidecarHttp.newCall(request).execute().use { r ->
186 if (r.code != 200) return@use null
187 val text = readSidecarBody(r) ?: return@use null
188 lyricsFromLrcText(text)
189 }
190 } catch (_: IOException) {
191 null
192 }
193 }
194
195 /**
196 * The body of a sidecar response, or null when it doesn't look like an LRC file.
197 * Servers that map paths by id and ignore the extension, or answer unknown paths
198 * with an HTML page and status 200, would otherwise have audio or HTML read whole
199 * into memory, shown as lyrics and cached for a week.
200 */
201 private fun readSidecarBody(r: Response): String? {
202 val body = r.body ?: return null
203 val type = body.contentType()
204 if (type != null) {
205 val t = type.type.lowercase()
206 val sub = type.subtype.lowercase()
207 val textual = (t == "text" && sub != "html" && !sub.contains("xml")) ||
208 (t == "application" && (sub == "octet-stream" || sub.contains("lrc"))) ||
209 sub.contains("lrc")
210 if (!textual) return null
211 }
212 if (body.contentLength() > MAX_LRC_BYTES) return null
213 val source = body.source()
214 // request(n) is true when at least n bytes are available: one past the cap
215 // means the file is too big to be lyrics.
216 if (source.request(MAX_LRC_BYTES + 1)) return null
217 val bytes = source.buffer.readByteArray()
218 if (bytes.any { it == 0.toByte() }) return null // binary, not text
219 val text = String(bytes, type?.charset(Charsets.UTF_8) ?: Charsets.UTF_8)
220 val head = text.trimStart().take(64).lowercase()
221 if (head.startsWith("<!doctype") || head.startsWith("<html") || head.startsWith("<?xml")) return null
222 return text.takeIf { it.isNotBlank() }
223 }
224
225 private fun lyricsFromLrcText(text: String): LyricsResult.Loaded? {
226 if (text.isBlank()) return null
227 val synced = LrcParser.parse(text)
228 if (synced.isNotEmpty()) return LyricsResult.Loaded(synced = synced, plain = null)
229 // Plain lyrics: drop `[ar:…]`-style header tags (our own exports carry them).
230 val plain = text.lineSequence()
231 .filterNot { LRC_TAG_LINE.matches(it.trim()) }
232 .joinToString("\n").trim()
233 return if (plain.isBlank()) null else LyricsResult.Loaded(synced = emptyList(), plain = plain)
234 }
235
236 // ---- Export -------------------------------------------------------------
237
238 /** Save found lyrics as `<artist> - <title> [hash8].lrc` in the export folder,
239 * with LRC header tags (ported from Android). The hash suffix keeps two
240 * recordings with the same artist and title (live, remaster) apart.
241 * Best effort: export is a convenience, not a correctness requirement. */
242 private fun exportLrc(track: DidlItem.Track, r: LyricsResult.Loaded) {
243 try {
244 val content = buildLrcText(track, r)
245 if (content.isBlank()) return
246 StavePaths.ensurePrivateDir(exportDir)
247 File(exportDir, buildExportFilename(track)).writeText(content, Charsets.UTF_8)
248 } catch (_: IOException) {
249 }
250 }
251
252 /** "Artist - Title [abcd1234].lrc" with every character other than letters,
253 * digits and ` -._[]()` replaced by `_` (so no path separators). */
254 private fun buildExportFilename(track: DidlItem.Track): String {
255 val artist = track.artist?.takeIf { it.isNotBlank() } ?: "Unknown Artist"
256 val title = track.title.ifBlank { "Unknown Title" }
257 val raw = "${artist.take(60)} - ${title.take(120)} [${cacheKey(track).take(8)}].lrc"
258 return raw.map { c ->
259 when {
260 c.isLetterOrDigit() -> c
261 c in " -._[]()" -> c
262 else -> '_'
263 }
264 }.joinToString("")
265 }
266
267 /** Loaded lyrics back to LRC: header tags, then `[mm:ss.xx]text` per synced line,
268 * or the plain text as-is. */
269 private fun buildLrcText(track: DidlItem.Track, result: LyricsResult.Loaded): String {
270 fun tag(s: String) = s.replace(Regex("[\\r\\n\\]]+"), " ")
271 val sb = StringBuilder()
272 track.artist?.takeIf { it.isNotBlank() }?.let { sb.append("[ar:${tag(it)}]\n") }
273 track.album?.takeIf { it.isNotBlank() }?.let { sb.append("[al:${tag(it)}]\n") }
274 if (track.title.isNotBlank()) sb.append("[ti:${tag(track.title)}]\n")
275 sb.append("[by:Earhorn (lrclib.net)]\n\n")
276 if (result.synced.isNotEmpty()) {
277 for (line in result.synced) {
278 val ms = line.start.inWholeMilliseconds
279 sb.append("[%02d:%02d.%02d]".format(ms / 60000, (ms / 1000) % 60, (ms % 1000) / 10))
280 sb.append(line.text).append('\n')
281 }
282 } else if (!result.plain.isNullOrBlank()) {
283 sb.append(result.plain)
284 }
285 return sb.toString()
286 }
287
288 // ---- HTTP ---------------------------------------------------------------
289
290 /** Try each title variant until LRCLIB knows one (ported from Android). Stops at
291 * the first Loaded / Instrumental, and at the first Error so a network fault
292 * isn't retried once per variant. */
293 private suspend fun fetchFromApi(track: DidlItem.Track, skipDuration: Boolean = false): LyricsResult {
294 var last: LyricsResult = LyricsResult.NotFound
295 for (candidate in titleCandidates(track.title, track.artist)) {
296 when (val r = requestOnce(candidate, track, skipDuration)) {
297 is LyricsResult.Loaded, LyricsResult.Instrumental -> return r
298 is LyricsResult.Error -> return r
299 else -> last = r
300 }
301 }
302 return last
303 }
304
305 /** Ordered, de-duplicated candidate titles: literal first, then with
306 * trailing parentheticals/brackets stripped (repeatedly — there can
307 * be several), then with a leading "Artist - " prefix removed when
308 * it matches the track's artist, then with a trailing " - suffix"
309 * (e.g. "- 2009 Remaster") removed. Suffixed text is treated as
310 * suspect for title relevance, never authoritative. Capped at 4.
311 * Verbatim from Android. */
312 internal fun titleCandidates(raw: String, artist: String?): List<String> {
313 val out = LinkedHashSet<String>()
314 var s = raw.trim()
315 if (s.isBlank()) return emptyList()
316 out += s
317 while (true) {
318 val stripped = s.replace(Regex("""\s*[(\[][^()\[\]]*[)\]]\s*${'$'}"""), "").trim()
319 if (stripped == s || stripped.isBlank()) break
320 s = stripped
321 out += s
322 }
323 val dash = s.indexOf(" - ")
324 if (dash > 0) {
325 val prefix = s.substring(0, dash).trim()
326 val rest = s.substring(dash + 3).trim()
327 if (rest.isNotBlank() &&
328 artist != null && prefix.equals(artist.trim(), ignoreCase = true)
329 ) {
330 out += rest
331 }
332 }
333 val lastDash = s.lastIndexOf(" - ")
334 if (lastDash > 0) {
335 val head = s.substring(0, lastDash).trim()
336 if (head.isNotBlank()) out += head
337 }
338 return out.toList().take(4)
339 }
340
341 private suspend fun requestOnce(
342 title: String,
343 track: DidlItem.Track,
344 skipDuration: Boolean,
345 ): LyricsResult {
346 val builder = "https://lrclib.net/api/get".toHttpUrlOrNull()
347 ?.newBuilder()
348 ?: return LyricsResult.Error("Bad LRCLIB URL (should never happen)")
349
350 builder.addQueryParameter("track_name", title)
351 track.artist?.takeIf { it.isNotBlank() }?.let {
352 builder.addQueryParameter("artist_name", it)
353 }
354 track.album?.takeIf { it.isNotBlank() }?.let {
355 builder.addQueryParameter("album_name", it)
356 }
357 if (!skipDuration) {
358 track.duration?.inWholeSeconds?.takeIf { it > 0 }?.let {
359 builder.addQueryParameter("duration", it.toString())
360 }
361 }
362
363 // LRCLIB's front end refuses OkHttp's default agent with a 520
364 // (found on Android); a descriptive one is answered.
365 val req = Request.Builder().url(builder.build())
366 .header("User-Agent", LRCLIB_USER_AGENT)
367 .header("Lrclib-Client", LRCLIB_USER_AGENT)
368 .build()
369
370 // Retry once on IOException (connect/read timeout, DNS, etc.). This handles
371 // the common "cold link" case where a first attempt times out because DNS
372 // or the TCP handshake didn't complete in time, but the second attempt
373 // benefits from warm caches and succeeds. A bad answer (unparseable JSON)
374 // is not retried.
375 var lastError: IOException? = null
376 repeat(2) { attempt ->
377 try {
378 return http.newCall(req).execute().use { r -> interpret(r) }
379 } catch (e: IOException) {
380 lastError = e
381 if (attempt == 0) kotlinx.coroutines.delay(500)
382 }
383 }
384 return LyricsResult.Error(lastError?.message ?: "Network error")
385 }
386
387 private fun interpret(r: Response): LyricsResult = when (r.code) {
388 200 -> {
389 val body = r.body?.string().orEmpty()
390 try {
391 val parsed = json.decodeFromString<LrclibResponse>(body)
392 when {
393 parsed.instrumental -> LyricsResult.Instrumental
394 !parsed.syncedLyrics.isNullOrBlank() -> LyricsResult.Loaded(
395 synced = LrcParser.parse(parsed.syncedLyrics),
396 plain = parsed.plainLyrics,
397 )
398 !parsed.plainLyrics.isNullOrBlank() -> LyricsResult.Loaded(
399 synced = emptyList(),
400 plain = parsed.plainLyrics,
401 )
402 else -> LyricsResult.NotFound
403 }
404 } catch (_: kotlinx.serialization.SerializationException) {
405 LyricsResult.Error("LRCLIB sent an unreadable answer")
406 } catch (_: IllegalArgumentException) {
407 LyricsResult.Error("LRCLIB sent an unreadable answer")
408 }
409 }
410 404 -> LyricsResult.NotFound
411 else -> LyricsResult.Error("LRCLIB returned HTTP ${r.code}")
412 }
413
414 // ---- Cache --------------------------------------------------------------
415
416 /** MD5 of the resourceUrl, hex-encoded. A hash is safer than the raw URL for
417 * a filesystem path — URLs contain characters we'd have to escape. */
418 private fun cacheKey(track: DidlItem.Track): String {
419 val md = MessageDigest.getInstance("MD5")
420 return md.digest(track.resourceUrl.toByteArray(Charsets.UTF_8))
421 .joinToString("") { "%02x".format(it) }
422 }
423
424 private fun readCache(file: File): LyricsResult? {
425 if (!file.exists()) return null
426 val age = System.currentTimeMillis() - file.lastModified()
427 return runCatching {
428 val env = json.decodeFromString<CachedEnvelope>(file.readText(Charsets.UTF_8))
429 val maxAge = if (env.isPositive()) POSITIVE_TTL_MS else NEGATIVE_TTL_MS
430 if (age > maxAge) null else env.toResult()
431 }.getOrNull()
432 }
433
434 private fun writeCache(file: File, result: LyricsResult) {
435 // Transient outcomes don't belong in the cache.
436 // Error: a network blip shouldn't poison the cache for a week.
437 // PrivacyDisabled: the user can flip the setting back on at any time;
438 // when they do we want a real fetch on the next refresh, not
439 // "you opted out" served from disk.
440 if (result is LyricsResult.Error) return
441 if (result is LyricsResult.PrivacyDisabled) return
442 runCatching {
443 val env = CachedEnvelope.from(result)
444 file.writeText(json.encodeToString(CachedEnvelope.serializer(), env), Charsets.UTF_8)
445 }
446 }
447
448 @Serializable
449 private data class CachedEnvelope(
450 @SerialName("kind") val kind: String,
451 @SerialName("syncedLines") val syncedLines: List<Pair<Long, String>> = emptyList(),
452 @SerialName("plain") val plain: String? = null,
453 ) {
454 fun isPositive(): Boolean = kind == "loaded"
455 fun toResult(): LyricsResult = when (kind) {
456 "loaded" -> LyricsResult.Loaded(
457 synced = syncedLines.map { (ms, text) -> LrcLine.at(ms, text) },
458 plain = plain,
459 )
460 "instrumental" -> LyricsResult.Instrumental
461 "notfound" -> LyricsResult.NotFound
462 else -> LyricsResult.NotFound
463 }
464
465 companion object {
466 fun from(r: LyricsResult): CachedEnvelope = when (r) {
467 is LyricsResult.Loaded -> CachedEnvelope(
468 kind = "loaded",
469 syncedLines = r.synced.map { it.start.inWholeMilliseconds to it.text },
470 plain = r.plain,
471 )
472 LyricsResult.Instrumental -> CachedEnvelope(kind = "instrumental")
473 LyricsResult.NotFound -> CachedEnvelope(kind = "notfound")
474 // Loading / Error / PrivacyDisabled are all filtered out by
475 // writeCache before this runs. The branches exist only to
476 // satisfy the compiler's exhaustiveness check; the "notfound"
477 // kind is the safest fallback if a future writeCache change
478 // ever lets one slip through.
479 LyricsResult.Loading,
480 LyricsResult.PrivacyDisabled,
481 is LyricsResult.Error -> CachedEnvelope(kind = "notfound")
482 }
483 }
484 }
485
486 companion object {
487 /** Upper bound for an LRC file read from a server or the export folder. */
488 private const val MAX_LRC_BYTES = 256L * 1024
489 private val LRC_TAG_LINE = Regex("""\[[a-zA-Z]+:[^\]]*\]""")
490 // Keep positive results a week — lyrics don't really change, and the user
491 // shouldn't pay network latency to open the same track's lyrics twice.
492 private const val POSITIVE_TTL_MS = 7L * 24 * 60 * 60 * 1000
493 // Refresh negative results every 24h so LRCLIB catalog growth eventually
494 // produces hits for tracks that used to miss.
495 private const val NEGATIVE_TTL_MS = 24L * 60 * 60 * 1000
496 private const val LRCLIB_USER_AGENT = "Earhorn Music Player (https://faveat.software)"
497 }
498}