use std::io::Seek as _;
use std::path::Path;
use lofty::config::WriteOptions;
use lofty::error::ErrorKind;
use lofty::file::{FileType, TaggedFileExt};
use lofty::id3::v2::Id3v2Tag;
use lofty::mp4::{Atom, AtomData, AtomIdent, Ilst};
use lofty::picture::{MimeType, Picture, PictureType};
use lofty::probe::Probe;
use lofty::tag::{Accessor, ItemKey, ItemValue, Tag, TagExt, TagItem};
#[derive(Debug, thiserror::Error)]
pub(crate) enum Error {
#[error("tag file operation failed at {path:?}")]
File {
path: std::path::PathBuf,
#[source]
source: std::io::Error,
},
#[error("audio tag processing failed at {path:?}")]
Tag {
path: std::path::PathBuf,
#[source]
source: lofty::error::LoftyError,
},
}
#[derive(Clone, Debug, Default)]
pub(crate) struct SongTags {
pub(crate) title: Option<String>,
pub(crate) artists: Vec<String>,
pub(crate) album: Option<String>,
pub(crate) album_artists: Vec<String>,
pub(crate) year: Option<u32>,
pub(crate) label: Option<String>,
pub(crate) lyrics_lrc: Option<String>,
pub(crate) cover: Option<Vec<u8>>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum WriteOutcome {
Tagged,
SkippedUnsupported,
}
pub(crate) fn write_tags(path: &Path, tags: &SongTags) -> Result<WriteOutcome, Error> {
let tmp = path.with_extension("part-tag");
std::fs::copy(path, &tmp).map_err(|source| Error::File {
path: tmp.clone(),
source,
})?;
match tag_in_place(&tmp, tags) {
Ok(WriteOutcome::Tagged) => {
if let Err(source) = std::fs::rename(&tmp, path) {
cleanup(&tmp);
return Err(Error::File {
path: path.to_path_buf(),
source,
});
}
Ok(WriteOutcome::Tagged)
}
Ok(outcome) => {
cleanup(&tmp);
Ok(outcome)
}
Err(e) => {
cleanup(&tmp);
Err(e)
}
}
}
fn tag_in_place(path: &Path, tags: &SongTags) -> Result<WriteOutcome, Error> {
let mut file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(path)
.map_err(|source| Error::File {
path: path.to_path_buf(),
source,
})?;
let probed = Probe::new(&mut file)
.guess_file_type()
.map_err(|source| Error::File {
path: path.to_path_buf(),
source,
})?;
let tagged = match probed.read() {
Ok(t) => t,
Err(e) if matches!(e.kind(), ErrorKind::UnknownFormat) => {
return Ok(WriteOutcome::SkippedUnsupported);
}
Err(e) => {
return Err(Error::Tag {
path: path.to_path_buf(),
source: e,
});
}
};
let file_type = tagged.file_type();
let mut tag = tagged
.primary_tag()
.cloned()
.unwrap_or_else(|| Tag::new(tagged.primary_tag_type()));
fill_tag(&mut tag, tags, file_type);
file.rewind().map_err(|source| Error::File {
path: path.to_path_buf(),
source,
})?;
match file_type {
FileType::Mpeg | FileType::Aac | FileType::Aiff | FileType::Wav => {
Id3v2Tag::from(tag)
.save_to(&mut file, WriteOptions::default())
.map_err(|source| Error::Tag {
path: path.to_path_buf(),
source,
})?;
}
FileType::Flac | FileType::Vorbis | FileType::Opus | FileType::Speex => {
tag.save_to(&mut file, WriteOptions::default())
.map_err(|source| Error::Tag {
path: path.to_path_buf(),
source,
})?;
}
FileType::Mp4 => {
let mut ilst = Ilst::from(tag);
collapse_multi_value(&mut ilst, *b"\xA9ART", &tags.artists);
collapse_multi_value(&mut ilst, *b"aART", &tags.album_artists);
ilst.save_to(&mut file, WriteOptions::default())
.map_err(|source| Error::Tag {
path: path.to_path_buf(),
source,
})?;
}
_ => return Ok(WriteOutcome::SkippedUnsupported),
}
file.sync_data().map_err(|source| Error::File {
path: path.to_path_buf(),
source,
})?;
Ok(WriteOutcome::Tagged)
}
fn fill_tag(tag: &mut Tag, tags: &SongTags, file_type: FileType) {
if let Some(title) = &tags.title {
tag.set_title(title.clone());
}
if let Some(album) = &tags.album {
tag.set_album(album.clone());
}
if !tags.artists.is_empty() {
tag.remove_key(&ItemKey::TrackArtist);
for name in &tags.artists {
tag.push(TagItem::new(
ItemKey::TrackArtist,
ItemValue::Text(name.clone()),
));
}
}
if !tags.album_artists.is_empty() {
tag.remove_key(&ItemKey::AlbumArtist);
for name in &tags.album_artists {
tag.push(TagItem::new(
ItemKey::AlbumArtist,
ItemValue::Text(name.clone()),
));
}
}
if let Some(year) = tags.year {
tag.set_year(year);
}
if let Some(label) = &tags.label {
tag.insert_text(ItemKey::Label, label.clone());
}
if let Some(lrc) = &tags.lyrics_lrc {
tag.insert_text(ItemKey::Lyrics, lrc.clone());
}
if let Some(bytes) = &tags.cover {
let mime = sniff_mime(bytes);
let cover_ok = file_type != FileType::Mp4
|| matches!(
mime,
MimeType::Jpeg | MimeType::Png | MimeType::Gif | MimeType::Bmp
);
if cover_ok {
tag.remove_picture_type(PictureType::CoverFront);
tag.push_picture(Picture::new_unchecked(
PictureType::CoverFront,
Some(mime),
None,
bytes.clone(),
));
} else {
mineral_log::warn!(target: "tagging", "MP4 不支持的封面格式,跳过封面(其余 tag 照写)");
}
}
}
fn collapse_multi_value(ilst: &mut Ilst, ident: [u8; 4], values: &[String]) {
if values.len() <= 1 {
return;
}
let data = values
.iter()
.map(|v| AtomData::UTF8(v.clone()))
.collect::<Vec<_>>();
if let Some(atom) = Atom::from_collection(AtomIdent::Fourcc(ident), data) {
ilst.replace_atom(atom);
}
}
fn sniff_mime(bytes: &[u8]) -> MimeType {
if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
MimeType::Jpeg
} else if bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
MimeType::Png
} else if bytes.starts_with(b"GIF8") {
MimeType::Gif
} else if bytes.starts_with(b"BM") {
MimeType::Bmp
} else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
MimeType::Unknown("image/webp".to_owned())
} else {
MimeType::Unknown("application/octet-stream".to_owned())
}
}
fn cleanup(tmp: &Path) {
if let Err(e) = std::fs::remove_file(tmp) {
mineral_log::warn!(target: "tagging", error = mineral_log::chain(&e), path = %tmp.display(), "清理临时副本失败");
}
}
#[cfg(test)]
mod tests {
use std::io::Read as _;
use super::*;
const MP3: &[u8] = include_bytes!("fixtures/tone.mp3");
const FLAC: &[u8] = include_bytes!("fixtures/tone.flac");
const M4A: &[u8] = include_bytes!("fixtures/tone.m4a");
const ADTS: &[u8] = include_bytes!("fixtures/tone.aac");
const PNG: &[u8] = &[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44,
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F,
0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78, 0xDA, 0x63, 0xF8,
0xCF, 0xC0, 0xF0, 0x1F, 0x00, 0x05, 0x05, 0x01, 0xFF, 0xA9, 0x99, 0x81, 0x69, 0x00, 0x00,
0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
];
fn full_tags() -> SongTags {
SongTags {
title: Some("晴天".to_owned()),
artists: vec!["周杰伦".to_owned(), "第二艺人".to_owned()],
album: Some("叶惠美".to_owned()),
album_artists: vec!["周杰伦".to_owned(), "专辑合艺人".to_owned()],
year: Some(2003),
label: Some("杰威尔".to_owned()),
lyrics_lrc: Some("[00:01.00]第一句\n[00:05.00]第二句\n".to_owned()),
cover: Some(PNG.to_vec()),
}
}
fn stage(
bytes: &[u8],
name: &str,
) -> color_eyre::Result<(tempfile::TempDir, std::path::PathBuf)> {
let dir = tempfile::tempdir()?;
let path = dir.path().join(name);
std::fs::write(&path, bytes)?;
Ok((dir, path))
}
fn assert_full_tags(path: &Path, context: &str) -> color_eyre::Result<()> {
let mut file = std::fs::File::open(path)?;
let tagged = Probe::new(&mut file).guess_file_type()?.read()?;
let tag = tagged
.primary_tag()
.ok_or_else(|| color_eyre::eyre::eyre!("{context}: 应有 primary tag"))?;
assert_eq!(tag.title().as_deref(), Some("晴天"), "{context} title");
assert_eq!(
tag.get_string(&ItemKey::AlbumTitle).map(str::to_owned),
Some("叶惠美".to_owned()),
"{context} album"
);
assert_eq!(tag.year(), Some(2003), "{context} year");
assert_eq!(
tag.get_string(&ItemKey::Label).map(str::to_owned),
Some("杰威尔".to_owned()),
"{context} label"
);
let lyrics = tag.get_string(&ItemKey::Lyrics).map(str::to_owned);
assert!(
lyrics.as_deref().is_some_and(|l| l.contains("第二句")),
"{context} lyrics 应含 lrc 文本,实际: {lyrics:?}"
);
let cover_ok = tag.pictures().iter().any(|p| p.data() == PNG);
assert!(cover_ok, "{context} 封面字节应一致");
Ok(())
}
fn assert_multi_value_generic(path: &Path, context: &str) -> color_eyre::Result<()> {
let mut file = std::fs::File::open(path)?;
let tagged = Probe::new(&mut file).guess_file_type()?.read()?;
let tag = tagged
.primary_tag()
.ok_or_else(|| color_eyre::eyre::eyre!("{context}: 应有 primary tag"))?;
let artists = tag.get_strings(&ItemKey::TrackArtist).collect::<Vec<_>>();
assert!(
artists.contains(&"周杰伦") && artists.contains(&"第二艺人"),
"{context} 多值艺人应都能读回,实际: {artists:?}"
);
let album_artists = tag.get_strings(&ItemKey::AlbumArtist).collect::<Vec<_>>();
assert!(
album_artists.contains(&"专辑合艺人"),
"{context} 多值专辑艺人应都能读回,实际: {album_artists:?}"
);
Ok(())
}
fn assert_multi_value_mp4(path: &Path) -> color_eyre::Result<()> {
use lofty::file::AudioFile as _;
use lofty::mp4::{AtomData, AtomIdent, Mp4File};
let mut file = std::fs::File::open(path)?;
let mp4 = Mp4File::read_from(&mut file, lofty::config::ParseOptions::new())?;
let ilst = mp4
.ilst()
.ok_or_else(|| color_eyre::eyre::eyre!("m4a: 应有 ilst"))?;
for (ident, expected) in [
(*b"\xA9ART", vec!["周杰伦", "第二艺人"]),
(*b"aART", vec!["周杰伦", "专辑合艺人"]),
] {
let atom = ilst
.get(&AtomIdent::Fourcc(ident))
.ok_or_else(|| color_eyre::eyre::eyre!("m4a: 缺 atom {ident:?}"))?;
let values = atom
.data()
.filter_map(|d| match d {
AtomData::UTF8(s) => Some(s.as_str()),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(values, expected, "m4a: atom {ident:?} 应单 atom 多 data");
}
Ok(())
}
#[test]
fn roundtrip_mp3() -> color_eyre::Result<()> {
let (_dir, path) = stage(MP3, "tone.mp3")?;
assert_eq!(write_tags(&path, &full_tags())?, WriteOutcome::Tagged);
assert_full_tags(&path, "mp3")?;
assert_multi_value_generic(&path, "mp3")
}
#[test]
fn roundtrip_flac() -> color_eyre::Result<()> {
let (_dir, path) = stage(FLAC, "tone.flac")?;
assert_eq!(write_tags(&path, &full_tags())?, WriteOutcome::Tagged);
assert_full_tags(&path, "flac")?;
assert_multi_value_generic(&path, "flac")
}
#[test]
fn roundtrip_m4a() -> color_eyre::Result<()> {
let (_dir, path) = stage(M4A, "tone.m4a")?;
assert_eq!(write_tags(&path, &full_tags())?, WriteOutcome::Tagged);
assert_full_tags(&path, "m4a")?;
assert_multi_value_mp4(&path)
}
#[test]
fn roundtrip_adts_via_prepended_id3v2() -> color_eyre::Result<()> {
let (_dir, path) = stage(ADTS, "tone.aac")?;
assert_eq!(write_tags(&path, &full_tags())?, WriteOutcome::Tagged);
assert_full_tags(&path, "adts")?;
assert_multi_value_generic(&path, "adts")
}
#[test]
fn replace_is_atomic_for_existing_readers() -> color_eyre::Result<()> {
let (_dir, path) = stage(MP3, "tone.mp3")?;
let mut old_reader = std::fs::File::open(&path)?;
assert_eq!(write_tags(&path, &full_tags())?, WriteOutcome::Tagged);
let mut old_bytes = Vec::new();
old_reader.read_to_end(&mut old_bytes)?;
assert_eq!(old_bytes, MP3, "旧 fd 应读到原始未打 tag 内容");
assert_full_tags(&path, "atomic-mp3")
}
#[test]
fn garbage_is_skipped_untouched() -> color_eyre::Result<()> {
let (_dir, path) = stage(b"NOT-AUDIO-GARBAGE", "garbage.bin")?;
assert_eq!(
write_tags(&path, &full_tags())?,
WriteOutcome::SkippedUnsupported
);
assert_eq!(std::fs::read(&path)?, b"NOT-AUDIO-GARBAGE", "原文件应不变");
assert!(
!path.with_extension("part-tag").exists(),
"跳过后应无残留副本"
);
Ok(())
}
#[cfg(unix)]
#[test]
fn readonly_dir_fails_clean() -> color_eyre::Result<()> {
use std::os::unix::fs::PermissionsExt as _;
let (_dir, path) = stage(MP3, "tone.mp3")?;
let parent = path
.parent()
.ok_or_else(|| color_eyre::eyre::eyre!("应有父目录"))?
.to_path_buf();
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o555))?;
let result = write_tags(&path, &full_tags());
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o755))?;
assert!(result.is_err(), "只读目录应报错");
assert_eq!(std::fs::read(&path)?, MP3, "原文件应不变");
assert!(
!path.with_extension("part-tag").exists(),
"失败后应无残留副本"
);
Ok(())
}
#[test]
fn mp4_skips_webp_cover_but_writes_rest() -> color_eyre::Result<()> {
let mut tags = full_tags();
tags.cover = Some(b"RIFF\x1A\x00\x00\x00WEBPVP8 ".to_vec());
let (_dir, path) = stage(M4A, "tone.m4a")?;
assert_eq!(write_tags(&path, &tags)?, WriteOutcome::Tagged);
let mut file = std::fs::File::open(&path)?;
let tagged = Probe::new(&mut file).guess_file_type()?.read()?;
let tag = tagged
.primary_tag()
.ok_or_else(|| color_eyre::eyre::eyre!("应有 primary tag"))?;
assert_eq!(tag.title().as_deref(), Some("晴天"), "其余字段应照写");
assert!(
tag.get_picture_type(PictureType::CoverFront).is_none(),
"webp 封面应被跳过"
);
Ok(())
}
#[test]
fn sniff_mime_magic_bytes() {
assert!(matches!(
sniff_mime(&[0xFF, 0xD8, 0xFF, 0xE0]),
MimeType::Jpeg
));
assert!(matches!(sniff_mime(PNG), MimeType::Png));
assert!(matches!(sniff_mime(b"GIF89a..."), MimeType::Gif));
assert!(matches!(sniff_mime(b"BM...."), MimeType::Bmp));
assert!(
matches!(
sniff_mime(b"RIFF\x00\x00\x00\x00WEBP...."),
MimeType::Unknown(_)
),
"webp 应识别为 Unknown(用于 MP4 封面门)"
);
}
}