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

LyricsRepository.kt

package dev.stave.lyrics

import dev.stave.data.StavePaths
import dev.stave.domain.model.DidlItem
import dev.stave.net.UrlPolicy
import kotlinx.coroutines.Dispatchers
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 okhttp3.Response
import java.io.IOException
import java.io.File
import java.security.MessageDigest
import java.util.concurrent.TimeUnit

/**
 * Fetches and caches time-synced lyrics from lrclib.net.
 *
 * **Lookup order** (cheapest first, as on Android):
 *  1. The local cache (`$XDG_CACHE_HOME/earhorn/lyrics/<hash>.json`).
 *  2. The export folder (`$XDG_CACHE_HOME/earhorn/lyrics-export/`): LRCs saved from
 *     earlier lookups, or placed there by hand under the same file name.
 *  3. A `.lrc` beside the song on the music server. Asked only when the server is on
 *     the local network, or when external calls are allowed — otherwise "external
 *     calls off" would not hold for, say, an internet radio URL from a followed Cast
 *     session.
 *  4. LRCLIB, only when external calls are allowed (Settings → Lyrics).
 *
 * **Cache strategy.** Keyed by an MD5 of the track's resourceUrl (the UPnP stream
 * URL is the closest thing to a stable identifier we have — titles can clash,
 * MinimServer itemIds aren't portable across rescans, but the stream URL points at
 * one specific file on the server).
 *
 * Negative results are cached too — nothing more wasteful than pinging LRCLIB every
 * time the user opens lyrics for an obscure demo recording. Negative cache TTL is
 * shorter than positive so re-lookups after LRCLIB grows its catalog eventually
 * succeed.
 *
 * **API.** `GET https://lrclib.net/api/get?track_name=…&artist_name=…&album_name=…
 * &duration=…`. LRCLIB is free, no auth, rate limits are generous. Durations match
 * with a few seconds of slack server-side. A 404 means "we don't know this track" —
 * we translate to [LyricsResult.NotFound] and cache briefly. `track_name` must match
 * exactly, so several cleaned-up title variants are tried (see [titleCandidates]).
 */
class LyricsRepository(
    private val settings: dev.stave.data.SettingsStore,
    cacheDir: File = StavePaths.lyricsCacheDir,
) {

    val cacheDir: File = StavePaths.ensurePrivateDir(cacheDir)

    /** Where found lyrics are saved as plain `.lrc` files (and read back from). */
    private val exportDir: File get() = File(cacheDir.parentFile, "lyrics-export")

    private val http: OkHttpClient = OkHttpClient.Builder()
        // Generous timeouts — LRCLIB is generally fast but we're hitting it over
        // whatever WAN the user happens to have, and DNS on cold connections can
        // be the dominant latency. Previously 5s/10s, which failed often enough
        // to be annoying on marginal links.
        .connectTimeout(15, TimeUnit.SECONDS)
        .readTimeout(20, TimeUnit.SECONDS)
        .writeTimeout(10, TimeUnit.SECONDS)
        // Let OkHttp itself retry at the connection layer when a route fails.
        // This is free recovery for cases like stale-socket or ephemeral DNS.
        .retryOnConnectionFailure(true)
        .build()

    /** Sidecar client: the music server is normally on the LAN, so short timeouts
     *  (a dead server must not hold up the LRCLIB lookup), and no redirects — a LAN
     *  server has no reason to bounce a sidecar request somewhere else, possibly
     *  off the LAN. */
    private val sidecarHttp: OkHttpClient = http.newBuilder()
        .connectTimeout(5, TimeUnit.SECONDS)
        .readTimeout(10, TimeUnit.SECONDS)
        .followRedirects(false)
        .followSslRedirects(false)
        .build()

    private val json = Json { ignoreUnknownKeys = true }

    suspend fun fetch(track: DidlItem.Track): LyricsResult = withContext(Dispatchers.IO) {
        // Can't lookup without at least a title. Artist matters too but LRCLIB does
        // fuzzy matching so blank artist occasionally still resolves.
        if (track.title.isBlank()) return@withContext LyricsResult.NotFound

        val cacheFile = File(cacheDir, cacheKey(track) + ".json")
        readCache(cacheFile)?.let { return@withContext it }

        // An LRC saved earlier (or dropped in by hand) — local disk, no network.
        fetchLocalExport(track)?.let { r ->
            writeCache(cacheFile, r)
            return@withContext r
        }

        // A .lrc next to the song on the server comes first (the maintainer's
        // answer 3, 2026-09-04 — as on Android). Gated inside: LAN servers
        // always, anything else only with external calls allowed.
        fetchServerSidecar(track)?.let { r ->
            writeCache(cacheFile, r)
            return@withContext r
        }

        // Privacy gate: external calls disabled → never hit LRCLIB. The cache
        // lookup above already ran (cache is local-disk only, no network), so a
        // previously-fetched track still works when the user later toggles off.
        // Toggling back on + LyricsViewModel.refresh() will then fetch on demand.
        if (!settings.allowExternalCalls.value) {
            return@withContext LyricsResult.PrivacyDisabled
        }

        val result = fetchFromApi(track)
        writeCache(cacheFile, result)
        if (result is LyricsResult.Loaded) exportLrc(track, result)
        result
    }

    /** Refresh: ask again, ignoring the cache; replace the cached answer only
     *  when a real one comes back (as ruled for Android). The old refresh
     *  re-read the cache and never asked.
     *
     *  [skipDuration] leaves the duration out of the LRCLIB request: the user
     *  pressed Refresh because the match was bad, and the duration filter is
     *  often what drops the better variant (as on Android). */
    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")
        if (!settings.allowExternalCalls.value) return@withContext LyricsResult.PrivacyDisabled
        val result = fetchFromApi(track, skipDuration = skipDuration)
        when (result) {
            is LyricsResult.Loaded, LyricsResult.Instrumental, LyricsResult.NotFound -> {
                writeCache(cacheFile, result)
                if (result is LyricsResult.Loaded) {
                    exportLrc(track, result)
                } else {
                    // The saved LRC was the bad match the user is replacing; without
                    // this, the next normal lookup would read it straight back.
                    File(exportDir, buildExportFilename(track)).delete()
                }
            }
            else -> Unit
        }
        result
    }

    // ---- Local export folder --------------------------------------------------

    /** Read a previously exported LRC (or a hand-placed one with the same name).
     *  Ported from Android's `fetchLocalExport`. */
    private fun fetchLocalExport(track: DidlItem.Track): LyricsResult? {
        val file = File(exportDir, buildExportFilename(track))
        if (!file.isFile || file.length() == 0L || file.length() > MAX_LRC_BYTES) return null
        val body = try {
            file.readText(Charsets.UTF_8)
        } catch (_: IOException) {
            return null
        }
        return lyricsFromLrcText(body)
    }

    // ---- Server-side sidecar ----------------------------------------------------

    /** `<song>.lrc` beside the song on the music server, if the server serves
     *  it. Stations and non-http resources are skipped, and so is any server off
     *  the local network unless external calls are allowed. */
    private fun fetchServerSidecar(track: DidlItem.Track): LyricsResult? {
        val base = track.resourceUrl
        if (!base.startsWith("http://", true) && !base.startsWith("https://", true)) return null
        // "External calls off" only holds if the server is on the LAN.
        if (!UrlPolicy.isLanUrl(base) && !settings.allowExternalCalls.value) return null
        val dot = base.lastIndexOf('.'); val slash = base.lastIndexOf('/')
        val lrcUrl = if (dot > slash) base.substring(0, dot) + ".lrc" else return null
        val request = try {
            Request.Builder().url(lrcUrl).build()
        } catch (_: IllegalArgumentException) {
            return null
        }
        return try {
            sidecarHttp.newCall(request).execute().use { r ->
                if (r.code != 200) return@use null
                val text = readSidecarBody(r) ?: return@use null
                lyricsFromLrcText(text)
            }
        } catch (_: IOException) {
            null
        }
    }

    /**
     * The body of a sidecar response, or null when it doesn't look like an LRC file.
     * Servers that map paths by id and ignore the extension, or answer unknown paths
     * with an HTML page and status 200, would otherwise have audio or HTML read whole
     * into memory, shown as lyrics and cached for a week.
     */
    private fun readSidecarBody(r: Response): String? {
        val body = r.body ?: return null
        val type = body.contentType()
        if (type != null) {
            val t = type.type.lowercase()
            val sub = type.subtype.lowercase()
            val textual = (t == "text" && sub != "html" && !sub.contains("xml")) ||
                (t == "application" && (sub == "octet-stream" || sub.contains("lrc"))) ||
                sub.contains("lrc")
            if (!textual) return null
        }
        if (body.contentLength() > MAX_LRC_BYTES) return null
        val source = body.source()
        // request(n) is true when at least n bytes are available: one past the cap
        // means the file is too big to be lyrics.
        if (source.request(MAX_LRC_BYTES + 1)) return null
        val bytes = source.buffer.readByteArray()
        if (bytes.any { it == 0.toByte() }) return null // binary, not text
        val text = String(bytes, type?.charset(Charsets.UTF_8) ?: Charsets.UTF_8)
        val head = text.trimStart().take(64).lowercase()
        if (head.startsWith("<!doctype") || head.startsWith("<html") || head.startsWith("<?xml")) return null
        return text.takeIf { it.isNotBlank() }
    }

    private fun lyricsFromLrcText(text: String): LyricsResult.Loaded? {
        if (text.isBlank()) return null
        val synced = LrcParser.parse(text)
        if (synced.isNotEmpty()) return LyricsResult.Loaded(synced = synced, plain = null)
        // Plain lyrics: drop `[ar:…]`-style header tags (our own exports carry them).
        val plain = text.lineSequence()
            .filterNot { LRC_TAG_LINE.matches(it.trim()) }
            .joinToString("\n").trim()
        return if (plain.isBlank()) null else LyricsResult.Loaded(synced = emptyList(), plain = plain)
    }

    // ---- Export -------------------------------------------------------------

    /** Save found lyrics as `<artist> - <title> [hash8].lrc` in the export folder,
     *  with LRC header tags (ported from Android). The hash suffix keeps two
     *  recordings with the same artist and title (live, remaster) apart.
     *  Best effort: export is a convenience, not a correctness requirement. */
    private fun exportLrc(track: DidlItem.Track, r: LyricsResult.Loaded) {
        try {
            val content = buildLrcText(track, r)
            if (content.isBlank()) return
            StavePaths.ensurePrivateDir(exportDir)
            File(exportDir, buildExportFilename(track)).writeText(content, Charsets.UTF_8)
        } catch (_: IOException) {
        }
    }

    /** "Artist - Title [abcd1234].lrc" with every character other than letters,
     *  digits and ` -._[]()` replaced by `_` (so no path separators). */
    private fun buildExportFilename(track: DidlItem.Track): String {
        val artist = track.artist?.takeIf { it.isNotBlank() } ?: "Unknown Artist"
        val title = track.title.ifBlank { "Unknown Title" }
        val raw = "${artist.take(60)} - ${title.take(120)} [${cacheKey(track).take(8)}].lrc"
        return raw.map { c ->
            when {
                c.isLetterOrDigit() -> c
                c in " -._[]()" -> c
                else -> '_'
            }
        }.joinToString("")
    }

    /** Loaded lyrics back to LRC: header tags, then `[mm:ss.xx]text` per synced line,
     *  or the plain text as-is. */
    private fun buildLrcText(track: DidlItem.Track, result: LyricsResult.Loaded): String {
        fun tag(s: String) = s.replace(Regex("[\\r\\n\\]]+"), " ")
        val sb = StringBuilder()
        track.artist?.takeIf { it.isNotBlank() }?.let { sb.append("[ar:${tag(it)}]\n") }
        track.album?.takeIf { it.isNotBlank() }?.let { sb.append("[al:${tag(it)}]\n") }
        if (track.title.isNotBlank()) sb.append("[ti:${tag(track.title)}]\n")
        sb.append("[by:Earhorn (lrclib.net)]\n\n")
        if (result.synced.isNotEmpty()) {
            for (line in result.synced) {
                val ms = line.start.inWholeMilliseconds
                sb.append("[%02d:%02d.%02d]".format(ms / 60000, (ms / 1000) % 60, (ms % 1000) / 10))
                sb.append(line.text).append('\n')
            }
        } else if (!result.plain.isNullOrBlank()) {
            sb.append(result.plain)
        }
        return sb.toString()
    }

    // ---- HTTP ---------------------------------------------------------------

    /** Try each title variant until LRCLIB knows one (ported from Android). Stops at
     *  the first Loaded / Instrumental, and at the first Error so a network fault
     *  isn't retried once per variant. */
    private suspend fun fetchFromApi(track: DidlItem.Track, skipDuration: Boolean = false): LyricsResult {
        var last: LyricsResult = LyricsResult.NotFound
        for (candidate in titleCandidates(track.title, track.artist)) {
            when (val r = requestOnce(candidate, track, skipDuration)) {
                is LyricsResult.Loaded, LyricsResult.Instrumental -> return r
                is LyricsResult.Error -> return r
                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.
     *  Verbatim from Android. */
    internal 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 suspend fun requestOnce(
        title: String,
        track: DidlItem.Track,
        skipDuration: Boolean,
    ): LyricsResult {
        val builder = "https://lrclib.net/api/get".toHttpUrlOrNull()
            ?.newBuilder()
            ?: return LyricsResult.Error("Bad LRCLIB URL (should never happen)")

        builder.addQueryParameter("track_name", title)
        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())
            }
        }

        // LRCLIB's front end refuses OkHttp's default agent with a 520
        // (found on Android); a descriptive one is answered.
        val req = Request.Builder().url(builder.build())
            .header("User-Agent", LRCLIB_USER_AGENT)
            .header("Lrclib-Client", LRCLIB_USER_AGENT)
            .build()

        // Retry once on IOException (connect/read timeout, DNS, etc.). This handles
        // the common "cold link" case where a first attempt times out because DNS
        // or the TCP handshake didn't complete in time, but the second attempt
        // benefits from warm caches and succeeds. A bad answer (unparseable JSON)
        // is not retried.
        var lastError: IOException? = null
        repeat(2) { attempt ->
            try {
                return http.newCall(req).execute().use { r -> interpret(r) }
            } catch (e: IOException) {
                lastError = e
                if (attempt == 0) kotlinx.coroutines.delay(500)
            }
        }
        return LyricsResult.Error(lastError?.message ?: "Network error")
    }

    private fun interpret(r: Response): LyricsResult = when (r.code) {
        200 -> {
            val body = r.body?.string().orEmpty()
            try {
                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
                }
            } catch (_: kotlinx.serialization.SerializationException) {
                LyricsResult.Error("LRCLIB sent an unreadable answer")
            } catch (_: IllegalArgumentException) {
                LyricsResult.Error("LRCLIB sent an unreadable answer")
            }
        }
        404 -> LyricsResult.NotFound
        else -> LyricsResult.Error("LRCLIB returned HTTP ${r.code}")
    }

    // ---- Cache --------------------------------------------------------------

    /** 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) {
        // Transient outcomes don't belong in the cache.
        //   Error: a network blip shouldn't poison the cache for a week.
        //   PrivacyDisabled: the user can flip the setting back on at any time;
        //     when they do we want a real fetch on the next refresh, not
        //     "you opted out" served from disk.
        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; the "notfound"
                // kind is the safest fallback if a future writeCache change
                // ever lets one slip through.
                LyricsResult.Loading,
                LyricsResult.PrivacyDisabled,
                is LyricsResult.Error -> CachedEnvelope(kind = "notfound")
            }
        }
    }

    companion object {
        /** Upper bound for an LRC file read from a server or the export folder. */
        private const val MAX_LRC_BYTES = 256L * 1024
        private val LRC_TAG_LINE = Regex("""\[[a-zA-Z]+:[^\]]*\]""")
        // Keep 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
        private const val LRCLIB_USER_AGENT = "Earhorn Music Player (https://faveat.software)"
    }
}