Skip to main content

ybd_core/
util.rs

1//! 辅助工具模块
2//!
3//! 包含 BV 号/媒体列表 ID 提取、音频元数据内嵌(mp4ameta)及格式检测等实用工具。
4
5use 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
15/// 从链接提取bvid
16///
17/// * `bv_id`: 视频url
18pub 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
25// eg. https://www.bilibili.com/list/ml2408095182?spm_id_from=333.1007.0.0&oid=115724639078994&bvid=BV1S4qWBkEGk
26pub 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
34/// 直接下载封面到内存
35///
36/// * `client`: 客户端
37/// * `cover_url`: 链接
38pub async fn download_cover_bytes(
39        client: &BiliClient,
40        cover_url: &str,
41) -> Result<Vec<u8>> {
42        // 接口偶尔返回协议相对地址(//i0.hdslb.com/...),统一补成 https
43        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        // 去掉 B 站 CDN 的处理后缀(`@672w_378h_1c.webp`),否则拿到的是 webp
49        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
57/// 判断文件里是否已经有封面
58pub 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
63/// 写入封面
64///
65/// * `file_path`: m4a/mp4 文件
66/// * `image_data`: 图片原始字节(仅支持 jpeg/png/bmp)
67pub 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                // mp4ameta 只认 jpeg/png/bmp,webp 直接写入会被播放器忽略
88                Err(Error::Normal(
89                        "封面是 webp 格式,mp4ameta 不支持,请先用 ?x-oss-process 转成 jpg".into(),
90                ))
91        } else {
92                Err(Error::Normal("无法识别的封面图片格式".into()))
93        }
94}
95
96/// 可写入 m4a/mp4 的元数据集合
97///
98/// 所有字段都是可选的,为 `None` 时保持文件原有内容不变。
99#[derive(Default, Debug, Clone, PartialEq)]
100pub struct MediaMeta {
101        /// 标题(视频标题)
102        pub title: Option<String>,
103        /// 歌手(UP 主)
104        pub artist: Option<String>,
105        /// 专辑(收藏夹/合集/分区)
106        pub album: Option<String>,
107        pub album_artist: Option<String>,
108        /// 备注(这里放视频原始链接)
109        pub comment: Option<String>,
110        /// 简介(视频描述)
111        pub description: Option<String>,
112        /// 流派(分区名)
113        pub genre: Option<String>,
114        pub year: Option<String>,
115        /// (第几P, 共几P)
116        pub track: Option<(u16, u16)>,
117        /// 写入工具标识
118        pub encoder: Option<String>,
119        pub copyright: Option<String>,
120        /// 歌词(可由字幕/同名 .lrc 转成 LRC 文本)
121        pub lyrics: Option<String>,
122        pub keywords: Vec<String>,
123        /// 章节,仅对“多P/合集合并成单个文件”有意义
124        pub chapters: Vec<(Duration, String)>,
125}
126
127impl MediaMeta {
128        /// 把元数据合并进已有 tag(`None` 的字段不覆盖)
129        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
168/// 写入元数据(不含封面)
169pub 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
179/// 一面写元数据、一面写封面的组合版本
180///
181/// * `cover`: 封面字节,`None` 表示跳过封面
182pub 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
197/// 判断文件元数据是否已经与目标一致(一致则无需重写)
198///
199/// 只对比“关键字段”,避免简介这类长文本变动导致反复写盘。
200pub 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
216/// 读取同名 `.lrc` 字幕/歌词,作为 `lyrics` 字段的内容
217///
218/// * `media_path`: 音频/视频文件路径,函数会自动把扩展名换成 `.lrc`
219pub 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}