// id3_tag.mbt
//
// ID3v2 标签解析(读取侧)。
//
// 结构:10 字节标签头 → 可选扩展头 → 帧序列 → 可选 padding → 可选 footer。
// 两个大版本的差异必须分别处理:
// • v2.3 的帧尺寸是普通 u32,标签级 unsynchronisation 作用于整个标签体;
// • v2.4 的帧尺寸是 synchsafe,且 unsynchronisation 按帧作用于帧载荷。
// 把两者混用是 ID3v2 实现最常见的 bug,这里用 `major` 分支显式区分。
///|
/// 一个 ID3v2 帧。
pub struct Frame {
/// 4 字符帧 ID(如 `TIT2`)。
id : String
/// 帧载荷(已完成 unsynchronisation 还原与 zlib 解压)。
data : Bytes
/// 帧头第一字节(状态标志)。
status_flags : Int32
/// 帧头第二字节(格式标志)。
format_flags : Int32
/// 分组标识字节(v2.3 `$20` / v2.4 `$40`);无分组时为 `None`。
///
/// 这个字节夹在帧头与载荷之间,读的时候必须跳过、写的时候必须补回,
/// 只跳过不补回会让读者把载荷首字节当成组号,整帧静默错位。
group : Int32?
}
///|
/// 解析出的 ID3v2 标签。
pub struct Id3Tag {
/// 主版本号(3 或 4)。
major : Int32
/// 修订号。
revision : Int32
/// 标签头原始 flags 字节。
flags : Int32
/// 帧序列(保持文件内顺序)。
frames : Array[Frame]
/// 标签(含头/扩展头/padding/footer)占用的总字节数,即音频数据起点。
total_size : Int32
}
///|
/// 解析 ID3v2 标签。数据必须从文件起点开始。
///
/// 没有 ID3 标签时抛 `NotTagged`;被截断时抛 `Truncated`。
pub fn parse(data : BytesView) -> Id3Tag raise TagError {
if data.length() < 10 {
raise Truncated(offset=0, need=10, actual=data.length())
}
if data.at(0) != b'I' || data.at(1) != b'D' || data.at(2) != b'3' {
raise NotTagged(format="id3v2")
}
let major = data.at(3).to_int()
let revision = data.at(4).to_int()
if major != 3 && major != 4 {
raise UnsupportedVersion(version=major)
}
let flags = data.at(5).to_int()
// flags 的低 4 位必须为 0(v2.4 定义了 bit4 = footer,v2.3 未定义 bit4)。
let size = read_synchsafe32(data, 6)
let has_footer = major == 4 && (flags & 0x10) != 0
let total_size = 10 + size + (if has_footer { 10 } else { 0 })
// 标签体按声明尺寸切片;文件里音频数据不足不影响读取标签。
let body_end = 10 + size
if body_end > data.length() {
raise Truncated(offset=10, need=size, actual=data.length() - 10)
}
let raw_body = data.exact_view(start=10, end=body_end)
// v2.3:unsynchronisation 作用于整个标签体,先整体还原再切帧。
// v2.4:作用于帧载荷,帧尺寸仍按磁盘上的字节数计算,所以不能整体还原。
let tag_unsync = (flags & 0x80) != 0
let body = if major == 3 && tag_unsync {
unsynchronise(raw_body).exact_view()
} else {
raw_body
}
let mut pos = 0
if (flags & 0x40) != 0 {
pos += extended_header_len(body, major)
}
let frames = parse_frames(body, pos, major, tag_unsync)
{ major, revision, flags, frames, total_size, }
}
///|
/// 跳过扩展头,返回其字节长度。
fn extended_header_len(body : BytesView, major : Int32) -> Int32 raise TagError {
if major == 3 {
// v2.3:4 字节普通 u32,值**不含自身**(6 或 10)。
let ext_size = read_u32be(body, 0)
if ext_size != 6 && ext_size != 10 {
raise BadHeader(
reason="v2.3 扩展头尺寸 \{ext_size} 非法(应为 6 或 10)",
)
}
4 + ext_size
} else {
// v2.4:4 字节 synchsafe,值**包含自身**,最小 6。
let ext_size = read_synchsafe32(body, 0)
if ext_size < 6 {
raise BadHeader(reason="v2.4 扩展头尺寸 \{ext_size} 小于 6")
}
ext_size
}
}
///|
/// 遍历帧直到 padding(全零)或标签体结束。
fn parse_frames(
body : BytesView,
start : Int32,
major : Int32,
tag_unsync : Bool,
) -> Array[Frame] raise TagError {
let out : Array[Frame] = []
let mut pos = start
while pos + 10 <= body.length() {
// padding:剩余的字节全是 0
if body.at(pos) == b'\x00' {
break
}
if !is_valid_frame_id(body, pos) {
// 非零且非合法帧 ID:规范上属于损坏数据,但真实文件里常见
// (尤其 v2.2 帧混入、尾部残留)。按 padding 处理更宽容。
break
}
let id = read_frame_id(body, pos)
// v2.3 普通 u32,v2.4 synchsafe —— 混用会让后续帧全部错位。
let size = if major == 4 {
read_synchsafe32(body, pos + 4)
} else {
read_u32be(body, pos + 4)
}
let status_flags = body.at(pos + 8).to_int()
let format_flags = body.at(pos + 9).to_int()
let payload_start = pos + 10
if size == 0 {
// 规范要求帧至少 1 字节;size 0 视为数据损坏。
raise BadFrameHeader(reason="帧 \{id} 的尺寸为 0")
}
if payload_start + size > body.length() {
raise FrameOutOfBounds(
frame_id=id,
size~,
available=body.length() - payload_start,
)
}
// 解析帧头的附加字段:分组字节要保留(写回时必须补回),
// 加密帧没有可用语义——规范把加密方法留给外部注册表,这里明确报不支持。
let compressed = if major == 4 {
(format_flags & 0x08) != 0
} else {
(format_flags & 0x80) != 0
}
let encrypted = if major == 4 {
(format_flags & 0x04) != 0
} else {
(format_flags & 0x40) != 0
}
if encrypted {
raise Unsupported(what="帧 \{id} 已加密")
}
let (extra, group_pos) = frame_extra_layout(major, format_flags)
let data_start = payload_start + extra
let data_end = payload_start + size
if data_start > data_end {
raise BadFrameHeader(reason="帧 \{id} 的附加头字段超出帧尺寸")
}
let group = if group_pos >= 0 {
Some(body.at(payload_start + group_pos).to_int())
} else {
None
}
// 压缩帧的解压上限(与 TagLib 的 maxCompressedFrameOutputSize 同量级)。
// 音频文件是不可信输入:一个几百字节的帧可以声明成 GB 级输出,
// 没有上限就等于把内存交给文件里的数字。
let declared = if compressed {
let d = if major == 4 {
if (format_flags & 0x01) == 0 {
// 规范原文:压缩帧 MUST 带 Data Length Indicator,缺了就没有可信的
// 解压后尺寸可用,明确报错而不是猜。
raise BadFrameHeader(
reason="帧 \{id} 声明压缩但缺少 Data Length Indicator",
)
}
read_synchsafe32(
body,
payload_start + (if group_pos >= 0 { 1 } else { 0 }),
)
} else {
read_u32be(body, payload_start)
}
if d > MAX_COMPRESSED_FRAME_OUTPUT {
raise BadFrameData(
frame_id=id,
reason="声明的解压后尺寸 \{d} 超出上限 \{MAX_COMPRESSED_FRAME_OUTPUT}",
)
}
Some(d)
} else {
None
}
let raw = body.exact_view(start=data_start, end=data_end)
// v2.4:帧级 unsync(或标签级 unsync 表示所有帧都还原)
let frame_unsync = major == 4 && (tag_unsync || (format_flags & 0x02) != 0)
let restored = if frame_unsync {
unsynchronise(raw)
} else {
slice_bytes(body, data_start, data_end)
}
// unsync 还原在解压之前——顺序反了会让 zlib 流被"还原"坏掉。
let data = if compressed {
inflate_frame(id, restored, declared)
} else {
restored
}
out.push({ id, data, status_flags, format_flags, group, })
pos = data_end
}
out
}
///|
/// 解析帧头的附加字段布局,返回 `(附加头字节数, 组号字节的相对偏移)`,
/// 无分组时偏移为 `-1`。
///
/// 规范要求附加字段"按标志位出现的顺序"排列(id3v2.4.0-structure §4.1、
/// id3v2.3.0 §3.3.1),两个版本恰好相反:
/// • v2.3 `%ijk00000` → [4 字节解压后尺寸][1 字节加密方法][1 字节组号];
/// • v2.4 `%0h00kmnp` → [1 字节组号][1 字节加密方法][4 字节 DLI]。
/// 抄错顺序会把载荷整体错位,是这里唯一值得显式分支的地方。
fn frame_extra_layout(major : Int32, format_flags : Int32) -> (Int32, Int32) {
if major == 3 {
let mut len = 0
if (format_flags & 0x80) != 0 {
len += 4
}
if (format_flags & 0x40) != 0 {
len += 1
}
let grouped = (format_flags & 0x20) != 0
let group_pos = if grouped { len } else { -1 }
if grouped {
len += 1
}
(len, group_pos)
} else {
let grouped = (format_flags & 0x40) != 0
let group_pos = if grouped { 0 } else { -1 }
let mut len = if grouped { 1 } else { 0 }
if (format_flags & 0x04) != 0 {
len += 1
}
if (format_flags & 0x01) != 0 {
len += 4
}
(len, group_pos)
}
}
///|
/// 单个压缩帧允许解压出的最大字节数(64 MiB)。
///
/// 压缩帧必须声明解压后尺寸(v2.3 的 4 字节 / v2.4 的 DLI),这个常量给声明值
/// 本身设上限,避免"几百字节的帧声明 GB 级输出"就把内存交出去。
const MAX_COMPRESSED_FRAME_OUTPUT : Int32 = 0x04000000
///|
/// 解压 zlib 压缩的帧载荷。
///
/// 压缩算法由规范点名(v2.3/v2.4 都指向 zlib / RFC 1950),因此这里不是
/// "猜格式":直接按 zlib 容器解。
///
/// **输出上限是硬的**:按声明尺寸预置一块固定缓冲交给解码器
/// (`Decoder::stream_into_buf` 越界即抛 `Overflow`,实测在 `write` 阶段就抛),
/// 因此解压产出的内存上界 = 声明尺寸 ≤ `MAX_COMPRESSED_FRAME_OUTPUT`。
/// 这样"声明值撒谎"的文件不会先吃满内存再被拒。
///
/// `declared` 是帧头里声明的解压后尺寸。解压结果与声明不符时同样报错:
/// 规范要求这个字段就是解压后长度,对不上说明数据不可信。
fn inflate_frame(
id : String,
data : Bytes,
declared : Int32?,
) -> Bytes raise TagError {
// 压缩帧一定有声明尺寸(缺失已在解析处报 `BadFrameHeader`);这里仍防御一次。
let cap = match declared {
Some(d) => d
None =>
raise BadFrameData(
frame_id=id,
reason="压缩帧缺少解压后尺寸声明,无法限定输出上限",
)
}
if cap <= 0 {
raise BadFrameData(
frame_id=id,
reason="声明的解压后尺寸 \{cap} 非法",
)
}
let buf : Array[Byte] = Array::make(cap, b'\x00')
let decoder = @moon_yazi.Decoder()
decoder.set_format(@moon_yazi.Format::Zlib)
let stream = decoder.stream_into_buf(buf)
stream.write(data) catch {
e =>
raise BadFrameData(
frame_id=id,
reason="zlib 解压失败或输出超出声明的 \{cap} 字节:\{e.to_string()}",
)
}
let (count, _checksum) = stream.finish() catch {
e =>
raise BadFrameData(
frame_id=id,
reason="zlib 解压失败或输出超出声明的 \{cap} 字节:\{e.to_string()}",
)
}
if count.to_int() != cap {
raise BadFrameData(
frame_id=id,
reason="解压后长度 \{count.to_int()} 与声明的 \{cap} 不一致",
)
}
Bytes::from_array(buf)
}
///|
/// 读 4 字符帧 ID。
///
/// 注意不能用 `Buffer::to_string`:它按 UTF-16LE 解释字节,会把
/// "TIT2" 变成乱码。这里逐字节转字符再拼。
fn read_frame_id(body : BytesView, pos : Int32) -> String {
let buf = StringBuilder()
for i in 0..<4 {
buf.write_char(Int32::unsafe_to_char(body.at(pos + i).to_int()))
}
buf.to_string()
}
///|
/// 取第一个指定 ID 的帧。
pub fn Id3Tag::frame(self : Id3Tag, id : String) -> Frame? {
for f in self.frames {
if f.id == id {
return Some(f)
}
}
None
}
///|
/// 取所有指定 ID 的帧(规范允许多值的帧,如多个 `TXXX`)。
pub fn Id3Tag::frames_with_id(self : Id3Tag, id : String) -> Array[Frame] {
let out : Array[Frame] = []
for f in self.frames {
if f.id == id {
out.push(f)
}
}
out
}
///|
/// 取某个文本帧的解码字符串;帧不存在或载荷为空时返回 `None`。
pub fn Id3Tag::text(self : Id3Tag, id : String) -> String? raise TagError {
match self.frame(id) {
None => None
Some(f) => f.as_text()
}
}
///|
/// 把帧当作文本帧解码;非文本帧(ID 不以 `T` 开头)返回 `None`。
///
/// `TXXX` 这类"描述 + 值"的帧只返回第一个字符串(描述)——
/// 需要分别取描述和值时用 `TXXX` 专用接口。
pub fn Frame::as_text(self : Frame) -> String? raise TagError {
if self.data.length() == 0 {
return None
}
// 文本帧 ID 一律以 T 开头;其他帧首字节不是编码字节,不能按文本解。
if self.id.length() != 4 || !self.id.has_prefix("T") {
return None
}
let code = self.data.at(0).to_int()
// 编码字节只允许 0..3;越界说明不是文本帧。
if code > 3 {
return None
}
let enc = TextEncoding::from_code(code)
let parts = split_strings(self.data.exact_view(), enc, 1, self.data.length())
if parts.length() == 0 {
Some("")
} else {
Some(parts[0])
}
}