use std::{path::Path, time::Duration};
use mp4ameta::{Chapter, Img, ImgFmt, Tag};
use regex::Regex;
use crate::{
client::BiliClient,
error::{Error, Result},
};
pub fn extract_bv_id(input: &str) -> Result<String> {
let regex = Regex::new(r"BV[a-z0-9A-Z]+").expect("不正确的正则表达式");
regex.find(input)
.map(|m| m.as_str().to_string())
.ok_or(Error::Normal("无法从输入中提取bv号".into()))
}
pub fn extract_media_id(input: &str) -> Result<String> {
let regex = Regex::new(r"ml(\d+)").expect("不正确的正则表达式");
regex.captures(input)
.and_then(|cap| cap.get(1))
.map(|m| m.as_str().to_string())
.ok_or(Error::Normal("无法从输入中提取mlid号".into()))
}
pub async fn download_cover_bytes(
client: &BiliClient,
cover_url: &str,
) -> Result<Vec<u8>> {
let mut url = if cover_url.starts_with("//") {
format!("https://{}", cover_url.trim_start_matches('/'))
} else {
cover_url.to_string()
};
if let Some(index) = url.find('@') {
url.truncate(index);
}
let response = client.get(&url).send().await?;
let bytes = response.bytes().await?;
Ok(bytes.to_vec())
}
pub fn check_cover_box(file_path: &Path) -> Result<bool> {
let tag = Tag::read_from_path(file_path)?;
Ok(tag.artwork().is_some())
}
pub fn add_cover_box(
file_path: &Path,
image_data: Vec<u8>,
) -> Result<()> {
let fmt = detect_image_format(&image_data)?;
let mut tag = Tag::read_from_path(file_path)?;
let image = Img::new(fmt, image_data);
tag.set_artwork(image);
tag.write_to_path(file_path)?;
Ok(())
}
fn detect_image_format(bytes: &[u8]) -> Result<ImgFmt> {
if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
Ok(ImgFmt::Jpeg)
} else if bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
Ok(ImgFmt::Png)
} else if bytes.starts_with(b"BM") {
Ok(ImgFmt::Bmp)
} else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
Err(Error::Normal(
"封面是 webp 格式,mp4ameta 不支持,请先用 ?x-oss-process 转成 jpg".into(),
))
} else {
Err(Error::Normal("无法识别的封面图片格式".into()))
}
}
#[derive(Default, Debug, Clone, PartialEq)]
pub struct MediaMeta {
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub album_artist: Option<String>,
pub comment: Option<String>,
pub description: Option<String>,
pub genre: Option<String>,
pub year: Option<String>,
pub track: Option<(u16, u16)>,
pub encoder: Option<String>,
pub copyright: Option<String>,
pub lyrics: Option<String>,
pub keywords: Vec<String>,
pub chapters: Vec<(Duration, String)>,
}
impl MediaMeta {
pub fn apply(
&self,
tag: &mut Tag,
) {
set_str(&self.title, |v| tag.set_title(v));
set_str(&self.artist, |v| tag.set_artist(v));
set_str(&self.album, |v| tag.set_album(v));
set_str(&self.album_artist, |v| tag.set_album_artist(v));
set_str(&self.comment, |v| tag.set_comment(v));
set_str(&self.description, |v| tag.set_description(v));
set_str(&self.genre, |v| tag.set_genre(v));
set_str(&self.year, |v| tag.set_year(v));
set_str(&self.encoder, |v| tag.set_encoder(v));
set_str(&self.copyright, |v| tag.set_copyright(v));
set_str(&self.lyrics, |v| tag.set_lyrics(v));
if let Some((index, total)) = self.track {
tag.set_track(index, total);
}
if !self.keywords.is_empty() {
tag.set_keywords(self.keywords.clone());
}
if !self.chapters.is_empty() {
tag.chapters_mut().clear();
for (start, title) in &self.chapters {
tag.chapters_mut().push(Chapter::new(*start, title.clone()));
}
}
}
}
fn set_str(
value: &Option<String>,
setter: impl FnOnce(String),
) {
if let Some(v) = value {
setter(v.clone());
}
}
pub fn write_metadata(
file_path: &Path,
meta: &MediaMeta,
) -> Result<()> {
let mut tag = Tag::read_from_path(file_path)?;
meta.apply(&mut tag);
tag.write_to_path(file_path)?;
Ok(())
}
pub fn write_metadata_with_cover(
file_path: &Path,
meta: &MediaMeta,
cover: Option<Vec<u8>>,
) -> Result<()> {
let mut tag = Tag::read_from_path(file_path)?;
meta.apply(&mut tag);
if let Some(bytes) = cover {
let fmt = detect_image_format(&bytes)?;
tag.set_artwork(Img::new(fmt, bytes));
}
tag.write_to_path(file_path)?;
Ok(())
}
pub fn check_metadata(
file_path: &Path,
meta: &MediaMeta,
) -> Result<bool> {
let tag = Tag::read_from_path(file_path)?;
let same = |actual: Option<&str>, expected: &Option<String>| match expected {
Some(e) => actual == Some(e.as_str()),
None => true,
};
let ok = same(tag.title(), &meta.title)
&& same(tag.artist(), &meta.artist)
&& same(tag.album(), &meta.album)
&& same(tag.comment(), &meta.comment);
Ok(ok)
}
pub fn read_lyrics_sidecar(media_path: &Path) -> Option<String> {
let lrc = media_path.with_extension("lrc");
let text = std::fs::read_to_string(lrc).ok()?;
if text.trim().is_empty() {
None
} else {
Some(text)
}
}