LyricsRepository.kt
Each line, with the release that last changed it.
| 0.5.0 | 1 | package dev.stave.lyrics |
| 2 | ||
| 3 | import dev.stave.data.StavePaths | |
| 4 | import dev.stave.domain.model.DidlItem | |
| 5 | import dev.stave.net.UrlPolicy | |
| 6 | import kotlinx.coroutines.Dispatchers | |
| 7 | import kotlinx.coroutines.withContext | |
| 8 | import kotlinx.serialization.SerialName | |
| 9 | import kotlinx.serialization.Serializable | |
| 10 | import kotlinx.serialization.json.Json | |
| 11 | import okhttp3.HttpUrl.Companion.toHttpUrlOrNull | |
| 12 | import okhttp3.OkHttpClient | |
| 13 | import okhttp3.Request | |
| 14 | import okhttp3.Response | |
| 15 | import java.io.IOException | |
| 16 | import java.io.File | |
| 17 | import java.security.MessageDigest | |
| 18 | import 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 | */ | |
| 49 | class 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 | } |


