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 = 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() val id3Size = id3DeclaredSize(probe) val tags = LinkedHashMap() 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 { 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() 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 { 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() 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 { val out = LinkedHashMap() // 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 { if (b.size < 8) return emptyMap() val out = LinkedHashMap() 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 { // 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 { // 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() 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) } }