// cmd/tag/apicdump.mbt
//
// 封面相关的调试/验证用子命令:把图片数据原样写到文件,
// 便于与原始 PNG/JPEG 做 SHA-256 比对。
//
// 各容器的封面机制不同,这里按魔数分派:
// MP3 → ID3v2 的 APIC 帧(二进制帧)
// Ogg → Vorbis Comment 的 METADATA_BLOCK_PICTURE(Base64 文本)
// FLAC → 原生 PICTURE 元数据块(块类型 6)
//
// 用法:moon run cmd/tag -- apic
///|
fn cmd_apic(src : String, dst : String) -> Unit {
let data = @fs.read_file_to_bytes(src) catch {
_ => {
println("读取失败:\{src}")
@sys.exit(1)
return
}
}
let format = @moontag.detect_format(data.exact_view())
let pic_opt : @moontag.Picture? = match format {
@moontag.Mp3 => mp3_picture(data)
@moontag.Ogg => ogg_picture(data)
@moontag.Flac => flac_cover(data)
_ => None
}
match pic_opt {
None => {
println("没有封面(\{format.name()})")
@sys.exit(1)
return
}
Some(pic) => {
@fs.write_bytes_to_file(dst, pic.data) catch {
_ => {
println("写入失败:\{dst}")
@sys.exit(1)
return
}
}
println(
"MIME=\{pic.mime} 描述=[\{pic.description}] 类型码=0x\{pic.raw_type.to_string(radix=16)} 图片=\{pic.data.length()} 字节 → \{dst}",
)
}
}
}
///|
/// 列出全部 APIC。
fn cmd_apics(src : String) -> Unit {
let data = @fs.read_file_to_bytes(src) catch {
_ => {
println("读取失败:\{src}")
@sys.exit(1)
return
}
}
let tag = @moontag.parse(data.exact_view()) catch {
e => {
println("解析失败: \{e.message()}")
@sys.exit(1)
return
}
}
let pics = tag.pictures() catch {
e => {
println("图片解析失败: \{e.message()}")
@sys.exit(1)
return
}
}
println("共 \{pics.length()} 张图片")
for i, pic in pics {
println(
" #\{i} MIME=\{pic.mime} 类型=0x\{pic.raw_type.to_string(radix=16)} 描述=[\{pic.description}] \{pic.data.length()} 字节",
)
}
}
///|
/// 给 Ogg(Vorbis / Opus)写入封面:改 comment → 重建头部页。
///
/// 解析失败返回 `None`,由调用方统一报错——CLI 层不需要区分"不是 Ogg"与
/// "结构坏了",库层(`set_vorbis_picture` / `set_ogg_comment`)保留细分错误。
fn ogg_set_cover(data : Bytes, pic : @moontag.Picture) -> Bytes? {
let loc = match @moontag.read_ogg_comment(data) {
Some(l) => l
None => return None
}
let vc = @moontag.parse_ogg_comment_payload(loc) catch { _ => return None }
let vc2 = @moontag.set_vorbis_picture(vc, Some(pic)) catch {
_ => return None
}
Some(@moontag.set_ogg_comment(data, vc2)) catch {
_ => None
}
}
///|
/// 从 Ogg 的 `METADATA_BLOCK_PICTURE` 取封面。
fn ogg_picture(data : Bytes) -> @moontag.Picture? {
let loc = match @moontag.read_ogg_comment(data) {
Some(l) => l
None => return None
}
let vc = @moontag.parse_ogg_comment_payload(loc) catch { _ => return None }
@moontag.vorbis_picture(vc)
}
///|
/// 从 ID3v2 的 APIC 帧取封面。
fn mp3_picture(data : Bytes) -> @moontag.Picture? {
let tag = @moontag.parse(data.exact_view()) catch { _ => return None }
tag.picture() catch {
_ => None
}
}
///|
/// 从 FLAC 原生 PICTURE 元数据块取封面(块类型 6,不是 comment 里的那个键)。
fn flac_cover(data : Bytes) -> @moontag.Picture? {
@moontag.flac_picture(data.exact_view()) catch {
_ => None
}
}
///|
/// 把一张图片写进文件:MP3 走 ID3v2 的 APIC,Ogg 走 Vorbis Comment 的
/// `METADATA_BLOCK_PICTURE`(Base64 的 FLAC PICTURE 载荷)。
///
/// 两种容器的封面机制完全不同——APIC 是二进制帧,METADATA_BLOCK_PICTURE
/// 是文本注释——所以这里按魔数分派,而不是让调用方自己判断。
fn cmd_cover(src : String, image : String) -> Unit {
let audio = @fs.read_file_to_bytes(src) catch {
_ => {
println("读取失败:\{src}")
@sys.exit(1)
return
}
}
let img = @fs.read_file_to_bytes(image) catch {
_ => {
println("读取图片失败:\{image}")
@sys.exit(1)
return
}
}
let mime = guess_mime(image)
let pic = @moontag.picture(mime, @moontag.FrontCover, "", img)
let format = @moontag.detect_format(audio.exact_view())
let stored_as = match format {
@moontag.Mp3 => "ID3v2 APIC"
@moontag.Ogg => "Vorbis METADATA_BLOCK_PICTURE"
_ => {
println(
"封面写入支持 MP3(APIC)/ Ogg(METADATA_BLOCK_PICTURE);\{format.name()} 暂不支持写入",
)
@sys.exit(1)
return
}
}
let out : Bytes? = match format {
@moontag.Mp3 =>
Some(
@moontag.set_picture(
audio.exact_view(),
Some(pic),
@moontag.WriteOptions::default(),
),
) catch {
_ => None
}
@moontag.Ogg => ogg_set_cover(audio, pic)
_ => None
}
let bytes = match out {
Some(b) => b
None => {
println(
"写入失败:\{format.name()} 文件无法解析或不支持该操作",
)
@sys.exit(1)
return
}
}
@fs.write_bytes_to_file(src, bytes) catch {
_ => {
println("保存失败:\{src}")
@sys.exit(1)
return
}
}
println(
"已嵌入封面 \{image}(\{mime},\{img.length()} 字节,\{stored_as})→ \{src}(\{audio.length()} → \{bytes.length()} 字节)",
)
}
///|
/// 按扩展名推断 MIME。
fn guess_mime(path : String) -> String {
let lower = path.to_lower()
if lower.has_suffix(".png") {
"image/png"
} else if lower.has_suffix(".jpg") || lower.has_suffix(".jpeg") {
"image/jpeg"
} else if lower.has_suffix(".gif") {
"image/gif"
} else if lower.has_suffix(".bmp") {
"image/bmp"
} else if lower.has_suffix(".webp") {
"image/webp"
} else {
"application/octet-stream"
}
}