EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

AudioMetadataReader.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.upnp
2
3import kotlinx.coroutines.Dispatchers
4import kotlinx.coroutines.withContext
5import okhttp3.OkHttpClient
6import okhttp3.Request
7import 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 */
34class 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}