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 `.` 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 { 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() } }