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()
}
}