Skip to main content

ybd_core/
actuator.rs

1//! 核心执行器模块
2//!
3//! 包含视频、音频、封面下载及使用 ffmpeg 进行音视频合并的高层 API。
4
5use std::path::{Path, PathBuf};
6
7use clap::ValueEnum;
8use futures::StreamExt;
9use reqwest::Client;
10use serde_json::Value;
11use tokio::{fs::File, io, process};
12
13use crate::{
14        client::BiliClient,
15        error::{Error, Result},
16        model::{
17                collection::CollectionUrlResponse,
18                detail::{VideoDetail, VideoInfoResponse},
19                download::DownloadOption,
20                video::PlayUrlResponse,
21        },
22        url::{MEDIO_LIST, UA, VIDEO_INFO, VIDEO_TAGS},
23        util,
24};
25
26/// 一站式写入元数据 + 封面
27///
28/// 已写入且内容一致的文件会被跳过,不重复写盘。
29/// 标签获取、封面下载失败都不会中断主流程。
30///
31/// * `client`: 带凭证的客户端
32/// * `file_path`: 目标 m4a/mp4 文件
33/// * `detail`: 视频详细信息(来自 [`get_video_detail`])
34/// * `album`: 合集/收藏夹名,`None` 时使用分区名
35/// * `cid`: 当前下载的分P,`None` 时不做分P 标记
36pub async fn embed_metadata(
37        client: &BiliClient,
38        file_path: &Path,
39        detail: &VideoDetail,
40        album: Option<&str>,
41        cid: Option<i64>,
42) -> Result<()> {
43        let mut meta = detail.media_meta(album, cid);
44
45        // 先做轻量判断,避免每次运行都重新拉标签/封面
46        let meta_ok = util::check_metadata(file_path, &meta).unwrap_or(false);
47        let cover_ok = util::check_cover_box(file_path).unwrap_or(false);
48        if meta_ok && cover_ok {
49                return Ok(());
50        }
51
52        if !meta_ok {
53                meta.lyrics = util::read_lyrics_sidecar(file_path);
54                meta.keywords = get_video_tags(&detail.bvid, Some(client)).await;
55        }
56
57        let cover = if cover_ok || detail.pic.is_empty() {
58                None
59        } else {
60                util::download_cover_bytes(client, &detail.pic).await.ok()
61        };
62
63        match util::write_metadata_with_cover(file_path, &meta, cover) {
64                Ok(()) => Ok(()),
65                // 封面格式不支持(比如 webp)时退化成只写元数据
66                Err(e) => {
67                        util::write_metadata(file_path, &meta).map_err(|_| e)?;
68                        Ok(())
69                },
70        }
71}
72
73#[derive(Debug, Clone, ValueEnum)]
74pub enum Mode {
75        Cover,
76        Audio,
77        Video,
78}
79
80/// 下载视频文件
81///
82/// * `bili_client`: 发送请求的带令牌的客户端
83/// * `pur`: 视频信息返回响应体
84/// * `video_quality`: [视频分辨率],
85/// * `video_encode`: [视频编码格式],
86/// * `path`: 下载到的路径
87pub async fn download_video(
88        bili_client: &BiliClient,
89        pur: &PlayUrlResponse,
90        download_option: &DownloadOption<'_>,
91) -> Result<()> {
92        let video_path = download_option
93                .video_path
94                .ok_or(Error::Path("video_path 未设置".into()))?;
95        let audio_path = download_option
96                .audio_path
97                .ok_or(Error::Path("audio_path 未设置".into()))?;
98        let output = download_option
99                .output
100                .ok_or(Error::Path("output 未设置".into()))?;
101
102        let (video_res, audio_res) = tokio::join!(
103                download_video_with_no_audio(bili_client, pur, download_option),
104                download_audio(bili_client, pur, download_option),
105        );
106        video_res?;
107        audio_res?;
108        merge_video_audio(video_path, audio_path, output, download_option.ffmpeg_path).await?;
109        tokio::fs::remove_file(video_path).await?;
110        tokio::fs::remove_file(audio_path).await?;
111        Ok(())
112}
113
114/// 下载视频封面
115///
116/// * `client`: reqwest请求客户端
117/// * `base_url`: 图片url
118/// * `path`: 下载到本地的路径
119/// * `sessdata`: 用户凭证
120pub async fn download_cover(
121        client: &Client,
122        url: &str,
123        path: &Path,
124) -> Result<()> {
125        if path.is_dir() {
126                return Err(Error::Path("路径不能是目录".into()));
127        }
128        let mut res = client.get(url).send().await?;
129        let mut file = File::create(sanitize_path(path)).await?;
130        while let Some(chunk) = res.chunk().await? {
131                io::AsyncWriteExt::write_all(&mut file, &chunk).await?;
132        }
133        Ok(())
134}
135
136/// 下载视频文件,没有音频
137///
138/// * `bili_client`: 发送请求的带令牌的客户端
139/// * `pur`: 视频信息返回响应体
140/// * `video_quality`: [视频分辨率],
141/// * `video_encode`: [视频编码格式],
142/// * `path`: 下载到的路径
143pub async fn download_video_with_no_audio(
144        bili_client: &BiliClient,
145        pur: &PlayUrlResponse,
146        download_option: &DownloadOption<'_>,
147) -> Result<()> {
148        let video_path = download_option
149                .video_path
150                .ok_or(Error::Path("没有输入视频输出路径".into()))?;
151        if video_path.is_dir() {
152                return Err(Error::Path("路径不能是目录".into()));
153        }
154        let video_data = pur.get_data()?;
155
156        let url = if download_option.video_quality.is_some()
157                || download_option.video_encode.is_some()
158        {
159                video_data
160                        .get_specified_video_url(
161                                download_option.video_quality,
162                                download_option.video_encode,
163                        )
164                        .ok_or(Error::Normal(
165                                "无法获取指定分辨率或编码格式的视频资源".into(),
166                        ))?
167        } else {
168                video_data
169                        .best_video_quality_url()
170                        .ok_or(Error::Normal("没有找到视频".into()))?
171        };
172
173        download_url(
174                bili_client,
175                url,
176                video_path,
177                download_option.on_video_progress.as_deref(),
178        )
179        .await
180}
181
182/// 下载音频文件,内部逻辑与视频一致
183///
184/// * `bili_client`: 发送请求的带令牌的客户端
185/// * `pur`: 视频信息返回响应体
186/// * `path`: 下载到的路径
187pub async fn download_audio(
188        bili_client: &BiliClient,
189        pur: &PlayUrlResponse,
190        download_option: &DownloadOption<'_>,
191) -> Result<()> {
192        let path = download_option
193                .audio_path
194                .ok_or(Error::Path("没有输入音频输出路径".into()))?;
195        if path.is_dir() {
196                return Err(Error::Path("路径不能是目录".into()));
197        }
198        let video_data = pur.get_data()?;
199        let url = match download_option.audio_quality {
200                Some(aq) => video_data
201                        .get_specified_audio_url(aq)
202                        .ok_or(Error::Normal("无法获取指定音质的音频资源".into()))?,
203                None => video_data
204                        .best_audio_url()
205                        .ok_or(Error::Normal("没有找到音频".into()))?,
206        };
207
208        download_url(
209                bili_client,
210                url,
211                path,
212                download_option.on_audio_progress.as_deref(),
213        )
214        .await
215}
216
217/// 获取视频完整信息(标题、UP 主、简介、发布时间、分P 列表等)
218///
219/// 基本信息不需要 SESSDATA,但部分受限视频需要。
220///
221/// * `bvid`: 视频bv号
222/// * `bili_client`: 带凭证的客户端,`None` 时使用匿名客户端
223pub async fn get_video_detail(
224        bvid: &str,
225        bili_client: Option<&BiliClient>,
226) -> Result<VideoDetail> {
227        let query: [(&str, &str); 1] = [("bvid", bvid)];
228        let resp: VideoInfoResponse = match bili_client {
229                Some(client) => {
230                        client.get(VIDEO_INFO)
231                                .header("Referer", "https://www.bilibili.com")
232                                .query(&query)
233                                .send()
234                                .await?
235                                .json()
236                                .await?
237                },
238                None => {
239                        let client = Client::builder().user_agent(UA).build()?;
240                        client.get(VIDEO_INFO)
241                                .header("Referer", "https://www.bilibili.com")
242                                .query(&query)
243                                .send()
244                                .await?
245                                .json()
246                                .await?
247                },
248        };
249        resp.into_detail()
250}
251
252/// 获取视频标签(作为 `keywords` 写入元数据)
253///
254/// 失败时返回空列表,不阻塞下载流程。
255pub async fn get_video_tags(
256        bvid: &str,
257        bili_client: Option<&BiliClient>,
258) -> Vec<String> {
259        let resp: Value = match bili_client {
260                Some(client) => {
261                        match client
262                                .get(VIDEO_TAGS)
263                                .header("Referer", "https://www.bilibili.com")
264                                .query(&[("bvid", bvid)])
265                                .send()
266                                .await
267                        {
268                                Ok(r) => r.json().await.unwrap_or_default(),
269                                Err(_) => Value::Null,
270                        }
271                },
272                None => Value::Null,
273        };
274
275        resp["data"]
276                .as_array()
277                .map(|tags| {
278                        tags.iter()
279                                .filter_map(|tag| tag["tag_name"].as_str())
280                                .map(|name| name.to_string())
281                                .collect()
282                })
283                .unwrap_or_default()
284}
285
286/// 获取视频基本信息,基本信息不需要SESSDATA
287///
288/// * `client`: reqwest请求客户端
289/// * `bvid`: 视频bv号
290/// * `sessdata`: 用户凭证
291///
292/// # Retures
293///
294/// (title, pic, cid) -> 视频标题,视频封面url, 分p标识号
295pub async fn get_basic_video_info(
296        bvid: &str,
297        bili_client: Option<&BiliClient>,
298) -> Result<(String, String, String)> {
299        let resp: Value = if let Some(biliclient) = bili_client {
300                biliclient
301                        .get(VIDEO_INFO)
302                        .header("Referer", "https://www.bilibili.com")
303                        .query(&[("bvid", bvid)])
304                        .send()
305                        .await?
306                        .json()
307                        .await?
308        } else {
309                let client = Client::builder().user_agent(UA).build()?;
310                client.get(VIDEO_INFO)
311                        .header("Referer", "https://www.bilibili.com")
312                        .query(&[("bvid", bvid)])
313                        .send()
314                        .await?
315                        .json()
316                        .await?
317        };
318
319        let detail = serde_json::from_value::<VideoInfoResponse>(resp)?.into_detail()?;
320
321        let pic = if detail.pic.is_empty() {
322                return Err(Error::Normal("无法获取视频封面url".into()));
323        } else {
324                detail.pic.clone()
325        };
326        if detail.cid == 0 {
327                return Err(Error::Normal("无法获取 cid".into()));
328        }
329        let title = if detail.title.is_empty() {
330                "未知标题".to_string()
331        } else {
332                detail.title.clone()
333        };
334
335        Ok((title, pic, detail.cid.to_string()))
336}
337
338/// 获取收藏夹信息
339///
340/// * `ml_id`: 收藏夹标识
341/// * `page_number`: 页号
342/// * `page_size`: 页内数
343/// * `bili_client`: 如果收藏夹是私有未公开需要认证信息
344pub async fn get_basic_collection_info(
345        ml_id: &str,
346        page_number: usize,
347        page_size: usize,
348        bili_client: Option<&BiliClient>,
349) -> Result<CollectionUrlResponse> {
350        if let Some(bilibili_client) = bili_client {
351                let cur: CollectionUrlResponse = bilibili_client
352                        .get(MEDIO_LIST)
353                        .query(&[
354                                ("media_id", ml_id),
355                                ("ps", &page_size.to_string()),
356                                ("pn", &page_number.to_string()),
357                        ])
358                        .send()
359                        .await?
360                        .json()
361                        .await?;
362                return Ok(cur);
363        }
364        let client = Client::builder().user_agent(UA).build()?;
365        let cur: CollectionUrlResponse = client
366                .get(MEDIO_LIST)
367                .header("Referer", "https://www.bilibili.com")
368                .query(&[
369                        ("media_id", ml_id),
370                        ("ps", &page_size.to_string()),
371                        ("pn", &page_number.to_string()),
372                ])
373                .send()
374                .await?
375                .json()
376                .await?;
377        Ok(cur)
378}
379
380pub async fn merge_video_audio(
381        video_path: &Path,
382        audio_path: &Path,
383        output_path: &Path,
384        ffmpeg_path: Option<&Path>,
385) -> Result<()> {
386        let ffmpeg = ffmpeg_path.unwrap_or(Path::new("ffmpeg"));
387        let video_str = video_path
388                .to_str()
389                .ok_or(Error::Path("视频临时路径包含非法编码字符".into()))?;
390        let audio_str = audio_path
391                .to_str()
392                .ok_or(Error::Path("音频临时路径包含非法编码字符".into()))?;
393        let output_str = output_path
394                .to_str()
395                .ok_or(Error::Path("输出路径包含非法编码字符".into()))?;
396
397        let output = process::Command::new(ffmpeg)
398                .args([
399                        "-y", "-i", video_str, "-i", audio_str, "-c", "copy", output_str,
400                ])
401                .stdin(std::process::Stdio::null())
402                .stdout(std::process::Stdio::null())
403                .stderr(std::process::Stdio::piped())
404                .output()
405                .await?;
406        if !output.status.success() {
407                let err_msg = String::from_utf8_lossy(&output.stderr);
408                let clean_msg = err_msg.lines().next_back().unwrap_or("FFmpeg 合并失败");
409                return Err(Error::Normal(format!("FFmpeg 合并失败: {}", clean_msg)));
410        }
411        Ok(())
412}
413
414async fn download_url(
415        bili_client: &BiliClient,
416        url: &str,
417        path: &Path,
418        on_progress: Option<&(dyn Fn(u64, Option<u64>) + Send + Sync)>,
419) -> Result<()> {
420        if path.is_dir() {
421                return Err(Error::Path("路径不能是目录".into()));
422        }
423
424        let safe_path = sanitize_path(path);
425
426        let mut last_err = None;
427        for attempt in 0..3 {
428                if attempt > 0 {
429                        tokio::time::sleep(tokio::time::Duration::from_secs(attempt * 2)).await;
430                }
431                match try_download_url(bili_client, url, &safe_path, on_progress).await {
432                        Ok(()) => return Ok(()),
433                        Err(e) => last_err = Some(e),
434                }
435        }
436        Err(last_err.unwrap_or(Error::Normal("下载失败,无可用重试结果".into())))
437}
438
439async fn try_download_url(
440        bili_client: &BiliClient,
441        url: &str,
442        path: &Path,
443        on_progress: Option<&(dyn Fn(u64, Option<u64>) + Send + Sync)>,
444) -> Result<()> {
445        let res = bili_client.get(url).send().await?;
446        let total = res.content_length(); // 这里拿总大小
447        let mut file = File::create(path).await?;
448        let mut downloaded = 0u64;
449        let mut stream = res.bytes_stream();
450        while let Some(chunk) = stream.next().await {
451                let chunk = chunk?;
452                downloaded += chunk.len() as u64;
453                io::AsyncWriteExt::write_all(&mut file, &chunk).await?;
454                if let Some(cb) = on_progress {
455                        cb(downloaded, total);
456                }
457        }
458        Ok(())
459}
460
461/// 清理路径中的文件名,替换掉 Windows/Linux 下非法的字符(如 `/`、`\`、`:`、`"` 等),
462/// 只保留目录部分不变,避免视频标题中含特殊字符时创建文件失败
463///
464/// * `path`: 原始路径
465fn sanitize_path(path: &Path) -> PathBuf {
466        path.parent()
467                .unwrap_or(Path::new("."))
468                .join(sanitize_filename::sanitize(
469                        path.file_name()
470                                .unwrap_or_default()
471                                .to_string_lossy()
472                                .as_ref(),
473                ))
474}