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