package dev.stave.upnp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
/**
* Multi-format audio metadata reader. Fetches audio stream bytes over HTTP with Range
* requests 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.
*/
@Singleton
class AudioMetadataReader @Inject constructor(
@dagger.hilt.android.qualifiers.ApplicationContext private val context: android.content.Context,
) {
// 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? {
// Local files (content://) are read through the ContentResolver
// (design decision 2026-09-02, I.82): OkHttp cannot fetch them,
// so "Show tags" on a phone file used to report no tags without
// ever looking.
if (url.startsWith("content://")) return fetchLocal(url, 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())
/** Byte range of a content:// item, via the ContentResolver. */
private fun fetchLocal(uri: String, first: Long, last: Long): ByteArray? = runCatching {
context.contentResolver.openInputStream(android.net.Uri.parse(uri))?.use { inp ->
var toSkip = first
while (toSkip > 0) {
val skipped = inp.skip(toSkip)
if (skipped <= 0) break
toSkip -= skipped
}
val want = (last - first + 1).coerceAtLeast(0).toInt()
val buf = ByteArray(want)
var off = 0
while (off < want) {
val n = inp.read(buf, off, want - off)
if (n < 0) break
off += n
}
if (off == want) buf else buf.copyOf(off)
}
}.getOrNull()
// --------------------------------------------------------------------------
// 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)
}
}