AudioMetadataReader.kt
Each line, with the release that last changed it.
| 0.5.0 | 1 | package dev.stave.upnp |
| 2 | ||
| 3 | import kotlinx.coroutines.Dispatchers | |
| 4 | import kotlinx.coroutines.withContext | |
| 5 | import okhttp3.OkHttpClient | |
| 6 | import okhttp3.Request | |
| 7 | import java.util.concurrent.TimeUnit | |
| 8 | ||
| 9 | /** | |
| 10 | * Multi-format audio metadata reader. Fetches audio stream bytes over HTTP with Range | |
| 11 | * requests (or straight from disk for `file:` URLs and absolute paths) and parses | |
| 12 | * whatever embedded tags it finds. Supports: | |
| 13 | * | |
| 14 | * • **ID3v2.2 / v2.3 / v2.4** — the classic MP3 tag. Also appears on many FLAC files | |
| 15 | * that were tagged by non-FLAC-aware software (common in messy libraries). | |
| 16 | * • **FLAC native metadata** — Vorbis Comments stored in FLAC metadata blocks. | |
| 17 | * • **OGG Vorbis / Opus** — Vorbis Comments carried in the OGG container's second page. | |
| 18 | * • **MP4 / M4A** — iTunes-style "ilst" tags nested inside moov.udta.meta. | |
| 19 | * | |
| 20 | * Design choices: | |
| 21 | * | |
| 22 | * • **Exact-size fetches.** Rather than a one-shot "grab the first N KB and hope", | |
| 23 | * each format's header declares the payload size — we read just enough to learn | |
| 24 | * the size, then fetch precisely what we need. A 10MB embedded hi-res cover no | |
| 25 | * longer truncates the text tags that follow it. | |
| 26 | * • **Hard ceiling of 20MB.** Prevents a pathological server from pinning us trying | |
| 27 | * to stream the whole file when a declared tag size is garbage. | |
| 28 | * • **Text-only output.** Binary frames (APIC artwork, METADATA_BLOCK_PICTURE, | |
| 29 | * MP4 covr) are recognised but not decoded — we already have UPnP's albumArtURI | |
| 30 | * for display purposes, and we don't want to balloon the in-memory map. | |
| 31 | * • **Best-effort everywhere.** Any parse failure is swallowed and surfaced as an | |
| 32 | * empty map; the UI treats "no tag" and "fetch failed" identically. | |
| 33 | */ | |
| 34 | class AudioMetadataReader { | |
| 35 | ||
| 36 | // Reasonable timeouts — server is usually local-network, payloads modest. | |
| 37 | private val http: OkHttpClient = OkHttpClient.Builder() | |
| 38 | .connectTimeout(5, TimeUnit.SECONDS) | |
| 39 | .readTimeout(20, TimeUnit.SECONDS) | |
| 40 | .writeTimeout(5, TimeUnit.SECONDS) | |
| 41 | .build() | |
| 42 | ||
| 43 | suspend fun readTags(url: String): Map<String, String> = withContext(Dispatchers.IO) { | |
| 44 | runCatching { | |
| 45 | // 4KB probe: enough for magic-byte detection and the declared sizes of the | |
| 46 | // immediate headers of every supported format. | |
| 47 | val probe = fetch(url, 0, 4095) ?: return@withContext emptyMap<String, String>() | |
| 48 | ||
| 49 | val id3Size = id3DeclaredSize(probe) | |
| 50 | val tags = LinkedHashMap<String, String>() | |
| 51 | ||
| 52 | if (id3Size > 0) { | |
| 53 | // Walk the tag frame by frame, fetching only what the next | |
| 54 | // frame needs and jumping over picture frames without | |
| 55 | // downloading them (review N.73, design decision | |
| 56 | // 2026-09-02: the old code fetched the whole tag, cover art | |
| 57 | // included — up to 20 MB — to read a few text frames). | |
| 58 | tags.putAll(parseId3Streaming(url, probe, id3Size)) | |
| 59 | // After ID3, the underlying container might be FLAC with additional | |
| 60 | // Vorbis Comments. Peek past the ID3 tag and see. | |
| 61 | val after = id3Size + 10 | |
| 62 | val nextProbe = fetch(url, after, after + 4095) ?: ByteArray(0) | |
| 63 | if (startsWith(nextProbe, FLAC_MAGIC)) { | |
| 64 | tags.putAll(parseFlacTags(url, after, nextProbe)) | |
| 65 | } | |
| 66 | return@withContext tags | |
| 67 | } | |
| 68 | ||
| 69 | when { | |
| 70 | startsWith(probe, FLAC_MAGIC) -> tags.putAll(parseFlacTags(url, 0, probe)) | |
| 71 | isMp4(probe) -> tags.putAll(parseMp4Tags(url, probe)) | |
| 72 | startsWith(probe, OGG_MAGIC) -> tags.putAll(parseOggVorbisComments(url, probe)) | |
| 73 | } | |
| 74 | tags | |
| 75 | }.getOrDefault(emptyMap()) | |
| 76 | } | |
| 77 | ||
| 78 | // -------------------------------------------------------------------------- | |
| 79 | // HTTP | |
| 80 | // -------------------------------------------------------------------------- | |
| 81 | ||
| 82 | private fun fetch(url: String, first: Long, last: Long): ByteArray? { | |
| 83 | localFile(url)?.let { return readLocal(it, first, last) } | |
| 84 | val req = Request.Builder() | |
| 85 | .url(url) | |
| 86 | .header("Range", "bytes=$first-$last") | |
| 87 | .build() | |
| 88 | return runCatching { | |
| 89 | http.newCall(req).execute().use { r -> | |
| 90 | if (!r.isSuccessful) return@use null | |
| 91 | // Read AT MOST the bytes asked for, whatever the server sends. | |
| 92 | // A radio server ignores Range and answers 200 with an | |
| 93 | // endless body; body.bytes() read it to EOF — an unbounded | |
| 94 | // download from every "Show id3 tags" tap while following a | |
| 95 | // station (review N.77, design decision 2026-09-02). | |
| 96 | // A 200 (not 206) to a mid-file Range request also means the | |
| 97 | // server sent the file from byte 0; those bytes are not the | |
| 98 | // ones asked for, so answer null rather than mislabel them. | |
| 99 | if (r.code == 200 && first > 0L) return@use null | |
| 100 | val want = (last - first + 1).coerceIn(0L, MAX_TAG_BYTES.toLong()).toInt() | |
| 101 | val body = r.body ?: return@use null | |
| 102 | body.byteStream().use { inp -> | |
| 103 | val out = ByteArray(want) | |
| 104 | var off = 0 | |
| 105 | while (off < want) { | |
| 106 | val n = inp.read(out, off, want - off) | |
| 107 | if (n < 0) break | |
| 108 | off += n | |
| 109 | } | |
| 110 | if (off == want) out else out.copyOf(off) | |
| 111 | } | |
| 112 | } | |
| 113 | }.getOrNull() | |
| 114 | } | |
| 115 | ||
| 116 | private fun fetch(url: String, first: Int, last: Int): ByteArray? = | |
| 117 | fetch(url, first.toLong(), last.toLong()) | |
| 118 | ||
| 119 | /** A `file:` URL or absolute path (e.g. an entry from a loaded M3U, which VLC | |
| 120 | * plays directly) as a local file, else null. Without this, "Show tags" on such | |
| 121 | * a track reported nothing because OkHttp only speaks http(s). */ | |
| 122 | private fun localFile(url: String): java.io.File? = when { | |
| 123 | url.startsWith("file:", ignoreCase = true) -> | |
| 124 | try { java.io.File(java.net.URI(url)) } catch (_: IllegalArgumentException) { null } | |
| 125 | catch (_: java.net.URISyntaxException) { null } | |
| 126 | url.startsWith("/") -> java.io.File(url) | |
| 127 | else -> null | |
| 128 | } | |
| 129 | ||
| 130 | /** Same contract as the HTTP [fetch]: at most the bytes asked for (capped at | |
| 131 | * [MAX_TAG_BYTES]), fewer at end of file, null when nothing can be read. */ | |
| 132 | private fun readLocal(file: java.io.File, first: Long, last: Long): ByteArray? { | |
| 133 | if (!file.isFile || first < 0) return null | |
| 134 | val want = (last - first + 1).coerceIn(0L, MAX_TAG_BYTES.toLong()).toInt() | |
| 135 | return try { | |
| 136 | java.io.RandomAccessFile(file, "r").use { raf -> | |
| 137 | if (first >= raf.length()) return null | |
| 138 | raf.seek(first) | |
| 139 | val out = ByteArray(want) | |
| 140 | var off = 0 | |
| 141 | while (off < want) { | |
| 142 | val n = raf.read(out, off, want - off) | |
| 143 | if (n < 0) break | |
| 144 | off += n | |
| 145 | } | |
| 146 | if (off == want) out else out.copyOf(off) | |
| 147 | } | |
| 148 | } catch (_: java.io.IOException) { | |
| 149 | null | |
| 150 | } catch (_: SecurityException) { | |
| 151 | null | |
| 152 | } | |
| 153 | } | |
| 154 | ||
| 155 | ||
| 156 | // -------------------------------------------------------------------------- | |
| 157 | // Format detection | |
| 158 | // -------------------------------------------------------------------------- | |
| 159 | ||
| 160 | private fun startsWith(b: ByteArray, magic: ByteArray): Boolean { | |
| 161 | if (b.size < magic.size) return false | |
| 162 | for (i in magic.indices) if (b[i] != magic[i]) return false | |
| 163 | return true | |
| 164 | } | |
| 165 | ||
| 166 | /** MP4/M4A has "ftyp" at byte 4 (size comes first, then atom type). */ | |
| 167 | private fun isMp4(b: ByteArray): Boolean = | |
| 168 | b.size >= 12 && | |
| 169 | b[4] == 'f'.code.toByte() && b[5] == 't'.code.toByte() && | |
| 170 | b[6] == 'y'.code.toByte() && b[7] == 'p'.code.toByte() | |
| 171 | ||
| 172 | /** Returns the declared ID3v2 tag size (excluding the 10-byte header), or 0 if no tag. */ | |
| 173 | private fun id3DeclaredSize(b: ByteArray): Int { | |
| 174 | if (b.size < 10) return 0 | |
| 175 | if (b[0] != 'I'.code.toByte() || b[1] != 'D'.code.toByte() || b[2] != '3'.code.toByte()) return 0 | |
| 176 | return synchsafe(b, 6) | |
| 177 | } | |
| 178 | ||
| 179 | // -------------------------------------------------------------------------- | |
| 180 | // ID3v2 | |
| 181 | // -------------------------------------------------------------------------- | |
| 182 | ||
| 183 | private fun parseId3(b: ByteArray): Map<String, String> { | |
| 184 | if (b.size < 10) return emptyMap() | |
| 185 | val major = b[3].toInt() and 0xFF | |
| 186 | val tagSize = synchsafe(b, 6) | |
| 187 | val end = (10 + tagSize).coerceAtMost(b.size) | |
| 188 | ||
| 189 | val frameHeaderSize = if (major < 3) 6 else 10 | |
| 190 | val frameIdSize = if (major < 3) 3 else 4 | |
| 191 | val out = LinkedHashMap<String, String>() | |
| 192 | ||
| 193 | var p = 10 | |
| 194 | while (p + frameHeaderSize <= end) { | |
| 195 | val id = String(b, p, frameIdSize, Charsets.US_ASCII) | |
| 196 | if (id.isEmpty() || id[0] == '\u0000' || !id.all { it.isLetterOrDigit() }) break | |
| 197 | val size = when { | |
| 198 | major < 3 -> bigEndian(b, p + 3, 3) | |
| 199 | major == 3 -> bigEndian(b, p + 4, 4) | |
| 200 | else -> synchsafe(b, p + 4) | |
| 201 | } | |
| 202 | if (size <= 0 || p + frameHeaderSize + size > end) break | |
| 203 | val data = b.copyOfRange(p + frameHeaderSize, p + frameHeaderSize + size) | |
| 204 | parseId3Frame(id, data)?.let { out[friendlyId3(id)] = it } | |
| 205 | p += frameHeaderSize + size | |
| 206 | } | |
| 207 | return out | |
| 208 | } | |
| 209 | ||
| 210 | /** | |
| 211 | * ID3v2 walk that reads the tag in pieces. `base` is the file offset of | |
| 212 | * `buf[0]`; picture frames (APIC / PIC) are skipped by advancing the file | |
| 213 | * position without fetching their bytes. Text frames are fetched 64 KB at | |
| 214 | * a time. Same frame semantics as [parseId3]; that function is kept for | |
| 215 | * callers that already hold the whole tag. | |
| 216 | */ | |
| 217 | private fun parseId3Streaming(url: String, probe: ByteArray, id3Size: Int): Map<String, String> { | |
| 218 | if (probe.size < 10) return emptyMap() | |
| 219 | val major = probe[3].toInt() and 0xFF | |
| 220 | val end = (10L + id3Size).coerceAtMost(MAX_TAG_BYTES.toLong()) | |
| 221 | val frameHeaderSize = if (major < 3) 6 else 10 | |
| 222 | val frameIdSize = if (major < 3) 3 else 4 | |
| 223 | val out = LinkedHashMap<String, String>() | |
| 224 | ||
| 225 | var base = 0L | |
| 226 | var buf = probe | |
| 227 | // Ensure buf covers file bytes [pos, pos+len). May drop the buffer and | |
| 228 | // re-anchor at pos (after a skipped picture), or extend it. | |
| 229 | fun ensure(pos: Long, len: Int): Boolean { | |
| 230 | val needEnd = (pos + len).coerceAtMost(end) | |
| 231 | if (pos < base || pos - base > Int.MAX_VALUE) { | |
| 232 | base = pos; buf = ByteArray(0) | |
| 233 | } | |
| 234 | while (base + buf.size < needEnd) { | |
| 235 | val from = base + buf.size | |
| 236 | val to = (from + 65535).coerceAtMost(end - 1) | |
| 237 | if (to < from) return false | |
| 238 | val more = fetch(url, from, to) ?: return false | |
| 239 | if (more.isEmpty()) return false | |
| 240 | buf += more | |
| 241 | } | |
| 242 | return pos + len <= base + buf.size | |
| 243 | } | |
| 244 | ||
| 245 | var p = 10L | |
| 246 | while (p + frameHeaderSize <= end) { | |
| 247 | if (!ensure(p, frameHeaderSize)) break | |
| 248 | val hp = (p - base).toInt() | |
| 249 | val id = String(buf, hp, frameIdSize, Charsets.US_ASCII) | |
| 250 | if (id.isEmpty() || id[0] == '\u0000' || !id.all { it.isLetterOrDigit() }) break | |
| 251 | val size = when { | |
| 252 | major < 3 -> bigEndian(buf, hp + 3, 3) | |
| 253 | major == 3 -> bigEndian(buf, hp + 4, 4) | |
| 254 | else -> synchsafe(buf, hp + 4) | |
| 255 | } | |
| 256 | if (size <= 0 || p + frameHeaderSize + size > end) break | |
| 257 | if (id == "APIC" || id == "PIC") { | |
| 258 | // Picture: jump over it without fetching. | |
| 259 | p += frameHeaderSize + size | |
| 260 | continue | |
| 261 | } | |
| 262 | if (!ensure(p, frameHeaderSize + size)) break | |
| 263 | val dp = (p - base).toInt() + frameHeaderSize | |
| 264 | val data = buf.copyOfRange(dp, dp + size) | |
| 265 | parseId3Frame(id, data)?.let { out[friendlyId3(id)] = it } | |
| 266 | p += frameHeaderSize + size | |
| 267 | } | |
| 268 | return out | |
| 269 | } | |
| 270 | ||
| 271 | private fun parseId3Frame(id: String, data: ByteArray): String? { | |
| 272 | if (data.isEmpty()) return null | |
| 273 | return when { | |
| 274 | id.startsWith("T") -> { | |
| 275 | val enc = data[0].toInt() and 0xFF | |
| 276 | decodeText(data, 1, enc).trim('\u0000', ' ').ifBlank { null } | |
| 277 | } | |
| 278 | id == "COMM" || id == "COM" -> { | |
| 279 | if (data.size < 5) return null | |
| 280 | val enc = data[0].toInt() and 0xFF | |
| 281 | val textStart = if (enc == 1 || enc == 2) { | |
| 282 | val s = findDoubleNull(data, 4); if (s < 0) return null else s + 2 | |
| 283 | } else { | |
| 284 | val s = findSingleNull(data, 4); if (s < 0) return null else s + 1 | |
| 285 | } | |
| 286 | if (textStart >= data.size) return null | |
| 287 | decodeText(data, textStart, enc).trim('\u0000', ' ').ifBlank { null } | |
| 288 | } | |
| 289 | else -> null | |
| 290 | } | |
| 291 | } | |
| 292 | ||
| 293 | private fun friendlyId3(id: String): String = when (id) { | |
| 294 | "TIT2", "TT2" -> "Title" | |
| 295 | "TPE1", "TP1" -> "Artist" | |
| 296 | "TPE2", "TP2" -> "Album artist" | |
| 297 | "TPE3", "TP3" -> "Conductor" | |
| 298 | "TPE4", "TP4" -> "Interpreter" | |
| 299 | "TALB", "TAL" -> "Album" | |
| 300 | "TYER", "TYE" -> "Year" | |
| 301 | "TDRC" -> "Recording date" | |
| 302 | "TDRL" -> "Release date" | |
| 303 | "TCON", "TCO" -> "Genre" | |
| 304 | "TRCK", "TRK" -> "Track number" | |
| 305 | "TPOS", "TPA" -> "Disc" | |
| 306 | "TCOM", "TCM" -> "Composer" | |
| 307 | "TEXT", "TXT" -> "Lyricist" | |
| 308 | "TPUB" -> "Publisher" | |
| 309 | "TBPM" -> "BPM" | |
| 310 | "TKEY" -> "Key" | |
| 311 | "TLAN" -> "Language" | |
| 312 | "TLEN" -> "Length (ms)" | |
| 313 | "COMM", "COM" -> "Comment" | |
| 314 | "TSSE", "TSS" -> "Encoder" | |
| 315 | "TCOP", "TCR" -> "Copyright" | |
| 316 | "TENC", "TEN" -> "Encoded by" | |
| 317 | "TOPE", "TOA" -> "Original artist" | |
| 318 | "TOAL", "TOT" -> "Original album" | |
| 319 | "TIT1", "TT1" -> "Content group" | |
| 320 | "TIT3", "TT3" -> "Subtitle" | |
| 321 | "TOWN" -> "Owner" | |
| 322 | "TSRC", "TRC" -> "ISRC" | |
| 323 | "TMED", "TMT" -> "Media type" | |
| 324 | "TSOP" -> "Artist sort" | |
| 325 | "TSOA" -> "Album sort" | |
| 326 | "TSOT" -> "Title sort" | |
| 327 | else -> id | |
| 328 | } | |
| 329 | ||
| 330 | // -------------------------------------------------------------------------- | |
| 331 | // FLAC | |
| 332 | // -------------------------------------------------------------------------- | |
| 333 | ||
| 334 | /** | |
| 335 | * Walk FLAC metadata blocks from [fileOffset] (0 for a pure FLAC file, or just-past-ID3 | |
| 336 | * if the file has a prefixed ID3 tag). For each block we read its header from the | |
| 337 | * current buffer (or re-fetch if missing), and only fetch the block's payload when it's | |
| 338 | * the VORBIS_COMMENT block (type 4) — PICTURE blocks can be 10+MB of hi-res artwork | |
| 339 | * that we don't want to transfer just to read a text tag. | |
| 340 | */ | |
| 341 | private fun parseFlacTags(url: String, fileOffset: Int, initial: ByteArray): Map<String, String> { | |
| 342 | val out = LinkedHashMap<String, String>() | |
| 343 | // Skip "fLaC" magic — 4 bytes | |
| 344 | var pos = 4 | |
| 345 | var buf = initial | |
| 346 | ||
| 347 | // Buf spans [fileOffset .. fileOffset + buf.size) | |
| 348 | fun absAt(p: Int) = fileOffset + p | |
| 349 | ||
| 350 | while (true) { | |
| 351 | if (pos + 4 > buf.size) { | |
| 352 | val newBuf = fetch(url, absAt(pos), absAt(pos) + 4095) ?: return out | |
| 353 | if (newBuf.isEmpty()) return out | |
| 354 | buf = newBuf | |
| 355 | pos = 0 | |
| 356 | } | |
| 357 | val header = buf.copyOfRange(pos, pos + 4) | |
| 358 | val last = (header[0].toInt() and 0x80) != 0 | |
| 359 | val blockType = header[0].toInt() and 0x7F | |
| 360 | val blockLen = ((header[1].toInt() and 0xFF) shl 16) or | |
| 361 | ((header[2].toInt() and 0xFF) shl 8) or | |
| 362 | (header[3].toInt() and 0xFF) | |
| 363 | pos += 4 | |
| 364 | ||
| 365 | if (blockType == 4) { | |
| 366 | // VORBIS_COMMENT — fetch the payload if we don't have it in the buffer | |
| 367 | val payload = if (pos + blockLen <= buf.size) { | |
| 368 | buf.copyOfRange(pos, pos + blockLen) | |
| 369 | } else { | |
| 370 | fetch(url, absAt(pos), absAt(pos) + blockLen - 1) ?: return out | |
| 371 | } | |
| 372 | parseVorbisComments(payload).forEach { (k, v) -> out[k] = v } | |
| 373 | } | |
| 374 | ||
| 375 | if (last) break | |
| 376 | // Skip this block's payload; if the next header isn't in buf, we'll re-fetch. | |
| 377 | pos += blockLen | |
| 378 | if (pos > MAX_TAG_BYTES) break // safety stop | |
| 379 | } | |
| 380 | return out | |
| 381 | } | |
| 382 | ||
| 383 | /** | |
| 384 | * Vorbis Comments payload: LE u32 vendor_len, vendor_str, LE u32 count, then for each | |
| 385 | * entry LE u32 len + "FIELD=value" (UTF-8). Case-insensitive field names by convention. | |
| 386 | */ | |
| 387 | private fun parseVorbisComments(b: ByteArray): Map<String, String> { | |
| 388 | if (b.size < 8) return emptyMap() | |
| 389 | val out = LinkedHashMap<String, String>() | |
| 390 | var p = 0 | |
| 391 | val vendorLen = leInt(b, p); p += 4 | |
| 392 | if (p + vendorLen > b.size) return emptyMap() | |
| 393 | p += vendorLen | |
| 394 | if (p + 4 > b.size) return out | |
| 395 | val count = leInt(b, p); p += 4 | |
| 396 | for (i in 0 until count) { | |
| 397 | if (p + 4 > b.size) break | |
| 398 | val len = leInt(b, p); p += 4 | |
| 399 | if (len < 0 || p + len > b.size) break | |
| 400 | val entry = String(b, p, len, Charsets.UTF_8) | |
| 401 | p += len | |
| 402 | val eq = entry.indexOf('=') | |
| 403 | if (eq <= 0) continue | |
| 404 | val key = entry.substring(0, eq) | |
| 405 | val value = entry.substring(eq + 1) | |
| 406 | if (value.isNotBlank()) out[friendlyVorbis(key)] = value | |
| 407 | } | |
| 408 | return out | |
| 409 | } | |
| 410 | ||
| 411 | private fun friendlyVorbis(key: String): String = when (key.uppercase()) { | |
| 412 | "TITLE" -> "Title" | |
| 413 | "ARTIST" -> "Artist" | |
| 414 | "ALBUMARTIST", "ALBUM ARTIST" -> "Album artist" | |
| 415 | "ALBUM" -> "Album" | |
| 416 | "DATE", "YEAR" -> "Date" | |
| 417 | "ORIGINALDATE" -> "Original date" | |
| 418 | "GENRE" -> "Genre" | |
| 419 | "TRACKNUMBER", "TRACK" -> "Track number" | |
| 420 | "TRACKTOTAL", "TOTALTRACKS" -> "Total tracks" | |
| 421 | "DISCNUMBER", "DISC" -> "Disc number" | |
| 422 | "DISCTOTAL", "TOTALDISCS" -> "Total discs" | |
| 423 | "COMPOSER" -> "Composer" | |
| 424 | "CONDUCTOR" -> "Conductor" | |
| 425 | "PERFORMER" -> "Performer" | |
| 426 | "ENSEMBLE" -> "Ensemble" | |
| 427 | "LYRICIST" -> "Lyricist" | |
| 428 | "ARRANGER" -> "Arranger" | |
| 429 | "PUBLISHER", "ORGANIZATION" -> "Publisher" | |
| 430 | "LABEL" -> "Label" | |
| 431 | "COPYRIGHT" -> "Copyright" | |
| 432 | "ISRC" -> "ISRC" | |
| 433 | "BARCODE" -> "Barcode" | |
| 434 | "CATALOGNUMBER" -> "Catalog number" | |
| 435 | "COMMENT", "DESCRIPTION" -> "Comment" | |
| 436 | "LANGUAGE" -> "Language" | |
| 437 | "LYRICS", "UNSYNCED_LYRICS" -> "Lyrics" | |
| 438 | "BPM" -> "BPM" | |
| 439 | "MOOD" -> "Mood" | |
| 440 | "ENCODEDBY", "ENCODED-BY" -> "Encoded by" | |
| 441 | "ENCODER", "ENCODERSETTINGS" -> "Encoder" | |
| 442 | "COMPILATION" -> "Compilation" | |
| 443 | "REPLAYGAIN_TRACK_GAIN" -> "ReplayGain track gain" | |
| 444 | "REPLAYGAIN_TRACK_PEAK" -> "ReplayGain track peak" | |
| 445 | "REPLAYGAIN_ALBUM_GAIN" -> "ReplayGain album gain" | |
| 446 | "REPLAYGAIN_ALBUM_PEAK" -> "ReplayGain album peak" | |
| 447 | else -> key | |
| 448 | } | |
| 449 | ||
| 450 | // -------------------------------------------------------------------------- | |
| 451 | // OGG (Vorbis / Opus) | |
| 452 | // -------------------------------------------------------------------------- | |
| 453 | ||
| 454 | /** | |
| 455 | * OGG container: a stream of pages, each prefixed with "OggS". First page holds the | |
| 456 | * identification header (Vorbis or Opus), second page holds the comment header with | |
| 457 | * Vorbis Comments. This parses the comment header only — skips the identification | |
| 458 | * page entirely. | |
| 459 | */ | |
| 460 | private fun parseOggVorbisComments(url: String, probe: ByteArray): Map<String, String> { | |
| 461 | // Walk pages until we find one whose packet begins with \x03"vorbis" (Vorbis | |
| 462 | // comment) or "OpusTags" (Opus comment). | |
| 463 | var offset = 0 | |
| 464 | var buf = probe | |
| 465 | while (true) { | |
| 466 | val pageStart = findSubarray(buf, OGG_MAGIC, offset) | |
| 467 | if (pageStart < 0) { | |
| 468 | // Fetch more if we haven't hit the end of the file. `buf` | |
| 469 | // always starts at file offset 0, so the next unread byte is | |
| 470 | // at buf.size — NOT offset + buf.size (review N.71, | |
| 471 | // design decision 2026-09-02: that skipped `offset` bytes | |
| 472 | // and glued the wrong part of the file onto the buffer). | |
| 473 | val nextStart = buf.size.toLong() | |
| 474 | val more = fetch(url, nextStart, nextStart + 16383) ?: return emptyMap() | |
| 475 | if (more.isEmpty()) return emptyMap() | |
| 476 | buf += more | |
| 477 | continue | |
| 478 | } | |
| 479 | if (pageStart + 27 > buf.size) return emptyMap() // not enough for header | |
| 480 | ||
| 481 | val segCount = buf[pageStart + 26].toInt() and 0xFF | |
| 482 | if (pageStart + 27 + segCount > buf.size) return emptyMap() | |
| 483 | var segTableSum = 0 | |
| 484 | for (i in 0 until segCount) { | |
| 485 | segTableSum += buf[pageStart + 27 + i].toInt() and 0xFF | |
| 486 | } | |
| 487 | val payloadStart = pageStart + 27 + segCount | |
| 488 | if (payloadStart + segTableSum > buf.size) { | |
| 489 | // Need more bytes for this page's payload — again from the | |
| 490 | // end of what we hold, not offset past it (N.71). | |
| 491 | val nextStart = buf.size.toLong() | |
| 492 | val more = fetch(url, nextStart, nextStart + 16383) ?: return emptyMap() | |
| 493 | if (more.isEmpty()) return emptyMap() | |
| 494 | buf += more | |
| 495 | continue | |
| 496 | } | |
| 497 | val payload = buf.copyOfRange(payloadStart, payloadStart + segTableSum) | |
| 498 | ||
| 499 | // Vorbis comment packet: 0x03 "vorbis" then Vorbis Comment structure. | |
| 500 | if (payload.size >= 7 && | |
| 501 | payload[0].toInt() == 0x03 && | |
| 502 | String(payload, 1, 6, Charsets.US_ASCII) == "vorbis" | |
| 503 | ) { | |
| 504 | val vc = payload.copyOfRange(7, payload.size) | |
| 505 | return parseVorbisComments(vc) + mapOf("Format" to "OGG Vorbis") | |
| 506 | } | |
| 507 | // Opus comment packet: "OpusTags" then Vorbis Comment structure. | |
| 508 | if (payload.size >= 8 && | |
| 509 | String(payload, 0, 8, Charsets.US_ASCII) == "OpusTags" | |
| 510 | ) { | |
| 511 | val vc = payload.copyOfRange(8, payload.size) | |
| 512 | return parseVorbisComments(vc) + mapOf("Format" to "Opus") | |
| 513 | } | |
| 514 | ||
| 515 | // Skip this page and keep looking. | |
| 516 | offset = pageStart + (payloadStart + segTableSum - pageStart) | |
| 517 | if (offset > MAX_TAG_BYTES) return emptyMap() | |
| 518 | } | |
| 519 | } | |
| 520 | ||
| 521 | // -------------------------------------------------------------------------- | |
| 522 | // MP4 / M4A | |
| 523 | // -------------------------------------------------------------------------- | |
| 524 | ||
| 525 | /** | |
| 526 | * MP4 atom tree: each box is (u32 size) + (4 ASCII type) + payload. Metadata lives at | |
| 527 | * moov > udta > meta > ilst. We recurse into the container boxes we care about, and | |
| 528 | * skip everything else (mdat can be hundreds of MB of audio data; we never touch it). | |
| 529 | */ | |
| 530 | private fun parseMp4Tags(url: String, probe: ByteArray): Map<String, String> { | |
| 531 | // moov is typically near the start but can be large if it contains cover art. | |
| 532 | // Grow the buffer as we walk atoms — whenever we need to inspect a box whose | |
| 533 | // declared size exceeds what we've fetched, we pull more. | |
| 534 | var buf = probe | |
| 535 | fun ensureSize(n: Int): Boolean { | |
| 536 | if (n <= buf.size) return true | |
| 537 | val more = fetch(url, buf.size, (n - 1).coerceAtMost(MAX_TAG_BYTES)) ?: return false | |
| 538 | if (more.isEmpty()) return false | |
| 539 | buf += more | |
| 540 | return buf.size >= n | |
| 541 | } | |
| 542 | ||
| 543 | // Find 'moov' at the top level | |
| 544 | val moov = findTopLevelAtom("moov", { buf }) { need -> ensureSize(need) } ?: return emptyMap() | |
| 545 | if (!ensureSize(moov.end)) return emptyMap() | |
| 546 | val moovContent = buf.copyOfRange(moov.payloadStart, moov.end) | |
| 547 | ||
| 548 | val udta = findAtom(moovContent, "udta") ?: return emptyMap() | |
| 549 | val udtaContent = moovContent.copyOfRange(udta.payloadStart, udta.end) | |
| 550 | ||
| 551 | val meta = findAtom(udtaContent, "meta") ?: return emptyMap() | |
| 552 | // meta atom has a 4-byte version/flags header before its children in some writers. | |
| 553 | val metaInner = if (meta.end - meta.payloadStart > 4) { | |
| 554 | udtaContent.copyOfRange(meta.payloadStart + 4, meta.end) | |
| 555 | } else { | |
| 556 | udtaContent.copyOfRange(meta.payloadStart, meta.end) | |
| 557 | } | |
| 558 | ||
| 559 | val ilst = findAtom(metaInner, "ilst") ?: return emptyMap() | |
| 560 | val ilstContent = metaInner.copyOfRange(ilst.payloadStart, ilst.end) | |
| 561 | ||
| 562 | val out = LinkedHashMap<String, String>() | |
| 563 | var p = 0 | |
| 564 | while (p + 8 <= ilstContent.size) { | |
| 565 | val size = bigEndian(ilstContent, p, 4) | |
| 566 | if (size < 8 || p + size > ilstContent.size) break | |
| 567 | val type = String(ilstContent, p + 4, 4, Charsets.ISO_8859_1) | |
| 568 | // Child 'data' atom holds the value: size, "data", version/flags(4), null(4), | |
| 569 | // then payload. Payload is UTF-8 for text atoms; skipped for binary types. | |
| 570 | val inner = ilstContent.copyOfRange(p + 8, p + size) | |
| 571 | val dataAtom = findAtom(inner, "data") | |
| 572 | if (dataAtom != null && dataAtom.end - dataAtom.payloadStart >= 8) { | |
| 573 | val flags = bigEndian(inner, dataAtom.payloadStart, 4) and 0xFFFFFF | |
| 574 | val payload = inner.copyOfRange(dataAtom.payloadStart + 8, dataAtom.end) | |
| 575 | val value = when (flags) { | |
| 576 | 1 -> String(payload, Charsets.UTF_8) // text | |
| 577 | 13, 14 -> null // JPEG/PNG cover art — skip | |
| 578 | else -> { | |
| 579 | // Numeric types (flags=0 usually). Track and disc numbers in | |
| 580 | // iTunes store as big-endian shorts inside this payload. | |
| 581 | if (type == "trkn" || type == "disk") { | |
| 582 | if (payload.size >= 6) { | |
| 583 | val n = bigEndian(payload, 2, 2) | |
| 584 | val total = if (payload.size >= 8) bigEndian(payload, 4, 2) else 0 | |
| 585 | if (total > 0) "$n / $total" else n.toString() | |
| 586 | } else null | |
| 587 | } else { | |
| 588 | // Fall back to UTF-8 decode if it looks like text | |
| 589 | val s = String(payload, Charsets.UTF_8) | |
| 590 | if (s.any { it.isLetter() || it.isDigit() }) s else null | |
| 591 | } | |
| 592 | } | |
| 593 | } | |
| 594 | if (value != null && value.isNotBlank()) { | |
| 595 | out[friendlyMp4(type)] = value.trim() | |
| 596 | } | |
| 597 | } | |
| 598 | p += size | |
| 599 | } | |
| 600 | return out | |
| 601 | } | |
| 602 | ||
| 603 | private data class Atom(val start: Int, val payloadStart: Int, val end: Int) | |
| 604 | ||
| 605 | /** | |
| 606 | * Walk top-level atoms, fetching more of [buf] as declared sizes reveal them. Returns | |
| 607 | * the first atom of [type] we find, or null if we reach end/fetch failure first. | |
| 608 | */ | |
| 609 | /** Walk top-level atoms. [current] returns the caller's buffer AS IT IS | |
| 610 | * NOW — after [ensure] has grown it. (Until 2026-09-02 this function | |
| 611 | * re-read a snapshot parameter, so any `moov` past the first probe was | |
| 612 | * never found; I.71, design decision.) */ | |
| 613 | private inline fun findTopLevelAtom( | |
| 614 | type: String, | |
| 615 | current: () -> ByteArray, | |
| 616 | ensure: (Int) -> Boolean, | |
| 617 | ): Atom? { | |
| 618 | var p = 0 | |
| 619 | while (true) { | |
| 620 | var localBuf = current() | |
| 621 | if (p + 8 > localBuf.size) { | |
| 622 | if (!ensure(p + 8)) return null | |
| 623 | localBuf = current() | |
| 624 | if (p + 8 > localBuf.size) return null | |
| 625 | } | |
| 626 | val size = bigEndian(localBuf, p, 4) | |
| 627 | if (size < 8) return null | |
| 628 | val atomType = String(localBuf, p + 4, 4, Charsets.ISO_8859_1) | |
| 629 | if (atomType == type) { | |
| 630 | if (!ensure(p + size)) return null | |
| 631 | return Atom(p, p + 8, p + size) | |
| 632 | } | |
| 633 | p += size | |
| 634 | if (p > MAX_TAG_BYTES) return null | |
| 635 | } | |
| 636 | } | |
| 637 | ||
| 638 | /** Simple non-recursive atom find within a fully-available buffer. */ | |
| 639 | private fun findAtom(buf: ByteArray, type: String): Atom? { | |
| 640 | var p = 0 | |
| 641 | while (p + 8 <= buf.size) { | |
| 642 | val size = bigEndian(buf, p, 4) | |
| 643 | if (size < 8 || p + size > buf.size) return null | |
| 644 | val atomType = String(buf, p + 4, 4, Charsets.ISO_8859_1) | |
| 645 | if (atomType == type) return Atom(p, p + 8, p + size) | |
| 646 | p += size | |
| 647 | } | |
| 648 | return null | |
| 649 | } | |
| 650 | ||
| 651 | private fun friendlyMp4(type: String): String = when (type) { | |
| 652 | "\u00A9nam" -> "Title" | |
| 653 | "\u00A9ART" -> "Artist" | |
| 654 | "aART" -> "Album artist" | |
| 655 | "\u00A9alb" -> "Album" | |
| 656 | "\u00A9day" -> "Year" | |
| 657 | "\u00A9gen", "gnre" -> "Genre" | |
| 658 | "trkn" -> "Track number" | |
| 659 | "disk" -> "Disc number" | |
| 660 | "\u00A9wrt" -> "Composer" | |
| 661 | "\u00A9cmt" -> "Comment" | |
| 662 | "\u00A9too" -> "Encoder" | |
| 663 | "\u00A9lyr" -> "Lyrics" | |
| 664 | "cprt" -> "Copyright" | |
| 665 | "\u00A9grp" -> "Content group" | |
| 666 | "cpil" -> "Compilation" | |
| 667 | "tmpo" -> "BPM" | |
| 668 | "\u00A9con" -> "Conductor" | |
| 669 | "soal" -> "Album sort" | |
| 670 | "soar" -> "Artist sort" | |
| 671 | "sonm" -> "Title sort" | |
| 672 | "soco" -> "Composer sort" | |
| 673 | "soaa" -> "Album artist sort" | |
| 674 | "cond" -> "Conductor" | |
| 675 | "pub " -> "Publisher" | |
| 676 | "desc" -> "Description" | |
| 677 | "ldes" -> "Long description" | |
| 678 | else -> type | |
| 679 | } | |
| 680 | ||
| 681 | // -------------------------------------------------------------------------- | |
| 682 | // Bit twiddling | |
| 683 | // -------------------------------------------------------------------------- | |
| 684 | ||
| 685 | private fun synchsafe(b: ByteArray, off: Int): Int = | |
| 686 | ((b[off].toInt() and 0x7F) shl 21) or | |
| 687 | ((b[off + 1].toInt() and 0x7F) shl 14) or | |
| 688 | ((b[off + 2].toInt() and 0x7F) shl 7) or | |
| 689 | (b[off + 3].toInt() and 0x7F) | |
| 690 | ||
| 691 | private fun bigEndian(b: ByteArray, off: Int, len: Int): Int { | |
| 692 | var r = 0 | |
| 693 | for (i in 0 until len) r = (r shl 8) or (b[off + i].toInt() and 0xFF) | |
| 694 | return r | |
| 695 | } | |
| 696 | ||
| 697 | private fun leInt(b: ByteArray, off: Int): Int = | |
| 698 | (b[off].toInt() and 0xFF) or | |
| 699 | ((b[off + 1].toInt() and 0xFF) shl 8) or | |
| 700 | ((b[off + 2].toInt() and 0xFF) shl 16) or | |
| 701 | ((b[off + 3].toInt() and 0xFF) shl 24) | |
| 702 | ||
| 703 | private fun findSingleNull(b: ByteArray, from: Int): Int { | |
| 704 | for (i in from until b.size) if (b[i].toInt() == 0) return i | |
| 705 | return -1 | |
| 706 | } | |
| 707 | ||
| 708 | private fun findDoubleNull(b: ByteArray, from: Int): Int { | |
| 709 | var i = from | |
| 710 | while (i < b.size - 1) { | |
| 711 | if (b[i].toInt() == 0 && b[i + 1].toInt() == 0) return i | |
| 712 | i += 2 | |
| 713 | } | |
| 714 | return -1 | |
| 715 | } | |
| 716 | ||
| 717 | private fun findSubarray(hay: ByteArray, needle: ByteArray, from: Int): Int { | |
| 718 | outer@ for (i in from..(hay.size - needle.size)) { | |
| 719 | for (j in needle.indices) if (hay[i + j] != needle[j]) continue@outer | |
| 720 | return i | |
| 721 | } | |
| 722 | return -1 | |
| 723 | } | |
| 724 | ||
| 725 | private fun decodeText(buf: ByteArray, offset: Int, encoding: Int): String { | |
| 726 | val len = buf.size - offset | |
| 727 | if (len <= 0) return "" | |
| 728 | return when (encoding) { | |
| 729 | 0 -> String(buf, offset, len, Charsets.ISO_8859_1) | |
| 730 | 1 -> { | |
| 731 | if (len >= 2) { | |
| 732 | val b0 = buf[offset].toInt() and 0xFF | |
| 733 | val b1 = buf[offset + 1].toInt() and 0xFF | |
| 734 | when { | |
| 735 | b0 == 0xFF && b1 == 0xFE -> String(buf, offset + 2, len - 2, Charsets.UTF_16LE) | |
| 736 | b0 == 0xFE && b1 == 0xFF -> String(buf, offset + 2, len - 2, Charsets.UTF_16BE) | |
| 737 | else -> String(buf, offset, len, Charsets.UTF_16) | |
| 738 | } | |
| 739 | } else String(buf, offset, len, Charsets.UTF_16) | |
| 740 | } | |
| 741 | 2 -> String(buf, offset, len, Charsets.UTF_16BE) | |
| 742 | 3 -> String(buf, offset, len, Charsets.UTF_8) | |
| 743 | else -> String(buf, offset, len, Charsets.ISO_8859_1) | |
| 744 | } | |
| 745 | } | |
| 746 | ||
| 747 | companion object { | |
| 748 | private const val MAX_TAG_BYTES = 20 * 1024 * 1024 // 20MB hard ceiling | |
| 749 | private val FLAC_MAGIC = "fLaC".toByteArray(Charsets.US_ASCII) | |
| 750 | private val OGG_MAGIC = "OggS".toByteArray(Charsets.US_ASCII) | |
| 751 | } | |
| 752 | } |


