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

LyricsRepository.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.lyrics
2
3import android.content.Context
4import dagger.hilt.android.qualifiers.ApplicationContext
5import dev.stave.domain.model.DidlItem
6import kotlinx.coroutines.Dispatchers
7import kotlinx.coroutines.flow.first
8import kotlinx.coroutines.withContext
9import kotlinx.serialization.SerialName
10import kotlinx.serialization.Serializable
11import kotlinx.serialization.json.Json
12import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
13import okhttp3.OkHttpClient
14import okhttp3.Request
15import java.io.File
16import java.security.MessageDigest
17import java.util.concurrent.TimeUnit
18import javax.inject.Inject
19import 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
35class 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}