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

AlbumInfoLoader.kt

package dev.stave.upnp

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton

/**
 * Locate prose notes for an album.
 *
 * Source priority:
 *   1. **ID3 COMMENT / Vorbis DESCRIPTION** embedded in the first 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. A few UPnP servers
 *      (notably MinimServer) expose adjacent filesystem entries; many do
 *      not, in which case the fetch quietly 404s and we return null.
 *
 * The fetch is best-effort — both paths are wrapped in [runCatching] and
 * any failure (network, parse, missing tag) collapses to null. The UI
 * surfaces a generic "no information available" state when null comes back.
 *
 * No file caching beyond OkHttp's defaults: album views are short-lived,
 * the comment payload is at worst a few KB, and a stale-cache invalidation
 * story is more code than the savings justify.
 */
@Singleton
class AlbumInfoLoader @Inject constructor(
    private val reader: AudioMetadataReader,
) {
    private val http: OkHttpClient = OkHttpClient.Builder()
        .connectTimeout(5, TimeUnit.SECONDS)
        .readTimeout(15, TimeUnit.SECONDS)
        .writeTimeout(5, TimeUnit.SECONDS)
        .build()

    /**
     * Returns the album-info text, or null when nothing is available.
     *
     * @param resourceUrl the streaming URL of any track in the album. ID3
     *   tags are read from this URL directly; the sibling `.txt` lookup
     *   replaces the trailing `.<ext>` with `.txt` and tries that.
     */
    suspend fun load(resourceUrl: String): String? = withContext(Dispatchers.IO) {
        // 1. ID3 COMMENT / Vorbis DESCRIPTION via the existing reader.
        val tags = runCatching { reader.readTags(resourceUrl) }.getOrNull().orEmpty()
        val comment = tags["Comment"]?.takeIf { it.isNotBlank() }
        if (comment != null) return@withContext comment

        // 2. Sibling .txt file. Try a couple of common review filename
        //    patterns: same basename with .txt, plus generic "review.txt"
        //    and "info.txt" in the same directory.
        val siblings = siblingTxtUrls(resourceUrl)
        for (url in siblings) {
            val body = fetchText(url)?.trim()
            if (!body.isNullOrEmpty()) return@withContext body
        }
        null
    }

    /** Build candidate URLs for sibling `.txt` files. The UPnP resource URL
     *  is typically `http://server:port/path/to/track.flac`; we replace the
     *  filename with several common review-text conventions. Return order
     *  matters — most-specific first so we don't pick up a generic
     *  `review.txt` at a higher directory level when the album-specific
     *  text is right next to the file. */
    private fun siblingTxtUrls(resourceUrl: String): List<String> {
        val slash = resourceUrl.lastIndexOf('/')
        if (slash <= 0) return emptyList()
        val dir = resourceUrl.substring(0, slash + 1)
        val filename = resourceUrl.substring(slash + 1)
        val basename = filename.substringBeforeLast('.', filename)
        return listOf(
            "$dir$basename.txt",   // track-name.txt
            "${dir}review.txt",    // album-folder/review.txt
            "${dir}info.txt",      // album-folder/info.txt
            "${dir}notes.txt",     // album-folder/notes.txt
        )
    }

    private fun fetchText(url: String): String? {
        val req = Request.Builder().url(url).build()
        return runCatching {
            http.newCall(req).execute().use { r ->
                if (!r.isSuccessful) null else r.body?.string()
            }
        }.getOrNull()
    }
}