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

AudioMetadataReader.kt

package dev.stave.upnp

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit

/**
 * Multi-format audio metadata reader. Fetches audio stream bytes over HTTP with Range
 * requests (or straight from disk for `file:` URLs and absolute paths) and parses
 * whatever embedded tags it finds. Supports:
 *
 *   • **ID3v2.2 / v2.3 / v2.4** — the classic MP3 tag. Also appears on many FLAC files
 *     that were tagged by non-FLAC-aware software (common in messy libraries).
 *   • **FLAC native metadata** — Vorbis Comments stored in FLAC metadata blocks.
 *   • **OGG Vorbis / Opus** — Vorbis Comments carried in the OGG container's second page.
 *   • **MP4 / M4A** — iTunes-style "ilst" tags nested inside moov.udta.meta.
 *
 * Design choices:
 *
 *   • **Exact-size fetches.** Rather than a one-shot "grab the first N KB and hope",
 *     each format's header declares the payload size — we read just enough to learn
 *     the size, then fetch precisely what we need. A 10MB embedded hi-res cover no
 *     longer truncates the text tags that follow it.
 *   • **Hard ceiling of 20MB.** Prevents a pathological server from pinning us trying
 *     to stream the whole file when a declared tag size is garbage.
 *   • **Text-only output.** Binary frames (APIC artwork, METADATA_BLOCK_PICTURE,
 *     MP4 covr) are recognised but not decoded — we already have UPnP's albumArtURI
 *     for display purposes, and we don't want to balloon the in-memory map.
 *   • **Best-effort everywhere.** Any parse failure is swallowed and surfaced as an
 *     empty map; the UI treats "no tag" and "fetch failed" identically.
 */
class AudioMetadataReader {

    // Reasonable timeouts — server is usually local-network, payloads modest.
    private val http: OkHttpClient = OkHttpClient.Builder()
        .connectTimeout(5, TimeUnit.SECONDS)
        .readTimeout(20, TimeUnit.SECONDS)
        .writeTimeout(5, TimeUnit.SECONDS)
        .build()

    suspend fun readTags(url: String): Map<String, String> = withContext(Dispatchers.IO) {
        runCatching {
            // 4KB probe: enough for magic-byte detection and the declared sizes of the
            // immediate headers of every supported format.
            val probe = fetch(url, 0, 4095) ?: return@withContext emptyMap<String, String>()

            val id3Size = id3DeclaredSize(probe)
            val tags = LinkedHashMap<String, String>()

            if (id3Size > 0) {
                // Walk the tag frame by frame, fetching only what the next
                // frame needs and jumping over picture frames without
                // downloading them (review N.73, design decision
                // 2026-09-02: the old code fetched the whole tag, cover art
                // included — up to 20 MB — to read a few text frames).
                tags.putAll(parseId3Streaming(url, probe, id3Size))
                // After ID3, the underlying container might be FLAC with additional
                // Vorbis Comments. Peek past the ID3 tag and see.
                val after = id3Size + 10
                val nextProbe = fetch(url, after, after + 4095) ?: ByteArray(0)
                if (startsWith(nextProbe, FLAC_MAGIC)) {
                    tags.putAll(parseFlacTags(url, after, nextProbe))
                }
                return@withContext tags
            }

            when {
                startsWith(probe, FLAC_MAGIC) -> tags.putAll(parseFlacTags(url, 0, probe))
                isMp4(probe) -> tags.putAll(parseMp4Tags(url, probe))
                startsWith(probe, OGG_MAGIC) -> tags.putAll(parseOggVorbisComments(url, probe))
            }
            tags
        }.getOrDefault(emptyMap())
    }

    // --------------------------------------------------------------------------
    // HTTP
    // --------------------------------------------------------------------------

    private fun fetch(url: String, first: Long, last: Long): ByteArray? {
        localFile(url)?.let { return readLocal(it, first, last) }
        val req = Request.Builder()
            .url(url)
            .header("Range", "bytes=$first-$last")
            .build()
        return runCatching {
            http.newCall(req).execute().use { r ->
                if (!r.isSuccessful) return@use null
                // Read AT MOST the bytes asked for, whatever the server sends.
                // A radio server ignores Range and answers 200 with an
                // endless body; body.bytes() read it to EOF — an unbounded
                // download from every "Show id3 tags" tap while following a
                // station (review N.77, design decision 2026-09-02).
                // A 200 (not 206) to a mid-file Range request also means the
                // server sent the file from byte 0; those bytes are not the
                // ones asked for, so answer null rather than mislabel them.
                if (r.code == 200 && first > 0L) return@use null
                val want = (last - first + 1).coerceIn(0L, MAX_TAG_BYTES.toLong()).toInt()
                val body = r.body ?: return@use null
                body.byteStream().use { inp ->
                    val out = ByteArray(want)
                    var off = 0
                    while (off < want) {
                        val n = inp.read(out, off, want - off)
                        if (n < 0) break
                        off += n
                    }
                    if (off == want) out else out.copyOf(off)
                }
            }
        }.getOrNull()
    }

    private fun fetch(url: String, first: Int, last: Int): ByteArray? =
        fetch(url, first.toLong(), last.toLong())

    /** A `file:` URL or absolute path (e.g. an entry from a loaded M3U, which VLC
     *  plays directly) as a local file, else null. Without this, "Show tags" on such
     *  a track reported nothing because OkHttp only speaks http(s). */
    private fun localFile(url: String): java.io.File? = when {
        url.startsWith("file:", ignoreCase = true) ->
            try { java.io.File(java.net.URI(url)) } catch (_: IllegalArgumentException) { null }
                catch (_: java.net.URISyntaxException) { null }
        url.startsWith("/") -> java.io.File(url)
        else -> null
    }

    /** Same contract as the HTTP [fetch]: at most the bytes asked for (capped at
     *  [MAX_TAG_BYTES]), fewer at end of file, null when nothing can be read. */
    private fun readLocal(file: java.io.File, first: Long, last: Long): ByteArray? {
        if (!file.isFile || first < 0) return null
        val want = (last - first + 1).coerceIn(0L, MAX_TAG_BYTES.toLong()).toInt()
        return try {
            java.io.RandomAccessFile(file, "r").use { raf ->
                if (first >= raf.length()) return null
                raf.seek(first)
                val out = ByteArray(want)
                var off = 0
                while (off < want) {
                    val n = raf.read(out, off, want - off)
                    if (n < 0) break
                    off += n
                }
                if (off == want) out else out.copyOf(off)
            }
        } catch (_: java.io.IOException) {
            null
        } catch (_: SecurityException) {
            null
        }
    }


    // --------------------------------------------------------------------------
    // Format detection
    // --------------------------------------------------------------------------

    private fun startsWith(b: ByteArray, magic: ByteArray): Boolean {
        if (b.size < magic.size) return false
        for (i in magic.indices) if (b[i] != magic[i]) return false
        return true
    }

    /** MP4/M4A has "ftyp" at byte 4 (size comes first, then atom type). */
    private fun isMp4(b: ByteArray): Boolean =
        b.size >= 12 &&
            b[4] == 'f'.code.toByte() && b[5] == 't'.code.toByte() &&
            b[6] == 'y'.code.toByte() && b[7] == 'p'.code.toByte()

    /** Returns the declared ID3v2 tag size (excluding the 10-byte header), or 0 if no tag. */
    private fun id3DeclaredSize(b: ByteArray): Int {
        if (b.size < 10) return 0
        if (b[0] != 'I'.code.toByte() || b[1] != 'D'.code.toByte() || b[2] != '3'.code.toByte()) return 0
        return synchsafe(b, 6)
    }

    // --------------------------------------------------------------------------
    // ID3v2
    // --------------------------------------------------------------------------

    private fun parseId3(b: ByteArray): Map<String, String> {
        if (b.size < 10) return emptyMap()
        val major = b[3].toInt() and 0xFF
        val tagSize = synchsafe(b, 6)
        val end = (10 + tagSize).coerceAtMost(b.size)

        val frameHeaderSize = if (major < 3) 6 else 10
        val frameIdSize = if (major < 3) 3 else 4
        val out = LinkedHashMap<String, String>()

        var p = 10
        while (p + frameHeaderSize <= end) {
            val id = String(b, p, frameIdSize, Charsets.US_ASCII)
            if (id.isEmpty() || id[0] == '\u0000' || !id.all { it.isLetterOrDigit() }) break
            val size = when {
                major < 3 -> bigEndian(b, p + 3, 3)
                major == 3 -> bigEndian(b, p + 4, 4)
                else -> synchsafe(b, p + 4)
            }
            if (size <= 0 || p + frameHeaderSize + size > end) break
            val data = b.copyOfRange(p + frameHeaderSize, p + frameHeaderSize + size)
            parseId3Frame(id, data)?.let { out[friendlyId3(id)] = it }
            p += frameHeaderSize + size
        }
        return out
    }

    /**
     * ID3v2 walk that reads the tag in pieces. `base` is the file offset of
     * `buf[0]`; picture frames (APIC / PIC) are skipped by advancing the file
     * position without fetching their bytes. Text frames are fetched 64 KB at
     * a time. Same frame semantics as [parseId3]; that function is kept for
     * callers that already hold the whole tag.
     */
    private fun parseId3Streaming(url: String, probe: ByteArray, id3Size: Int): Map<String, String> {
        if (probe.size < 10) return emptyMap()
        val major = probe[3].toInt() and 0xFF
        val end = (10L + id3Size).coerceAtMost(MAX_TAG_BYTES.toLong())
        val frameHeaderSize = if (major < 3) 6 else 10
        val frameIdSize = if (major < 3) 3 else 4
        val out = LinkedHashMap<String, String>()

        var base = 0L
        var buf = probe
        // Ensure buf covers file bytes [pos, pos+len). May drop the buffer and
        // re-anchor at pos (after a skipped picture), or extend it.
        fun ensure(pos: Long, len: Int): Boolean {
            val needEnd = (pos + len).coerceAtMost(end)
            if (pos < base || pos - base > Int.MAX_VALUE) {
                base = pos; buf = ByteArray(0)
            }
            while (base + buf.size < needEnd) {
                val from = base + buf.size
                val to = (from + 65535).coerceAtMost(end - 1)
                if (to < from) return false
                val more = fetch(url, from, to) ?: return false
                if (more.isEmpty()) return false
                buf += more
            }
            return pos + len <= base + buf.size
        }

        var p = 10L
        while (p + frameHeaderSize <= end) {
            if (!ensure(p, frameHeaderSize)) break
            val hp = (p - base).toInt()
            val id = String(buf, hp, frameIdSize, Charsets.US_ASCII)
            if (id.isEmpty() || id[0] == '\u0000' || !id.all { it.isLetterOrDigit() }) break
            val size = when {
                major < 3 -> bigEndian(buf, hp + 3, 3)
                major == 3 -> bigEndian(buf, hp + 4, 4)
                else -> synchsafe(buf, hp + 4)
            }
            if (size <= 0 || p + frameHeaderSize + size > end) break
            if (id == "APIC" || id == "PIC") {
                // Picture: jump over it without fetching.
                p += frameHeaderSize + size
                continue
            }
            if (!ensure(p, frameHeaderSize + size)) break
            val dp = (p - base).toInt() + frameHeaderSize
            val data = buf.copyOfRange(dp, dp + size)
            parseId3Frame(id, data)?.let { out[friendlyId3(id)] = it }
            p += frameHeaderSize + size
        }
        return out
    }

    private fun parseId3Frame(id: String, data: ByteArray): String? {
        if (data.isEmpty()) return null
        return when {
            id.startsWith("T") -> {
                val enc = data[0].toInt() and 0xFF
                decodeText(data, 1, enc).trim('\u0000', ' ').ifBlank { null }
            }
            id == "COMM" || id == "COM" -> {
                if (data.size < 5) return null
                val enc = data[0].toInt() and 0xFF
                val textStart = if (enc == 1 || enc == 2) {
                    val s = findDoubleNull(data, 4); if (s < 0) return null else s + 2
                } else {
                    val s = findSingleNull(data, 4); if (s < 0) return null else s + 1
                }
                if (textStart >= data.size) return null
                decodeText(data, textStart, enc).trim('\u0000', ' ').ifBlank { null }
            }
            else -> null
        }
    }

    private fun friendlyId3(id: String): String = when (id) {
        "TIT2", "TT2" -> "Title"
        "TPE1", "TP1" -> "Artist"
        "TPE2", "TP2" -> "Album artist"
        "TPE3", "TP3" -> "Conductor"
        "TPE4", "TP4" -> "Interpreter"
        "TALB", "TAL" -> "Album"
        "TYER", "TYE" -> "Year"
        "TDRC" -> "Recording date"
        "TDRL" -> "Release date"
        "TCON", "TCO" -> "Genre"
        "TRCK", "TRK" -> "Track number"
        "TPOS", "TPA" -> "Disc"
        "TCOM", "TCM" -> "Composer"
        "TEXT", "TXT" -> "Lyricist"
        "TPUB" -> "Publisher"
        "TBPM" -> "BPM"
        "TKEY" -> "Key"
        "TLAN" -> "Language"
        "TLEN" -> "Length (ms)"
        "COMM", "COM" -> "Comment"
        "TSSE", "TSS" -> "Encoder"
        "TCOP", "TCR" -> "Copyright"
        "TENC", "TEN" -> "Encoded by"
        "TOPE", "TOA" -> "Original artist"
        "TOAL", "TOT" -> "Original album"
        "TIT1", "TT1" -> "Content group"
        "TIT3", "TT3" -> "Subtitle"
        "TOWN" -> "Owner"
        "TSRC", "TRC" -> "ISRC"
        "TMED", "TMT" -> "Media type"
        "TSOP" -> "Artist sort"
        "TSOA" -> "Album sort"
        "TSOT" -> "Title sort"
        else -> id
    }

    // --------------------------------------------------------------------------
    // FLAC
    // --------------------------------------------------------------------------

    /**
     * Walk FLAC metadata blocks from [fileOffset] (0 for a pure FLAC file, or just-past-ID3
     * if the file has a prefixed ID3 tag). For each block we read its header from the
     * current buffer (or re-fetch if missing), and only fetch the block's payload when it's
     * the VORBIS_COMMENT block (type 4) — PICTURE blocks can be 10+MB of hi-res artwork
     * that we don't want to transfer just to read a text tag.
     */
    private fun parseFlacTags(url: String, fileOffset: Int, initial: ByteArray): Map<String, String> {
        val out = LinkedHashMap<String, String>()
        // Skip "fLaC" magic — 4 bytes
        var pos = 4
        var buf = initial

        // Buf spans [fileOffset .. fileOffset + buf.size)
        fun absAt(p: Int) = fileOffset + p

        while (true) {
            if (pos + 4 > buf.size) {
                val newBuf = fetch(url, absAt(pos), absAt(pos) + 4095) ?: return out
                if (newBuf.isEmpty()) return out
                buf = newBuf
                pos = 0
            }
            val header = buf.copyOfRange(pos, pos + 4)
            val last = (header[0].toInt() and 0x80) != 0
            val blockType = header[0].toInt() and 0x7F
            val blockLen = ((header[1].toInt() and 0xFF) shl 16) or
                ((header[2].toInt() and 0xFF) shl 8) or
                (header[3].toInt() and 0xFF)
            pos += 4

            if (blockType == 4) {
                // VORBIS_COMMENT — fetch the payload if we don't have it in the buffer
                val payload = if (pos + blockLen <= buf.size) {
                    buf.copyOfRange(pos, pos + blockLen)
                } else {
                    fetch(url, absAt(pos), absAt(pos) + blockLen - 1) ?: return out
                }
                parseVorbisComments(payload).forEach { (k, v) -> out[k] = v }
            }

            if (last) break
            // Skip this block's payload; if the next header isn't in buf, we'll re-fetch.
            pos += blockLen
            if (pos > MAX_TAG_BYTES) break  // safety stop
        }
        return out
    }

    /**
     * Vorbis Comments payload: LE u32 vendor_len, vendor_str, LE u32 count, then for each
     * entry LE u32 len + "FIELD=value" (UTF-8). Case-insensitive field names by convention.
     */
    private fun parseVorbisComments(b: ByteArray): Map<String, String> {
        if (b.size < 8) return emptyMap()
        val out = LinkedHashMap<String, String>()
        var p = 0
        val vendorLen = leInt(b, p); p += 4
        if (p + vendorLen > b.size) return emptyMap()
        p += vendorLen
        if (p + 4 > b.size) return out
        val count = leInt(b, p); p += 4
        for (i in 0 until count) {
            if (p + 4 > b.size) break
            val len = leInt(b, p); p += 4
            if (len < 0 || p + len > b.size) break
            val entry = String(b, p, len, Charsets.UTF_8)
            p += len
            val eq = entry.indexOf('=')
            if (eq <= 0) continue
            val key = entry.substring(0, eq)
            val value = entry.substring(eq + 1)
            if (value.isNotBlank()) out[friendlyVorbis(key)] = value
        }
        return out
    }

    private fun friendlyVorbis(key: String): String = when (key.uppercase()) {
        "TITLE" -> "Title"
        "ARTIST" -> "Artist"
        "ALBUMARTIST", "ALBUM ARTIST" -> "Album artist"
        "ALBUM" -> "Album"
        "DATE", "YEAR" -> "Date"
        "ORIGINALDATE" -> "Original date"
        "GENRE" -> "Genre"
        "TRACKNUMBER", "TRACK" -> "Track number"
        "TRACKTOTAL", "TOTALTRACKS" -> "Total tracks"
        "DISCNUMBER", "DISC" -> "Disc number"
        "DISCTOTAL", "TOTALDISCS" -> "Total discs"
        "COMPOSER" -> "Composer"
        "CONDUCTOR" -> "Conductor"
        "PERFORMER" -> "Performer"
        "ENSEMBLE" -> "Ensemble"
        "LYRICIST" -> "Lyricist"
        "ARRANGER" -> "Arranger"
        "PUBLISHER", "ORGANIZATION" -> "Publisher"
        "LABEL" -> "Label"
        "COPYRIGHT" -> "Copyright"
        "ISRC" -> "ISRC"
        "BARCODE" -> "Barcode"
        "CATALOGNUMBER" -> "Catalog number"
        "COMMENT", "DESCRIPTION" -> "Comment"
        "LANGUAGE" -> "Language"
        "LYRICS", "UNSYNCED_LYRICS" -> "Lyrics"
        "BPM" -> "BPM"
        "MOOD" -> "Mood"
        "ENCODEDBY", "ENCODED-BY" -> "Encoded by"
        "ENCODER", "ENCODERSETTINGS" -> "Encoder"
        "COMPILATION" -> "Compilation"
        "REPLAYGAIN_TRACK_GAIN" -> "ReplayGain track gain"
        "REPLAYGAIN_TRACK_PEAK" -> "ReplayGain track peak"
        "REPLAYGAIN_ALBUM_GAIN" -> "ReplayGain album gain"
        "REPLAYGAIN_ALBUM_PEAK" -> "ReplayGain album peak"
        else -> key
    }

    // --------------------------------------------------------------------------
    // OGG (Vorbis / Opus)
    // --------------------------------------------------------------------------

    /**
     * OGG container: a stream of pages, each prefixed with "OggS". First page holds the
     * identification header (Vorbis or Opus), second page holds the comment header with
     * Vorbis Comments. This parses the comment header only — skips the identification
     * page entirely.
     */
    private fun parseOggVorbisComments(url: String, probe: ByteArray): Map<String, String> {
        // Walk pages until we find one whose packet begins with \x03"vorbis" (Vorbis
        // comment) or "OpusTags" (Opus comment).
        var offset = 0
        var buf = probe
        while (true) {
            val pageStart = findSubarray(buf, OGG_MAGIC, offset)
            if (pageStart < 0) {
                // Fetch more if we haven't hit the end of the file. `buf`
                // always starts at file offset 0, so the next unread byte is
                // at buf.size — NOT offset + buf.size (review N.71,
                // design decision 2026-09-02: that skipped `offset` bytes
                // and glued the wrong part of the file onto the buffer).
                val nextStart = buf.size.toLong()
                val more = fetch(url, nextStart, nextStart + 16383) ?: return emptyMap()
                if (more.isEmpty()) return emptyMap()
                buf += more
                continue
            }
            if (pageStart + 27 > buf.size) return emptyMap()  // not enough for header

            val segCount = buf[pageStart + 26].toInt() and 0xFF
            if (pageStart + 27 + segCount > buf.size) return emptyMap()
            var segTableSum = 0
            for (i in 0 until segCount) {
                segTableSum += buf[pageStart + 27 + i].toInt() and 0xFF
            }
            val payloadStart = pageStart + 27 + segCount
            if (payloadStart + segTableSum > buf.size) {
                // Need more bytes for this page's payload — again from the
                // end of what we hold, not offset past it (N.71).
                val nextStart = buf.size.toLong()
                val more = fetch(url, nextStart, nextStart + 16383) ?: return emptyMap()
                if (more.isEmpty()) return emptyMap()
                buf += more
                continue
            }
            val payload = buf.copyOfRange(payloadStart, payloadStart + segTableSum)

            // Vorbis comment packet: 0x03 "vorbis" then Vorbis Comment structure.
            if (payload.size >= 7 &&
                payload[0].toInt() == 0x03 &&
                String(payload, 1, 6, Charsets.US_ASCII) == "vorbis"
            ) {
                val vc = payload.copyOfRange(7, payload.size)
                return parseVorbisComments(vc) + mapOf("Format" to "OGG Vorbis")
            }
            // Opus comment packet: "OpusTags" then Vorbis Comment structure.
            if (payload.size >= 8 &&
                String(payload, 0, 8, Charsets.US_ASCII) == "OpusTags"
            ) {
                val vc = payload.copyOfRange(8, payload.size)
                return parseVorbisComments(vc) + mapOf("Format" to "Opus")
            }

            // Skip this page and keep looking.
            offset = pageStart + (payloadStart + segTableSum - pageStart)
            if (offset > MAX_TAG_BYTES) return emptyMap()
        }
    }

    // --------------------------------------------------------------------------
    // MP4 / M4A
    // --------------------------------------------------------------------------

    /**
     * MP4 atom tree: each box is (u32 size) + (4 ASCII type) + payload. Metadata lives at
     * moov > udta > meta > ilst. We recurse into the container boxes we care about, and
     * skip everything else (mdat can be hundreds of MB of audio data; we never touch it).
     */
    private fun parseMp4Tags(url: String, probe: ByteArray): Map<String, String> {
        // moov is typically near the start but can be large if it contains cover art.
        // Grow the buffer as we walk atoms — whenever we need to inspect a box whose
        // declared size exceeds what we've fetched, we pull more.
        var buf = probe
        fun ensureSize(n: Int): Boolean {
            if (n <= buf.size) return true
            val more = fetch(url, buf.size, (n - 1).coerceAtMost(MAX_TAG_BYTES)) ?: return false
            if (more.isEmpty()) return false
            buf += more
            return buf.size >= n
        }

        // Find 'moov' at the top level
        val moov = findTopLevelAtom("moov", { buf }) { need -> ensureSize(need) } ?: return emptyMap()
        if (!ensureSize(moov.end)) return emptyMap()
        val moovContent = buf.copyOfRange(moov.payloadStart, moov.end)

        val udta = findAtom(moovContent, "udta") ?: return emptyMap()
        val udtaContent = moovContent.copyOfRange(udta.payloadStart, udta.end)

        val meta = findAtom(udtaContent, "meta") ?: return emptyMap()
        // meta atom has a 4-byte version/flags header before its children in some writers.
        val metaInner = if (meta.end - meta.payloadStart > 4) {
            udtaContent.copyOfRange(meta.payloadStart + 4, meta.end)
        } else {
            udtaContent.copyOfRange(meta.payloadStart, meta.end)
        }

        val ilst = findAtom(metaInner, "ilst") ?: return emptyMap()
        val ilstContent = metaInner.copyOfRange(ilst.payloadStart, ilst.end)

        val out = LinkedHashMap<String, String>()
        var p = 0
        while (p + 8 <= ilstContent.size) {
            val size = bigEndian(ilstContent, p, 4)
            if (size < 8 || p + size > ilstContent.size) break
            val type = String(ilstContent, p + 4, 4, Charsets.ISO_8859_1)
            // Child 'data' atom holds the value: size, "data", version/flags(4), null(4),
            // then payload. Payload is UTF-8 for text atoms; skipped for binary types.
            val inner = ilstContent.copyOfRange(p + 8, p + size)
            val dataAtom = findAtom(inner, "data")
            if (dataAtom != null && dataAtom.end - dataAtom.payloadStart >= 8) {
                val flags = bigEndian(inner, dataAtom.payloadStart, 4) and 0xFFFFFF
                val payload = inner.copyOfRange(dataAtom.payloadStart + 8, dataAtom.end)
                val value = when (flags) {
                    1 -> String(payload, Charsets.UTF_8)  // text
                    13, 14 -> null  // JPEG/PNG cover art — skip
                    else -> {
                        // Numeric types (flags=0 usually). Track and disc numbers in
                        // iTunes store as big-endian shorts inside this payload.
                        if (type == "trkn" || type == "disk") {
                            if (payload.size >= 6) {
                                val n = bigEndian(payload, 2, 2)
                                val total = if (payload.size >= 8) bigEndian(payload, 4, 2) else 0
                                if (total > 0) "$n / $total" else n.toString()
                            } else null
                        } else {
                            // Fall back to UTF-8 decode if it looks like text
                            val s = String(payload, Charsets.UTF_8)
                            if (s.any { it.isLetter() || it.isDigit() }) s else null
                        }
                    }
                }
                if (value != null && value.isNotBlank()) {
                    out[friendlyMp4(type)] = value.trim()
                }
            }
            p += size
        }
        return out
    }

    private data class Atom(val start: Int, val payloadStart: Int, val end: Int)

    /**
     * Walk top-level atoms, fetching more of [buf] as declared sizes reveal them. Returns
     * the first atom of [type] we find, or null if we reach end/fetch failure first.
     */
    /** Walk top-level atoms. [current] returns the caller's buffer AS IT IS
     *  NOW — after [ensure] has grown it. (Until 2026-09-02 this function
     *  re-read a snapshot parameter, so any `moov` past the first probe was
     *  never found; I.71, design decision.) */
    private inline fun findTopLevelAtom(
        type: String,
        current: () -> ByteArray,
        ensure: (Int) -> Boolean,
    ): Atom? {
        var p = 0
        while (true) {
            var localBuf = current()
            if (p + 8 > localBuf.size) {
                if (!ensure(p + 8)) return null
                localBuf = current()
                if (p + 8 > localBuf.size) return null
            }
            val size = bigEndian(localBuf, p, 4)
            if (size < 8) return null
            val atomType = String(localBuf, p + 4, 4, Charsets.ISO_8859_1)
            if (atomType == type) {
                if (!ensure(p + size)) return null
                return Atom(p, p + 8, p + size)
            }
            p += size
            if (p > MAX_TAG_BYTES) return null
        }
    }

    /** Simple non-recursive atom find within a fully-available buffer. */
    private fun findAtom(buf: ByteArray, type: String): Atom? {
        var p = 0
        while (p + 8 <= buf.size) {
            val size = bigEndian(buf, p, 4)
            if (size < 8 || p + size > buf.size) return null
            val atomType = String(buf, p + 4, 4, Charsets.ISO_8859_1)
            if (atomType == type) return Atom(p, p + 8, p + size)
            p += size
        }
        return null
    }

    private fun friendlyMp4(type: String): String = when (type) {
        "\u00A9nam" -> "Title"
        "\u00A9ART" -> "Artist"
        "aART" -> "Album artist"
        "\u00A9alb" -> "Album"
        "\u00A9day" -> "Year"
        "\u00A9gen", "gnre" -> "Genre"
        "trkn" -> "Track number"
        "disk" -> "Disc number"
        "\u00A9wrt" -> "Composer"
        "\u00A9cmt" -> "Comment"
        "\u00A9too" -> "Encoder"
        "\u00A9lyr" -> "Lyrics"
        "cprt" -> "Copyright"
        "\u00A9grp" -> "Content group"
        "cpil" -> "Compilation"
        "tmpo" -> "BPM"
        "\u00A9con" -> "Conductor"
        "soal" -> "Album sort"
        "soar" -> "Artist sort"
        "sonm" -> "Title sort"
        "soco" -> "Composer sort"
        "soaa" -> "Album artist sort"
        "cond" -> "Conductor"
        "pub " -> "Publisher"
        "desc" -> "Description"
        "ldes" -> "Long description"
        else -> type
    }

    // --------------------------------------------------------------------------
    // Bit twiddling
    // --------------------------------------------------------------------------

    private fun synchsafe(b: ByteArray, off: Int): Int =
        ((b[off].toInt() and 0x7F) shl 21) or
            ((b[off + 1].toInt() and 0x7F) shl 14) or
            ((b[off + 2].toInt() and 0x7F) shl 7) or
            (b[off + 3].toInt() and 0x7F)

    private fun bigEndian(b: ByteArray, off: Int, len: Int): Int {
        var r = 0
        for (i in 0 until len) r = (r shl 8) or (b[off + i].toInt() and 0xFF)
        return r
    }

    private fun leInt(b: ByteArray, off: Int): Int =
        (b[off].toInt() and 0xFF) or
            ((b[off + 1].toInt() and 0xFF) shl 8) or
            ((b[off + 2].toInt() and 0xFF) shl 16) or
            ((b[off + 3].toInt() and 0xFF) shl 24)

    private fun findSingleNull(b: ByteArray, from: Int): Int {
        for (i in from until b.size) if (b[i].toInt() == 0) return i
        return -1
    }

    private fun findDoubleNull(b: ByteArray, from: Int): Int {
        var i = from
        while (i < b.size - 1) {
            if (b[i].toInt() == 0 && b[i + 1].toInt() == 0) return i
            i += 2
        }
        return -1
    }

    private fun findSubarray(hay: ByteArray, needle: ByteArray, from: Int): Int {
        outer@ for (i in from..(hay.size - needle.size)) {
            for (j in needle.indices) if (hay[i + j] != needle[j]) continue@outer
            return i
        }
        return -1
    }

    private fun decodeText(buf: ByteArray, offset: Int, encoding: Int): String {
        val len = buf.size - offset
        if (len <= 0) return ""
        return when (encoding) {
            0 -> String(buf, offset, len, Charsets.ISO_8859_1)
            1 -> {
                if (len >= 2) {
                    val b0 = buf[offset].toInt() and 0xFF
                    val b1 = buf[offset + 1].toInt() and 0xFF
                    when {
                        b0 == 0xFF && b1 == 0xFE -> String(buf, offset + 2, len - 2, Charsets.UTF_16LE)
                        b0 == 0xFE && b1 == 0xFF -> String(buf, offset + 2, len - 2, Charsets.UTF_16BE)
                        else -> String(buf, offset, len, Charsets.UTF_16)
                    }
                } else String(buf, offset, len, Charsets.UTF_16)
            }
            2 -> String(buf, offset, len, Charsets.UTF_16BE)
            3 -> String(buf, offset, len, Charsets.UTF_8)
            else -> String(buf, offset, len, Charsets.ISO_8859_1)
        }
    }

    companion object {
        private const val MAX_TAG_BYTES = 20 * 1024 * 1024  // 20MB hard ceiling
        private val FLAC_MAGIC = "fLaC".toByteArray(Charsets.US_ASCII)
        private val OGG_MAGIC = "OggS".toByteArray(Charsets.US_ASCII)
    }
}