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


