1use std::{path::Path, time::Duration};
6
7use mp4ameta::{Chapter, Img, ImgFmt, Tag};
8use regex::Regex;
9
10use crate::{
11 client::BiliClient,
12 error::{Error, Result},
13};
14
15pub fn extract_bv_id(input: &str) -> Result<String> {
19 let regex = Regex::new(r"BV[a-z0-9A-Z]+").expect("不正确的正则表达式");
20 regex.find(input)
21 .map(|m| m.as_str().to_string())
22 .ok_or(Error::Normal("无法从输入中提取bv号".into()))
23}
24
25pub fn extract_media_id(input: &str) -> Result<String> {
27 let regex = Regex::new(r"ml(\d+)").expect("不正确的正则表达式");
28 regex.captures(input)
29 .and_then(|cap| cap.get(1))
30 .map(|m| m.as_str().to_string())
31 .ok_or(Error::Normal("无法从输入中提取mlid号".into()))
32}
33
34pub async fn download_cover_bytes(
39 client: &BiliClient,
40 cover_url: &str,
41) -> Result<Vec<u8>> {
42 let mut url = if cover_url.starts_with("//") {
44 format!("https://{}", cover_url.trim_start_matches('/'))
45 } else {
46 cover_url.to_string()
47 };
48 if let Some(index) = url.find('@') {
50 url.truncate(index);
51 }
52 let response = client.get(&url).send().await?;
53 let bytes = response.bytes().await?;
54 Ok(bytes.to_vec())
55}
56
57pub fn check_cover_box(file_path: &Path) -> Result<bool> {
59 let tag = Tag::read_from_path(file_path)?;
60 Ok(tag.artwork().is_some())
61}
62
63pub fn add_cover_box(
68 file_path: &Path,
69 image_data: Vec<u8>,
70) -> Result<()> {
71 let fmt = detect_image_format(&image_data)?;
72 let mut tag = Tag::read_from_path(file_path)?;
73 let image = Img::new(fmt, image_data);
74 tag.set_artwork(image);
75 tag.write_to_path(file_path)?;
76 Ok(())
77}
78
79fn detect_image_format(bytes: &[u8]) -> Result<ImgFmt> {
80 if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
81 Ok(ImgFmt::Jpeg)
82 } else if bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
83 Ok(ImgFmt::Png)
84 } else if bytes.starts_with(b"BM") {
85 Ok(ImgFmt::Bmp)
86 } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
87 Err(Error::Normal(
89 "封面是 webp 格式,mp4ameta 不支持,请先用 ?x-oss-process 转成 jpg".into(),
90 ))
91 } else {
92 Err(Error::Normal("无法识别的封面图片格式".into()))
93 }
94}
95
96#[derive(Default, Debug, Clone, PartialEq)]
100pub struct MediaMeta {
101 pub title: Option<String>,
103 pub artist: Option<String>,
105 pub album: Option<String>,
107 pub album_artist: Option<String>,
108 pub comment: Option<String>,
110 pub description: Option<String>,
112 pub genre: Option<String>,
114 pub year: Option<String>,
115 pub track: Option<(u16, u16)>,
117 pub encoder: Option<String>,
119 pub copyright: Option<String>,
120 pub lyrics: Option<String>,
122 pub keywords: Vec<String>,
123 pub chapters: Vec<(Duration, String)>,
125}
126
127impl MediaMeta {
128 pub fn apply(
130 &self,
131 tag: &mut Tag,
132 ) {
133 set_str(&self.title, |v| tag.set_title(v));
134 set_str(&self.artist, |v| tag.set_artist(v));
135 set_str(&self.album, |v| tag.set_album(v));
136 set_str(&self.album_artist, |v| tag.set_album_artist(v));
137 set_str(&self.comment, |v| tag.set_comment(v));
138 set_str(&self.description, |v| tag.set_description(v));
139 set_str(&self.genre, |v| tag.set_genre(v));
140 set_str(&self.year, |v| tag.set_year(v));
141 set_str(&self.encoder, |v| tag.set_encoder(v));
142 set_str(&self.copyright, |v| tag.set_copyright(v));
143 set_str(&self.lyrics, |v| tag.set_lyrics(v));
144 if let Some((index, total)) = self.track {
145 tag.set_track(index, total);
146 }
147 if !self.keywords.is_empty() {
148 tag.set_keywords(self.keywords.clone());
149 }
150 if !self.chapters.is_empty() {
151 tag.chapters_mut().clear();
152 for (start, title) in &self.chapters {
153 tag.chapters_mut().push(Chapter::new(*start, title.clone()));
154 }
155 }
156 }
157}
158
159fn set_str(
160 value: &Option<String>,
161 setter: impl FnOnce(String),
162) {
163 if let Some(v) = value {
164 setter(v.clone());
165 }
166}
167
168pub fn write_metadata(
170 file_path: &Path,
171 meta: &MediaMeta,
172) -> Result<()> {
173 let mut tag = Tag::read_from_path(file_path)?;
174 meta.apply(&mut tag);
175 tag.write_to_path(file_path)?;
176 Ok(())
177}
178
179pub fn write_metadata_with_cover(
183 file_path: &Path,
184 meta: &MediaMeta,
185 cover: Option<Vec<u8>>,
186) -> Result<()> {
187 let mut tag = Tag::read_from_path(file_path)?;
188 meta.apply(&mut tag);
189 if let Some(bytes) = cover {
190 let fmt = detect_image_format(&bytes)?;
191 tag.set_artwork(Img::new(fmt, bytes));
192 }
193 tag.write_to_path(file_path)?;
194 Ok(())
195}
196
197pub fn check_metadata(
201 file_path: &Path,
202 meta: &MediaMeta,
203) -> Result<bool> {
204 let tag = Tag::read_from_path(file_path)?;
205 let same = |actual: Option<&str>, expected: &Option<String>| match expected {
206 Some(e) => actual == Some(e.as_str()),
207 None => true,
208 };
209 let ok = same(tag.title(), &meta.title)
210 && same(tag.artist(), &meta.artist)
211 && same(tag.album(), &meta.album)
212 && same(tag.comment(), &meta.comment);
213 Ok(ok)
214}
215
216pub fn read_lyrics_sidecar(media_path: &Path) -> Option<String> {
220 let lrc = media_path.with_extension("lrc");
221 let text = std::fs::read_to_string(lrc).ok()?;
222 if text.trim().is_empty() {
223 None
224 } else {
225 Some(text)
226 }
227}