LyricsRepository.kt
498 lines · 23.2 KB
Changed in: 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 | } |


