AlbumInfoLoader.kt
Each line, with the release that last changed it.
| 0.5.0 | 1 | package dev.stave.upnp |
| 2 | ||
| 3 | import kotlinx.coroutines.CancellationException | |
| 4 | import kotlinx.coroutines.Dispatchers | |
| 5 | import kotlinx.coroutines.withContext | |
| 6 | import okhttp3.OkHttpClient | |
| 7 | import okhttp3.Request | |
| 8 | import java.io.File | |
| 9 | import java.io.IOException | |
| 10 | import java.net.Inet4Address | |
| 11 | import java.net.InetAddress | |
| 12 | import java.net.URI | |
| 13 | import java.util.concurrent.TimeUnit | |
| 14 | ||
| 15 | /** | |
| 16 | * Locate prose notes for an album. Port of Android's `AlbumInfoLoader` | |
| 17 | * (README: "Album notes. AlbumInfoLoader reads ID3 / Vorbis comments and | |
| 18 | * sibling .txt notes"). | |
| 19 | * | |
| 20 | * Source priority: | |
| 21 | * 1. **ID3 COMMENT / Vorbis DESCRIPTION** embedded in the track's audio file. | |
| 22 | * [AudioMetadataReader] surfaces these under the display key "Comment". | |
| 23 | * Most ripped-from-CD libraries store album reviews and liner notes here. | |
| 24 | * 2. **Sibling `.txt` file** alongside the audio: `<track>.txt`, then | |
| 25 | * `review.txt`, `info.txt`, `notes.txt` in the same folder. A few UPnP | |
| 26 | * servers (notably MinimServer) expose adjacent filesystem entries; many do | |
| 27 | * not, in which case the fetch quietly 404s and we return null. | |
| 28 | * | |
| 29 | * Best-effort: any failure (network, parse, missing tag) collapses to null and | |
| 30 | * the UI shows its "no album notes" state. | |
| 31 | * | |
| 32 | * Desktop differences from Android: | |
| 33 | * - `file:` tracks (dropped files, local M3U entries) read their sibling notes | |
| 34 | * straight from disk; OkHttp can't fetch `file:`. | |
| 35 | * - Bodies are capped at [MAX_NOTE_BYTES] (as the tag reader caps tags), and | |
| 36 | * only `text/plain` (or untyped) responses count, so a server's HTML 404 page | |
| 37 | * is never shown as notes. | |
| 38 | * - Redirects are not followed: the notes come from the same host that serves | |
| 39 | * the music, never from somewhere it points to. | |
| 40 | * - With "Allow external service calls" off, nothing is fetched from a host | |
| 41 | * outside the local network (same rule as lyrics; see [isLanUrl]). | |
| 42 | */ | |
| 43 | class AlbumInfoLoader( | |
| 44 | private val reader: AudioMetadataReader, | |
| 45 | private val allowExternalCalls: () -> Boolean = { true }, | |
| 46 | ) { | |
| 47 | private val http: OkHttpClient = OkHttpClient.Builder() | |
| 48 | .connectTimeout(5, TimeUnit.SECONDS) | |
| 49 | .readTimeout(15, TimeUnit.SECONDS) | |
| 50 | .writeTimeout(5, TimeUnit.SECONDS) | |
| 51 | .followRedirects(false) | |
| 52 | .followSslRedirects(false) | |
| 53 | .build() | |
| 54 | ||
| 55 | /** | |
| 56 | * Returns the album-info text, or null when nothing is available. | |
| 57 | * | |
| 58 | * @param resourceUrl the streaming URL of any track in the album. Tags are | |
| 59 | * read from this URL directly; the sibling `.txt` lookup replaces the | |
| 60 | * file name with each candidate in turn. | |
| 61 | */ | |
| 62 | suspend fun load(resourceUrl: String): String? = withContext(Dispatchers.IO) { | |
| 63 | val url = resourceUrl.trim() | |
| 64 | val isFile = url.startsWith("file:", ignoreCase = true) | |
| 65 | val isHttp = url.startsWith("http://", ignoreCase = true) || | |
| 66 | url.startsWith("https://", ignoreCase = true) | |
| 67 | if (!isFile && !isHttp) return@withContext null | |
| 68 | if (isHttp && !allowExternalCalls() && !isLanUrl(url)) return@withContext null | |
| 69 | ||
| 70 | // 1. ID3 COMMENT / Vorbis DESCRIPTION via the existing reader. | |
| 71 | val tags = try { | |
| 72 | reader.readTags(url) | |
| 73 | } catch (c: CancellationException) { | |
| 74 | throw c | |
| 75 | } catch (e: Exception) { | |
| 76 | emptyMap() | |
| 77 | } | |
| 78 | tags["Comment"]?.trim()?.takeIf { it.isNotEmpty() }?.let { return@withContext it } | |
| 79 | ||
| 80 | // 2. Sibling .txt file. | |
| 81 | if (isFile) { | |
| 82 | val track = try { File(URI(url)) } catch (e: IllegalArgumentException) { null } | |
| 83 | ?: return@withContext null | |
| 84 | for (candidate in siblingNames(track.name)) { | |
| 85 | val body = readLocalText(File(track.parentFile ?: return@withContext null, candidate)) | |
| 86 | if (!body.isNullOrEmpty()) return@withContext body | |
| 87 | } | |
| 88 | } else { | |
| 89 | for (candidate in siblingTxtUrls(url)) { | |
| 90 | val body = fetchText(candidate) | |
| 91 | if (!body.isNullOrEmpty()) return@withContext body | |
| 92 | } | |
| 93 | } | |
| 94 | null | |
| 95 | } | |
| 96 | ||
| 97 | /** Candidate file names, most specific first, so a generic `review.txt` never | |
| 98 | * wins over the notes written for this exact file. */ | |
| 99 | private fun siblingNames(fileName: String): List<String> { | |
| 100 | val basename = fileName.substringBeforeLast('.', fileName) | |
| 101 | return listOf("$basename.txt", "review.txt", "info.txt", "notes.txt") | |
| 102 | } | |
| 103 | ||
| 104 | /** `http://server:port/path/to/track.flac?x` → `http://server:port/path/to/<candidate>`. */ | |
| 105 | private fun siblingTxtUrls(resourceUrl: String): List<String> { | |
| 106 | val noQuery = resourceUrl.substringBefore('#').substringBefore('?') | |
| 107 | val slash = noQuery.lastIndexOf('/') | |
| 108 | // Need at least "scheme://host/" before the file name. | |
| 109 | if (slash <= noQuery.indexOf("//") + 1) return emptyList() | |
| 110 | val dir = noQuery.substring(0, slash + 1) | |
| 111 | val filename = noQuery.substring(slash + 1) | |
| 112 | if (filename.isEmpty()) return emptyList() | |
| 113 | return siblingNames(filename).map { dir + it } | |
| 114 | } | |
| 115 | ||
| 116 | private fun fetchText(url: String): String? { | |
| 117 | val req = try { | |
| 118 | Request.Builder().url(url).build() | |
| 119 | } catch (e: IllegalArgumentException) { | |
| 120 | return null | |
| 121 | } | |
| 122 | return try { | |
| 123 | http.newCall(req).execute().use { r -> | |
| 124 | if (!r.isSuccessful) return null | |
| 125 | val body = r.body ?: return null | |
| 126 | val type = body.contentType() | |
| 127 | if (type != null && !(type.type == "text" && type.subtype == "plain")) return null | |
| 128 | val source = body.source() | |
| 129 | source.request(MAX_NOTE_BYTES) | |
| 130 | val n = minOf(source.buffer.size, MAX_NOTE_BYTES) | |
| 131 | val charset = type?.charset(Charsets.UTF_8) ?: Charsets.UTF_8 | |
| 132 | String(source.buffer.readByteArray(n), charset).trim() | |
| 133 | } | |
| 134 | } catch (e: IOException) { | |
| 135 | null | |
| 136 | } | |
| 137 | } | |
| 138 | ||
| 139 | private fun readLocalText(f: File): String? { | |
| 140 | if (!f.isFile) return null | |
| 141 | return try { | |
| 142 | f.inputStream().use { input -> | |
| 143 | val bytes = input.readNBytes(MAX_NOTE_BYTES.toInt()) | |
| 144 | String(bytes, Charsets.UTF_8).trim() | |
| 145 | } | |
| 146 | } catch (e: IOException) { | |
| 147 | null | |
| 148 | } catch (e: SecurityException) { | |
| 149 | null | |
| 150 | } | |
| 151 | } | |
| 152 | ||
| 153 | companion object { | |
| 154 | /** Notes are prose; 64 KB is several thousand words. */ | |
| 155 | const val MAX_NOTE_BYTES: Long = 64L * 1024 | |
| 156 | ||
| 157 | /** Same LAN rule as lyrics: delegates to the shared [dev.stave.net.UrlPolicy] | |
| 158 | * (pure string checks, never DNS). */ | |
| 159 | internal fun isLanUrl(url: String): Boolean = dev.stave.net.UrlPolicy.isLanUrl(url) | |
| 160 | } | |
| 161 | } |


