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: `.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 { 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/`. */ private fun siblingTxtUrls(resourceUrl: String): List { 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) } }