use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result, bail};
use lofty::TextEncoding;
use lofty::config::{ParseOptions, WriteOptions};
use lofty::file::{AudioFile, FileType, TaggedFile};
use lofty::flac::FlacFile;
use lofty::id3::v2::{Frame, Id3v2Tag, UnsynchronizedTextFrame};
use lofty::mp4::{Atom, AtomData, AtomIdent, Ilst, Mp4File};
use lofty::mpeg::MpegFile;
use lofty::ogg::tag::VorbisComments;
use lofty::ogg::{OggPictureStorage, VorbisFile};
use lofty::picture::{MimeType, Picture, PictureInformation, PictureType};
use lofty::probe::Probe;
use lofty::tag::Accessor;
use tokio::sync::OnceCell;
use tracing::debug;
use crate::model::Song;
#[derive(Debug)]
pub struct Cover {
pub data: Vec<u8>,
pub mime: MimeType,
}
pub fn sniff_image(data: &[u8]) -> Option<MimeType> {
if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
Some(MimeType::Jpeg)
} else if data.starts_with(b"\x89PNG\r\n\x1a\n") {
Some(MimeType::Png)
} else {
None
}
}
type CoverCell = Arc<OnceCell<Option<Arc<Cover>>>>;
#[derive(Default)]
pub struct CoverCache {
cells: Mutex<HashMap<String, CoverCell>>,
}
impl CoverCache {
pub async fn get(&self, client: &reqwest::Client, song: &Song) -> Option<Arc<Cover>> {
let url = song.cover_url.clone()?;
let cell = self
.cells
.lock()
.expect("封面缓存锁")
.entry(url.clone())
.or_default()
.clone();
cell.get_or_init(|| async {
match fetch_cover(client, &url).await {
Ok(c) => Some(Arc::new(c)),
Err(e) => {
debug!(url, "获取封面失败: {e:#}");
None
}
}
})
.await
.clone()
}
}
async fn fetch_cover(client: &reqwest::Client, url: &str) -> Result<Cover> {
let data = crate::net::send(client.get(url))
.await?
.error_for_status()?
.bytes()
.await?
.to_vec();
let mime = sniff_image(&data).context("封面不是 JPEG/PNG")?;
Ok(Cover { data, mime })
}
#[derive(Clone, Copy)]
pub struct TagData<'a> {
pub song: &'a Song,
pub lyric: Option<&'a str>,
pub cover: Option<&'a Cover>,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Written {
pub basic: bool,
pub lyric: bool,
pub cover: bool,
pub track: bool,
}
pub trait Target: Accessor {
fn set_artists(&mut self, singers: &[String]);
fn has_lyric(&self) -> bool;
fn set_lyric(&mut self, text: &str);
fn has_cover(&self) -> bool;
fn set_cover(&mut self, picture: Picture);
}
impl Target for VorbisComments {
fn set_artists(&mut self, singers: &[String]) {
for s in singers {
self.push("ARTIST".into(), s.clone());
}
}
fn has_lyric(&self) -> bool {
self.get("LYRICS").is_some()
}
fn set_lyric(&mut self, text: &str) {
self.insert("LYRICS".into(), text.into());
}
fn has_cover(&self) -> bool {
!self.pictures().is_empty()
}
fn set_cover(&mut self, picture: Picture) {
let info = PictureInformation::from_picture(&picture).unwrap_or_default();
if let Err(e) = self.insert_picture(picture, Some(info)) {
debug!("插入封面失败: {e}");
}
}
}
impl Target for Id3v2Tag {
fn set_artists(&mut self, singers: &[String]) {
self.set_artist(singers.join("/"));
}
fn has_lyric(&self) -> bool {
self.unsync_text().next().is_some()
}
fn set_lyric(&mut self, text: &str) {
let frame = UnsynchronizedTextFrame::new(TextEncoding::UTF8, *b"XXX", "", text.to_string());
self.insert(Frame::UnsynchronizedText(frame));
}
fn has_cover(&self) -> bool {
self.into_iter().any(|f| matches!(f, Frame::Picture(_)))
}
fn set_cover(&mut self, picture: Picture) {
self.insert_picture(picture);
}
}
const MP4_LYRICS: AtomIdent<'static> = AtomIdent::Fourcc(*b"\xa9lyr");
impl Target for Ilst {
fn set_artists(&mut self, singers: &[String]) {
self.set_artist(singers.join(", "));
}
fn has_lyric(&self) -> bool {
self.get(&MP4_LYRICS).is_some()
}
fn set_lyric(&mut self, text: &str) {
self.insert(Atom::new(MP4_LYRICS, AtomData::UTF8(text.to_string())));
}
fn has_cover(&self) -> bool {
self.pictures().is_some_and(|mut p| p.next().is_some())
}
fn set_cover(&mut self, picture: Picture) {
self.insert_picture(picture);
}
}
pub fn apply<T: Target>(tag: &mut T, data: &TagData) -> Written {
let mut w = Written::default();
let song = data.song;
if tag.title().is_none() && !song.title.is_empty() {
tag.set_title(song.title.clone());
w.basic = true;
}
if tag.album().is_none() && !song.album.is_empty() {
tag.set_album(song.album.clone());
w.basic = true;
}
if tag.artist().is_none() && !song.singers.is_empty() {
tag.set_artists(&song.singers);
w.basic = true;
}
if let Some(t) = song.track {
if tag.track().is_none() {
tag.set_track(t.number);
tag.set_track_total(t.total);
w.track = true;
}
if let Some((disc, discs)) = t.disc
&& tag.disk().is_none()
{
tag.set_disk(disc);
tag.set_disk_total(discs);
w.track = true;
}
}
if let Some(text) = data.lyric.filter(|t| !t.trim().is_empty())
&& !tag.has_lyric()
{
tag.set_lyric(text);
w.lyric = true;
}
if let Some(cover) = data.cover
&& !tag.has_cover()
{
tag.set_cover(cover_picture(cover));
w.cover = true;
}
w
}
fn cover_picture(cover: &Cover) -> Picture {
Picture::unchecked(cover.data.clone())
.pic_type(PictureType::CoverFront)
.mime_type(cover.mime.clone())
.description("Cover")
.build()
}
fn open(path: &Path) -> Result<TaggedFile> {
Ok(Probe::open(path)?.guess_file_type()?.read()?)
}
pub fn readable(path: &Path) -> bool {
open(path).is_ok()
}
pub fn write_file(path: &Path, data: &TagData) -> Result<Written> {
let ty = Probe::open(path)?
.guess_file_type()?
.file_type()
.context("无法识别音频格式")?;
let mut f = std::fs::File::open(path)?;
let opts = ParseOptions::new();
match ty {
FileType::Flac => {
let mut file = FlacFile::read_from(&mut f, opts).context("无法解析 FLAC")?;
let data = TagData {
cover: data.cover.filter(|_| file.pictures().is_empty()),
..*data
};
let mut vc = file.remove_vorbis_comments().unwrap_or_default();
let w = apply(&mut vc, &data);
file.set_vorbis_comments(vc);
save(&file, path, w)
}
FileType::Vorbis => {
let mut file = VorbisFile::read_from(&mut f, opts).context("无法解析 OGG")?;
let w = apply(file.vorbis_comments_mut(), data);
save(&file, path, w)
}
FileType::Mpeg => {
let mut file = MpegFile::read_from(&mut f, opts).context("无法解析 MP3")?;
let mut tag = file.remove_id3v2().unwrap_or_default();
let w = apply(&mut tag, data);
file.set_id3v2(tag);
save(&file, path, w)
}
FileType::Mp4 => {
let mut file = Mp4File::read_from(&mut f, opts).context("无法解析 M4A")?;
let mut tag = file.remove_ilst().unwrap_or_default();
let w = apply(&mut tag, data);
file.set_ilst(tag);
save(&file, path, w)
}
other => bail!("暂不支持给 {other:?} 写标签"),
}
}
fn save<F: AudioFile>(file: &F, path: &Path, w: Written) -> Result<Written> {
if w != Written::default() {
file.save_to_path(path, WriteOptions::default())
.context("写入标签失败")?;
open(path).context("写标签后文件无法解析")?;
}
Ok(w)
}
#[cfg(test)]
mod tests {
use super::*;
use lofty::file::TaggedFileExt;
use lofty::tag::ItemKey;
fn song() -> Song {
Song {
id: "001zMQr71F1Qo8".into(),
title: "珊瑚海".into(),
singers: vec!["周杰伦".into(), "Lara梁心颐".into()],
album: "十一月的萧邦".into(),
album_id: "0024bjiL2aocxT".into(),
interval: 255,
..Song::default()
}
}
fn cover() -> Cover {
Cover {
data: vec![0xFF, 0xD8, 0xFF, 0xE0, 1, 2, 3],
mime: MimeType::Jpeg,
}
}
fn all() -> Written {
Written {
basic: true,
lyric: true,
cover: true,
track: false,
}
}
#[test]
fn image_sniff() {
assert_eq!(sniff_image(&[0xFF, 0xD8, 0xFF, 0xE0]), Some(MimeType::Jpeg));
assert_eq!(sniff_image(b"\x89PNG\r\n\x1a\n...."), Some(MimeType::Png));
assert_eq!(sniff_image(b"<html>"), None);
}
#[test]
fn vorbis_fill_empty() {
let (s, c) = (song(), cover());
let mut vc = VorbisComments::default();
let data = TagData {
song: &s,
lyric: Some("[00:01.00]歌词"),
cover: Some(&c),
};
assert_eq!(apply(&mut vc, &data), all());
assert_eq!(vc.get("TITLE"), Some("珊瑚海"));
assert_eq!(vc.get("ALBUM"), Some("十一月的萧邦"));
assert_eq!(
vc.get_all("ARTIST").collect::<Vec<_>>(),
["周杰伦", "Lara梁心颐"]
);
assert_eq!(vc.get("LYRICS"), Some("[00:01.00]歌词"));
assert_eq!(vc.pictures().len(), 1);
assert_eq!(vc.pictures()[0].0.pic_type(), PictureType::CoverFront);
}
#[test]
fn vorbis_keeps_existing() {
let (s, c) = (song(), cover());
let mut vc = VorbisComments::default();
vc.push("TITLE".into(), "原标题".into());
vc.push("ARTIST".into(), "原歌手".into());
vc.push("ALBUM".into(), "原专辑".into());
vc.push("LYRICS".into(), "原歌词".into());
vc.push("QMQUALITY".into(), "Premium-Master".into());
vc.set_cover(cover_picture(&c));
let data = TagData {
song: &s,
lyric: Some("新歌词"),
cover: Some(&c),
};
assert_eq!(apply(&mut vc, &data), Written::default());
assert_eq!(vc.get("TITLE"), Some("原标题"));
assert_eq!(vc.get_all("ARTIST").collect::<Vec<_>>(), ["原歌手"]);
assert_eq!(vc.get("LYRICS"), Some("原歌词"));
assert_eq!(vc.get("QMQUALITY"), Some("Premium-Master"));
assert_eq!(vc.pictures().len(), 1);
}
#[test]
fn id3_fill_and_keep() {
let (s, c) = (song(), cover());
let mut tag = Id3v2Tag::default();
let data = TagData {
song: &s,
lyric: Some("歌词"),
cover: Some(&c),
};
assert_eq!(apply(&mut tag, &data), all());
assert_eq!(tag.title().as_deref(), Some("珊瑚海"));
assert_eq!(tag.artist().as_deref(), Some("周杰伦/Lara梁心颐"));
assert_eq!(tag.unsync_text().next().unwrap().content, "歌词");
assert!(tag.has_cover());
assert_eq!(apply(&mut tag, &data), Written::default());
assert_eq!(tag.unsync_text().count(), 1);
}
#[test]
fn ilst_fill() {
let (s, c) = (song(), cover());
let mut tag = Ilst::default();
let data = TagData {
song: &s,
lyric: Some("歌词"),
cover: Some(&c),
};
assert_eq!(apply(&mut tag, &data), all());
assert_eq!(tag.artist().as_deref(), Some("周杰伦, Lara梁心颐"));
assert!(tag.has_lyric() && tag.has_cover());
assert_eq!(apply(&mut tag, &data), Written::default());
}
fn album_song(number: u32, total: u32, disc: Option<(u32, u32)>) -> Song {
Song {
track: Some(crate::model::Track {
number,
total,
disc,
}),
..song()
}
}
#[test]
fn track_written_per_format() {
let s = album_song(3, 12, None);
let data = TagData {
song: &s,
lyric: None,
cover: None,
};
let mut vc = VorbisComments::default();
assert!(apply(&mut vc, &data).track);
assert_eq!(
(vc.get("TRACKNUMBER"), vc.get("TRACKTOTAL")),
(Some("3"), Some("12"))
);
assert_eq!(vc.get("DISCNUMBER"), None);
let mut id3 = Id3v2Tag::default();
assert!(apply(&mut id3, &data).track);
assert_eq!((id3.track(), id3.track_total()), (Some(3), Some(12)));
let mut ilst = Ilst::default();
assert!(apply(&mut ilst, &data).track);
assert_eq!((ilst.track(), ilst.track_total()), (Some(3), Some(12)));
let plain = song();
let mut vc = VorbisComments::default();
let w = apply(
&mut vc,
&TagData {
song: &plain,
lyric: None,
cover: None,
},
);
assert!(!w.track);
assert_eq!(vc.get("TRACKNUMBER"), None);
}
#[test]
fn disc_and_keep_existing_track() {
let s = album_song(22, 36, Some((2, 2)));
let data = TagData {
song: &s,
lyric: None,
cover: None,
};
let mut vc = VorbisComments::default();
apply(&mut vc, &data);
assert_eq!(
(vc.get("DISCNUMBER"), vc.get("DISCTOTAL")),
(Some("2"), Some("2"))
);
let mut vc = VorbisComments::default();
vc.push("TRACKNUMBER".into(), "7".into());
vc.push("DISCNUMBER".into(), "1".into());
let w = apply(&mut vc, &data);
assert!(!w.track);
assert_eq!(
(vc.get("TRACKNUMBER"), vc.get("DISCNUMBER")),
(Some("7"), Some("1"))
);
}
#[test]
fn blank_lyric_ignored() {
let s = song();
let mut vc = VorbisComments::default();
let w = apply(
&mut vc,
&TagData {
song: &s,
lyric: Some(" "),
cover: None,
},
);
assert!(!w.lyric && !w.cover);
}
fn flac(comments: &[&str]) -> Vec<u8> {
let mut v = b"fLaC".to_vec();
let last = if comments.is_empty() { 0x80 } else { 0x00 };
v.extend([last, 0x00, 0x00, 0x22]); v.extend([0x10, 0x00, 0x10, 0x00]); v.extend([0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); v.extend([0x0A, 0xC4, 0x42, 0xF0, 0x00, 0x00, 0x00, 0x00]);
v.extend([0u8; 16]); if !comments.is_empty() {
let mut body = Vec::new();
body.extend(2u32.to_le_bytes());
body.extend(b"qq");
body.extend((comments.len() as u32).to_le_bytes());
for c in comments {
body.extend((c.len() as u32).to_le_bytes());
body.extend(c.as_bytes());
}
let len = body.len() as u32;
v.extend([0x84, (len >> 16) as u8, (len >> 8) as u8, len as u8]); v.extend(body);
}
v
}
fn temp_file(name: &str, bytes: &[u8]) -> (std::path::PathBuf, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("uta-{name}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("t.flac.part");
std::fs::write(&path, bytes).unwrap();
(dir, path)
}
#[test]
fn flac_roundtrip() {
let (dir, path) = temp_file("flac-rt", &flac(&[]));
let (s, c) = (song(), cover());
let data = TagData {
song: &s,
lyric: Some("[00:01.00]歌词"),
cover: Some(&c),
};
assert_eq!(write_file(&path, &data).unwrap(), all());
let mut f = std::fs::File::open(&path).unwrap();
let file = FlacFile::read_from(&mut f, ParseOptions::new()).unwrap();
let vc = file.vorbis_comments().unwrap();
assert_eq!(vc.get("TITLE"), Some("珊瑚海"));
assert_eq!(
vc.get_all("ARTIST").collect::<Vec<_>>(),
["周杰伦", "Lara梁心颐"]
);
assert_eq!(vc.get("LYRICS"), Some("[00:01.00]歌词"));
assert_eq!(file.pictures().len(), 1);
assert_eq!(file.pictures()[0].0.data(), c.data.as_slice());
let tagged = open(&path).unwrap();
assert_eq!(
tagged.primary_tag().unwrap().get_string(ItemKey::Lyrics),
Some("[00:01.00]歌词")
);
let before = std::fs::read(&path).unwrap();
assert_eq!(write_file(&path, &data).unwrap(), Written::default());
assert_eq!(std::fs::read(&path).unwrap(), before);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn flac_track_roundtrip() {
let (dir, path) = temp_file("flac-track", &flac(&["TITLE=夜曲"]));
let s = album_song(1, 12, None);
let w = write_file(
&path,
&TagData {
song: &s,
lyric: None,
cover: None,
},
)
.unwrap();
assert!(w.track);
let tagged = open(&path).unwrap();
let tag = tagged.primary_tag().unwrap();
assert_eq!((tag.track(), tag.track_total()), (Some(1), Some(12)));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn flac_keeps_existing_and_unknown_fields() {
let (dir, path) = temp_file(
"flac-keep",
&flac(&["TITLE=原标题", "QMQUALITY=Premium-Master"]),
);
let (s, c) = (song(), cover());
let data = TagData {
song: &s,
lyric: Some("歌词"),
cover: Some(&c),
};
assert_eq!(write_file(&path, &data).unwrap(), all());
let mut f = std::fs::File::open(&path).unwrap();
let file = FlacFile::read_from(&mut f, ParseOptions::new()).unwrap();
let vc = file.vorbis_comments().unwrap();
assert_eq!(vc.get("TITLE"), Some("原标题"));
assert_eq!(vc.get("QMQUALITY"), Some("Premium-Master"));
assert_eq!(vc.vendor(), "qq");
assert_eq!(vc.get("ALBUM"), Some("十一月的萧邦"));
assert_eq!(file.pictures().len(), 1);
std::fs::remove_dir_all(&dir).unwrap();
}
}