// tags.mbt
//
// 统一标签模型(F7):把四种容器的元数据归一成同一组字段,
// 让调用方不必关心文件格式。
//
// 映射关系(写入时按容器选择对应的帧 ID / 键名):
// title ID3v2 TIT2 | Vorbis TITLE | APEv2 Title | MP4 ©nam
// artist ID3v2 TPE1 | Vorbis ARTIST | APEv2 Artist | MP4 ©ART
// album ID3v2 TALB | Vorbis ALBUM | APEv2 Album | MP4 ©alb
// track ID3v2 TRCK | Vorbis TRACKNUMBER | APEv2 Track | MP4 trkn
// date ID3v2 TYER/TDRC | Vorbis DATE | APEv2 Year | MP4 ©day
// genre ID3v2 TCON | Vorbis GENRE | APEv2 Genre | MP4 ©gen
// comment ID3v2 COMM | Vorbis COMMENT | APEv2 Comment | MP4 ©cmt
///|
/// 音频文件格式。
pub enum AudioFormat {
/// MP3(ID3v2 头部标签)
Mp3
/// FLAC(Vorbis Comment 元数据块)
Flac
/// MP4 / M4A(ilst)
Mp4
/// Ogg(Vorbis / Opus 的 comment packet)
Ogg
/// 无法识别
Unknown
}
///|
/// 按文件魔数识别格式。
pub fn detect_format(data : BytesView) -> AudioFormat {
// Ogg:页头魔数 OggS
if data.length() >= 4 &&
data.at(0) == b'O' &&
data.at(1) == b'g' &&
data.at(2) == b'g' &&
data.at(3) == b'S' {
return Ogg
}
if data.length() >= 4 &&
data.at(0) == b'f' &&
data.at(1) == b'L' &&
data.at(2) == b'a' &&
data.at(3) == b'C' {
return Flac
}
// MP4:第 4..8 字节是 ftyp(部分文件是其他盒,但 ftyp 是绝大多数情况)
if data.length() >= 12 {
let k = read_ascii(data, 4, 8)
if k == "ftyp" {
return Mp4
}
}
// ID3v2
if data.length() >= 3 &&
data.at(0) == b'I' &&
data.at(1) == b'D' &&
data.at(2) == b'3' {
return Mp3
}
// MP3 帧同步(0xFF 0xE0 掩码),或尾部有 APEv2
if data.length() >= 2 &&
data.at(0) == b'\xFF' &&
(data.at(1).to_int() & 0xE0) == 0xE0 {
return Mp3
}
Unknown
}
///|
/// 统一的可读标签视图(只含常用字段,缺失为 `None`)。
pub struct TrackInfo {
/// 格式。
format : AudioFormat
title : String?
artist : String?
album : String?
track : String?
date : String?
genre : String?
comment : String?
/// 是否有封面。
has_cover : Bool
}
///|
/// 读取任意支持格式的标签(统一视图)。
pub fn read_track_info(data : BytesView) -> TrackInfo raise TagError {
let format = detect_format(data)
match format {
Flac => read_flac_info(data)
Mp4 => read_mp4_info(data)
Mp3 => read_mp3_info(data)
Ogg => read_ogg_info(data)
Unknown => empty_info(Unknown)
}
}
///|
/// Ogg(Vorbis / Opus)的统一视图。
///
/// Ogg 的 comment 读取接口基于 `Bytes`(页解析需要随机访问),这里一次性
/// 物化视图——调用方本就把整个文件读进了内存,`read_track_info` 是便利入口,
/// 需要零拷贝时请直接用 `read_ogg_comment`。
fn read_ogg_info(data : BytesView) -> TrackInfo raise TagError {
let raw = slice_bytes(data, 0, data.length())
let loc = match read_ogg_comment(raw) {
Some(l) => l
None => return empty_info(Ogg)
}
let vc = parse_ogg_comment_payload(loc)
{
format: Ogg,
title: vc.get("TITLE"),
artist: vc.get("ARTIST"),
album: vc.get("ALBUM"),
track: vc.get("TRACKNUMBER"),
date: vc.get("DATE"),
genre: vc.get("GENRE"),
comment: vc.get("COMMENT"),
has_cover: false,
}
}
///|
/// FLAC 的统一视图。
fn read_flac_info(data : BytesView) -> TrackInfo raise TagError {
let vc = match read_vorbis_comment(data) {
Some(c) => c
None => return empty_info(Flac)
}
{
format: Flac,
title: vc.get("TITLE"),
artist: vc.get("ARTIST"),
album: vc.get("ALBUM"),
track: vc.get("TRACKNUMBER"),
date: vc.get("DATE"),
genre: vc.get("GENRE"),
comment: vc.get("COMMENT"),
has_cover: false,
}
}
///|
/// MP4 的统一视图。
fn read_mp4_info(data : BytesView) -> TrackInfo raise TagError {
let entries = read_mp4_tags(data)
{
format: Mp4,
title: text_of(entries, "©nam"),
artist: text_of(entries, "©ART"),
album: text_of(entries, "©alb"),
track: text_of(entries, "trkn"),
date: text_of(entries, "©day"),
genre: text_of(entries, "©gen"),
comment: text_of(entries, "©cmt"),
has_cover: cover_of(entries) is Some(_),
}
}
///|
/// MP3 的统一视图:ID3v2 优先,缺失的字段逐项回退到文件尾部的 APEv2。
///
/// 现实中两类文件都存在——只有 ID3v2 的、只有 APEv2 的(ape 生态)、
/// 以及两者并存的(转换工具留下的双标签)。只看 ID3v2 会漏掉后两种。
fn read_mp3_info(data : BytesView) -> TrackInfo raise TagError {
let ape : ApeTag? = parse_ape(data) catch { _ => None }
// "没有标签"与"有标签但读不出来"必须分开:
// • 连 ID3v2 标签头都不存在(文件太短或无 "ID3" 魔数)→ 正常的无标签文件;
// • 标签头存在、内部结构却读不出来(压缩帧、损坏帧、超版本)→ 必须抛出。
// 后者一旦被吞掉,"有标签但读不出"就会被伪装成"这个文件没有标签",
// 调用方(含 CLI)既拿不到字段,也看不到失败原因。
let has_id3_header = data.length() >= 10 &&
data.at(0) == b'I' &&
data.at(1) == b'D' &&
data.at(2) == b'3'
let id3 : Id3Tag? = if has_id3_header {
// 有 APEv2 兜底时按"双标签文件"继续(ID3 侧坏掉仍有字段可用)
match ape {
Some(_) => Some(parse(data)) catch { _ => None }
None => Some(parse(data))
}
} else {
None
}
if id3 is None && ape is None {
return empty_info(Mp3)
}
// v2.3 用 TYER,v2.4 用 TDRC
let id3_date = match id3 {
Some(t) =>
match t.text("TDRC") {
Some(d) => Some(d)
None => t.text("TYER")
}
None => None
}
{
format: Mp3,
title: mp3_field(id3, ape, "TIT2", "Title"),
artist: mp3_field(id3, ape, "TPE1", "Artist"),
album: mp3_field(id3, ape, "TALB", "Album"),
track: mp3_field(id3, ape, "TRCK", "Track"),
date: match id3_date {
Some(d) => Some(d)
None => ape_text(ape, "Year")
},
genre: mp3_field(id3, ape, "TCON", "Genre"),
comment: mp3_field(id3, ape, "COMM", "Comment"),
has_cover: mp3_has_cover(id3, ape),
}
}
///|
/// ID3v2 帧优先,缺失时回退 APEv2 条目(APEv2 键名大小写不敏感)。
fn mp3_field(
id3 : Id3Tag?,
ape : ApeTag?,
id3_key : String,
ape_key : String,
) -> String? raise TagError {
let v = match id3 {
Some(t) => t.text(id3_key)
None => None
}
match v {
Some(_) => v
None =>
match ape {
Some(a) => a.text(ape_key)
None => None
}
}
}
///|
/// 取 APEv2 文本字段。
fn ape_text(ape : ApeTag?, key : String) -> String? {
match ape {
Some(a) => a.text(key)
None => None
}
}
///|
/// 封面:ID3v2 的 APIC 或 APEv2 的 `Cover Art (Front)`。
fn mp3_has_cover(id3 : Id3Tag?, ape : ApeTag?) -> Bool raise TagError {
let from_id3 = match id3 {
Some(t) => t.picture() is Some(_)
None => false
}
if from_id3 {
return true
}
match ape {
Some(a) => a.get("Cover Art (Front)") is Some(_)
None => false
}
}
///|
/// 空视图。
fn empty_info(format : AudioFormat) -> TrackInfo {
{
format,
title: None,
artist: None,
album: None,
track: None,
date: None,
genre: None,
comment: None,
has_cover: false,
}
}
///|
/// 格式的可读名称。
pub fn AudioFormat::name(self : AudioFormat) -> String {
match self {
Mp3 => "MP3 / ID3v2"
Flac => "FLAC / Vorbis Comment"
Mp4 => "MP4 / ilst"
Ogg => "Ogg / Vorbis Comment"
Unknown => "未知格式"
}
}