1use 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
26pub 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 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 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
80pub 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
114pub 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
136pub 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
182pub 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
217pub 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
252pub 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
286pub 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
338pub 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(); 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
461fn 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}