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

AlbumInfoLoader.kt

package dev.stave.upnp

import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.IOException
import java.net.Inet4Address
import java.net.InetAddress
import java.net.URI
import java.util.concurrent.TimeUnit

/**
 * Locate prose notes for an album. Port of Android's `AlbumInfoLoader`
 * (README: "Album notes. AlbumInfoLoader reads ID3 / Vorbis comments and
 * sibling .txt notes").
 *
 * Source priority:
 *   1. **ID3 COMMENT / Vorbis DESCRIPTION** embedded in the track's audio file.
 *      [AudioMetadataReader] surfaces these under the display key "Comment".
 *      Most ripped-from-CD libraries store album reviews and liner notes here.
 *   2. **Sibling `.txt` file** alongside the audio: `<track>.txt`, then
 *      `review.txt`, `info.txt`, `notes.txt` in the same folder. A few UPnP
 *      servers (notably MinimServer) expose adjacent filesystem entries; many do
 *      not, in which case the fetch quietly 404s and we return null.
 *
 * Best-effort: any failure (network, parse, missing tag) collapses to null and
 * the UI shows its "no album notes" state.
 *
 * Desktop differences from Android:
 *  - `file:` tracks (dropped files, local M3U entries) read their sibling notes
 *    straight from disk; OkHttp can't fetch `file:`.
 *  - Bodies are capped at [MAX_NOTE_BYTES] (as the tag reader caps tags), and
 *    only `text/plain` (or untyped) responses count, so a server's HTML 404 page
 *    is never shown as notes.
 *  - Redirects are not followed: the notes come from the same host that serves
 *    the music, never from somewhere it points to.
 *  - With "Allow external service calls" off, nothing is fetched from a host
 *    outside the local network (same rule as lyrics; see [isLanUrl]).
 */
class AlbumInfoLoader(
    private val reader: AudioMetadataReader,
    private val allowExternalCalls: () -> Boolean = { true },
) {
    private val http: OkHttpClient = OkHttpClient.Builder()
        .connectTimeout(5, TimeUnit.SECONDS)
        .readTimeout(15, TimeUnit.SECONDS)
        .writeTimeout(5, TimeUnit.SECONDS)
        .followRedirects(false)
        .followSslRedirects(false)
        .build()

    /**
     * Returns the album-info text, or null when nothing is available.
     *
     * @param resourceUrl the streaming URL of any track in the album. Tags are
     *   read from this URL directly; the sibling `.txt` lookup replaces the
     *   file name with each candidate in turn.
     */
    suspend fun load(resourceUrl: String): String? = withContext(Dispatchers.IO) {
        val url = resourceUrl.trim()
        val isFile = url.startsWith("file:", ignoreCase = true)
        val isHttp = url.startsWith("http://", ignoreCase = true) ||
            url.startsWith("https://", ignoreCase = true)
        if (!isFile && !isHttp) return@withContext null
        if (isHttp && !allowExternalCalls() && !isLanUrl(url)) return@withContext null

        // 1. ID3 COMMENT / Vorbis DESCRIPTION via the existing reader.
        val tags = try {
            reader.readTags(url)
        } catch (c: CancellationException) {
            throw c
        } catch (e: Exception) {
            emptyMap()
        }
        tags["Comment"]?.trim()?.takeIf { it.isNotEmpty() }?.let { return@withContext it }

        // 2. Sibling .txt file.
        if (isFile) {
            val track = try { File(URI(url)) } catch (e: IllegalArgumentException) { null }
                ?: return@withContext null
            for (candidate in siblingNames(track.name)) {
                val body = readLocalText(File(track.parentFile ?: return@withContext null, candidate))
                if (!body.isNullOrEmpty()) return@withContext body
            }
        } else {
            for (candidate in siblingTxtUrls(url)) {
                val body = fetchText(candidate)
                if (!body.isNullOrEmpty()) return@withContext body
            }
        }
        null
    }

    /** Candidate file names, most specific first, so a generic `review.txt` never
     *  wins over the notes written for this exact file. */
    private fun siblingNames(fileName: String): List<String> {
        val basename = fileName.substringBeforeLast('.', fileName)
        return listOf("$basename.txt", "review.txt", "info.txt", "notes.txt")
    }

    /** `http://server:port/path/to/track.flac?x` → `http://server:port/path/to/<candidate>`. */
    private fun siblingTxtUrls(resourceUrl: String): List<String> {
        val noQuery = resourceUrl.substringBefore('#').substringBefore('?')
        val slash = noQuery.lastIndexOf('/')
        // Need at least "scheme://host/" before the file name.
        if (slash <= noQuery.indexOf("//") + 1) return emptyList()
        val dir = noQuery.substring(0, slash + 1)
        val filename = noQuery.substring(slash + 1)
        if (filename.isEmpty()) return emptyList()
        return siblingNames(filename).map { dir + it }
    }

    private fun fetchText(url: String): String? {
        val req = try {
            Request.Builder().url(url).build()
        } catch (e: IllegalArgumentException) {
            return null
        }
        return try {
            http.newCall(req).execute().use { r ->
                if (!r.isSuccessful) return null
                val body = r.body ?: return null
                val type = body.contentType()
                if (type != null && !(type.type == "text" && type.subtype == "plain")) return null
                val source = body.source()
                source.request(MAX_NOTE_BYTES)
                val n = minOf(source.buffer.size, MAX_NOTE_BYTES)
                val charset = type?.charset(Charsets.UTF_8) ?: Charsets.UTF_8
                String(source.buffer.readByteArray(n), charset).trim()
            }
        } catch (e: IOException) {
            null
        }
    }

    private fun readLocalText(f: File): String? {
        if (!f.isFile) return null
        return try {
            f.inputStream().use { input ->
                val bytes = input.readNBytes(MAX_NOTE_BYTES.toInt())
                String(bytes, Charsets.UTF_8).trim()
            }
        } catch (e: IOException) {
            null
        } catch (e: SecurityException) {
            null
        }
    }

    companion object {
        /** Notes are prose; 64 KB is several thousand words. */
        const val MAX_NOTE_BYTES: Long = 64L * 1024

        /** Same LAN rule as lyrics: delegates to the shared [dev.stave.net.UrlPolicy]
         *  (pure string checks, never DNS). */
        internal fun isLanUrl(url: String): Boolean = dev.stave.net.UrlPolicy.isLanUrl(url)
    }
}