package dev.stave.lyrics
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import dev.stave.domain.model.DidlItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
/**
* Fetches and caches time-synced lyrics from lrclib.net.
*
* Cache is keyed by MD5 of the track's resourceUrl (closest thing to a stable identifier
* — titles can clash, MinimServer itemIds aren't portable across rescans, but the stream
* URL points at one specific file on the server). Stored at
* `<app cacheDir>/stave/lyrics/<hash>.json`.
*
* Negative results are cached briefly so we don't ping LRCLIB every time the user opens
* lyrics for an obscure track; positive results are cached for a week since lyrics
* don't really change. Errors are never cached — transient failures shouldn't poison
* lookups.
*/
@Singleton
class LyricsRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val settings: dev.stave.data.SettingsStore,
) {
private val cacheDir: File = context.cacheDir.resolve("stave/lyrics").also { it.mkdirs() }
/** User-visible export directory for fetched LRC files. Android TV users can
* pull this via ADB (`adb pull`) or over USB to deploy sidecar .lrc files
* next to their audio library. Path: <app external files>/lyrics/. */
private val exportDir: File? = runCatching {
context.getExternalFilesDir("lyrics")
}.getOrNull()?.also { it.mkdirs() }
private val http: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
private val json = Json { ignoreUnknownKeys = true }
suspend fun fetch(track: DidlItem.Track): LyricsResult = withContext(Dispatchers.IO) {
if (track.title.isBlank()) return@withContext LyricsResult.NotFound
// Lookup order, cheapest to most expensive:
// 1) In-app cache (the file we wrote last time we fetched).
// 2) User-facing export dir — previously-fetched LRCs the user may have
// retained. Consulted BEFORE the HTTP sidecar so a once-fetched track
// is found without any network activity.
// 3) Server-side .lrc sidecar (HTTP GET next to the audio file).
// 4) LRCLIB — only if privacy allows network calls.
// On a successful LRCLIB fetch we also export the result to the local
// dir so future lookups land at step 2 without needing LRCLIB again.
val cacheFile = File(cacheDir, cacheKey(track) + ".json")
readCache(cacheFile)?.let { return@withContext it }
fetchLocalExport(track)?.let {
writeCache(cacheFile, it)
return@withContext it
}
fetchServerSidecar(track)?.let {
writeCache(cacheFile, it)
return@withContext it
}
val allow = settings.allowExternalCalls.first()
if (!allow) return@withContext LyricsResult.PrivacyDisabled
val result = fetchFromApi(track)
writeCache(cacheFile, result)
if (result is LyricsResult.Loaded) exportLrc(track, result)
result
}
// ---- Local export-dir lookup --------------------------------------------
/** Read a previously-exported LRC from the external files directory. This is
* where we drop successful LRCLIB fetches (and where the user can add their
* own hand-placed LRCs). Filename matches what [buildExportFilename] produced
* at write time, so a round trip (fetch → export → fetch again) hits this
* step without going near the network. */
private fun fetchLocalExport(track: DidlItem.Track): LyricsResult? {
val dir = exportDir ?: return null
val file = File(dir, buildExportFilename(track))
if (!file.exists() || file.length() == 0L) return null
return runCatching {
val body = file.readText(Charsets.UTF_8)
if (body.isBlank()) return@runCatching null
val synced = LrcParser.parse(body)
if (synced.isNotEmpty()) {
LyricsResult.Loaded(synced = synced, plain = null)
} else {
// Plain-text LRC (metadata headers only or free text) — surface as
// plain so the user still sees something.
LyricsResult.Loaded(synced = emptyList(), plain = body)
}
}.getOrNull()
}
// ---- Server-side HTTP sidecar -------------------------------------------
/** Try to fetch a `.lrc` file sibling to the track's audio file via HTTP.
* Works when the UPnP server mirrors the on-disk filesystem (MinimServer,
* Plex). URL-scheme servers (MediaTomb `/content/<id>`) have no predictable
* sidecar path — this returns null and we fall through. */
private fun fetchServerSidecar(track: DidlItem.Track): LyricsResult? {
val base = track.resourceUrl
if (base.isBlank()) return null
// Sidecar is HTTP-only. OkHttp's Request.Builder().url(String) throws
// IllegalArgumentException for any scheme other than http/https, and
// that throw is OUTSIDE the runCatching below — so a local-file
// track (content:// MediaStore URI) was crashing the lyrics tap with
// an uncaught exception in the IO coroutine. Server-side .lrc
// sidecar lookup only makes sense when the track is served via HTTP
// anyway; bail early for everything else and let the caller fall
// through to LRCLIB.
if (!base.startsWith("http://", ignoreCase = true) &&
!base.startsWith("https://", ignoreCase = true)) {
return null
}
val dot = base.lastIndexOf('.')
val slash = base.lastIndexOf('/')
// Only replace a dot that's in the last path component (after the final slash).
// Otherwise URLs like "http://server.local/stream?id=5.flac" would mangle.
val lrcUrl = if (dot > slash) base.substring(0, dot) + ".lrc" else "$base.lrc"
val req = Request.Builder().url(lrcUrl).build()
return runCatching {
http.newCall(req).execute().use { r ->
if (!r.isSuccessful) return@use null
val body = r.body?.string().orEmpty()
if (body.isBlank()) return@use null
val synced = LrcParser.parse(body)
if (synced.isNotEmpty()) {
LyricsResult.Loaded(synced = synced, plain = null)
} else {
LyricsResult.Loaded(synced = emptyList(), plain = body)
}
}
}.getOrNull()
}
// ---- HTTP ---------------------------------------------------------------
/** Force a fresh network fetch for this track — bypasses both the cache AND the
* local .lrc sidecar (since a user clicking "Refresh" means "the current match
* is bad, try harder"). Deletes the cached entry so subsequent normal fetches
* use the fresh result. Privacy gate still applies.
*
* [skipDuration] omits the duration parameter on the LRCLIB request, widening
* the fuzzy-match candidate set — frequently the duration filter is what drops
* a better-matching lyric variant. */
suspend fun fetchFresh(
track: DidlItem.Track,
skipDuration: Boolean = false,
): LyricsResult = withContext(Dispatchers.IO) {
if (track.title.isBlank()) return@withContext LyricsResult.NotFound
val cacheFile = File(cacheDir, cacheKey(track) + ".json")
val allow = settings.allowExternalCalls.first()
if (!allow) return@withContext LyricsResult.PrivacyDisabled
// Ask first; replace only on a real answer. The old order deleted the
// cached entry and the exported .lrc before asking, so a refresh that
// came back NotFound/Error left the user with less than they had
// (review N.69, the maintainer's order 2026-09-02).
val result = fetchFromApi(track, skipDuration = skipDuration)
when (result) {
is LyricsResult.Loaded, LyricsResult.Instrumental, LyricsResult.NotFound -> {
runCatching { cacheFile.delete() }
exportDir?.let { runCatching { File(it, buildExportFilename(track)).delete() } }
writeCache(cacheFile, result)
if (result is LyricsResult.Loaded) exportLrc(track, result)
}
else -> Unit // Error / PrivacyDisabled / Loading: keep what we had
}
result
}
private fun fetchFromApi(
track: DidlItem.Track,
skipDuration: Boolean = false,
): LyricsResult {
// Titles carry suspect suffixes — "(stereo)", "(mono)", "[live]",
// "(2004 Remaster)" — and filename-style "Artist - Title" prefixes
// that LRCLIB's exact track_name match will never hit. Try the
// literal title first, then progressively sanitized candidates.
var last: LyricsResult = LyricsResult.NotFound
for (candidate in titleCandidates(track.title, track.artist)) {
val r = requestOnce(candidate, track, skipDuration)
when (r) {
is LyricsResult.Loaded, LyricsResult.Instrumental -> return r
is LyricsResult.Error -> return r // network fault: don't hammer
else -> last = r
}
}
return last
}
/** Ordered, de-duplicated candidate titles: literal first, then with
* trailing parentheticals/brackets stripped (repeatedly — there can
* be several), then with a leading "Artist - " prefix removed when
* it matches the track's artist, then with a trailing " - suffix"
* (e.g. "- 2009 Remaster") removed. Suffixed text is treated as
* suspect for title relevance, never authoritative. Capped at 4. */
private fun titleCandidates(raw: String, artist: String?): List<String> {
val out = LinkedHashSet<String>()
var s = raw.trim()
if (s.isBlank()) return emptyList()
out += s
while (true) {
val stripped = s.replace(Regex("""\s*[(\[][^()\[\]]*[)\]]\s*${'$'}"""), "").trim()
if (stripped == s || stripped.isBlank()) break
s = stripped
out += s
}
val dash = s.indexOf(" - ")
if (dash > 0) {
val prefix = s.substring(0, dash).trim()
val rest = s.substring(dash + 3).trim()
if (rest.isNotBlank() &&
artist != null && prefix.equals(artist.trim(), ignoreCase = true)
) {
out += rest
}
}
val lastDash = s.lastIndexOf(" - ")
if (lastDash > 0) {
val head = s.substring(0, lastDash).trim()
if (head.isNotBlank()) out += head
}
return out.toList().take(4)
}
private fun requestOnce(
titleOverride: String,
track: DidlItem.Track,
skipDuration: Boolean = false,
): LyricsResult {
val builder = "https://lrclib.net/api/get".toHttpUrlOrNull()
?.newBuilder()
?: return LyricsResult.Error("Bad LRCLIB URL (should never happen)")
builder.addQueryParameter("track_name", titleOverride)
track.artist?.takeIf { it.isNotBlank() }?.let {
builder.addQueryParameter("artist_name", it)
}
track.album?.takeIf { it.isNotBlank() }?.let {
builder.addQueryParameter("album_name", it)
}
if (!skipDuration) {
track.duration?.inWholeSeconds?.takeIf { it > 0 }?.let {
builder.addQueryParameter("duration", it.toString())
}
}
// Audit 2026-09-02: this request carried no User-Agent, so OkHttp
// sent its default ("okhttp/4.x"). lrclib.net sits behind Cloudflare,
// which now answers that agent with HTTP 520 — reproduced from a
// clean host: "okhttp/4.12.0" → 520, a descriptive agent → 200.
// Every lyrics tap on every screen has been failing with "LRCLIB
// returned HTTP 520" since Cloudflare began rejecting it. LRCLIB's
// API guidance asks for an identifying agent; send one.
val req = Request.Builder()
.url(builder.build())
.header("User-Agent", LRCLIB_USER_AGENT)
.header("Lrclib-Client", LRCLIB_USER_AGENT)
.build()
return runCatching {
http.newCall(req).execute().use { r ->
when (r.code) {
200 -> {
val body = r.body?.string().orEmpty()
val parsed = json.decodeFromString<LrclibResponse>(body)
when {
parsed.instrumental -> LyricsResult.Instrumental
!parsed.syncedLyrics.isNullOrBlank() -> LyricsResult.Loaded(
synced = LrcParser.parse(parsed.syncedLyrics),
plain = parsed.plainLyrics,
)
!parsed.plainLyrics.isNullOrBlank() -> LyricsResult.Loaded(
synced = emptyList(),
plain = parsed.plainLyrics,
)
else -> LyricsResult.NotFound
}
}
404 -> LyricsResult.NotFound
else -> LyricsResult.Error("LRCLIB returned HTTP ${r.code}")
}
}
}.getOrElse { LyricsResult.Error(it.message ?: "Network error") }
}
// ---- Cache --------------------------------------------------------------
/** Export the Loaded lyrics to a human-readable .lrc file in the app's external
* files directory. Users can pull these via ADB (`adb pull`) or over USB and
* drop them alongside their audio files for zero-network lookup next time.
*
* Filename: "<artist> - <title>.lrc" with filesystem-illegal characters
* stripped, and the first 8 hex of the resourceUrl MD5 appended to
* disambiguate same-artist-same-title tracks (live versions, remasters, etc.).
*
* Best-effort: failures are swallowed — export is a convenience, not a
* correctness requirement. */
private fun exportLrc(track: DidlItem.Track, result: LyricsResult.Loaded) {
val dir = exportDir ?: return
runCatching {
val filename = buildExportFilename(track)
val file = File(dir, filename)
val content = buildLrcText(track, result)
if (content.isNotBlank()) {
file.writeText(content, Charsets.UTF_8)
}
}
}
/** Build a filesystem-safe filename in the form
* "Artist - Title [abcd1234].lrc". The hash suffix disambiguates recordings
* with identical artist+title (lives, remasters). */
private fun buildExportFilename(track: DidlItem.Track): String {
val artist = track.artist?.takeIf { it.isNotBlank() } ?: "Unknown Artist"
val title = track.title.ifBlank { "Unknown Title" }
val hashSuffix = cacheKey(track).take(8)
val raw = "$artist - $title [$hashSuffix].lrc"
// Strip filesystem-illegal and awkward characters. Keep unicode letters,
// digits, dash, dot, space, underscore, brackets.
return raw.map { c ->
when {
c.isLetterOrDigit() -> c
c in " -._[]()" -> c
else -> '_'
}
}.joinToString("")
}
/** Serialize a Loaded result back to LRC format: metadata headers, then each
* synced line as `[mm:ss.xx]text`. If no synced lyrics exist, writes the
* plain-text block as-is. */
private fun buildLrcText(track: DidlItem.Track, result: LyricsResult.Loaded): String {
val sb = StringBuilder()
// LRC metadata headers — not strictly required but common convention.
track.artist?.takeIf { it.isNotBlank() }?.let { sb.append("[ar:$it]\n") }
track.album?.takeIf { it.isNotBlank() }?.let { sb.append("[al:$it]\n") }
if (track.title.isNotBlank()) sb.append("[ti:${track.title}]\n")
sb.append("[by:Earhorn (lrclib.net)]\n")
sb.append('\n')
if (result.synced.isNotEmpty()) {
for (line in result.synced) {
val ms = line.start.inWholeMilliseconds
val totalSeconds = ms / 1000
val mm = totalSeconds / 60
val ss = totalSeconds % 60
val xx = (ms % 1000) / 10
sb.append("[%02d:%02d.%02d]".format(mm, ss, xx))
sb.append(line.text)
sb.append('\n')
}
} else if (!result.plain.isNullOrBlank()) {
sb.append(result.plain)
}
return sb.toString()
}
/** MD5 of the resourceUrl, hex-encoded. A hash is safer than the raw URL for
* a filesystem path — URLs contain characters we'd have to escape. */
private fun cacheKey(track: DidlItem.Track): String {
val md = MessageDigest.getInstance("MD5")
return md.digest(track.resourceUrl.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
}
private fun readCache(file: File): LyricsResult? {
if (!file.exists()) return null
val age = System.currentTimeMillis() - file.lastModified()
return runCatching {
val env = json.decodeFromString<CachedEnvelope>(file.readText(Charsets.UTF_8))
val maxAge = if (env.isPositive()) POSITIVE_TTL_MS else NEGATIVE_TTL_MS
if (age > maxAge) null else env.toResult()
}.getOrNull()
}
private fun writeCache(file: File, result: LyricsResult) {
// Don't cache errors or privacy-gated results. Transient failures shouldn't
// poison; privacy-gated shouldn't persist past a setting change.
if (result is LyricsResult.Error) return
if (result is LyricsResult.PrivacyDisabled) return
runCatching {
val env = CachedEnvelope.from(result)
file.writeText(json.encodeToString(CachedEnvelope.serializer(), env), Charsets.UTF_8)
}
}
@Serializable
private data class CachedEnvelope(
@SerialName("kind") val kind: String,
@SerialName("syncedLines") val syncedLines: List<Pair<Long, String>> = emptyList(),
@SerialName("plain") val plain: String? = null,
) {
fun isPositive(): Boolean = kind == "loaded"
fun toResult(): LyricsResult = when (kind) {
"loaded" -> LyricsResult.Loaded(
synced = syncedLines.map { (ms, text) -> LrcLine.at(ms, text) },
plain = plain,
)
"instrumental" -> LyricsResult.Instrumental
"notfound" -> LyricsResult.NotFound
else -> LyricsResult.NotFound
}
companion object {
fun from(r: LyricsResult): CachedEnvelope = when (r) {
is LyricsResult.Loaded -> CachedEnvelope(
kind = "loaded",
syncedLines = r.synced.map { it.start.inWholeMilliseconds to it.text },
plain = r.plain,
)
LyricsResult.Instrumental -> CachedEnvelope(kind = "instrumental")
LyricsResult.NotFound -> CachedEnvelope(kind = "notfound")
// Loading/Error/PrivacyDisabled are all filtered out by writeCache before
// this runs — the branches exist only to satisfy the compiler's
// exhaustiveness check on the sealed type.
LyricsResult.Loading,
LyricsResult.PrivacyDisabled,
is LyricsResult.Error -> CachedEnvelope(kind = "notfound")
}
}
}
companion object {
/** Identifying agent for lrclib.net (see requestOnce). */
private const val LRCLIB_USER_AGENT = "Earhorn Music Player (https://faveat.software)"
// Positive results a week — lyrics don't really change, and the user
// shouldn't pay network latency to open the same track's lyrics twice.
private const val POSITIVE_TTL_MS = 7L * 24 * 60 * 60 * 1000
// Refresh negative results every 24h so LRCLIB catalog growth eventually
// produces hits for tracks that used to miss.
private const val NEGATIVE_TTL_MS = 24L * 60 * 60 * 1000
}
}