AlbumInfoLoader.kt
161 lines · 6.7 KB
Changed in: 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 | } |


