LyricsRepository.kt
Each line, with the release that last changed it.
| 0.5.0 | 1 | package dev.stave.lyrics |
| 2 | ||
| 3 | import android.content.Context | |
| 4 | import dagger.hilt.android.qualifiers.ApplicationContext | |
| 5 | import dev.stave.domain.model.DidlItem | |
| 6 | import kotlinx.coroutines.Dispatchers | |
| 7 | import kotlinx.coroutines.flow.first | |
| 8 | import kotlinx.coroutines.withContext | |
| 9 | import kotlinx.serialization.SerialName | |
| 10 | import kotlinx.serialization.Serializable | |
| 11 | import kotlinx.serialization.json.Json | |
| 12 | import okhttp3.HttpUrl.Companion.toHttpUrlOrNull | |
| 13 | import okhttp3.OkHttpClient | |
| 14 | import okhttp3.Request | |
| 15 | import java.io.File | |
| 16 | import java.security.MessageDigest | |
| 17 | import java.util.concurrent.TimeUnit | |
| 18 | import javax.inject.Inject | |
| 19 | import javax.inject.Singleton | |
| 20 | ||
| 21 | /** | |
| 22 | * Fetches and caches time-synced lyrics from lrclib.net. | |
| 23 | * | |
| 24 | * Cache is keyed by MD5 of the track's resourceUrl (closest thing to a stable identifier | |
| 25 | * — titles can clash, MinimServer itemIds aren't portable across rescans, but the stream | |
| 26 | * URL points at one specific file on the server). Stored at | |
| 27 | * `<app cacheDir>/stave/lyrics/<hash>.json`. | |
| 28 | * | |
| 29 | * Negative results are cached briefly so we don't ping LRCLIB every time the user opens | |
| 30 | * lyrics for an obscure track; positive results are cached for a week since lyrics | |
| 31 | * don't really change. Errors are never cached — transient failures shouldn't poison | |
| 32 | * lookups. | |
| 33 | */ | |
| 34 | @Singleton | |
| 35 | class LyricsRepository @Inject constructor( | |
| 36 | @ApplicationContext private val context: Context, | |
| 37 | private val settings: dev.stave.data.SettingsStore, | |
| 38 | ) { | |
| 39 | ||
| 40 | private val cacheDir: File = context.cacheDir.resolve("stave/lyrics").also { it.mkdirs() } | |
| 41 | ||
| 42 | /** User-visible export directory for fetched LRC files. Android TV users can | |
| 43 | * pull this via ADB (`adb pull`) or over USB to deploy sidecar .lrc files | |
| 44 | * next to their audio library. Path: <app external files>/lyrics/. */ | |
| 45 | private val exportDir: File? = runCatching { | |
| 46 | context.getExternalFilesDir("lyrics") | |
| 47 | }.getOrNull()?.also { it.mkdirs() } | |
| 48 | ||
| 49 | private val http: OkHttpClient = OkHttpClient.Builder() | |
| 50 | .connectTimeout(5, TimeUnit.SECONDS) | |
| 51 | .readTimeout(10, TimeUnit.SECONDS) | |
| 52 | .build() | |
| 53 | ||
| 54 | private val json = Json { ignoreUnknownKeys = true } | |
| 55 | ||
| 56 | suspend fun fetch(track: DidlItem.Track): LyricsResult = withContext(Dispatchers.IO) { | |
| 57 | if (track.title.isBlank()) return@withContext LyricsResult.NotFound | |
| 58 | ||
| 59 | // Lookup order, cheapest to most expensive: | |
| 60 | // 1) In-app cache (the file we wrote last time we fetched). | |
| 61 | // 2) User-facing export dir — previously-fetched LRCs the user may have | |
| 62 | // retained. Consulted BEFORE the HTTP sidecar so a once-fetched track | |
| 63 | // is found without any network activity. | |
| 64 | // 3) Server-side .lrc sidecar (HTTP GET next to the audio file). | |
| 65 | // 4) LRCLIB — only if privacy allows network calls. | |
| 66 | // On a successful LRCLIB fetch we also export the result to the local | |
| 67 | // dir so future lookups land at step 2 without needing LRCLIB again. | |
| 68 | val cacheFile = File(cacheDir, cacheKey(track) + ".json") | |
| 69 | readCache(cacheFile)?.let { return@withContext it } | |
| 70 | ||
| 71 | fetchLocalExport(track)?.let { | |
| 72 | writeCache(cacheFile, it) | |
| 73 | return@withContext it | |
| 74 | } | |
| 75 | ||
| 76 | fetchServerSidecar(track)?.let { | |
| 77 | writeCache(cacheFile, it) | |
| 78 | return@withContext it | |
| 79 | } | |
| 80 | ||
| 81 | val allow = settings.allowExternalCalls.first() | |
| 82 | if (!allow) return@withContext LyricsResult.PrivacyDisabled | |
| 83 | ||
| 84 | val result = fetchFromApi(track) | |
| 85 | writeCache(cacheFile, result) | |
| 86 | if (result is LyricsResult.Loaded) exportLrc(track, result) | |
| 87 | result | |
| 88 | } | |
| 89 | ||
| 90 | // ---- Local export-dir lookup -------------------------------------------- | |
| 91 | ||
| 92 | /** Read a previously-exported LRC from the external files directory. This is | |
| 93 | * where we drop successful LRCLIB fetches (and where the user can add their | |
| 94 | * own hand-placed LRCs). Filename matches what [buildExportFilename] produced | |
| 95 | * at write time, so a round trip (fetch → export → fetch again) hits this | |
| 96 | * step without going near the network. */ | |
| 97 | private fun fetchLocalExport(track: DidlItem.Track): LyricsResult? { | |
| 98 | val dir = exportDir ?: return null | |
| 99 | val file = File(dir, buildExportFilename(track)) | |
| 100 | if (!file.exists() || file.length() == 0L) return null | |
| 101 | return runCatching { | |
| 102 | val body = file.readText(Charsets.UTF_8) | |
| 103 | if (body.isBlank()) return@runCatching null | |
| 104 | val synced = LrcParser.parse(body) | |
| 105 | if (synced.isNotEmpty()) { | |
| 106 | LyricsResult.Loaded(synced = synced, plain = null) | |
| 107 | } else { | |
| 108 | // Plain-text LRC (metadata headers only or free text) — surface as | |
| 109 | // plain so the user still sees something. | |
| 110 | LyricsResult.Loaded(synced = emptyList(), plain = body) | |
| 111 | } | |
| 112 | }.getOrNull() | |
| 113 | } | |
| 114 | ||
| 115 | // ---- Server-side HTTP sidecar ------------------------------------------- | |
| 116 | ||
| 117 | /** Try to fetch a `.lrc` file sibling to the track's audio file via HTTP. | |
| 118 | * Works when the UPnP server mirrors the on-disk filesystem (MinimServer, | |
| 119 | * Plex). URL-scheme servers (MediaTomb `/content/<id>`) have no predictable | |
| 120 | * sidecar path — this returns null and we fall through. */ | |
| 121 | private fun fetchServerSidecar(track: DidlItem.Track): LyricsResult? { | |
| 122 | val base = track.resourceUrl | |
| 123 | if (base.isBlank()) return null | |
| 124 | // Sidecar is HTTP-only. OkHttp's Request.Builder().url(String) throws | |
| 125 | // IllegalArgumentException for any scheme other than http/https, and | |
| 126 | // that throw is OUTSIDE the runCatching below — so a local-file | |
| 127 | // track (content:// MediaStore URI) was crashing the lyrics tap with | |
| 128 | // an uncaught exception in the IO coroutine. Server-side .lrc | |
| 129 | // sidecar lookup only makes sense when the track is served via HTTP | |
| 130 | // anyway; bail early for everything else and let the caller fall | |
| 131 | // through to LRCLIB. | |
| 132 | if (!base.startsWith("http://", ignoreCase = true) && | |
| 133 | !base.startsWith("https://", ignoreCase = true)) { | |
| 134 | return null | |
| 135 | } | |
| 136 | val dot = base.lastIndexOf('.') | |
| 137 | val slash = base.lastIndexOf('/') | |
| 138 | // Only replace a dot that's in the last path component (after the final slash). | |
| 139 | // Otherwise URLs like "http://server.local/stream?id=5.flac" would mangle. | |
| 140 | val lrcUrl = if (dot > slash) base.substring(0, dot) + ".lrc" else "$base.lrc" | |
| 141 | ||
| 142 | val req = Request.Builder().url(lrcUrl).build() | |
| 143 | return runCatching { | |
| 144 | http.newCall(req).execute().use { r -> | |
| 145 | if (!r.isSuccessful) return@use null | |
| 146 | val body = r.body?.string().orEmpty() | |
| 147 | if (body.isBlank()) return@use null | |
| 148 | val synced = LrcParser.parse(body) | |
| 149 | if (synced.isNotEmpty()) { | |
| 150 | LyricsResult.Loaded(synced = synced, plain = null) | |
| 151 | } else { | |
| 152 | LyricsResult.Loaded(synced = emptyList(), plain = body) | |
| 153 | } | |
| 154 | } | |
| 155 | }.getOrNull() | |
| 156 | } | |
| 157 | ||
| 158 | // ---- HTTP --------------------------------------------------------------- | |
| 159 | ||
| 160 | /** Force a fresh network fetch for this track — bypasses both the cache AND the | |
| 161 | * local .lrc sidecar (since a user clicking "Refresh" means "the current match | |
| 162 | * is bad, try harder"). Deletes the cached entry so subsequent normal fetches | |
| 163 | * use the fresh result. Privacy gate still applies. | |
| 164 | * | |
| 165 | * [skipDuration] omits the duration parameter on the LRCLIB request, widening | |
| 166 | * the fuzzy-match candidate set — frequently the duration filter is what drops | |
| 167 | * a better-matching lyric variant. */ | |
| 168 | suspend fun fetchFresh( | |
| 169 | track: DidlItem.Track, | |
| 170 | skipDuration: Boolean = false, | |
| 171 | ): LyricsResult = withContext(Dispatchers.IO) { | |
| 172 | if (track.title.isBlank()) return@withContext LyricsResult.NotFound | |
| 173 | ||
| 174 | val cacheFile = File(cacheDir, cacheKey(track) + ".json") | |
| 175 | val allow = settings.allowExternalCalls.first() | |
| 176 | if (!allow) return@withContext LyricsResult.PrivacyDisabled | |
| 177 | ||
| 178 | // Ask first; replace only on a real answer. The old order deleted the | |
| 179 | // cached entry and the exported .lrc before asking, so a refresh that | |
| 180 | // came back NotFound/Error left the user with less than they had | |
| 181 | // (review N.69, the maintainer's order 2026-09-02). | |
| 182 | val result = fetchFromApi(track, skipDuration = skipDuration) | |
| 183 | when (result) { | |
| 184 | is LyricsResult.Loaded, LyricsResult.Instrumental, LyricsResult.NotFound -> { | |
| 185 | runCatching { cacheFile.delete() } | |
| 186 | exportDir?.let { runCatching { File(it, buildExportFilename(track)).delete() } } | |
| 187 | writeCache(cacheFile, result) | |
| 188 | if (result is LyricsResult.Loaded) exportLrc(track, result) | |
| 189 | } | |
| 190 | else -> Unit // Error / PrivacyDisabled / Loading: keep what we had | |
| 191 | } | |
| 192 | result | |
| 193 | } | |
| 194 | ||
| 195 | private fun fetchFromApi( | |
| 196 | track: DidlItem.Track, | |
| 197 | skipDuration: Boolean = false, | |
| 198 | ): LyricsResult { | |
| 199 | // Titles carry suspect suffixes — "(stereo)", "(mono)", "[live]", | |
| 200 | // "(2004 Remaster)" — and filename-style "Artist - Title" prefixes | |
| 201 | // that LRCLIB's exact track_name match will never hit. Try the | |
| 202 | // literal title first, then progressively sanitized candidates. | |
| 203 | var last: LyricsResult = LyricsResult.NotFound | |
| 204 | for (candidate in titleCandidates(track.title, track.artist)) { | |
| 205 | val r = requestOnce(candidate, track, skipDuration) | |
| 206 | when (r) { | |
| 207 | is LyricsResult.Loaded, LyricsResult.Instrumental -> return r | |
| 208 | is LyricsResult.Error -> return r // network fault: don't hammer | |
| 209 | else -> last = r | |
| 210 | } | |
| 211 | } | |
| 212 | return last | |
| 213 | } | |
| 214 | ||
| 215 | /** Ordered, de-duplicated candidate titles: literal first, then with | |
| 216 | * trailing parentheticals/brackets stripped (repeatedly — there can | |
| 217 | * be several), then with a leading "Artist - " prefix removed when | |
| 218 | * it matches the track's artist, then with a trailing " - suffix" | |
| 219 | * (e.g. "- 2009 Remaster") removed. Suffixed text is treated as | |
| 220 | * suspect for title relevance, never authoritative. Capped at 4. */ | |
| 221 | private fun titleCandidates(raw: String, artist: String?): List<String> { | |
| 222 | val out = LinkedHashSet<String>() | |
| 223 | var s = raw.trim() | |
| 224 | if (s.isBlank()) return emptyList() | |
| 225 | out += s | |
| 226 | while (true) { | |
| 227 | val stripped = s.replace(Regex("""\s*[(\[][^()\[\]]*[)\]]\s*${'$'}"""), "").trim() | |
| 228 | if (stripped == s || stripped.isBlank()) break | |
| 229 | s = stripped | |
| 230 | out += s | |
| 231 | } | |
| 232 | val dash = s.indexOf(" - ") | |
| 233 | if (dash > 0) { | |
| 234 | val prefix = s.substring(0, dash).trim() | |
| 235 | val rest = s.substring(dash + 3).trim() | |
| 236 | if (rest.isNotBlank() && | |
| 237 | artist != null && prefix.equals(artist.trim(), ignoreCase = true) | |
| 238 | ) { | |
| 239 | out += rest | |
| 240 | } | |
| 241 | } | |
| 242 | val lastDash = s.lastIndexOf(" - ") | |
| 243 | if (lastDash > 0) { | |
| 244 | val head = s.substring(0, lastDash).trim() | |
| 245 | if (head.isNotBlank()) out += head | |
| 246 | } | |
| 247 | return out.toList().take(4) | |
| 248 | } | |
| 249 | ||
| 250 | private fun requestOnce( | |
| 251 | titleOverride: String, | |
| 252 | track: DidlItem.Track, | |
| 253 | skipDuration: Boolean = false, | |
| 254 | ): LyricsResult { | |
| 255 | val builder = "https://lrclib.net/api/get".toHttpUrlOrNull() | |
| 256 | ?.newBuilder() | |
| 257 | ?: return LyricsResult.Error("Bad LRCLIB URL (should never happen)") | |
| 258 | ||
| 259 | builder.addQueryParameter("track_name", titleOverride) | |
| 260 | track.artist?.takeIf { it.isNotBlank() }?.let { | |
| 261 | builder.addQueryParameter("artist_name", it) | |
| 262 | } | |
| 263 | track.album?.takeIf { it.isNotBlank() }?.let { | |
| 264 | builder.addQueryParameter("album_name", it) | |
| 265 | } | |
| 266 | if (!skipDuration) { | |
| 267 | track.duration?.inWholeSeconds?.takeIf { it > 0 }?.let { | |
| 268 | builder.addQueryParameter("duration", it.toString()) | |
| 269 | } | |
| 270 | } | |
| 271 | ||
| 272 | // Audit 2026-09-02: this request carried no User-Agent, so OkHttp | |
| 273 | // sent its default ("okhttp/4.x"). lrclib.net sits behind Cloudflare, | |
| 274 | // which now answers that agent with HTTP 520 — reproduced from a | |
| 275 | // clean host: "okhttp/4.12.0" → 520, a descriptive agent → 200. | |
| 276 | // Every lyrics tap on every screen has been failing with "LRCLIB | |
| 277 | // returned HTTP 520" since Cloudflare began rejecting it. LRCLIB's | |
| 278 | // API guidance asks for an identifying agent; send one. | |
| 279 | val req = Request.Builder() | |
| 280 | .url(builder.build()) | |
| 281 | .header("User-Agent", LRCLIB_USER_AGENT) | |
| 282 | .header("Lrclib-Client", LRCLIB_USER_AGENT) | |
| 283 | .build() | |
| 284 | return runCatching { | |
| 285 | http.newCall(req).execute().use { r -> | |
| 286 | when (r.code) { | |
| 287 | 200 -> { | |
| 288 | val body = r.body?.string().orEmpty() | |
| 289 | val parsed = json.decodeFromString<LrclibResponse>(body) | |
| 290 | when { | |
| 291 | parsed.instrumental -> LyricsResult.Instrumental | |
| 292 | !parsed.syncedLyrics.isNullOrBlank() -> LyricsResult.Loaded( | |
| 293 | synced = LrcParser.parse(parsed.syncedLyrics), | |
| 294 | plain = parsed.plainLyrics, | |
| 295 | ) | |
| 296 | !parsed.plainLyrics.isNullOrBlank() -> LyricsResult.Loaded( | |
| 297 | synced = emptyList(), | |
| 298 | plain = parsed.plainLyrics, | |
| 299 | ) | |
| 300 | else -> LyricsResult.NotFound | |
| 301 | } | |
| 302 | } | |
| 303 | 404 -> LyricsResult.NotFound | |
| 304 | else -> LyricsResult.Error("LRCLIB returned HTTP ${r.code}") | |
| 305 | } | |
| 306 | } | |
| 307 | }.getOrElse { LyricsResult.Error(it.message ?: "Network error") } | |
| 308 | } | |
| 309 | ||
| 310 | // ---- Cache -------------------------------------------------------------- | |
| 311 | ||
| 312 | /** Export the Loaded lyrics to a human-readable .lrc file in the app's external | |
| 313 | * files directory. Users can pull these via ADB (`adb pull`) or over USB and | |
| 314 | * drop them alongside their audio files for zero-network lookup next time. | |
| 315 | * | |
| 316 | * Filename: "<artist> - <title>.lrc" with filesystem-illegal characters | |
| 317 | * stripped, and the first 8 hex of the resourceUrl MD5 appended to | |
| 318 | * disambiguate same-artist-same-title tracks (live versions, remasters, etc.). | |
| 319 | * | |
| 320 | * Best-effort: failures are swallowed — export is a convenience, not a | |
| 321 | * correctness requirement. */ | |
| 322 | private fun exportLrc(track: DidlItem.Track, result: LyricsResult.Loaded) { | |
| 323 | val dir = exportDir ?: return | |
| 324 | runCatching { | |
| 325 | val filename = buildExportFilename(track) | |
| 326 | val file = File(dir, filename) | |
| 327 | val content = buildLrcText(track, result) | |
| 328 | if (content.isNotBlank()) { | |
| 329 | file.writeText(content, Charsets.UTF_8) | |
| 330 | } | |
| 331 | } | |
| 332 | } | |
| 333 | ||
| 334 | /** Build a filesystem-safe filename in the form | |
| 335 | * "Artist - Title [abcd1234].lrc". The hash suffix disambiguates recordings | |
| 336 | * with identical artist+title (lives, remasters). */ | |
| 337 | private fun buildExportFilename(track: DidlItem.Track): String { | |
| 338 | val artist = track.artist?.takeIf { it.isNotBlank() } ?: "Unknown Artist" | |
| 339 | val title = track.title.ifBlank { "Unknown Title" } | |
| 340 | val hashSuffix = cacheKey(track).take(8) | |
| 341 | val raw = "$artist - $title [$hashSuffix].lrc" | |
| 342 | // Strip filesystem-illegal and awkward characters. Keep unicode letters, | |
| 343 | // digits, dash, dot, space, underscore, brackets. | |
| 344 | return raw.map { c -> | |
| 345 | when { | |
| 346 | c.isLetterOrDigit() -> c | |
| 347 | c in " -._[]()" -> c | |
| 348 | else -> '_' | |
| 349 | } | |
| 350 | }.joinToString("") | |
| 351 | } | |
| 352 | ||
| 353 | /** Serialize a Loaded result back to LRC format: metadata headers, then each | |
| 354 | * synced line as `[mm:ss.xx]text`. If no synced lyrics exist, writes the | |
| 355 | * plain-text block as-is. */ | |
| 356 | private fun buildLrcText(track: DidlItem.Track, result: LyricsResult.Loaded): String { | |
| 357 | val sb = StringBuilder() | |
| 358 | // LRC metadata headers — not strictly required but common convention. | |
| 359 | track.artist?.takeIf { it.isNotBlank() }?.let { sb.append("[ar:$it]\n") } | |
| 360 | track.album?.takeIf { it.isNotBlank() }?.let { sb.append("[al:$it]\n") } | |
| 361 | if (track.title.isNotBlank()) sb.append("[ti:${track.title}]\n") | |
| 362 | sb.append("[by:Earhorn (lrclib.net)]\n") | |
| 363 | sb.append('\n') | |
| 364 | ||
| 365 | if (result.synced.isNotEmpty()) { | |
| 366 | for (line in result.synced) { | |
| 367 | val ms = line.start.inWholeMilliseconds | |
| 368 | val totalSeconds = ms / 1000 | |
| 369 | val mm = totalSeconds / 60 | |
| 370 | val ss = totalSeconds % 60 | |
| 371 | val xx = (ms % 1000) / 10 | |
| 372 | sb.append("[%02d:%02d.%02d]".format(mm, ss, xx)) | |
| 373 | sb.append(line.text) | |
| 374 | sb.append('\n') | |
| 375 | } | |
| 376 | } else if (!result.plain.isNullOrBlank()) { | |
| 377 | sb.append(result.plain) | |
| 378 | } | |
| 379 | return sb.toString() | |
| 380 | } | |
| 381 | ||
| 382 | /** MD5 of the resourceUrl, hex-encoded. A hash is safer than the raw URL for | |
| 383 | * a filesystem path — URLs contain characters we'd have to escape. */ | |
| 384 | private fun cacheKey(track: DidlItem.Track): String { | |
| 385 | val md = MessageDigest.getInstance("MD5") | |
| 386 | return md.digest(track.resourceUrl.toByteArray(Charsets.UTF_8)) | |
| 387 | .joinToString("") { "%02x".format(it) } | |
| 388 | } | |
| 389 | ||
| 390 | private fun readCache(file: File): LyricsResult? { | |
| 391 | if (!file.exists()) return null | |
| 392 | val age = System.currentTimeMillis() - file.lastModified() | |
| 393 | return runCatching { | |
| 394 | val env = json.decodeFromString<CachedEnvelope>(file.readText(Charsets.UTF_8)) | |
| 395 | val maxAge = if (env.isPositive()) POSITIVE_TTL_MS else NEGATIVE_TTL_MS | |
| 396 | if (age > maxAge) null else env.toResult() | |
| 397 | }.getOrNull() | |
| 398 | } | |
| 399 | ||
| 400 | private fun writeCache(file: File, result: LyricsResult) { | |
| 401 | // Don't cache errors or privacy-gated results. Transient failures shouldn't | |
| 402 | // poison; privacy-gated shouldn't persist past a setting change. | |
| 403 | if (result is LyricsResult.Error) return | |
| 404 | if (result is LyricsResult.PrivacyDisabled) return | |
| 405 | runCatching { | |
| 406 | val env = CachedEnvelope.from(result) | |
| 407 | file.writeText(json.encodeToString(CachedEnvelope.serializer(), env), Charsets.UTF_8) | |
| 408 | } | |
| 409 | } | |
| 410 | ||
| 411 | @Serializable | |
| 412 | private data class CachedEnvelope( | |
| 413 | @SerialName("kind") val kind: String, | |
| 414 | @SerialName("syncedLines") val syncedLines: List<Pair<Long, String>> = emptyList(), | |
| 415 | @SerialName("plain") val plain: String? = null, | |
| 416 | ) { | |
| 417 | fun isPositive(): Boolean = kind == "loaded" | |
| 418 | fun toResult(): LyricsResult = when (kind) { | |
| 419 | "loaded" -> LyricsResult.Loaded( | |
| 420 | synced = syncedLines.map { (ms, text) -> LrcLine.at(ms, text) }, | |
| 421 | plain = plain, | |
| 422 | ) | |
| 423 | "instrumental" -> LyricsResult.Instrumental | |
| 424 | "notfound" -> LyricsResult.NotFound | |
| 425 | else -> LyricsResult.NotFound | |
| 426 | } | |
| 427 | ||
| 428 | companion object { | |
| 429 | fun from(r: LyricsResult): CachedEnvelope = when (r) { | |
| 430 | is LyricsResult.Loaded -> CachedEnvelope( | |
| 431 | kind = "loaded", | |
| 432 | syncedLines = r.synced.map { it.start.inWholeMilliseconds to it.text }, | |
| 433 | plain = r.plain, | |
| 434 | ) | |
| 435 | LyricsResult.Instrumental -> CachedEnvelope(kind = "instrumental") | |
| 436 | LyricsResult.NotFound -> CachedEnvelope(kind = "notfound") | |
| 437 | // Loading/Error/PrivacyDisabled are all filtered out by writeCache before | |
| 438 | // this runs — the branches exist only to satisfy the compiler's | |
| 439 | // exhaustiveness check on the sealed type. | |
| 440 | LyricsResult.Loading, | |
| 441 | LyricsResult.PrivacyDisabled, | |
| 442 | is LyricsResult.Error -> CachedEnvelope(kind = "notfound") | |
| 443 | } | |
| 444 | } | |
| 445 | } | |
| 446 | ||
| 447 | companion object { | |
| 448 | /** Identifying agent for lrclib.net (see requestOnce). */ | |
| 449 | private const val LRCLIB_USER_AGENT = "Earhorn Music Player (https://faveat.software)" | |
| 450 | ||
| 451 | // Positive results a week — lyrics don't really change, and the user | |
| 452 | // shouldn't pay network latency to open the same track's lyrics twice. | |
| 453 | private const val POSITIVE_TTL_MS = 7L * 24 * 60 * 60 * 1000 | |
| 454 | // Refresh negative results every 24h so LRCLIB catalog growth eventually | |
| 455 | // produces hits for tracks that used to miss. | |
| 456 | private const val NEGATIVE_TTL_MS = 24L * 60 * 60 * 1000 | |
| 457 | } | |
| 458 | } |


