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

AudioMetadataReader.kt

744 lines · 32.4 KB

Changed in: 0.5.0

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