1use crate::{
2 BiliClient, DownloadEntry, DownloadPlan, Error, FlvSegment, Input, MediaStream, Result,
3 Selection, SubtitleFormat, SubtitleTrack,
4};
5use futures_util::StreamExt;
6use md5::{Digest, Md5};
7use reqwest::StatusCode;
8use reqwest::header::{CONTENT_RANGE, RANGE};
9use serde::{Deserialize, Serialize};
10use std::collections::HashSet;
11use std::ffi::OsString;
12use std::fmt::Write as _;
13use std::path::{Path, PathBuf};
14use std::process::Stdio;
15use std::time::{Duration, SystemTime, UNIX_EPOCH};
16use tokio::fs::{self, OpenOptions};
17use tokio::io::AsyncWriteExt;
18use tokio::process::Command;
19
20const MAX_FILE_NAME_BYTES: usize = 80;
21const MAX_FILE_COMPONENT_BYTES: usize = 240;
22const MAX_SUBTITLE_EXTENSION_BYTES: usize = 16;
23
24#[non_exhaustive]
25#[derive(Clone, Debug)]
26pub struct DownloadOptions {
27 pub output_dir: PathBuf,
28 pub retry: RetryPolicy,
29 pub stream_selection: StreamSelection,
30 pub resume: bool,
31 pub include_subtitles: bool,
32 pub include_danmaku: bool,
33 pub mux: MuxOptions,
34 pub download_idle_timeout: Option<Duration>,
35}
36
37impl Default for DownloadOptions {
38 fn default() -> Self {
39 Self {
40 output_dir: PathBuf::from("."),
41 retry: RetryPolicy::default(),
42 stream_selection: StreamSelection::default(),
43 resume: true,
44 include_subtitles: true,
45 include_danmaku: true,
46 mux: MuxOptions::Disabled,
47 download_idle_timeout: Some(Duration::from_secs(30)),
48 }
49 }
50}
51
52impl DownloadOptions {
53 #[must_use]
54 pub fn new(output_dir: impl Into<PathBuf>) -> Self {
55 Self {
56 output_dir: output_dir.into(),
57 ..Self::default()
58 }
59 }
60
61 #[must_use]
62 pub fn with_retry_policy(mut self, retry: RetryPolicy) -> Self {
63 self.retry = retry;
64 self
65 }
66
67 #[must_use]
68 pub fn with_stream_selection(mut self, stream_selection: StreamSelection) -> Self {
69 self.stream_selection = stream_selection;
70 self
71 }
72
73 #[must_use]
74 pub fn with_resume(mut self, resume: bool) -> Self {
75 self.resume = resume;
76 self
77 }
78
79 #[must_use]
80 pub fn with_subtitles(mut self, include_subtitles: bool) -> Self {
81 self.include_subtitles = include_subtitles;
82 self
83 }
84
85 #[must_use]
86 pub fn with_danmaku(mut self, include_danmaku: bool) -> Self {
87 self.include_danmaku = include_danmaku;
88 self
89 }
90
91 #[must_use]
92 pub fn with_mux(mut self, mux: MuxOptions) -> Self {
93 self.mux = mux;
94 self
95 }
96
97 #[must_use]
98 pub fn with_download_idle_timeout(mut self, download_idle_timeout: Option<Duration>) -> Self {
99 self.download_idle_timeout = download_idle_timeout;
100 self
101 }
102}
103
104#[non_exhaustive]
105#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
106pub struct StreamSelection {
107 pub video_quality: Option<u32>,
108 pub audio_quality: Option<u32>,
109}
110
111impl StreamSelection {
112 #[must_use]
113 pub const fn new(video_quality: Option<u32>, audio_quality: Option<u32>) -> Self {
114 Self {
115 video_quality,
116 audio_quality,
117 }
118 }
119
120 #[must_use]
121 pub const fn video(video_quality: u32) -> Self {
122 Self {
123 video_quality: Some(video_quality),
124 audio_quality: None,
125 }
126 }
127
128 #[must_use]
129 pub const fn audio(audio_quality: u32) -> Self {
130 Self {
131 video_quality: None,
132 audio_quality: Some(audio_quality),
133 }
134 }
135
136 #[must_use]
137 pub const fn has_selection(self) -> bool {
138 self.video_quality.is_some() || self.audio_quality.is_some()
139 }
140}
141
142#[non_exhaustive]
143#[derive(Clone, Copy, Debug)]
144pub struct RetryPolicy {
145 pub max_attempts: u32,
146 pub backoff: Duration,
147}
148
149impl RetryPolicy {
150 #[must_use]
151 pub const fn new(max_attempts: u32, backoff: Duration) -> Self {
152 Self {
153 max_attempts,
154 backoff,
155 }
156 }
157
158 #[must_use]
159 pub const fn single_attempt() -> Self {
160 Self {
161 max_attempts: 1,
162 backoff: Duration::ZERO,
163 }
164 }
165}
166
167impl Default for RetryPolicy {
168 fn default() -> Self {
169 Self {
170 max_attempts: 3,
171 backoff: Duration::from_millis(250),
172 }
173 }
174}
175
176#[derive(Clone, Debug, Eq, PartialEq)]
177pub enum MuxOptions {
178 Disabled,
179 Ffmpeg { binary: PathBuf },
180}
181
182impl MuxOptions {
183 #[must_use]
184 pub fn ffmpeg(binary: impl Into<PathBuf>) -> Self {
185 Self::Ffmpeg {
186 binary: binary.into(),
187 }
188 }
189}
190
191#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
192#[serde(rename_all = "snake_case")]
193pub enum DuplicateDecision {
194 Replace,
195 KeepBoth,
196 Cancel,
197}
198
199#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
200pub struct DownloadArchive {
201 pub records: Vec<DownloadArchiveRecord>,
202}
203
204impl DownloadArchive {
205 #[must_use]
206 pub fn new(records: Vec<DownloadArchiveRecord>) -> Self {
207 Self { records }
208 }
209
210 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
211 let path = path.as_ref();
212 let raw = match std::fs::read_to_string(path) {
213 Ok(raw) => raw,
214 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
215 return Ok(Self::default());
216 }
217 Err(error) => return Err(Error::Io(error)),
218 };
219 serde_json::from_str(&raw).map_err(Error::from)
220 }
221
222 pub fn save(&self, path: impl AsRef<Path>) -> Result<()> {
223 let path = path.as_ref();
224 let save_path = archive_storage_path(path)?;
225 if save_path.is_dir() {
226 return Err(Error::InvalidInput(
227 "download archive path is a directory".to_owned(),
228 ));
229 }
230 if let Some(parent) = save_path
231 .parent()
232 .filter(|parent| !parent.as_os_str().is_empty())
233 {
234 std::fs::create_dir_all(parent)?;
235 }
236 let temporary_path = archive_sidecar_path(&save_path, ".bbdown-archive-tmp");
237 std::fs::write(&temporary_path, serde_json::to_vec_pretty(self)?)?;
238 replace_archive_file(&temporary_path, &save_path)?;
239 Ok(())
240 }
241
242 pub fn record_download(&mut self, plan: &DownloadPlan, report: &DownloadReport) {
243 let record = DownloadArchiveRecord::from_report(plan, report, current_unix_seconds());
244 let record_output_key = comparable_output_path_key(&record.output_dir);
245 self.records.retain(|existing| {
246 comparable_output_path_key(&existing.output_dir) != record_output_key
247 });
248 self.records.push(record);
249 }
250
251 #[must_use]
252 pub fn records_for_plan(&self, plan: &DownloadPlan) -> Vec<DownloadArchiveRecord> {
253 let plan_match = ArchivePlanMatch::new(plan);
254 self.records
255 .iter()
256 .filter(|record| plan_match.matches_record(record))
257 .cloned()
258 .collect()
259 }
260}
261
262#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
263pub struct DownloadArchiveRecord {
264 pub content_key: String,
265 pub title: String,
266 pub output_dir: PathBuf,
267 pub completed_at_unix: u64,
268 pub entries: Vec<DownloadArchiveEntryRecord>,
269}
270
271impl DownloadArchiveRecord {
272 fn from_report(plan: &DownloadPlan, report: &DownloadReport, completed_at_unix: u64) -> Self {
273 Self {
274 content_key: download_plan_content_key(plan),
275 title: report.title.clone(),
276 output_dir: archive_record_path(&report.output_dir),
277 completed_at_unix,
278 entries: plan
279 .entries
280 .iter()
281 .zip(&report.entries)
282 .map(|(plan_entry, report_entry)| DownloadArchiveEntryRecord {
283 content_key: download_entry_content_key(plan_entry),
284 index: report_entry.index,
285 aid: plan_entry.aid,
286 bvid: plan_entry.bvid.clone(),
287 cid: plan_entry.cid,
288 epid: plan_entry.epid,
289 title: report_entry.title.clone(),
290 directory: archive_record_path(&report_entry.directory),
291 files: report_entry
292 .files
293 .iter()
294 .map(|file| archive_record_path(&file.path))
295 .collect(),
296 mux_output: report_entry
297 .mux
298 .as_ref()
299 .map(|mux| archive_record_path(&mux.output_path)),
300 })
301 .collect(),
302 }
303 }
304}
305
306#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
307pub struct DownloadArchiveEntryRecord {
308 pub content_key: String,
309 pub index: u32,
310 pub aid: u64,
311 pub bvid: Option<String>,
312 pub cid: u64,
313 pub epid: Option<u64>,
314 pub title: String,
315 pub directory: PathBuf,
316 pub files: Vec<PathBuf>,
317 pub mux_output: Option<PathBuf>,
318}
319
320#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
321pub struct DownloadPreflight {
322 pub content_key: String,
323 pub title: String,
324 pub planned_output_dir: PathBuf,
325 pub archived_records: Vec<DownloadArchiveRecord>,
326 pub output_conflict: Option<DownloadOutputConflict>,
327 #[doc(hidden)]
328 #[serde(default)]
329 pub reserved_output_dirs: Vec<PathBuf>,
330}
331
332impl DownloadPreflight {
333 pub fn inspect(
334 plan: &DownloadPlan,
335 options: &DownloadOptions,
336 archive: Option<&DownloadArchive>,
337 ) -> Result<Self> {
338 let planned_output_dir = default_plan_output_dir(plan, options);
339 let output_conflict =
340 path_is_occupied(&planned_output_dir)?.then_some(DownloadOutputConflict {
341 path: planned_output_dir.clone(),
342 });
343 let archived_records = archive.map_or_else(Vec::new, |archive| {
344 archive_records_for_preflight(archive, plan, &planned_output_dir)
345 });
346 let reserved_output_dirs = archive.map_or_else(Vec::new, |archive| {
347 archive
348 .records
349 .iter()
350 .map(|record| record.output_dir.clone())
351 .collect()
352 });
353 Ok(Self {
354 content_key: download_plan_content_key(plan),
355 title: plan.title.clone(),
356 planned_output_dir: planned_output_dir.clone(),
357 archived_records,
358 output_conflict,
359 reserved_output_dirs,
360 })
361 }
362
363 #[must_use]
364 pub fn requires_decision(&self) -> bool {
365 !self.archived_records.is_empty() || self.output_conflict.is_some()
366 }
367
368 #[must_use]
369 pub const fn suggested_decision(&self) -> DuplicateDecision {
370 DuplicateDecision::Cancel
371 }
372
373 pub fn output_dir_for_decision(&self, decision: DuplicateDecision) -> Result<PathBuf> {
374 match decision {
375 DuplicateDecision::KeepBoth => next_available_output_dir_avoiding(
376 &self.planned_output_dir,
377 &self.reserved_output_dirs_for_decision(),
378 ),
379 DuplicateDecision::Replace | DuplicateDecision::Cancel => {
380 Ok(self.planned_output_dir.clone())
381 }
382 }
383 }
384
385 fn reserved_output_dirs_for_decision(&self) -> Vec<PathBuf> {
386 let mut reserved = self.reserved_output_dirs.clone();
387 for record in &self.archived_records {
388 if !path_is_reserved(&record.output_dir, &reserved) {
389 reserved.push(record.output_dir.clone());
390 }
391 }
392 reserved
393 }
394}
395
396#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
397pub struct DownloadOutputConflict {
398 pub path: PathBuf,
399}
400
401#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
402pub struct DownloadReport {
403 pub title: String,
404 pub output_dir: PathBuf,
405 pub entries: Vec<EntryDownloadReport>,
406}
407
408#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
409pub struct EntryDownloadReport {
410 pub index: u32,
411 pub title: String,
412 pub directory: PathBuf,
413 pub files: Vec<DownloadedFile>,
414 pub mux: Option<MuxReport>,
415}
416
417#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
418pub struct DownloadedFile {
419 pub kind: DownloadFileKind,
420 pub path: PathBuf,
421 pub bytes_written: u64,
422 pub resumed_from: u64,
423}
424
425#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
426#[serde(rename_all = "snake_case")]
427pub enum DownloadFileKind {
428 Video,
429 Audio,
430 FlvSegment,
431 Subtitle,
432 Danmaku,
433}
434
435#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
436pub struct MuxReport {
437 pub output_path: PathBuf,
438 pub command: Vec<String>,
439}
440
441impl BiliClient {
442 pub async fn download_input(
443 &self,
444 raw: &str,
445 selection: Option<Selection>,
446 options: DownloadOptions,
447 ) -> Result<DownloadReport> {
448 let input = Input::parse(raw)?;
449 self.download(input, selection, options).await
450 }
451
452 pub async fn download(
453 &self,
454 input: Input,
455 selection: Option<Selection>,
456 options: DownloadOptions,
457 ) -> Result<DownloadReport> {
458 let plan = self.plan(input, selection).await?;
459 self.download_plan(&plan, options).await
460 }
461
462 pub async fn download_plan(
463 &self,
464 plan: &DownloadPlan,
465 options: DownloadOptions,
466 ) -> Result<DownloadReport> {
467 validate_plan_stream_selection(plan, options.stream_selection)?;
468 let output_dir = default_plan_output_dir(plan, &options);
469 self.download_plan_to_output_dir(plan, options, output_dir)
470 .await
471 }
472
473 pub async fn download_plan_with_archive_decision(
474 &self,
475 plan: &DownloadPlan,
476 options: DownloadOptions,
477 archive: &mut DownloadArchive,
478 decision: DuplicateDecision,
479 ) -> Result<DownloadReport> {
480 let preflight = DownloadPreflight::inspect(plan, &options, Some(archive))?;
481 self.download_plan_with_archive_preflight_decision(
482 plan, options, archive, &preflight, decision,
483 )
484 .await
485 }
486
487 pub async fn download_plan_with_archive_preflight_decision(
488 &self,
489 plan: &DownloadPlan,
490 options: DownloadOptions,
491 archive: &mut DownloadArchive,
492 preflight: &DownloadPreflight,
493 decision: DuplicateDecision,
494 ) -> Result<DownloadReport> {
495 validate_archive_preflight(plan, &options, archive, preflight)?;
496 let mut effective_options = options;
497 let output_dir = match decision {
498 DuplicateDecision::Cancel if preflight.requires_decision() => {
499 return Err(Error::InvalidInput(
500 "download canceled because archive or output conflict requires a decision"
501 .to_owned(),
502 ));
503 }
504 DuplicateDecision::Cancel => default_plan_output_dir(plan, &effective_options),
505 DuplicateDecision::Replace => {
506 if path_is_occupied(&preflight.planned_output_dir)? {
507 validate_plan_stream_selection(plan, effective_options.stream_selection)?;
508 remove_output_root_if_exists(&preflight.planned_output_dir).await?;
509 effective_options.resume = false;
510 }
511 preflight.planned_output_dir.clone()
512 }
513 DuplicateDecision::KeepBoth => preflight.output_dir_for_decision(decision)?,
514 };
515 if decision != DuplicateDecision::Replace && path_is_occupied(&output_dir)? {
516 return Err(Error::InvalidInput(format!(
517 "output directory appeared after duplicate preflight: {}",
518 output_dir.display()
519 )));
520 }
521 let report = self
522 .download_plan_to_output_dir(plan, effective_options, output_dir)
523 .await?;
524 archive.record_download(plan, &report);
525 Ok(report)
526 }
527
528 async fn download_plan_to_output_dir(
529 &self,
530 plan: &DownloadPlan,
531 options: DownloadOptions,
532 output_dir: PathBuf,
533 ) -> Result<DownloadReport> {
534 validate_plan_stream_selection(plan, options.stream_selection)?;
535 fs::create_dir_all(&output_dir).await?;
536 let mut entries = Vec::new();
537 for entry in &plan.entries {
538 entries.push(self.download_entry(entry, &output_dir, &options).await?);
539 }
540 Ok(DownloadReport {
541 title: plan.title.clone(),
542 output_dir,
543 entries,
544 })
545 }
546
547 async fn download_entry(
548 &self,
549 entry: &DownloadEntry,
550 output_dir: &Path,
551 options: &DownloadOptions,
552 ) -> Result<EntryDownloadReport> {
553 let entry_dir = output_dir.join(entry_dir_name(entry));
554 fs::create_dir_all(&entry_dir).await?;
555 let mut files = Vec::new();
556 let has_dash_pair = !entry.streams.videos.is_empty() && !entry.streams.audios.is_empty();
557 let use_flv_fallback = !has_dash_pair && !entry.streams.flv_segments.is_empty();
558 if has_dash_pair {
559 let video = select_media_stream(
560 &entry.streams.videos,
561 options.stream_selection.video_quality,
562 "video",
563 )?;
564 let audio = select_media_stream(
565 &entry.streams.audios,
566 options.stream_selection.audio_quality,
567 "audio",
568 )?;
569 files.push(
570 self.download_media_stream(video, DownloadFileKind::Video, &entry_dir, options)
571 .await?,
572 );
573 files.push(
574 self.download_media_stream(audio, DownloadFileKind::Audio, &entry_dir, options)
575 .await?,
576 );
577 } else if use_flv_fallback {
578 if options.stream_selection.has_selection() {
579 return Err(Error::InvalidInput(
580 "stream quality selection requires DASH media; selected entry only has FLV segments"
581 .to_owned(),
582 ));
583 }
584 for segment in &entry.streams.flv_segments {
585 files.push(
586 self.download_flv_segment(segment, &entry_dir, options)
587 .await?,
588 );
589 }
590 } else {
591 return Err(Error::MissingField("complete DASH media or FLV segments"));
592 }
593 if options.include_subtitles {
594 let mut seen_subtitles = HashSet::new();
595 for (index, subtitle) in entry.subtitles.iter().enumerate() {
596 if !seen_subtitles.insert(subtitle_dedup_key(&subtitle.url)) {
597 continue;
598 }
599 files.push(
600 self.download_subtitle(index, subtitle, &entry_dir, options)
601 .await?,
602 );
603 }
604 }
605 if options.include_danmaku {
606 files.push(
607 self.download_url_to_file(
608 &entry.danmaku.xml_url,
609 &entry_dir.join("danmaku.xml"),
610 DownloadFileKind::Danmaku,
611 None,
612 options,
613 )
614 .await?,
615 );
616 }
617 let mux = self.mux_entry(entry, &entry_dir, &files, options).await?;
618 Ok(EntryDownloadReport {
619 index: entry.index,
620 title: entry.title.clone(),
621 directory: entry_dir,
622 files,
623 mux,
624 })
625 }
626
627 async fn download_media_stream(
628 &self,
629 stream: &MediaStream,
630 kind: DownloadFileKind,
631 entry_dir: &Path,
632 options: &DownloadOptions,
633 ) -> Result<DownloadedFile> {
634 let label = match kind {
635 DownloadFileKind::Video => "video",
636 DownloadFileKind::Audio => "audio",
637 DownloadFileKind::FlvSegment
638 | DownloadFileKind::Subtitle
639 | DownloadFileKind::Danmaku => "media",
640 };
641 let path = entry_dir.join(media_file_name(label, stream));
642 self.download_candidate_urls_to_file(
643 &candidate_urls(&stream.base_url, &stream.backup_urls),
644 &path,
645 kind,
646 stream.size,
647 options,
648 )
649 .await
650 }
651
652 async fn download_flv_segment(
653 &self,
654 segment: &FlvSegment,
655 entry_dir: &Path,
656 options: &DownloadOptions,
657 ) -> Result<DownloadedFile> {
658 let path = entry_dir.join(format!("segment-{:03}.flv", segment.order));
659 self.download_candidate_urls_to_file(
660 &candidate_urls(&segment.url, &segment.backup_urls),
661 &path,
662 DownloadFileKind::FlvSegment,
663 segment.size,
664 options,
665 )
666 .await
667 }
668
669 async fn download_subtitle(
670 &self,
671 index: usize,
672 subtitle: &SubtitleTrack,
673 entry_dir: &Path,
674 options: &DownloadOptions,
675 ) -> Result<DownloadedFile> {
676 let path = entry_dir.join(subtitle_file_name(index, subtitle));
677 self.download_url_to_file(
678 &subtitle.url,
679 &path,
680 DownloadFileKind::Subtitle,
681 None,
682 options,
683 )
684 .await
685 }
686
687 async fn download_url_to_file(
688 &self,
689 url: &str,
690 path: &Path,
691 kind: DownloadFileKind,
692 expected_size: Option<u64>,
693 options: &DownloadOptions,
694 ) -> Result<DownloadedFile> {
695 let attempts = options.retry.max_attempts.max(1);
696 let mut last_error = None;
697 for attempt in 1..=attempts {
698 match self
699 .try_download_url_to_file(url, path, kind.clone(), expected_size, options)
700 .await
701 {
702 Ok(file) => return Ok(file),
703 Err(error) if attempt < attempts => {
704 last_error = Some(error);
705 if !options.retry.backoff.is_zero() {
706 tokio::time::sleep(options.retry.backoff).await;
707 }
708 }
709 Err(error) => return Err(error),
710 }
711 }
712 Err(last_error.unwrap_or_else(|| Error::InvalidInput("download retry failed".to_owned())))
713 }
714
715 async fn download_candidate_urls_to_file(
716 &self,
717 urls: &[String],
718 path: &Path,
719 kind: DownloadFileKind,
720 expected_size: Option<u64>,
721 options: &DownloadOptions,
722 ) -> Result<DownloadedFile> {
723 let mut last_error = None;
724 for url in urls {
725 match self
726 .download_url_to_file(url, path, kind.clone(), expected_size, options)
727 .await
728 {
729 Ok(file) => return Ok(file),
730 Err(error) => last_error = Some(error),
731 }
732 }
733 Err(last_error.unwrap_or_else(|| Error::InvalidInput("empty download URL list".to_owned())))
734 }
735
736 async fn try_download_url_to_file(
737 &self,
738 url: &str,
739 path: &Path,
740 kind: DownloadFileKind,
741 expected_size: Option<u64>,
742 options: &DownloadOptions,
743 ) -> Result<DownloadedFile> {
744 if let Some(parent) = path.parent() {
745 fs::create_dir_all(parent).await?;
746 }
747 let existing_len = existing_file_len(path).await?;
748 let resume_from = if options.resume { existing_len } else { 0 };
749 let response = self.send_download_request(url, resume_from).await?;
750 let status = response.status();
751 if resume_from > 0 && status == StatusCode::RANGE_NOT_SATISFIABLE {
752 if content_range_complete_len(response.headers()) == Some(resume_from)
753 && expected_size.is_none_or(|size| size == resume_from)
754 {
755 return Ok(DownloadedFile {
756 kind,
757 path: path.to_path_buf(),
758 bytes_written: 0,
759 resumed_from: resume_from,
760 });
761 }
762 return Err(Error::InvalidInput(
763 "server rejected resume range for a different file length".to_owned(),
764 ));
765 }
766 let response = response
767 .error_for_status()
768 .map_err(BiliClient::http_error_without_url)?;
769 let has_content_range = response.headers().contains_key(CONTENT_RANGE);
770 let content_range = content_range(response.headers())?;
771 let response_content_len = response.content_length();
772 let append =
773 validate_resume_response(status, resume_from, has_content_range, content_range)?;
774 let start_offset = if append { resume_from } else { 0 };
775 let full_retry_after_ignored_range = resume_from > 0 && !append;
776 let validation_expected_size = validation_size_for_full_retry(
777 expected_size,
778 content_range,
779 response_content_len,
780 full_retry_after_ignored_range,
781 );
782 if full_retry_after_ignored_range && validation_expected_size.is_none() {
783 return Err(Error::InvalidInput(
784 "server ignored resume range without a verifiable full response length".to_owned(),
785 ));
786 }
787 let replace_existing = existing_len > 0 && !append;
788 let write_path = if replace_existing {
789 temporary_download_path(path)
790 } else {
791 path.to_path_buf()
792 };
793 let mut file = OpenOptions::new()
794 .create(true)
795 .write(true)
796 .append(append)
797 .truncate(!append)
798 .open(&write_path)
799 .await?;
800 let write_result = write_response_body_to_file(
801 &mut file,
802 response,
803 content_range,
804 start_offset,
805 validation_expected_size,
806 options.download_idle_timeout,
807 )
808 .await;
809 drop(file);
810 let bytes_written = match write_result {
811 Ok(bytes_written) => bytes_written,
812 Err(error) => {
813 if replace_existing {
814 let _ = fs::remove_file(&write_path).await;
815 }
816 return Err(error);
817 }
818 };
819 if is_empty_unexpected_media_response(&kind, bytes_written) {
820 if !append {
821 let _ = fs::remove_file(&write_path).await;
822 }
823 return Err(Error::InvalidInput("empty media response".to_owned()));
824 }
825 if replace_existing {
826 replace_file(&write_path, path).await?;
827 }
828 Ok(DownloadedFile {
829 kind,
830 path: path.to_path_buf(),
831 bytes_written,
832 resumed_from: start_offset,
833 })
834 }
835
836 async fn send_download_request(
837 &self,
838 url: &str,
839 resume_from: u64,
840 ) -> Result<reqwest::Response> {
841 let mut request = self.http.get(url).headers(self.media_headers()?);
842 if resume_from > 0 {
843 request = request.header(RANGE, format!("bytes={resume_from}-"));
844 }
845 tokio::time::timeout(self.config.request_timeout, request.send())
846 .await
847 .map_err(|_| Error::InvalidInput("download request timeout elapsed".to_owned()))?
848 .map_err(BiliClient::http_error_without_url)
849 }
850
851 async fn mux_entry(
852 &self,
853 entry: &DownloadEntry,
854 entry_dir: &Path,
855 files: &[DownloadedFile],
856 options: &DownloadOptions,
857 ) -> Result<Option<MuxReport>> {
858 let MuxOptions::Ffmpeg { binary } = &options.mux else {
859 return Ok(None);
860 };
861 let media_files = files
862 .iter()
863 .filter(|file| file.kind.is_media())
864 .map(|file| file.path.clone())
865 .collect::<Vec<_>>();
866 if media_files.is_empty() {
867 return Ok(None);
868 }
869 let output_path = entry_dir.join(format!("{}.mp4", safe_file_name(&entry.title)));
870 let mux_output_path = temporary_mux_path(&output_path);
871 remove_file_if_exists(&mux_output_path).await?;
872 let mut args = Vec::new();
873 args.push(OsString::from("-y"));
874 args.push(OsString::from("-nostdin"));
875 if only_flv_segments(files) {
876 let list_path = entry_dir.join("ffmpeg-concat.txt");
877 fs::write(&list_path, concat_file_list(&media_files, entry_dir)).await?;
878 args.extend([
879 OsString::from("-f"),
880 OsString::from("concat"),
881 OsString::from("-safe"),
882 OsString::from("0"),
883 OsString::from("-i"),
884 list_path.into_os_string(),
885 ]);
886 } else {
887 for media_file in &media_files {
888 args.push(OsString::from("-i"));
889 args.push(media_file.as_os_str().to_os_string());
890 }
891 }
892 args.extend([
893 OsString::from("-c"),
894 OsString::from("copy"),
895 mux_output_path.as_os_str().to_os_string(),
896 ]);
897 let status = Command::new(binary)
898 .args(&args)
899 .stdin(Stdio::null())
900 .stdout(Stdio::null())
901 .stderr(Stdio::null())
902 .status()
903 .await?;
904 if !status.success() {
905 let _ = fs::remove_file(&mux_output_path).await;
906 return Err(Error::MuxFailed {
907 status: status.code().map_or_else(
908 || "terminated by signal".to_owned(),
909 |code| code.to_string(),
910 ),
911 });
912 }
913 let Ok(metadata) = fs::metadata(&mux_output_path).await else {
914 let _ = fs::remove_file(&mux_output_path).await;
915 return Err(Error::MuxFailed {
916 status: "missing output file".to_owned(),
917 });
918 };
919 if !metadata.is_file() {
920 let _ = fs::remove_file(&mux_output_path).await;
921 return Err(Error::MuxFailed {
922 status: "missing output file".to_owned(),
923 });
924 }
925 if metadata.len() == 0 {
926 let _ = fs::remove_file(&mux_output_path).await;
927 return Err(Error::MuxFailed {
928 status: "empty output file".to_owned(),
929 });
930 }
931 replace_file(&mux_output_path, &output_path).await?;
932 Ok(Some(MuxReport {
933 output_path,
934 command: command_report(binary, &args),
935 }))
936 }
937}
938
939impl DownloadFileKind {
940 fn is_media(&self) -> bool {
941 matches!(self, Self::Video | Self::Audio | Self::FlvSegment)
942 }
943}
944
945fn command_report(binary: &Path, args: &[OsString]) -> Vec<String> {
946 let mut command = Vec::with_capacity(args.len() + 1);
947 command.push(binary.to_string_lossy().into_owned());
948 command.extend(args.iter().map(|arg| arg.to_string_lossy().into_owned()));
949 command
950}
951
952async fn write_response_body_to_file(
953 file: &mut tokio::fs::File,
954 response: reqwest::Response,
955 content_range: Option<ParsedContentRange>,
956 start_offset: u64,
957 expected_size: Option<u64>,
958 download_idle_timeout: Option<Duration>,
959) -> Result<u64> {
960 let mut bytes_written = 0;
961 let mut stream = response.bytes_stream();
962 while let Some(chunk) = match next_download_chunk(&mut stream, download_idle_timeout).await {
963 Ok(chunk) => chunk,
964 Err(error) => {
965 rollback_download_file(file, start_offset).await?;
966 return Err(error);
967 }
968 } {
969 let chunk = match chunk {
970 Ok(chunk) => chunk,
971 Err(error) => {
972 rollback_download_file(file, start_offset).await?;
973 return Err(BiliClient::http_error_without_url(error));
974 }
975 };
976 if let Err(error) = file.write_all(&chunk).await {
977 rollback_download_file(file, start_offset).await?;
978 return Err(Error::Io(error));
979 }
980 bytes_written += u64::try_from(chunk.len()).unwrap_or(u64::MAX);
981 }
982 if let Err(error) = file.flush().await {
983 rollback_download_file(file, start_offset).await?;
984 return Err(Error::Io(error));
985 }
986 if let Err(error) =
987 validate_download_completion(expected_size, content_range, start_offset, bytes_written)
988 {
989 rollback_download_file(file, start_offset).await?;
990 return Err(error);
991 }
992 Ok(bytes_written)
993}
994
995async fn existing_file_len(path: &Path) -> Result<u64> {
996 match fs::metadata(path).await {
997 Ok(metadata) => Ok(metadata.len()),
998 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(0),
999 Err(error) => Err(Error::Io(error)),
1000 }
1001}
1002
1003fn temporary_download_path(path: &Path) -> PathBuf {
1004 temporary_path_with_suffix(path, ".bbdown-download")
1005}
1006
1007fn temporary_replace_path(path: &Path) -> PathBuf {
1008 temporary_path_with_suffix(path, ".bbdown-replace")
1009}
1010
1011fn temporary_mux_path(path: &Path) -> PathBuf {
1012 temporary_path_with_suffix(path, ".bbdown-mux")
1013}
1014
1015fn temporary_path_with_suffix(path: &Path, suffix: &str) -> PathBuf {
1016 let base = path
1017 .file_name()
1018 .and_then(std::ffi::OsStr::to_str)
1019 .unwrap_or("download");
1020 let budget = MAX_FILE_COMPONENT_BYTES.saturating_sub(suffix.len()).max(1);
1021 path.with_file_name(format!(
1022 "{}{suffix}",
1023 safe_file_name_with_budget(base, budget)
1024 ))
1025}
1026
1027fn archive_sidecar_path(path: &Path, suffix: &str) -> PathBuf {
1028 let base = path.file_name().map_or_else(
1029 || "download-archive".to_owned(),
1030 |name| name.to_string_lossy().into_owned(),
1031 );
1032 path.with_file_name(format!("{base}{suffix}"))
1033}
1034
1035fn archive_storage_path(path: &Path) -> Result<PathBuf> {
1036 let metadata = match std::fs::symlink_metadata(path) {
1037 Ok(metadata) => metadata,
1038 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1039 return Ok(path.to_path_buf());
1040 }
1041 Err(error) => return Err(Error::Io(error)),
1042 };
1043 if !metadata.file_type().is_symlink() {
1044 return Ok(path.to_path_buf());
1045 }
1046 let target = std::fs::read_link(path)?;
1047 let target = if target.is_absolute() {
1048 target
1049 } else {
1050 path.parent().unwrap_or_else(|| Path::new("")).join(target)
1051 };
1052 Ok(canonicalize_existing_prefix(&absolute_path(&target)))
1053}
1054
1055fn replace_archive_file(source: &Path, target: &Path) -> Result<()> {
1056 let target = archive_storage_path(target)?;
1057 let backup = archive_sidecar_path(&target, ".bbdown-archive-backup");
1058 match std::fs::remove_file(&backup) {
1059 Ok(()) => {}
1060 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1061 Err(error) => return Err(Error::Io(error)),
1062 }
1063 match std::fs::rename(&target, &backup) {
1064 Ok(()) => {}
1065 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1066 Err(error) => return Err(Error::Io(error)),
1067 }
1068 match std::fs::rename(source, &target) {
1069 Ok(()) => {
1070 let _ = std::fs::remove_file(&backup);
1071 Ok(())
1072 }
1073 Err(error) => {
1074 let _ = std::fs::rename(&backup, &target);
1075 Err(Error::Io(error))
1076 }
1077 }
1078}
1079
1080async fn replace_file(source: &Path, target: &Path) -> Result<()> {
1081 match fs::rename(source, target).await {
1082 Ok(()) => return Ok(()),
1083 Err(error) if error.kind() != std::io::ErrorKind::AlreadyExists => {
1084 return Err(Error::Io(error));
1085 }
1086 Err(_) => {}
1087 }
1088
1089 let backup = temporary_replace_path(target);
1090 match fs::remove_file(&backup).await {
1091 Ok(()) => {}
1092 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1093 Err(error) => return Err(Error::Io(error)),
1094 }
1095 fs::rename(target, &backup).await?;
1096 match fs::rename(source, target).await {
1097 Ok(()) => {
1098 let _ = fs::remove_file(&backup).await;
1099 Ok(())
1100 }
1101 Err(error) => {
1102 let _ = fs::rename(&backup, target).await;
1103 Err(Error::Io(error))
1104 }
1105 }
1106}
1107
1108fn candidate_urls(primary: &str, backups: &[String]) -> Vec<String> {
1109 let mut urls = Vec::with_capacity(backups.len() + 1);
1110 urls.push(primary.to_owned());
1111 urls.extend(backups.iter().filter(|url| !url.is_empty()).cloned());
1112 urls
1113}
1114
1115#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1116struct ParsedContentRange {
1117 start: u64,
1118 end: u64,
1119 complete_len: Option<u64>,
1120}
1121
1122impl ParsedContentRange {
1123 fn body_len(self) -> Result<u64> {
1124 self.end
1125 .checked_sub(self.start)
1126 .and_then(|value| value.checked_add(1))
1127 .ok_or_else(|| Error::InvalidInput("invalid Content-Range span".to_owned()))
1128 }
1129
1130 fn final_len(self) -> Result<u64> {
1131 self.end
1132 .checked_add(1)
1133 .ok_or_else(|| Error::InvalidInput("invalid Content-Range end".to_owned()))
1134 }
1135}
1136
1137async fn next_download_chunk<S>(
1138 stream: &mut S,
1139 idle_timeout: Option<Duration>,
1140) -> Result<Option<S::Item>>
1141where
1142 S: futures_util::Stream + Unpin,
1143{
1144 match idle_timeout {
1145 Some(timeout) => match tokio::time::timeout(timeout, stream.next()).await {
1146 Ok(chunk) => Ok(chunk),
1147 Err(_) => Err(Error::InvalidInput(
1148 "download idle timeout elapsed".to_owned(),
1149 )),
1150 },
1151 None => Ok(stream.next().await),
1152 }
1153}
1154
1155async fn rollback_download_file(file: &tokio::fs::File, len: u64) -> Result<()> {
1156 file.set_len(len).await?;
1157 Ok(())
1158}
1159
1160async fn remove_file_if_exists(path: &Path) -> Result<()> {
1161 match fs::remove_file(path).await {
1162 Ok(()) => Ok(()),
1163 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1164 Err(error) => Err(Error::Io(error)),
1165 }
1166}
1167
1168async fn remove_output_root_if_exists(path: &Path) -> Result<()> {
1169 let metadata = match fs::symlink_metadata(path).await {
1170 Ok(metadata) => metadata,
1171 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1172 Err(error) => return Err(Error::Io(error)),
1173 };
1174 if metadata.file_type().is_dir() {
1175 fs::remove_dir_all(path).await?;
1176 } else {
1177 fs::remove_file(path).await?;
1178 }
1179 Ok(())
1180}
1181
1182fn validate_resume_response(
1183 status: StatusCode,
1184 resume_from: u64,
1185 has_content_range: bool,
1186 content_range: Option<ParsedContentRange>,
1187) -> Result<bool> {
1188 let append = resume_from > 0 && status == StatusCode::PARTIAL_CONTENT;
1189 if status != StatusCode::PARTIAL_CONTENT && has_content_range {
1190 return Err(Error::InvalidInput(
1191 "server returned Content-Range without partial content".to_owned(),
1192 ));
1193 }
1194 if status == StatusCode::PARTIAL_CONTENT {
1195 let range = content_range.ok_or_else(|| {
1196 Error::InvalidInput("server returned partial content without Content-Range".to_owned())
1197 })?;
1198 let expected_start = if append { resume_from } else { 0 };
1199 if range.start != expected_start {
1200 return Err(Error::InvalidInput(
1201 "server returned an unexpected Content-Range for resume".to_owned(),
1202 ));
1203 }
1204 }
1205 Ok(append)
1206}
1207
1208fn validation_size_for_full_retry(
1209 expected_size: Option<u64>,
1210 content_range: Option<ParsedContentRange>,
1211 response_content_len: Option<u64>,
1212 full_retry_after_ignored_range: bool,
1213) -> Option<u64> {
1214 expected_size.or_else(|| {
1215 if full_retry_after_ignored_range && content_range.is_none() {
1216 response_content_len
1217 } else {
1218 None
1219 }
1220 })
1221}
1222
1223fn is_empty_unexpected_media_response(kind: &DownloadFileKind, bytes_written: u64) -> bool {
1224 kind.is_media() && bytes_written == 0
1225}
1226
1227fn validate_download_completion(
1228 expected_size: Option<u64>,
1229 content_range: Option<ParsedContentRange>,
1230 start_offset: u64,
1231 bytes_written: u64,
1232) -> Result<()> {
1233 if let Some(range) = content_range {
1234 let range_body_len = range.body_len()?;
1235 if bytes_written != range_body_len {
1236 return Err(Error::InvalidInput(
1237 "download body length did not match Content-Range".to_owned(),
1238 ));
1239 }
1240 let final_len = range.final_len()?;
1241 if let Some(total) = range.complete_len {
1242 if final_len != total {
1243 return Err(Error::InvalidInput(
1244 "download did not reach Content-Range total length".to_owned(),
1245 ));
1246 }
1247 if expected_size.is_some_and(|size| size != total) {
1248 return Err(Error::InvalidInput(
1249 "Content-Range total length did not match expected media size".to_owned(),
1250 ));
1251 }
1252 } else if let Some(size) = expected_size {
1253 if size != final_len {
1254 return Err(Error::InvalidInput(
1255 "downloaded file length did not match expected media size".to_owned(),
1256 ));
1257 }
1258 } else {
1259 return Err(Error::InvalidInput(
1260 "Content-Range total length is unknown".to_owned(),
1261 ));
1262 }
1263 if expected_size.is_some_and(|size| size != final_len) {
1264 return Err(Error::InvalidInput(
1265 "downloaded file length did not match expected media size".to_owned(),
1266 ));
1267 }
1268 return Ok(());
1269 }
1270
1271 if let Some(expected_size) = expected_size {
1272 let final_len = start_offset
1273 .checked_add(bytes_written)
1274 .ok_or_else(|| Error::InvalidInput("downloaded file length overflowed".to_owned()))?;
1275 if final_len != expected_size {
1276 return Err(Error::InvalidInput(
1277 "downloaded file length did not match expected media size".to_owned(),
1278 ));
1279 }
1280 }
1281 Ok(())
1282}
1283
1284fn content_range(headers: &reqwest::header::HeaderMap) -> Result<Option<ParsedContentRange>> {
1285 let Some(value) = headers.get(CONTENT_RANGE) else {
1286 return Ok(None);
1287 };
1288 let value = value
1289 .to_str()
1290 .map_err(|_| Error::InvalidInput("invalid Content-Range".to_owned()))?;
1291 let range = value
1292 .strip_prefix("bytes ")
1293 .ok_or_else(|| Error::InvalidInput("invalid Content-Range".to_owned()))?;
1294 if range.starts_with("*/") {
1295 return Ok(None);
1296 }
1297 let (span, complete) = range
1298 .split_once('/')
1299 .ok_or_else(|| Error::InvalidInput("invalid Content-Range".to_owned()))?;
1300 let (start, end) = span
1301 .split_once('-')
1302 .ok_or_else(|| Error::InvalidInput("invalid Content-Range".to_owned()))?;
1303 let complete_len = if complete == "*" {
1304 None
1305 } else {
1306 Some(
1307 complete
1308 .parse()
1309 .map_err(|_| Error::InvalidInput("invalid Content-Range".to_owned()))?,
1310 )
1311 };
1312 Ok(Some(ParsedContentRange {
1313 start: start
1314 .parse()
1315 .map_err(|_| Error::InvalidInput("invalid Content-Range".to_owned()))?,
1316 end: end
1317 .parse()
1318 .map_err(|_| Error::InvalidInput("invalid Content-Range".to_owned()))?,
1319 complete_len,
1320 }))
1321}
1322
1323fn content_range_complete_len(headers: &reqwest::header::HeaderMap) -> Option<u64> {
1324 let value = headers.get(CONTENT_RANGE)?.to_str().ok()?;
1325 value.strip_prefix("bytes */")?.parse().ok()
1326}
1327
1328fn only_flv_segments(files: &[DownloadedFile]) -> bool {
1329 let media = files
1330 .iter()
1331 .filter(|file| file.kind.is_media())
1332 .collect::<Vec<_>>();
1333 !media.is_empty()
1334 && media
1335 .iter()
1336 .all(|file| file.kind == DownloadFileKind::FlvSegment)
1337}
1338
1339fn concat_file_list(paths: &[PathBuf], base: &Path) -> String {
1340 paths.iter().fold(String::new(), |mut output, path| {
1341 let list_path = path.strip_prefix(base).unwrap_or(path);
1342 let escaped = list_path.to_string_lossy().replace('\'', "'\\''");
1343 let _ = writeln!(output, "file '{escaped}'");
1344 output
1345 })
1346}
1347
1348fn default_plan_output_dir(plan: &DownloadPlan, options: &DownloadOptions) -> PathBuf {
1349 options.output_dir.join(safe_file_name(&plan.title))
1350}
1351
1352fn next_available_output_dir_avoiding(base: &Path, reserved: &[PathBuf]) -> Result<PathBuf> {
1353 if !path_is_occupied(base)? && !path_is_reserved(base, reserved) {
1354 return Ok(base.to_path_buf());
1355 }
1356 let parent = base.parent().unwrap_or_else(|| Path::new(""));
1357 let stem = base
1358 .file_name()
1359 .and_then(std::ffi::OsStr::to_str)
1360 .unwrap_or("download");
1361 let mut index = 2;
1362 loop {
1363 let candidate = parent.join(format!("{stem} ({index})"));
1364 if !path_is_occupied(&candidate)? && !path_is_reserved(&candidate, reserved) {
1365 return Ok(candidate);
1366 }
1367 index += 1;
1368 }
1369}
1370
1371fn path_is_occupied(path: &Path) -> Result<bool> {
1372 match std::fs::symlink_metadata(path) {
1373 Ok(_) => Ok(true),
1374 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
1375 Err(error) => Err(Error::Io(error)),
1376 }
1377}
1378
1379fn path_is_reserved(path: &Path, reserved: &[PathBuf]) -> bool {
1380 let path_key = comparable_output_path_key(path);
1381 reserved
1382 .iter()
1383 .any(|reserved_path| comparable_output_path_key(reserved_path) == path_key)
1384}
1385
1386fn validate_archive_preflight(
1387 plan: &DownloadPlan,
1388 options: &DownloadOptions,
1389 archive: &DownloadArchive,
1390 preflight: &DownloadPreflight,
1391) -> Result<()> {
1392 if preflight.content_key != download_plan_content_key(plan) {
1393 return Err(Error::InvalidInput(
1394 "download preflight does not match the download plan".to_owned(),
1395 ));
1396 }
1397 let planned_output_dir = default_plan_output_dir(plan, options);
1398 if comparable_output_path_key(&preflight.planned_output_dir)
1399 != comparable_output_path_key(&planned_output_dir)
1400 {
1401 return Err(Error::InvalidInput(
1402 "download preflight does not match the download output options".to_owned(),
1403 ));
1404 }
1405 let current_archived_records =
1406 archive_records_for_preflight(archive, plan, &planned_output_dir);
1407 if preflight.archived_records != current_archived_records {
1408 return Err(Error::InvalidInput(
1409 "download preflight does not match the current download archive".to_owned(),
1410 ));
1411 }
1412 let current_reserved_keys = archive
1413 .records
1414 .iter()
1415 .map(|record| comparable_output_path_key(&record.output_dir))
1416 .collect::<HashSet<_>>();
1417 let preflight_reserved_keys = preflight
1418 .reserved_output_dirs_for_decision()
1419 .iter()
1420 .map(|path| comparable_output_path_key(path))
1421 .collect::<HashSet<_>>();
1422 if preflight_reserved_keys != current_reserved_keys {
1423 return Err(Error::InvalidInput(
1424 "download preflight does not match the current download archive".to_owned(),
1425 ));
1426 }
1427 Ok(())
1428}
1429
1430fn archive_records_for_preflight(
1431 archive: &DownloadArchive,
1432 plan: &DownloadPlan,
1433 planned_output_dir: &Path,
1434) -> Vec<DownloadArchiveRecord> {
1435 let plan_match = ArchivePlanMatch::new(plan);
1436 let output_key = comparable_output_path_key(planned_output_dir);
1437 archive
1438 .records
1439 .iter()
1440 .filter(|record| {
1441 plan_match.matches_record(record)
1442 || comparable_output_path_key(&record.output_dir) == output_key
1443 })
1444 .cloned()
1445 .collect()
1446}
1447
1448struct ArchivePlanMatch {
1449 content_key: String,
1450 entry_keys: HashSet<String>,
1451}
1452
1453impl ArchivePlanMatch {
1454 fn new(plan: &DownloadPlan) -> Self {
1455 Self {
1456 content_key: download_plan_content_key(plan),
1457 entry_keys: plan
1458 .entries
1459 .iter()
1460 .map(download_entry_content_key)
1461 .collect(),
1462 }
1463 }
1464
1465 fn matches_record(&self, record: &DownloadArchiveRecord) -> bool {
1466 record.content_key == self.content_key
1467 || record.entries.iter().any(|entry| {
1468 self.entry_keys.contains(&entry.content_key)
1469 || self.entry_keys.contains(&archive_entry_content_key(entry))
1470 })
1471 }
1472}
1473
1474fn archive_record_path(path: &Path) -> PathBuf {
1475 comparable_output_path(path)
1476}
1477
1478fn comparable_output_path_key(path: &Path) -> String {
1479 comparable_output_path(path)
1480 .components()
1481 .map(path_component_key)
1482 .collect::<Vec<_>>()
1483 .join("\0")
1484}
1485
1486fn comparable_output_path(path: &Path) -> PathBuf {
1487 canonicalize_existing_prefix(&absolute_path(path))
1488}
1489
1490fn absolute_path(path: &Path) -> PathBuf {
1491 if path.is_absolute() {
1492 path.to_path_buf()
1493 } else {
1494 std::env::current_dir().map_or_else(|_| path.to_path_buf(), |cwd| cwd.join(path))
1495 }
1496}
1497
1498fn canonicalize_existing_prefix(path: &Path) -> PathBuf {
1499 let mut existing_prefix = path.to_path_buf();
1500 let mut missing_components = Vec::new();
1501 while !existing_prefix.exists() {
1502 let Some(file_name) = existing_prefix.file_name() else {
1503 break;
1504 };
1505 missing_components.push(file_name.to_os_string());
1506 if !existing_prefix.pop() {
1507 break;
1508 }
1509 }
1510 let mut normalized = std::fs::canonicalize(&existing_prefix).unwrap_or(existing_prefix);
1511 for component in missing_components.iter().rev() {
1512 normalized.push(component);
1513 }
1514 lexical_clean_path(&normalized)
1515}
1516
1517fn lexical_clean_path(path: &Path) -> PathBuf {
1518 let mut clean = PathBuf::new();
1519 for component in path.components() {
1520 match component {
1521 std::path::Component::Prefix(prefix) => clean.push(prefix.as_os_str()),
1522 std::path::Component::RootDir => clean.push(component.as_os_str()),
1523 std::path::Component::CurDir => {}
1524 std::path::Component::ParentDir => {
1525 let _ = clean.pop();
1526 }
1527 std::path::Component::Normal(part) => clean.push(part),
1528 }
1529 }
1530 clean
1531}
1532
1533fn path_component_key(component: std::path::Component<'_>) -> String {
1534 let value = component.as_os_str().to_string_lossy();
1535 if cfg!(windows) || cfg!(target_os = "macos") {
1536 value.to_lowercase()
1537 } else {
1538 value.into_owned()
1539 }
1540}
1541
1542fn download_plan_content_key(plan: &DownloadPlan) -> String {
1543 let mut key = String::from("plan");
1544 for entry in &plan.entries {
1545 key.push('|');
1546 key.push_str(&download_entry_content_key(entry));
1547 }
1548 key
1549}
1550
1551fn download_entry_content_key(entry: &DownloadEntry) -> String {
1552 format!("aid={};cid={}", entry.aid, entry.cid)
1553}
1554
1555fn archive_entry_content_key(entry: &DownloadArchiveEntryRecord) -> String {
1556 format!("aid={};cid={}", entry.aid, entry.cid)
1557}
1558
1559fn entry_dir_name(entry: &DownloadEntry) -> String {
1560 let prefix = format!("P{:03}-{}-", entry.index, entry_content_identity(entry));
1561 format_file_component(&prefix, &entry.title, "")
1562}
1563
1564fn format_file_component(prefix: &str, variable: &str, suffix: &str) -> String {
1565 let used = prefix.len().saturating_add(suffix.len());
1566 let variable_budget = MAX_FILE_COMPONENT_BYTES.saturating_sub(used).max(1);
1567 let component = format!(
1568 "{prefix}{}{suffix}",
1569 safe_file_name_with_budget(variable, variable_budget)
1570 );
1571 truncate_utf8_component(&component, MAX_FILE_COMPONENT_BYTES)
1572}
1573
1574fn entry_content_identity(entry: &DownloadEntry) -> String {
1575 let primary = entry
1576 .bvid
1577 .as_deref()
1578 .filter(|value| !value.is_empty())
1579 .map_or_else(
1580 || {
1581 entry
1582 .epid
1583 .map_or_else(|| format!("av{}", entry.aid), |epid| format!("ep{epid}"))
1584 },
1585 safe_file_name,
1586 );
1587 format!("{primary}-cid{}", entry.cid)
1588}
1589
1590fn safe_file_name(raw: &str) -> String {
1591 safe_file_name_with_budget(raw, MAX_FILE_NAME_BYTES)
1592}
1593
1594fn current_unix_seconds() -> u64 {
1595 SystemTime::now()
1596 .duration_since(UNIX_EPOCH)
1597 .map_or(0, |duration| duration.as_secs())
1598}
1599
1600fn safe_file_name_with_budget(raw: &str, max_bytes: usize) -> String {
1601 let mut value = raw
1602 .chars()
1603 .map(|character| match character {
1604 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
1605 character if character.is_control() => '-',
1606 character => character,
1607 })
1608 .collect::<String>()
1609 .trim()
1610 .trim_matches('.')
1611 .to_owned();
1612 if value.is_empty() {
1613 "untitled".clone_into(&mut value);
1614 }
1615 truncate_utf8_component(&value, max_bytes)
1616}
1617
1618fn truncate_utf8_component(value: &str, max_bytes: usize) -> String {
1619 let limit = max_bytes.max(1);
1620 if value.len() <= limit {
1621 return value.to_owned();
1622 }
1623 let mut end = 0;
1624 for (index, character) in value.char_indices() {
1625 let next = index + character.len_utf8();
1626 if next > limit {
1627 break;
1628 }
1629 end = next;
1630 }
1631 let truncated = value[..end].trim().trim_matches(['-', '.', '_']).to_owned();
1632 if truncated.is_empty() {
1633 fallback_file_component(limit)
1634 } else {
1635 truncated
1636 }
1637}
1638
1639fn fallback_file_component(max_bytes: usize) -> String {
1640 if max_bytes >= "untitled".len() {
1641 "untitled".to_owned()
1642 } else {
1643 "u".repeat(max_bytes.max(1))
1644 }
1645}
1646
1647fn media_extension(url: &str, mime_type: Option<&str>) -> &'static str {
1648 if mime_type.is_some_and(|value| value.contains("mp4")) {
1649 return "m4s";
1650 }
1651 if url_path_extension(url).is_some_and(|extension| extension.eq_ignore_ascii_case("flv")) {
1652 return "flv";
1653 }
1654 "m4s"
1655}
1656
1657fn media_file_name(label: &str, stream: &MediaStream) -> String {
1658 let identity = media_stream_identity(stream);
1659 let prefix = format!("{label}-{}-", stream.id);
1660 let suffix = format!(
1661 ".{}",
1662 media_extension(&stream.base_url, stream.mime_type.as_deref())
1663 );
1664 format_file_component(&prefix, &identity, &suffix)
1665}
1666
1667fn select_media_stream<'a>(
1668 streams: &'a [MediaStream],
1669 requested_id: Option<u32>,
1670 kind: &str,
1671) -> Result<&'a MediaStream> {
1672 if let Some(id) = requested_id {
1673 return streams.iter().find(|stream| stream.id == id).ok_or_else(|| {
1674 Error::InvalidInput(format!(
1675 "requested {kind} quality {id} is not available; available {kind} qualities: {}",
1676 available_stream_ids(streams)
1677 ))
1678 });
1679 }
1680 streams
1681 .first()
1682 .ok_or(Error::MissingField("selected media stream"))
1683}
1684
1685fn validate_plan_stream_selection(plan: &DownloadPlan, selection: StreamSelection) -> Result<()> {
1686 if !selection.has_selection() {
1687 return Ok(());
1688 }
1689 for entry in &plan.entries {
1690 validate_entry_stream_selection(entry, selection)?;
1691 }
1692 Ok(())
1693}
1694
1695fn validate_entry_stream_selection(
1696 entry: &DownloadEntry,
1697 selection: StreamSelection,
1698) -> Result<()> {
1699 let has_dash_pair = !entry.streams.videos.is_empty() && !entry.streams.audios.is_empty();
1700 let use_flv_fallback = !has_dash_pair && !entry.streams.flv_segments.is_empty();
1701 if has_dash_pair {
1702 let _ = select_media_stream(&entry.streams.videos, selection.video_quality, "video")?;
1703 let _ = select_media_stream(&entry.streams.audios, selection.audio_quality, "audio")?;
1704 } else if use_flv_fallback {
1705 return Err(Error::InvalidInput(
1706 "stream quality selection requires DASH media; selected entry only has FLV segments"
1707 .to_owned(),
1708 ));
1709 } else {
1710 return Err(Error::MissingField("complete DASH media or FLV segments"));
1711 }
1712 Ok(())
1713}
1714
1715fn available_stream_ids(streams: &[MediaStream]) -> String {
1716 if streams.is_empty() {
1717 return "none".to_owned();
1718 }
1719 let mut ids = Vec::new();
1720 for stream in streams {
1721 if !ids.contains(&stream.id) {
1722 ids.push(stream.id);
1723 }
1724 }
1725 ids.into_iter()
1726 .map(|id| id.to_string())
1727 .collect::<Vec<_>>()
1728 .join(", ")
1729}
1730
1731fn media_stream_identity(stream: &MediaStream) -> String {
1732 let mut parts = Vec::new();
1733 push_identity_part(&mut parts, stream.codecs.as_deref());
1734 push_identity_part(&mut parts, stream.mime_type.as_deref());
1735 if let Some(bandwidth) = stream.bandwidth {
1736 parts.push(format!("bw{bandwidth}"));
1737 }
1738 if let (Some(width), Some(height)) = (stream.width, stream.height) {
1739 parts.push(format!("{width}x{height}"));
1740 }
1741 push_identity_part(&mut parts, stream.frame_rate.as_deref());
1742 if let Some(size) = stream.size {
1743 parts.push(format!("s{size}"));
1744 }
1745 if !parts.is_empty() {
1746 return parts.join("-");
1747 }
1748 short_identity_hash(&url_identity_source(&stream.base_url))
1749}
1750
1751fn push_identity_part(parts: &mut Vec<String>, value: Option<&str>) {
1752 if let Some(token) = value.map(file_name_token).filter(|token| !token.is_empty()) {
1753 parts.push(token);
1754 }
1755}
1756
1757fn file_name_token(raw: &str) -> String {
1758 raw.chars()
1759 .map(|character| {
1760 if character.is_ascii_alphanumeric() {
1761 character.to_ascii_lowercase()
1762 } else if matches!(character, '.' | '_' | '-') {
1763 character
1764 } else {
1765 '-'
1766 }
1767 })
1768 .collect::<String>()
1769 .trim_matches(['-', '.', '_'])
1770 .to_owned()
1771}
1772
1773fn short_identity_hash(raw: &str) -> String {
1774 let digest = format!("{:x}", Md5::digest(raw.as_bytes()));
1775 format!("h{}", &digest[..8])
1776}
1777
1778fn url_identity_source(url: &str) -> String {
1779 if let Ok(parsed) = url::Url::parse(url) {
1780 let path = parsed.path();
1781 if !path.is_empty() {
1782 return path.to_owned();
1783 }
1784 }
1785 url.split(['?', '#']).next().unwrap_or(url).to_owned()
1786}
1787
1788fn subtitle_extension(subtitle: &SubtitleTrack) -> String {
1789 let extension = match subtitle.format {
1790 SubtitleFormat::Json => "json".to_owned(),
1791 SubtitleFormat::Ass => "ass".to_owned(),
1792 SubtitleFormat::Unknown => {
1793 url_path_extension(&subtitle.url).unwrap_or_else(|| "subtitle".to_owned())
1794 }
1795 };
1796 let sanitized = file_name_token(&extension);
1797 let extension = if sanitized.is_empty() {
1798 "subtitle".to_owned()
1799 } else {
1800 sanitized
1801 };
1802 safe_file_name_with_budget(&extension, MAX_SUBTITLE_EXTENSION_BYTES)
1803}
1804
1805fn subtitle_file_name(index: usize, subtitle: &SubtitleTrack) -> String {
1806 let prefix = "subtitle-";
1807 let suffix = format!(
1808 "-{:02}-{}.{}",
1809 index.saturating_add(1),
1810 short_identity_hash(&subtitle.url),
1811 subtitle_extension(subtitle)
1812 );
1813 format_file_component(prefix, &subtitle.language, &suffix)
1814}
1815
1816fn url_path_extension(url: &str) -> Option<String> {
1817 url::Url::parse(url)
1818 .ok()
1819 .and_then(|parsed| {
1820 Path::new(parsed.path())
1821 .extension()
1822 .and_then(std::ffi::OsStr::to_str)
1823 .map(ToOwned::to_owned)
1824 })
1825 .filter(|extension| !extension.is_empty())
1826}
1827
1828fn subtitle_dedup_key(url: &str) -> String {
1829 url::Url::parse(url).map_or_else(
1830 |_| url.split('#').next().unwrap_or(url).to_owned(),
1831 |mut parsed| {
1832 parsed.set_fragment(None);
1833 parsed.to_string()
1834 },
1835 )
1836}
1837
1838#[cfg(test)]
1839mod tests {
1840 use super::{
1841 DownloadArchive, DownloadArchiveEntryRecord, DownloadArchiveRecord, DownloadOptions,
1842 DownloadPreflight, DownloadReport, DownloadedFile, DuplicateDecision, EntryDownloadReport,
1843 MAX_FILE_COMPONENT_BYTES, MAX_FILE_NAME_BYTES, MAX_SUBTITLE_EXTENSION_BYTES, MuxOptions,
1844 RetryPolicy, archive_sidecar_path, comparable_output_path, default_plan_output_dir,
1845 download_entry_content_key, download_plan_content_key, entry_dir_name, media_file_name,
1846 path_is_occupied, safe_file_name, safe_file_name_with_budget, select_media_stream,
1847 subtitle_dedup_key, subtitle_extension, subtitle_file_name, temporary_download_path,
1848 temporary_mux_path, temporary_replace_path,
1849 };
1850 use crate::models::{
1851 DanmakuTrack, DownloadEntry, DownloadPlan, FlvSegment, MediaStream, StreamDiagnostics,
1852 StreamQuality, StreamSet, StreamSource, SubtitleFormat, SubtitleTrack,
1853 };
1854 use crate::{BiliClient, ClientConfig, Credentials, DownloadFileKind};
1855 use httpmock::MockServer;
1856 use httpmock::prelude::*;
1857 use std::io::{Read, Write};
1858 use std::net::TcpListener;
1859 use std::path::Path;
1860 use std::time::Duration;
1861 #[cfg(unix)]
1862 use std::{fs as std_fs, os::unix::fs::PermissionsExt};
1863
1864 #[test]
1865 fn media_file_name_distinguishes_stream_identity_without_query() {
1866 let mut stream = MediaStream {
1867 id: 80,
1868 base_url: "https://cdn.example/path/video.m4s?token=first".to_owned(),
1869 backup_urls: Vec::new(),
1870 codecs: Some("avc1.640028".to_owned()),
1871 bandwidth: None,
1872 width: None,
1873 height: None,
1874 frame_rate: None,
1875 mime_type: Some("video/mp4".to_owned()),
1876 size: None,
1877 };
1878 let first = media_file_name("video", &stream);
1879 stream.base_url = "https://cdn.example/path/video.m4s?token=second".to_owned();
1880 assert_eq!(media_file_name("video", &stream), first);
1881 stream.base_url = "https://mirror.example/other/path/video.m4s?token=third".to_owned();
1882 assert_eq!(media_file_name("video", &stream), first);
1883 stream.codecs = Some("hev1.1.6.L120.90".to_owned());
1884 assert_ne!(media_file_name("video", &stream), first);
1885 stream.codecs = None;
1886 stream.mime_type = None;
1887 assert!(media_file_name("video", &stream).starts_with("video-80-h"));
1888 }
1889
1890 #[test]
1891 fn select_media_stream_reports_available_ids() -> crate::Result<()> {
1892 let streams = vec![
1893 media_stream(80, "https://cdn.example/80.m4s"),
1894 media_stream(80, "https://cdn.example/80-hevc.m4s"),
1895 media_stream(64, "https://cdn.example/64.m4s"),
1896 ];
1897
1898 let selected = select_media_stream(&streams, Some(64), "video")?;
1899 assert_eq!(selected.id, 64);
1900
1901 let Err(error) = select_media_stream(&streams, Some(32), "video") else {
1902 return Err(crate::Error::InvalidInput(
1903 "unexpectedly selected missing video stream".to_owned(),
1904 ));
1905 };
1906 assert_eq!(
1907 error.to_string(),
1908 "invalid input: requested video quality 32 is not available; available video qualities: 80, 64"
1909 );
1910 Ok(())
1911 }
1912
1913 #[test]
1914 fn download_options_builders_configure_embedding_controls() -> anyhow::Result<()> {
1915 let options = DownloadOptions::new("downloads")
1916 .with_retry_policy(RetryPolicy::new(5, Duration::from_secs(2)))
1917 .with_stream_selection(super::StreamSelection::video(80))
1918 .with_download_idle_timeout(None)
1919 .with_resume(false)
1920 .with_subtitles(false)
1921 .with_danmaku(false)
1922 .with_mux(MuxOptions::ffmpeg("ffmpeg-custom"));
1923
1924 assert_eq!(options.output_dir.as_path(), Path::new("downloads"));
1925 assert_eq!(options.retry.max_attempts, 5);
1926 assert_eq!(options.retry.backoff, Duration::from_secs(2));
1927 assert_eq!(options.stream_selection.video_quality, Some(80));
1928 assert_eq!(options.stream_selection.audio_quality, None);
1929 assert_eq!(options.download_idle_timeout, None);
1930 assert!(!options.resume);
1931 assert!(!options.include_subtitles);
1932 assert!(!options.include_danmaku);
1933 let MuxOptions::Ffmpeg { binary } = options.mux else {
1934 return Err(anyhow::anyhow!("expected ffmpeg mux options"));
1935 };
1936 assert_eq!(binary.as_path(), Path::new("ffmpeg-custom"));
1937
1938 let audio_selection = super::StreamSelection::audio(30216);
1939 assert_eq!(audio_selection.video_quality, None);
1940 assert_eq!(audio_selection.audio_quality, Some(30216));
1941 Ok(())
1942 }
1943
1944 #[test]
1945 fn safe_file_name_limits_utf8_bytes() {
1946 let raw = "界".repeat(200);
1947 let name = safe_file_name(&raw);
1948
1949 assert!(name.len() <= MAX_FILE_NAME_BYTES);
1950 assert!(std::str::from_utf8(name.as_bytes()).is_ok());
1951 }
1952
1953 #[test]
1954 fn safe_file_name_with_tiny_budget_stays_in_budget() {
1955 let name = safe_file_name_with_budget("界", 1);
1956
1957 assert!(name.len() <= 1);
1958 assert_eq!(name, "u");
1959 }
1960
1961 #[test]
1962 fn entry_dir_name_limits_final_component_bytes() {
1963 let server = MockServer::start();
1964 let mut plan = test_plan(&server);
1965 plan.entries[0].title = "界".repeat(200);
1966
1967 assert!(entry_dir_name(&plan.entries[0]).len() <= MAX_FILE_COMPONENT_BYTES);
1968 }
1969
1970 #[test]
1971 fn entry_dir_name_distinguishes_content_identity() {
1972 let server = MockServer::start();
1973 let first = test_plan(&server);
1974 let mut second = test_plan(&server);
1975 second.entries[0].aid = 170_002;
1976 second.entries[0].bvid = Some("BV1yy411c7mD".to_owned());
1977 second.entries[0].cid = 3;
1978
1979 assert_ne!(
1980 entry_dir_name(&first.entries[0]),
1981 entry_dir_name(&second.entries[0])
1982 );
1983 }
1984
1985 #[test]
1986 fn subtitle_file_name_distinguishes_duplicate_languages() {
1987 let first = SubtitleTrack {
1988 language: "en".to_owned(),
1989 language_doc: Some("English".to_owned()),
1990 url: "https://subtitle.example/first.ass".to_owned(),
1991 format: SubtitleFormat::Ass,
1992 };
1993 let second = SubtitleTrack {
1994 language: "en".to_owned(),
1995 language_doc: Some("English".to_owned()),
1996 url: "https://subtitle.example/second.ass".to_owned(),
1997 format: SubtitleFormat::Ass,
1998 };
1999
2000 assert_ne!(
2001 subtitle_file_name(0, &first),
2002 subtitle_file_name(1, &second)
2003 );
2004 }
2005
2006 #[test]
2007 fn subtitle_file_name_limits_unknown_extension_bytes() {
2008 let subtitle = SubtitleTrack {
2009 language: "en".to_owned(),
2010 language_doc: Some("English".to_owned()),
2011 url: format!("https://subtitle.example/file.{}", "x".repeat(200)),
2012 format: SubtitleFormat::Unknown,
2013 };
2014
2015 assert!(subtitle_extension(&subtitle).len() <= MAX_SUBTITLE_EXTENSION_BYTES);
2016 assert!(subtitle_file_name(0, &subtitle).len() <= MAX_FILE_COMPONENT_BYTES);
2017 }
2018
2019 #[test]
2020 fn download_entry_content_key_ignores_display_index() {
2021 let server = MockServer::start();
2022 let mut first = test_plan(&server).entries.remove(0);
2023 let mut second = first.clone();
2024 first.index = 1;
2025 second.index = 99;
2026
2027 assert_eq!(
2028 download_entry_content_key(&first),
2029 download_entry_content_key(&second)
2030 );
2031 }
2032
2033 #[test]
2034 fn download_entry_content_key_matches_episode_and_video_forms() {
2035 let server = MockServer::start();
2036 let mut video_entry = test_plan(&server).entries.remove(0);
2037 let mut episode_entry = video_entry.clone();
2038 video_entry.epid = None;
2039 video_entry.bvid = Some("BV1xx411c7mD".to_owned());
2040 episode_entry.epid = Some(664_928);
2041 episode_entry.bvid = None;
2042
2043 assert_eq!(
2044 download_entry_content_key(&video_entry),
2045 download_entry_content_key(&episode_entry)
2046 );
2047 }
2048
2049 #[test]
2050 fn temporary_file_names_reserve_suffix_budget() {
2051 let path = std::path::PathBuf::from("a".repeat(MAX_FILE_COMPONENT_BYTES));
2052
2053 for temporary in [
2054 temporary_download_path(&path),
2055 temporary_replace_path(&path),
2056 temporary_mux_path(&path),
2057 ] {
2058 assert!(
2059 temporary
2060 .file_name()
2061 .and_then(std::ffi::OsStr::to_str)
2062 .is_some_and(|name| name.len() <= MAX_FILE_COMPONENT_BYTES)
2063 );
2064 }
2065 }
2066
2067 #[test]
2068 fn subtitle_dedup_key_ignores_url_fragment() {
2069 assert_eq!(
2070 subtitle_dedup_key("https://subtitle.example/track.ass#first"),
2071 subtitle_dedup_key("https://subtitle.example/track.ass#second")
2072 );
2073 }
2074
2075 #[tokio::test]
2076 async fn downloads_media_sidecars_and_danmaku() -> anyhow::Result<()> {
2077 let server = MockServer::start();
2078 server.mock(|when, then| {
2079 when.method(GET).path("/video.m4s");
2080 then.status(200).body("video");
2081 });
2082 server.mock(|when, then| {
2083 when.method(GET).path("/audio.m4s");
2084 then.status(200).body("audio");
2085 });
2086 server.mock(|when, then| {
2087 when.method(GET).path("/subtitle.ass");
2088 then.status(200).body("[Script Info]");
2089 });
2090 server.mock(|when, then| {
2091 when.method(GET).path("/danmaku.xml");
2092 then.status(200).body("<i/>");
2093 });
2094 let temp = tempfile::tempdir()?;
2095 let client = BiliClient::new(ClientConfig::default());
2096 let plan = test_plan(&server);
2097
2098 let report = client
2099 .download_plan(
2100 &plan,
2101 DownloadOptions {
2102 output_dir: temp.path().to_path_buf(),
2103 retry: RetryPolicy::single_attempt(),
2104 mux: MuxOptions::Disabled,
2105 ..DownloadOptions::default()
2106 },
2107 )
2108 .await?;
2109
2110 let entry = &report.entries[0];
2111 assert_eq!(entry.files.len(), 4);
2112 let video = entry
2113 .files
2114 .iter()
2115 .find(|file| file.kind == DownloadFileKind::Video)
2116 .ok_or_else(|| anyhow::anyhow!("missing video"))?;
2117 assert_eq!(tokio::fs::read_to_string(&video.path).await?, "video");
2118 assert!(entry.mux.is_none());
2119 Ok(())
2120 }
2121
2122 #[tokio::test]
2123 async fn invalid_audio_selection_fails_before_media_writes() -> anyhow::Result<()> {
2124 let server = MockServer::start();
2125 server.mock(|when, then| {
2126 when.method(GET).path("/video.m4s");
2127 then.status(200).body("video");
2128 });
2129 server.mock(|when, then| {
2130 when.method(GET).path("/audio.m4s");
2131 then.status(200).body("audio");
2132 });
2133 let temp = tempfile::tempdir()?;
2134 let output_dir = temp.path().join("downloads");
2135 let client = BiliClient::new(ClientConfig::default());
2136 let plan = test_plan(&server);
2137
2138 let Err(error) = client
2139 .download_plan(
2140 &plan,
2141 DownloadOptions {
2142 output_dir: output_dir.clone(),
2143 retry: RetryPolicy::single_attempt(),
2144 stream_selection: super::StreamSelection {
2145 video_quality: Some(80),
2146 audio_quality: Some(30216),
2147 },
2148 mux: MuxOptions::Disabled,
2149 ..DownloadOptions::default()
2150 },
2151 )
2152 .await
2153 else {
2154 return Err(anyhow::anyhow!("missing audio selection should fail"));
2155 };
2156
2157 assert!(error.to_string().contains("requested audio quality 30216"));
2158 assert!(!output_dir.exists());
2159 Ok(())
2160 }
2161
2162 #[tokio::test]
2163 async fn multi_entry_invalid_selection_fails_before_any_media_writes() -> anyhow::Result<()> {
2164 let server = MockServer::start();
2165 server.mock(|when, then| {
2166 when.method(GET).path("/video.m4s");
2167 then.status(200).body("video");
2168 });
2169 server.mock(|when, then| {
2170 when.method(GET).path("/audio.m4s");
2171 then.status(200).body("audio");
2172 });
2173 let temp = tempfile::tempdir()?;
2174 let output_dir = temp.path().join("downloads");
2175 let client = BiliClient::new(ClientConfig::default());
2176 let mut plan = test_plan(&server);
2177 let mut second = plan.entries[0].clone();
2178 second.index = 2;
2179 second.cid = 3;
2180 second.title = "Second".to_owned();
2181 second.streams.audios[0].id = 30216;
2182 plan.entries.push(second);
2183
2184 let Err(error) = client
2185 .download_plan(
2186 &plan,
2187 DownloadOptions {
2188 output_dir: output_dir.clone(),
2189 retry: RetryPolicy::single_attempt(),
2190 stream_selection: super::StreamSelection {
2191 video_quality: Some(80),
2192 audio_quality: Some(30280),
2193 },
2194 include_subtitles: false,
2195 include_danmaku: false,
2196 mux: MuxOptions::Disabled,
2197 ..DownloadOptions::default()
2198 },
2199 )
2200 .await
2201 else {
2202 return Err(anyhow::anyhow!("later missing audio selection should fail"));
2203 };
2204
2205 assert!(error.to_string().contains("requested audio quality 30280"));
2206 assert!(!output_dir.exists());
2207 Ok(())
2208 }
2209
2210 #[tokio::test]
2211 async fn skips_duplicate_subtitle_urls() -> anyhow::Result<()> {
2212 let server = MockServer::start();
2213 server.mock(|when, then| {
2214 when.method(GET).path("/video.m4s");
2215 then.status(200).body("video");
2216 });
2217 server.mock(|when, then| {
2218 when.method(GET).path("/audio.m4s");
2219 then.status(200).body("audio");
2220 });
2221 let subtitle_mock = server.mock(|when, then| {
2222 when.method(GET).path("/subtitle.ass");
2223 then.status(200).body("[Script Info]");
2224 });
2225 server.mock(|when, then| {
2226 when.method(GET).path("/danmaku.xml");
2227 then.status(200).body("<i/>");
2228 });
2229 let temp = tempfile::tempdir()?;
2230 let client = BiliClient::new(ClientConfig::default());
2231 let mut plan = test_plan(&server);
2232 plan.entries[0].subtitles.push(SubtitleTrack {
2233 language: "en".to_owned(),
2234 language_doc: Some("English duplicate".to_owned()),
2235 url: format!("{}/subtitle.ass#duplicate", server.base_url()),
2236 format: SubtitleFormat::Ass,
2237 });
2238
2239 let report = client
2240 .download_plan(
2241 &plan,
2242 DownloadOptions {
2243 output_dir: temp.path().to_path_buf(),
2244 retry: RetryPolicy::single_attempt(),
2245 mux: MuxOptions::Disabled,
2246 ..DownloadOptions::default()
2247 },
2248 )
2249 .await?;
2250
2251 assert_eq!(subtitle_mock.calls(), 1);
2252 assert_eq!(
2253 report.entries[0]
2254 .files
2255 .iter()
2256 .filter(|file| file.kind == DownloadFileKind::Subtitle)
2257 .count(),
2258 1
2259 );
2260 Ok(())
2261 }
2262
2263 #[tokio::test]
2264 async fn resumes_partial_files_with_range_request() -> anyhow::Result<()> {
2265 let server = MockServer::start();
2266 server.mock(|when, then| {
2267 when.method(GET)
2268 .path("/video.m4s")
2269 .header("range", "bytes=3-");
2270 then.status(206)
2271 .header("Content-Range", "bytes 3-5/6")
2272 .body("new");
2273 });
2274 server.mock(|when, then| {
2275 when.method(GET).path("/audio.m4s");
2276 then.status(200).body("audio");
2277 });
2278 let temp = tempfile::tempdir()?;
2279 let client = BiliClient::new(ClientConfig::default());
2280 let mut plan = test_plan(&server);
2281 plan.entries[0].streams.videos[0].size = Some(6);
2282 plan.entries[0].subtitles.clear();
2283 plan.entries[0].danmaku.xml_url = format!("{}/danmaku.xml", server.base_url());
2284 let output_dir = test_entry_dir(temp.path(), &plan);
2285 tokio::fs::create_dir_all(&output_dir).await?;
2286 tokio::fs::write(
2287 output_dir.join(media_file_name("video", &plan.entries[0].streams.videos[0])),
2288 "old",
2289 )
2290 .await?;
2291
2292 let report = client
2293 .download_plan(
2294 &plan,
2295 DownloadOptions {
2296 output_dir: temp.path().to_path_buf(),
2297 retry: RetryPolicy::single_attempt(),
2298 include_danmaku: false,
2299 mux: MuxOptions::Disabled,
2300 ..DownloadOptions::default()
2301 },
2302 )
2303 .await?;
2304
2305 let file = &report.entries[0].files[0];
2306 assert_eq!(file.resumed_from, 3);
2307 assert_eq!(tokio::fs::read_to_string(&file.path).await?, "oldnew");
2308 Ok(())
2309 }
2310
2311 #[tokio::test]
2312 async fn media_download_does_not_send_cookie_header() -> anyhow::Result<()> {
2313 let server = MockServer::start();
2314 let cookie_mock = server.mock(|when, then| {
2315 when.method(GET)
2316 .path("/video.m4s")
2317 .header("cookie", "SESSDATA=secret");
2318 then.status(500);
2319 });
2320 server.mock(|when, then| {
2321 when.method(GET).path("/video.m4s");
2322 then.status(200).body("video");
2323 });
2324 server.mock(|when, then| {
2325 when.method(GET).path("/audio.m4s");
2326 then.status(200).body("audio");
2327 });
2328 let temp = tempfile::tempdir()?;
2329 let client = BiliClient::new(ClientConfig {
2330 credentials: Credentials {
2331 cookie: Some("SESSDATA=secret".to_owned()),
2332 access_key: None,
2333 tv_access_key: None,
2334 },
2335 ..ClientConfig::default()
2336 });
2337 let plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
2338
2339 client
2340 .download_plan(
2341 &plan,
2342 DownloadOptions {
2343 output_dir: temp.path().to_path_buf(),
2344 retry: RetryPolicy::single_attempt(),
2345 include_danmaku: false,
2346 mux: MuxOptions::Disabled,
2347 ..DownloadOptions::default()
2348 },
2349 )
2350 .await?;
2351
2352 assert_eq!(cookie_mock.calls(), 0);
2353 Ok(())
2354 }
2355
2356 #[tokio::test]
2357 async fn falls_back_to_backup_media_url() -> anyhow::Result<()> {
2358 let server = MockServer::start();
2359 server.mock(|when, then| {
2360 when.method(GET).path("/primary.m4s");
2361 then.status(500);
2362 });
2363 server.mock(|when, then| {
2364 when.method(GET).path("/backup.m4s");
2365 then.status(200).body("backup");
2366 });
2367 server.mock(|when, then| {
2368 when.method(GET).path("/audio.m4s");
2369 then.status(200).body("audio");
2370 });
2371 let temp = tempfile::tempdir()?;
2372 let client = BiliClient::new(ClientConfig::default());
2373 let mut plan = single_video_plan(format!("{}/primary.m4s", server.base_url()));
2374 plan.entries[0].streams.videos[0]
2375 .backup_urls
2376 .push(format!("{}/backup.m4s", server.base_url()));
2377
2378 let report = client
2379 .download_plan(
2380 &plan,
2381 DownloadOptions {
2382 output_dir: temp.path().to_path_buf(),
2383 retry: RetryPolicy::single_attempt(),
2384 include_danmaku: false,
2385 mux: MuxOptions::Disabled,
2386 ..DownloadOptions::default()
2387 },
2388 )
2389 .await?;
2390
2391 assert_eq!(
2392 tokio::fs::read_to_string(&report.entries[0].files[0].path).await?,
2393 "backup"
2394 );
2395 Ok(())
2396 }
2397
2398 #[tokio::test]
2399 async fn matching_416_resume_response_is_treated_as_complete() -> anyhow::Result<()> {
2400 let server = MockServer::start();
2401 server.mock(|when, then| {
2402 when.method(GET)
2403 .path("/video.m4s")
2404 .header("range", "bytes=3-");
2405 then.status(416).header("Content-Range", "bytes */3");
2406 });
2407 server.mock(|when, then| {
2408 when.method(GET).path("/audio.m4s");
2409 then.status(200).body("audio");
2410 });
2411 let temp = tempfile::tempdir()?;
2412 let client = BiliClient::new(ClientConfig::default());
2413 let mut plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
2414 plan.entries[0].streams.videos[0].size = Some(3);
2415 let output_dir = test_entry_dir(temp.path(), &plan);
2416 tokio::fs::create_dir_all(&output_dir).await?;
2417 tokio::fs::write(
2418 output_dir.join(media_file_name("video", &plan.entries[0].streams.videos[0])),
2419 "old",
2420 )
2421 .await?;
2422
2423 let report = client
2424 .download_plan(
2425 &plan,
2426 DownloadOptions {
2427 output_dir: temp.path().to_path_buf(),
2428 retry: RetryPolicy::single_attempt(),
2429 include_danmaku: false,
2430 mux: MuxOptions::Disabled,
2431 ..DownloadOptions::default()
2432 },
2433 )
2434 .await?;
2435
2436 let file = &report.entries[0].files[0];
2437 assert_eq!(file.bytes_written, 0);
2438 assert_eq!(file.resumed_from, 3);
2439 assert_eq!(tokio::fs::read_to_string(&file.path).await?, "old");
2440 Ok(())
2441 }
2442
2443 #[tokio::test]
2444 async fn rejects_mismatched_content_range_on_resume() -> anyhow::Result<()> {
2445 let server = MockServer::start();
2446 server.mock(|when, then| {
2447 when.method(GET)
2448 .path("/video.m4s")
2449 .header("range", "bytes=3-");
2450 then.status(206)
2451 .header("Content-Range", "bytes 0-2/6")
2452 .body("bad");
2453 });
2454 let temp = tempfile::tempdir()?;
2455 let client = BiliClient::new(ClientConfig::default());
2456 let plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
2457 let output_dir = test_entry_dir(temp.path(), &plan);
2458 tokio::fs::create_dir_all(&output_dir).await?;
2459 let path = output_dir.join(media_file_name("video", &plan.entries[0].streams.videos[0]));
2460 tokio::fs::write(&path, "old").await?;
2461
2462 let Err(error) = client
2463 .download_plan(
2464 &plan,
2465 DownloadOptions {
2466 output_dir: temp.path().to_path_buf(),
2467 retry: RetryPolicy::single_attempt(),
2468 include_danmaku: false,
2469 mux: MuxOptions::Disabled,
2470 ..DownloadOptions::default()
2471 },
2472 )
2473 .await
2474 else {
2475 return Err(anyhow::anyhow!("mismatched Content-Range should fail"));
2476 };
2477
2478 assert!(error.to_string().contains("Content-Range"));
2479 assert_eq!(tokio::fs::read_to_string(&path).await?, "old");
2480 Ok(())
2481 }
2482
2483 #[tokio::test]
2484 async fn rejects_content_range_on_non_partial_response() -> anyhow::Result<()> {
2485 let server = MockServer::start();
2486 server.mock(|when, then| {
2487 when.method(GET)
2488 .path("/video.m4s")
2489 .header("range", "bytes=3-");
2490 then.status(200)
2491 .header("Content-Range", "bytes 3-5/6")
2492 .body("new");
2493 });
2494 let temp = tempfile::tempdir()?;
2495 let client = BiliClient::new(ClientConfig::default());
2496 let mut plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
2497 plan.entries[0].streams.videos[0].size = Some(6);
2498 let output_dir = test_entry_dir(temp.path(), &plan);
2499 tokio::fs::create_dir_all(&output_dir).await?;
2500 let path = output_dir.join(media_file_name("video", &plan.entries[0].streams.videos[0]));
2501 tokio::fs::write(&path, "old").await?;
2502
2503 let Err(error) = client
2504 .download_plan(
2505 &plan,
2506 DownloadOptions {
2507 output_dir: temp.path().to_path_buf(),
2508 retry: RetryPolicy::single_attempt(),
2509 include_danmaku: false,
2510 mux: MuxOptions::Disabled,
2511 ..DownloadOptions::default()
2512 },
2513 )
2514 .await
2515 else {
2516 return Err(anyhow::anyhow!(
2517 "non-partial Content-Range response should fail"
2518 ));
2519 };
2520
2521 assert!(error.to_string().contains("partial content"));
2522 assert_eq!(tokio::fs::read_to_string(&path).await?, "old");
2523 Ok(())
2524 }
2525
2526 #[tokio::test]
2527 async fn rejects_unsatisfied_content_range_on_non_partial_response() -> anyhow::Result<()> {
2528 let server = MockServer::start();
2529 server.mock(|when, then| {
2530 when.method(GET)
2531 .path("/video.m4s")
2532 .header("range", "bytes=3-");
2533 then.status(200).header("Content-Range", "bytes */3");
2534 });
2535 let temp = tempfile::tempdir()?;
2536 let client = BiliClient::new(ClientConfig::default());
2537 let plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
2538 let output_dir = test_entry_dir(temp.path(), &plan);
2539 tokio::fs::create_dir_all(&output_dir).await?;
2540 let path = output_dir.join(media_file_name("video", &plan.entries[0].streams.videos[0]));
2541 tokio::fs::write(&path, "old").await?;
2542
2543 let Err(error) = client
2544 .download_plan(
2545 &plan,
2546 DownloadOptions {
2547 output_dir: temp.path().to_path_buf(),
2548 retry: RetryPolicy::single_attempt(),
2549 include_danmaku: false,
2550 mux: MuxOptions::Disabled,
2551 ..DownloadOptions::default()
2552 },
2553 )
2554 .await
2555 else {
2556 return Err(anyhow::anyhow!(
2557 "unsatisfied non-partial Content-Range response should fail"
2558 ));
2559 };
2560
2561 assert!(error.to_string().contains("partial content"));
2562 assert_eq!(tokio::fs::read_to_string(&path).await?, "old");
2563 Ok(())
2564 }
2565
2566 #[tokio::test]
2567 async fn preserves_partial_file_when_full_retry_fails_after_ignored_range() -> anyhow::Result<()>
2568 {
2569 let server = MockServer::start();
2570 server.mock(|when, then| {
2571 when.method(GET)
2572 .path("/video.m4s")
2573 .header("range", "bytes=3-");
2574 then.status(200).body("new");
2575 });
2576 let temp = tempfile::tempdir()?;
2577 let client = BiliClient::new(ClientConfig::default());
2578 let mut plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
2579 plan.entries[0].streams.videos[0].size = Some(6);
2580 let output_dir = test_entry_dir(temp.path(), &plan);
2581 tokio::fs::create_dir_all(&output_dir).await?;
2582 let path = output_dir.join(media_file_name("video", &plan.entries[0].streams.videos[0]));
2583 tokio::fs::write(&path, "old").await?;
2584
2585 let Err(error) = client
2586 .download_plan(
2587 &plan,
2588 DownloadOptions {
2589 output_dir: temp.path().to_path_buf(),
2590 retry: RetryPolicy::single_attempt(),
2591 include_danmaku: false,
2592 mux: MuxOptions::Disabled,
2593 ..DownloadOptions::default()
2594 },
2595 )
2596 .await
2597 else {
2598 return Err(anyhow::anyhow!("short full retry should fail"));
2599 };
2600
2601 assert!(error.to_string().contains("expected media size"));
2602 assert_eq!(tokio::fs::read_to_string(&path).await?, "old");
2603 Ok(())
2604 }
2605
2606 #[tokio::test]
2607 async fn replaces_partial_file_when_full_retry_succeeds_after_ignored_range()
2608 -> anyhow::Result<()> {
2609 let server = MockServer::start();
2610 server.mock(|when, then| {
2611 when.method(GET)
2612 .path("/video.m4s")
2613 .header("range", "bytes=3-");
2614 then.status(200).body("oldnew");
2615 });
2616 server.mock(|when, then| {
2617 when.method(GET).path("/audio.m4s");
2618 then.status(200).body("audio");
2619 });
2620 let temp = tempfile::tempdir()?;
2621 let client = BiliClient::new(ClientConfig::default());
2622 let mut plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
2623 plan.entries[0].streams.videos[0].size = Some(6);
2624 let output_dir = test_entry_dir(temp.path(), &plan);
2625 tokio::fs::create_dir_all(&output_dir).await?;
2626 let path = output_dir.join(media_file_name("video", &plan.entries[0].streams.videos[0]));
2627 tokio::fs::write(&path, "old").await?;
2628
2629 let report = client
2630 .download_plan(
2631 &plan,
2632 DownloadOptions {
2633 output_dir: temp.path().to_path_buf(),
2634 retry: RetryPolicy::single_attempt(),
2635 include_danmaku: false,
2636 mux: MuxOptions::Disabled,
2637 ..DownloadOptions::default()
2638 },
2639 )
2640 .await?;
2641
2642 let file = &report.entries[0].files[0];
2643 assert_eq!(file.bytes_written, 6);
2644 assert_eq!(file.resumed_from, 0);
2645 assert_eq!(tokio::fs::read_to_string(&path).await?, "oldnew");
2646 Ok(())
2647 }
2648
2649 #[tokio::test]
2650 async fn replaces_existing_file_when_ignored_range_has_content_length() -> anyhow::Result<()> {
2651 let server = MockServer::start();
2652 server.mock(|when, then| {
2653 when.method(GET)
2654 .path("/video.m4s")
2655 .header("range", "bytes=3-");
2656 then.status(200).body("maybe-full");
2657 });
2658 server.mock(|when, then| {
2659 when.method(GET).path("/audio.m4s");
2660 then.status(200).body("audio");
2661 });
2662 let temp = tempfile::tempdir()?;
2663 let client = BiliClient::new(ClientConfig::default());
2664 let plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
2665 let output_dir = test_entry_dir(temp.path(), &plan);
2666 tokio::fs::create_dir_all(&output_dir).await?;
2667 let path = output_dir.join(media_file_name("video", &plan.entries[0].streams.videos[0]));
2668 tokio::fs::write(&path, "old").await?;
2669
2670 let report = client
2671 .download_plan(
2672 &plan,
2673 DownloadOptions {
2674 output_dir: temp.path().to_path_buf(),
2675 retry: RetryPolicy::single_attempt(),
2676 include_danmaku: false,
2677 mux: MuxOptions::Disabled,
2678 ..DownloadOptions::default()
2679 },
2680 )
2681 .await?;
2682
2683 let file = &report.entries[0].files[0];
2684 assert_eq!(file.bytes_written, 10);
2685 assert_eq!(file.resumed_from, 0);
2686 assert_eq!(tokio::fs::read_to_string(&path).await?, "maybe-full");
2687 Ok(())
2688 }
2689
2690 #[tokio::test]
2691 async fn rejects_ignored_range_without_length_proof() -> anyhow::Result<()> {
2692 let listener = TcpListener::bind("127.0.0.1:0")?;
2693 let address = listener.local_addr()?;
2694 let handle = std::thread::spawn(move || -> anyhow::Result<()> {
2695 let (mut stream, _) = listener.accept()?;
2696 let mut buffer = [0; 1024];
2697 let _ = stream.read(&mut buffer)?;
2698 stream.write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\nmaybe-full")?;
2699 Ok(())
2700 });
2701 let temp = tempfile::tempdir()?;
2702 let client = BiliClient::new(ClientConfig::default());
2703 let path = temp.path().join("video.m4s");
2704 tokio::fs::write(&path, "existing").await?;
2705 let options = DownloadOptions {
2706 retry: RetryPolicy::single_attempt(),
2707 include_danmaku: false,
2708 mux: MuxOptions::Disabled,
2709 ..DownloadOptions::default()
2710 };
2711
2712 let Err(error) = client
2713 .download_url_to_file(
2714 &format!("http://{address}/video.m4s"),
2715 &path,
2716 DownloadFileKind::Video,
2717 None,
2718 &options,
2719 )
2720 .await
2721 else {
2722 return Err(anyhow::anyhow!("unverified full retry should fail"));
2723 };
2724
2725 handle
2726 .join()
2727 .map_err(|_| anyhow::anyhow!("server thread panicked"))??;
2728 assert!(
2729 error
2730 .to_string()
2731 .contains("verifiable full response length")
2732 );
2733 assert_eq!(tokio::fs::read_to_string(&path).await?, "existing");
2734 Ok(())
2735 }
2736
2737 #[tokio::test]
2738 async fn no_resume_preserves_existing_file_when_fresh_write_fails() -> anyhow::Result<()> {
2739 let server = MockServer::start();
2740 server.mock(|when, then| {
2741 when.method(GET).path("/video.m4s");
2742 then.status(200).body("new");
2743 });
2744 let temp = tempfile::tempdir()?;
2745 let client = BiliClient::new(ClientConfig::default());
2746 let mut plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
2747 plan.entries[0].streams.videos[0].size = Some(6);
2748 let output_dir = test_entry_dir(temp.path(), &plan);
2749 tokio::fs::create_dir_all(&output_dir).await?;
2750 let path = output_dir.join(media_file_name("video", &plan.entries[0].streams.videos[0]));
2751 tokio::fs::write(&path, "old").await?;
2752
2753 let Err(error) = client
2754 .download_plan(
2755 &plan,
2756 DownloadOptions {
2757 output_dir: temp.path().to_path_buf(),
2758 retry: RetryPolicy::single_attempt(),
2759 resume: false,
2760 include_danmaku: false,
2761 mux: MuxOptions::Disabled,
2762 ..DownloadOptions::default()
2763 },
2764 )
2765 .await
2766 else {
2767 return Err(anyhow::anyhow!("invalid fresh write should fail"));
2768 };
2769
2770 assert!(error.to_string().contains("expected media size"));
2771 assert_eq!(tokio::fs::read_to_string(&path).await?, "old");
2772 Ok(())
2773 }
2774
2775 #[tokio::test]
2776 async fn no_resume_replaces_existing_file_after_fresh_write_validates() -> anyhow::Result<()> {
2777 let server = MockServer::start();
2778 server.mock(|when, then| {
2779 when.method(GET).path("/video.m4s");
2780 then.status(200).body("new");
2781 });
2782 server.mock(|when, then| {
2783 when.method(GET).path("/audio.m4s");
2784 then.status(200).body("audio");
2785 });
2786 let temp = tempfile::tempdir()?;
2787 let client = BiliClient::new(ClientConfig::default());
2788 let mut plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
2789 plan.entries[0].streams.videos[0].size = Some(3);
2790 let output_dir = test_entry_dir(temp.path(), &plan);
2791 tokio::fs::create_dir_all(&output_dir).await?;
2792 let path = output_dir.join(media_file_name("video", &plan.entries[0].streams.videos[0]));
2793 tokio::fs::write(&path, "old").await?;
2794
2795 client
2796 .download_plan(
2797 &plan,
2798 DownloadOptions {
2799 output_dir: temp.path().to_path_buf(),
2800 retry: RetryPolicy::single_attempt(),
2801 resume: false,
2802 include_danmaku: false,
2803 mux: MuxOptions::Disabled,
2804 ..DownloadOptions::default()
2805 },
2806 )
2807 .await?;
2808
2809 assert_eq!(tokio::fs::read_to_string(&path).await?, "new");
2810 Ok(())
2811 }
2812
2813 #[tokio::test]
2814 async fn rejects_short_content_range_body_on_resume() -> anyhow::Result<()> {
2815 let server = MockServer::start();
2816 server.mock(|when, then| {
2817 when.method(GET)
2818 .path("/video.m4s")
2819 .header("range", "bytes=3-");
2820 then.status(206)
2821 .header("Content-Range", "bytes 3-5/6")
2822 .body("ne");
2823 });
2824 let temp = tempfile::tempdir()?;
2825 let client = BiliClient::new(ClientConfig::default());
2826 let mut plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
2827 plan.entries[0].streams.videos[0].size = Some(6);
2828 let output_dir = test_entry_dir(temp.path(), &plan);
2829 tokio::fs::create_dir_all(&output_dir).await?;
2830 let path = output_dir.join(media_file_name("video", &plan.entries[0].streams.videos[0]));
2831 tokio::fs::write(&path, "old").await?;
2832
2833 let Err(error) = client
2834 .download_plan(
2835 &plan,
2836 DownloadOptions {
2837 output_dir: temp.path().to_path_buf(),
2838 retry: RetryPolicy::single_attempt(),
2839 include_danmaku: false,
2840 mux: MuxOptions::Disabled,
2841 ..DownloadOptions::default()
2842 },
2843 )
2844 .await
2845 else {
2846 return Err(anyhow::anyhow!("short Content-Range body should fail"));
2847 };
2848
2849 assert!(error.to_string().contains("Content-Range"));
2850 assert_eq!(tokio::fs::read_to_string(&path).await?, "old");
2851 Ok(())
2852 }
2853
2854 #[tokio::test]
2855 async fn rejects_unknown_total_content_range_without_expected_size() -> anyhow::Result<()> {
2856 let server = MockServer::start();
2857 server.mock(|when, then| {
2858 when.method(GET)
2859 .path("/video.m4s")
2860 .header("range", "bytes=3-");
2861 then.status(206)
2862 .header("Content-Range", "bytes 3-5/*")
2863 .body("new");
2864 });
2865 let temp = tempfile::tempdir()?;
2866 let client = BiliClient::new(ClientConfig::default());
2867 let plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
2868 let output_dir = test_entry_dir(temp.path(), &plan);
2869 tokio::fs::create_dir_all(&output_dir).await?;
2870 let path = output_dir.join(media_file_name("video", &plan.entries[0].streams.videos[0]));
2871 tokio::fs::write(&path, "old").await?;
2872
2873 let Err(error) = client
2874 .download_plan(
2875 &plan,
2876 DownloadOptions {
2877 output_dir: temp.path().to_path_buf(),
2878 retry: RetryPolicy::single_attempt(),
2879 include_danmaku: false,
2880 mux: MuxOptions::Disabled,
2881 ..DownloadOptions::default()
2882 },
2883 )
2884 .await
2885 else {
2886 return Err(anyhow::anyhow!(
2887 "unknown total Content-Range should fail without expected size"
2888 ));
2889 };
2890
2891 assert!(error.to_string().contains("Content-Range total length"));
2892 assert_eq!(tokio::fs::read_to_string(&path).await?, "old");
2893 Ok(())
2894 }
2895
2896 #[tokio::test]
2897 async fn accepts_unknown_total_content_range_when_expected_size_matches() -> anyhow::Result<()>
2898 {
2899 let server = MockServer::start();
2900 server.mock(|when, then| {
2901 when.method(GET)
2902 .path("/video.m4s")
2903 .header("range", "bytes=3-");
2904 then.status(206)
2905 .header("Content-Range", "bytes 3-5/*")
2906 .body("new");
2907 });
2908 server.mock(|when, then| {
2909 when.method(GET).path("/audio.m4s");
2910 then.status(200).body("audio");
2911 });
2912 let temp = tempfile::tempdir()?;
2913 let client = BiliClient::new(ClientConfig::default());
2914 let mut plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
2915 plan.entries[0].streams.videos[0].size = Some(6);
2916 let output_dir = test_entry_dir(temp.path(), &plan);
2917 tokio::fs::create_dir_all(&output_dir).await?;
2918 let path = output_dir.join(media_file_name("video", &plan.entries[0].streams.videos[0]));
2919 tokio::fs::write(&path, "old").await?;
2920
2921 client
2922 .download_plan(
2923 &plan,
2924 DownloadOptions {
2925 output_dir: temp.path().to_path_buf(),
2926 retry: RetryPolicy::single_attempt(),
2927 include_danmaku: false,
2928 mux: MuxOptions::Disabled,
2929 ..DownloadOptions::default()
2930 },
2931 )
2932 .await?;
2933
2934 assert_eq!(tokio::fs::read_to_string(&path).await?, "oldnew");
2935 Ok(())
2936 }
2937
2938 #[tokio::test]
2939 async fn rejects_content_range_total_mismatch_on_resume() -> anyhow::Result<()> {
2940 let server = MockServer::start();
2941 server.mock(|when, then| {
2942 when.method(GET)
2943 .path("/video.m4s")
2944 .header("range", "bytes=3-");
2945 then.status(206)
2946 .header("Content-Range", "bytes 3-5/999")
2947 .body("new");
2948 });
2949 let temp = tempfile::tempdir()?;
2950 let client = BiliClient::new(ClientConfig::default());
2951 let mut plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
2952 plan.entries[0].streams.videos[0].size = Some(6);
2953 let output_dir = test_entry_dir(temp.path(), &plan);
2954 tokio::fs::create_dir_all(&output_dir).await?;
2955 let path = output_dir.join(media_file_name("video", &plan.entries[0].streams.videos[0]));
2956 tokio::fs::write(&path, "old").await?;
2957
2958 let Err(error) = client
2959 .download_plan(
2960 &plan,
2961 DownloadOptions {
2962 output_dir: temp.path().to_path_buf(),
2963 retry: RetryPolicy::single_attempt(),
2964 include_danmaku: false,
2965 mux: MuxOptions::Disabled,
2966 ..DownloadOptions::default()
2967 },
2968 )
2969 .await
2970 else {
2971 return Err(anyhow::anyhow!("Content-Range total mismatch should fail"));
2972 };
2973
2974 assert!(error.to_string().contains("Content-Range total length"));
2975 assert_eq!(tokio::fs::read_to_string(&path).await?, "old");
2976 Ok(())
2977 }
2978
2979 #[tokio::test]
2980 async fn rejects_expected_media_size_mismatch() -> anyhow::Result<()> {
2981 let server = MockServer::start();
2982 server.mock(|when, then| {
2983 when.method(GET).path("/video.m4s");
2984 then.status(200).body("video");
2985 });
2986 server.mock(|when, then| {
2987 when.method(GET).path("/audio.m4s");
2988 then.status(200).body("audio");
2989 });
2990 let temp = tempfile::tempdir()?;
2991 let client = BiliClient::new(ClientConfig::default());
2992 let mut plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
2993 plan.entries[0].streams.videos[0].size = Some(6);
2994 let path = temp
2995 .path()
2996 .join(safe_file_name(&plan.title))
2997 .join(entry_dir_name(&plan.entries[0]))
2998 .join(media_file_name("video", &plan.entries[0].streams.videos[0]));
2999
3000 let Err(error) = client
3001 .download_plan(
3002 &plan,
3003 DownloadOptions {
3004 output_dir: temp.path().to_path_buf(),
3005 retry: RetryPolicy::single_attempt(),
3006 include_danmaku: false,
3007 mux: MuxOptions::Disabled,
3008 ..DownloadOptions::default()
3009 },
3010 )
3011 .await
3012 else {
3013 return Err(anyhow::anyhow!("media size mismatch should fail"));
3014 };
3015
3016 assert!(error.to_string().contains("expected media size"));
3017 assert_eq!(tokio::fs::metadata(&path).await?.len(), 0);
3018 Ok(())
3019 }
3020
3021 #[tokio::test]
3022 async fn rejects_empty_unknown_size_media_response() -> anyhow::Result<()> {
3023 let server = MockServer::start();
3024 server.mock(|when, then| {
3025 when.method(GET).path("/video.m4s");
3026 then.status(200).body("");
3027 });
3028 let temp = tempfile::tempdir()?;
3029 let client = BiliClient::new(ClientConfig::default());
3030 let plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
3031 let path = temp
3032 .path()
3033 .join(safe_file_name(&plan.title))
3034 .join(entry_dir_name(&plan.entries[0]))
3035 .join(media_file_name("video", &plan.entries[0].streams.videos[0]));
3036
3037 let Err(error) = client
3038 .download_plan(
3039 &plan,
3040 DownloadOptions {
3041 output_dir: temp.path().to_path_buf(),
3042 retry: RetryPolicy::single_attempt(),
3043 include_danmaku: false,
3044 mux: MuxOptions::Disabled,
3045 ..DownloadOptions::default()
3046 },
3047 )
3048 .await
3049 else {
3050 return Err(anyhow::anyhow!("empty media response should fail"));
3051 };
3052
3053 assert!(error.to_string().contains("empty media response"));
3054 assert!(!path.exists());
3055 Ok(())
3056 }
3057
3058 #[tokio::test]
3059 async fn rejects_empty_zero_size_media_response() -> anyhow::Result<()> {
3060 let server = MockServer::start();
3061 server.mock(|when, then| {
3062 when.method(GET).path("/video.m4s");
3063 then.status(200).body("");
3064 });
3065 let temp = tempfile::tempdir()?;
3066 let client = BiliClient::new(ClientConfig::default());
3067 let path = temp.path().join("video.m4s");
3068 let options = DownloadOptions {
3069 retry: RetryPolicy::single_attempt(),
3070 include_danmaku: false,
3071 mux: MuxOptions::Disabled,
3072 ..DownloadOptions::default()
3073 };
3074
3075 let Err(error) = client
3076 .download_url_to_file(
3077 &format!("{}/video.m4s", server.base_url()),
3078 &path,
3079 DownloadFileKind::Video,
3080 Some(0),
3081 &options,
3082 )
3083 .await
3084 else {
3085 return Err(anyhow::anyhow!(
3086 "empty zero-size media response should fail"
3087 ));
3088 };
3089
3090 assert!(error.to_string().contains("empty media response"));
3091 assert!(!path.exists());
3092 Ok(())
3093 }
3094
3095 #[tokio::test]
3096 async fn uses_flv_segments_when_dash_pair_is_incomplete() -> anyhow::Result<()> {
3097 let server = MockServer::start();
3098 let video_mock = server.mock(|when, then| {
3099 when.method(GET).path("/video.m4s");
3100 then.status(500);
3101 });
3102 let flv_mock = server.mock(|when, then| {
3103 when.method(GET).path("/segment.flv");
3104 then.status(200).body("segment");
3105 });
3106 let temp = tempfile::tempdir()?;
3107 let client = BiliClient::new(ClientConfig::default());
3108 let mut plan = test_plan(&server);
3109 plan.entries[0].streams.audios.clear();
3110 plan.entries[0].streams.flv_segments = vec![FlvSegment {
3111 order: 1,
3112 url: format!("{}/segment.flv", server.base_url()),
3113 backup_urls: Vec::new(),
3114 size: Some(7),
3115 length_ms: Some(1000),
3116 }];
3117 plan.entries[0].subtitles.clear();
3118
3119 let report = client
3120 .download_plan(
3121 &plan,
3122 DownloadOptions {
3123 output_dir: temp.path().to_path_buf(),
3124 retry: RetryPolicy::single_attempt(),
3125 include_danmaku: false,
3126 mux: MuxOptions::Disabled,
3127 ..DownloadOptions::default()
3128 },
3129 )
3130 .await?;
3131
3132 assert_eq!(video_mock.calls(), 0);
3133 assert_eq!(flv_mock.calls(), 1);
3134 assert_eq!(
3135 report.entries[0].files[0].kind,
3136 DownloadFileKind::FlvSegment
3137 );
3138 assert_eq!(
3139 tokio::fs::read_to_string(&report.entries[0].files[0].path).await?,
3140 "segment"
3141 );
3142 Ok(())
3143 }
3144
3145 #[tokio::test]
3146 async fn rejects_incomplete_dash_without_flv_fallback() -> anyhow::Result<()> {
3147 let server = MockServer::start();
3148 let temp = tempfile::tempdir()?;
3149 let client = BiliClient::new(ClientConfig::default());
3150 let mut plan = test_plan(&server);
3151 plan.entries[0].streams.audios.clear();
3152 plan.entries[0].subtitles.clear();
3153
3154 let Err(error) = client
3155 .download_plan(
3156 &plan,
3157 DownloadOptions {
3158 output_dir: temp.path().to_path_buf(),
3159 retry: RetryPolicy::single_attempt(),
3160 include_danmaku: false,
3161 mux: MuxOptions::Disabled,
3162 ..DownloadOptions::default()
3163 },
3164 )
3165 .await
3166 else {
3167 return Err(anyhow::anyhow!("incomplete DASH without FLV should fail"));
3168 };
3169
3170 assert!(error.to_string().contains("complete DASH media"));
3171 Ok(())
3172 }
3173
3174 #[tokio::test]
3175 async fn media_download_uses_idle_timeout_instead_of_request_total_timeout()
3176 -> anyhow::Result<()> {
3177 let listener = TcpListener::bind("127.0.0.1:0")?;
3178 let address = listener.local_addr()?;
3179 let handle = std::thread::spawn(move || -> anyhow::Result<()> {
3180 let (mut stream, _) = listener.accept()?;
3181 let mut buffer = [0; 1024];
3182 let _ = stream.read(&mut buffer)?;
3183 stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n")?;
3184 stream.flush()?;
3185 std::thread::sleep(Duration::from_millis(100));
3186 stream.write_all(b"ok")?;
3187 Ok(())
3188 });
3189 let temp = tempfile::tempdir()?;
3190 let client = BiliClient::new(ClientConfig {
3191 request_timeout: Duration::from_millis(20),
3192 ..ClientConfig::default()
3193 });
3194 let path = temp.path().join("video.m4s");
3195 let options = DownloadOptions {
3196 retry: RetryPolicy::single_attempt(),
3197 include_danmaku: false,
3198 mux: MuxOptions::Disabled,
3199 download_idle_timeout: Some(Duration::from_secs(1)),
3200 ..DownloadOptions::default()
3201 };
3202
3203 let file = client
3204 .download_url_to_file(
3205 &format!("http://{address}/video.m4s"),
3206 &path,
3207 DownloadFileKind::Video,
3208 Some(2),
3209 &options,
3210 )
3211 .await?;
3212
3213 handle
3214 .join()
3215 .map_err(|_| anyhow::anyhow!("server thread panicked"))??;
3216 assert_eq!(tokio::fs::read_to_string(&file.path).await?, "ok");
3217 Ok(())
3218 }
3219
3220 #[tokio::test]
3221 async fn media_download_request_timeout_still_bounds_response_headers() -> anyhow::Result<()> {
3222 let listener = TcpListener::bind("127.0.0.1:0")?;
3223 let address = listener.local_addr()?;
3224 let handle = std::thread::spawn(move || -> anyhow::Result<()> {
3225 let (mut stream, _) = listener.accept()?;
3226 let mut buffer = [0; 1024];
3227 let _ = stream.read(&mut buffer)?;
3228 std::thread::sleep(Duration::from_millis(100));
3229 Ok(())
3230 });
3231 let temp = tempfile::tempdir()?;
3232 let client = BiliClient::new(ClientConfig {
3233 request_timeout: Duration::from_millis(20),
3234 ..ClientConfig::default()
3235 });
3236 let path = temp.path().join("video.m4s");
3237 let options = DownloadOptions {
3238 retry: RetryPolicy::single_attempt(),
3239 include_danmaku: false,
3240 mux: MuxOptions::Disabled,
3241 download_idle_timeout: Some(Duration::from_secs(1)),
3242 ..DownloadOptions::default()
3243 };
3244
3245 let Err(error) = client
3246 .download_url_to_file(
3247 &format!("http://{address}/video.m4s"),
3248 &path,
3249 DownloadFileKind::Video,
3250 None,
3251 &options,
3252 )
3253 .await
3254 else {
3255 return Err(anyhow::anyhow!("hung response headers should time out"));
3256 };
3257
3258 handle
3259 .join()
3260 .map_err(|_| anyhow::anyhow!("server thread panicked"))??;
3261 assert!(error.to_string().contains("download request timeout"));
3262 Ok(())
3263 }
3264
3265 #[tokio::test]
3266 async fn retries_failed_downloads() -> anyhow::Result<()> {
3267 let listener = TcpListener::bind("127.0.0.1:0")?;
3268 let address = listener.local_addr()?;
3269 let handle = std::thread::spawn(move || -> anyhow::Result<()> {
3270 for attempt in 0..2 {
3271 let (mut stream, _) = listener.accept()?;
3272 let mut buffer = [0; 1024];
3273 let _ = stream.read(&mut buffer)?;
3274 if attempt == 0 {
3275 stream.write_all(
3276 b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n",
3277 )?;
3278 } else {
3279 stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 8\r\n\r\nretry-ok")?;
3280 }
3281 }
3282 Ok(())
3283 });
3284 let temp = tempfile::tempdir()?;
3285 let client = BiliClient::new(ClientConfig::default());
3286 let path = temp.path().join("video.m4s");
3287 let options = DownloadOptions {
3288 retry: RetryPolicy {
3289 max_attempts: 2,
3290 backoff: Duration::from_millis(1),
3291 },
3292 include_danmaku: false,
3293 mux: MuxOptions::Disabled,
3294 ..DownloadOptions::default()
3295 };
3296
3297 let file = client
3298 .download_url_to_file(
3299 &format!("http://{address}/video.m4s"),
3300 &path,
3301 DownloadFileKind::Video,
3302 None,
3303 &options,
3304 )
3305 .await?;
3306
3307 handle
3308 .join()
3309 .map_err(|_| anyhow::anyhow!("server thread panicked"))??;
3310 assert_eq!(tokio::fs::read_to_string(&file.path).await?, "retry-ok");
3311 Ok(())
3312 }
3313
3314 #[cfg(unix)]
3315 #[tokio::test]
3316 async fn ffmpeg_mux_success_is_reported() -> anyhow::Result<()> {
3317 let server = MockServer::start();
3318 server.mock(|when, then| {
3319 when.method(GET).path("/video.m4s");
3320 then.status(200).body("video");
3321 });
3322 server.mock(|when, then| {
3323 when.method(GET).path("/audio.m4s");
3324 then.status(200).body("audio");
3325 });
3326 let temp = tempfile::tempdir()?;
3327 let ffmpeg = write_fake_ffmpeg(temp.path(), fake_ffmpeg_creates_output_body())?;
3328 let client = BiliClient::new(ClientConfig::default());
3329 let plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
3330 let output_dir = temp.path().join("downloads");
3331 let entry_dir = test_entry_dir(&output_dir, &plan);
3332 tokio::fs::create_dir_all(&entry_dir).await?;
3333 tokio::fs::write(entry_dir.join("Main.mp4"), "stale").await?;
3334
3335 let report = client
3336 .download_plan(
3337 &plan,
3338 DownloadOptions {
3339 output_dir,
3340 retry: RetryPolicy::single_attempt(),
3341 include_danmaku: false,
3342 mux: MuxOptions::Ffmpeg { binary: ffmpeg },
3343 ..DownloadOptions::default()
3344 },
3345 )
3346 .await?;
3347
3348 let mux = report.entries[0]
3349 .mux
3350 .as_ref()
3351 .ok_or_else(|| anyhow::anyhow!("missing mux report"))?;
3352 assert_eq!(mux.command[1], "-y");
3353 assert!(mux.command.iter().any(|arg| arg == "-nostdin"));
3354 assert!(mux.command.iter().any(|arg| arg == "-c"));
3355 assert!(mux.output_path.ends_with("Main.mp4"));
3356 assert_eq!(tokio::fs::read_to_string(&mux.output_path).await?, "muxed");
3357 Ok(())
3358 }
3359
3360 #[cfg(unix)]
3361 #[tokio::test]
3362 async fn ffmpeg_mux_requires_output_file() -> anyhow::Result<()> {
3363 let server = MockServer::start();
3364 server.mock(|when, then| {
3365 when.method(GET).path("/video.m4s");
3366 then.status(200).body("video");
3367 });
3368 server.mock(|when, then| {
3369 when.method(GET).path("/audio.m4s");
3370 then.status(200).body("audio");
3371 });
3372 let temp = tempfile::tempdir()?;
3373 let ffmpeg = write_fake_ffmpeg(temp.path(), "exit 0")?;
3374 let client = BiliClient::new(ClientConfig::default());
3375 let plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
3376
3377 let Err(error) = client
3378 .download_plan(
3379 &plan,
3380 DownloadOptions {
3381 output_dir: temp.path().join("downloads"),
3382 retry: RetryPolicy::single_attempt(),
3383 include_danmaku: false,
3384 mux: MuxOptions::Ffmpeg { binary: ffmpeg },
3385 ..DownloadOptions::default()
3386 },
3387 )
3388 .await
3389 else {
3390 return Err(anyhow::anyhow!("missing mux output should fail"));
3391 };
3392
3393 assert!(error.to_string().contains("missing output file"));
3394 Ok(())
3395 }
3396
3397 #[cfg(unix)]
3398 #[tokio::test]
3399 async fn ffmpeg_mux_rejects_empty_output_file() -> anyhow::Result<()> {
3400 let server = MockServer::start();
3401 server.mock(|when, then| {
3402 when.method(GET).path("/video.m4s");
3403 then.status(200).body("video");
3404 });
3405 server.mock(|when, then| {
3406 when.method(GET).path("/audio.m4s");
3407 then.status(200).body("audio");
3408 });
3409 let temp = tempfile::tempdir()?;
3410 let ffmpeg = write_fake_ffmpeg(
3411 temp.path(),
3412 "last=\nfor arg do last=$arg; done\n: > \"$last\"\nexit 0",
3413 )?;
3414 let client = BiliClient::new(ClientConfig::default());
3415 let plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
3416
3417 let Err(error) = client
3418 .download_plan(
3419 &plan,
3420 DownloadOptions {
3421 output_dir: temp.path().join("downloads"),
3422 retry: RetryPolicy::single_attempt(),
3423 include_danmaku: false,
3424 mux: MuxOptions::Ffmpeg { binary: ffmpeg },
3425 ..DownloadOptions::default()
3426 },
3427 )
3428 .await
3429 else {
3430 return Err(anyhow::anyhow!("empty mux output should fail"));
3431 };
3432
3433 assert!(error.to_string().contains("empty output file"));
3434 Ok(())
3435 }
3436
3437 #[cfg(unix)]
3438 #[tokio::test]
3439 async fn ffmpeg_mux_rejects_stale_output_file() -> anyhow::Result<()> {
3440 let server = MockServer::start();
3441 server.mock(|when, then| {
3442 when.method(GET).path("/video.m4s");
3443 then.status(200).body("video");
3444 });
3445 server.mock(|when, then| {
3446 when.method(GET).path("/audio.m4s");
3447 then.status(200).body("audio");
3448 });
3449 let temp = tempfile::tempdir()?;
3450 let ffmpeg = write_fake_ffmpeg(temp.path(), "exit 0")?;
3451 let client = BiliClient::new(ClientConfig::default());
3452 let plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
3453 let entry_dir = test_entry_dir(&temp.path().join("downloads"), &plan);
3454 tokio::fs::create_dir_all(&entry_dir).await?;
3455 tokio::fs::write(entry_dir.join("Main.mp4"), "stale").await?;
3456
3457 let Err(error) = client
3458 .download_plan(
3459 &plan,
3460 DownloadOptions {
3461 output_dir: temp.path().join("downloads"),
3462 retry: RetryPolicy::single_attempt(),
3463 include_danmaku: false,
3464 mux: MuxOptions::Ffmpeg { binary: ffmpeg },
3465 ..DownloadOptions::default()
3466 },
3467 )
3468 .await
3469 else {
3470 return Err(anyhow::anyhow!("stale mux output should fail"));
3471 };
3472
3473 assert!(error.to_string().contains("missing output file"));
3474 assert_eq!(
3475 tokio::fs::read_to_string(entry_dir.join("Main.mp4")).await?,
3476 "stale"
3477 );
3478 Ok(())
3479 }
3480
3481 #[cfg(unix)]
3482 #[tokio::test]
3483 async fn ffmpeg_mux_failure_is_reported() -> anyhow::Result<()> {
3484 let server = MockServer::start();
3485 server.mock(|when, then| {
3486 when.method(GET).path("/video.m4s");
3487 then.status(200).body("video");
3488 });
3489 server.mock(|when, then| {
3490 when.method(GET).path("/audio.m4s");
3491 then.status(200).body("audio");
3492 });
3493 let temp = tempfile::tempdir()?;
3494 let ffmpeg = write_fake_ffmpeg(temp.path(), "exit 7")?;
3495 let client = BiliClient::new(ClientConfig::default());
3496 let plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
3497
3498 let Err(error) = client
3499 .download_plan(
3500 &plan,
3501 DownloadOptions {
3502 output_dir: temp.path().join("downloads"),
3503 retry: RetryPolicy::single_attempt(),
3504 include_danmaku: false,
3505 mux: MuxOptions::Ffmpeg { binary: ffmpeg },
3506 ..DownloadOptions::default()
3507 },
3508 )
3509 .await
3510 else {
3511 return Err(anyhow::anyhow!("ffmpeg failure should propagate"));
3512 };
3513
3514 assert!(error.to_string().contains("status 7"));
3515 Ok(())
3516 }
3517
3518 #[cfg(unix)]
3519 #[tokio::test]
3520 async fn ffmpeg_mux_failure_preserves_existing_output() -> anyhow::Result<()> {
3521 let server = MockServer::start();
3522 server.mock(|when, then| {
3523 when.method(GET).path("/video.m4s");
3524 then.status(200).body("video");
3525 });
3526 server.mock(|when, then| {
3527 when.method(GET).path("/audio.m4s");
3528 then.status(200).body("audio");
3529 });
3530 let temp = tempfile::tempdir()?;
3531 let ffmpeg = write_fake_ffmpeg(temp.path(), "exit 7")?;
3532 let output_dir = temp.path().join("downloads");
3533 let client = BiliClient::new(ClientConfig::default());
3534 let plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
3535 let entry_dir = test_entry_dir(&output_dir, &plan);
3536 tokio::fs::create_dir_all(&entry_dir).await?;
3537 let output_path = entry_dir.join("Main.mp4");
3538 tokio::fs::write(&output_path, "existing").await?;
3539
3540 let Err(error) = client
3541 .download_plan(
3542 &plan,
3543 DownloadOptions {
3544 output_dir,
3545 retry: RetryPolicy::single_attempt(),
3546 include_danmaku: false,
3547 mux: MuxOptions::Ffmpeg { binary: ffmpeg },
3548 ..DownloadOptions::default()
3549 },
3550 )
3551 .await
3552 else {
3553 return Err(anyhow::anyhow!("ffmpeg failure should propagate"));
3554 };
3555
3556 assert!(error.to_string().contains("status 7"));
3557 assert_eq!(tokio::fs::read_to_string(output_path).await?, "existing");
3558 Ok(())
3559 }
3560
3561 #[cfg(unix)]
3562 #[tokio::test]
3563 async fn flv_mux_concat_file_uses_paths_relative_to_entry_dir() -> anyhow::Result<()> {
3564 let server = MockServer::start();
3565 server.mock(|when, then| {
3566 when.method(GET).path("/segment.flv");
3567 then.status(200).body("segment");
3568 });
3569 let temp = tempfile::Builder::new()
3570 .prefix(".bbdown-flv-mux-")
3571 .tempdir_in(".")?;
3572 let ffmpeg = write_fake_ffmpeg(temp.path(), fake_ffmpeg_creates_output_body())?;
3573 let output_dir = temp.path().join("downloads");
3574 let client = BiliClient::new(ClientConfig::default());
3575 let mut plan = test_plan(&server);
3576 plan.entries[0].streams.videos.clear();
3577 plan.entries[0].streams.audios.clear();
3578 plan.entries[0].streams.flv_segments = vec![FlvSegment {
3579 order: 1,
3580 url: format!("{}/segment.flv", server.base_url()),
3581 backup_urls: Vec::new(),
3582 size: Some(7),
3583 length_ms: Some(1000),
3584 }];
3585 plan.entries[0].subtitles.clear();
3586
3587 client
3588 .download_plan(
3589 &plan,
3590 DownloadOptions {
3591 output_dir: output_dir.clone(),
3592 retry: RetryPolicy::single_attempt(),
3593 include_danmaku: false,
3594 mux: MuxOptions::Ffmpeg { binary: ffmpeg },
3595 ..DownloadOptions::default()
3596 },
3597 )
3598 .await?;
3599
3600 let concat_path = output_dir
3601 .join(safe_file_name(&plan.title))
3602 .join(entry_dir_name(&plan.entries[0]))
3603 .join("ffmpeg-concat.txt");
3604 assert_eq!(
3605 tokio::fs::read_to_string(concat_path).await?,
3606 "file 'segment-001.flv'\n"
3607 );
3608 Ok(())
3609 }
3610
3611 #[test]
3612 fn download_preflight_reports_archive_hit_and_output_conflict() -> anyhow::Result<()> {
3613 let server = MockServer::start();
3614 let temp = tempfile::tempdir()?;
3615 let plan = test_plan(&server);
3616 let options = DownloadOptions::new(temp.path().join("downloads"));
3617 let planned_output_dir = default_plan_output_dir(&plan, &options);
3618 std::fs::create_dir_all(&planned_output_dir)?;
3619 let record = DownloadArchiveRecord {
3620 content_key: download_plan_content_key(&plan),
3621 title: plan.title.clone(),
3622 output_dir: planned_output_dir.clone(),
3623 completed_at_unix: 42,
3624 entries: Vec::new(),
3625 };
3626 let archive = DownloadArchive::new(vec![record]);
3627
3628 let preflight = DownloadPreflight::inspect(&plan, &options, Some(&archive))?;
3629
3630 assert!(preflight.requires_decision());
3631 assert_eq!(preflight.archived_records.len(), 1);
3632 assert_eq!(
3633 preflight
3634 .output_conflict
3635 .as_ref()
3636 .map(|conflict| &conflict.path),
3637 Some(&planned_output_dir)
3638 );
3639 assert_eq!(preflight.suggested_decision(), DuplicateDecision::Cancel);
3640 Ok(())
3641 }
3642
3643 #[cfg(unix)]
3644 #[test]
3645 fn download_preflight_reports_broken_symlink_output_conflict() -> anyhow::Result<()> {
3646 let server = MockServer::start();
3647 let temp = tempfile::tempdir()?;
3648 let plan = test_plan(&server);
3649 let options = DownloadOptions::new(temp.path().join("downloads"));
3650 let planned_output_dir = default_plan_output_dir(&plan, &options);
3651 let output_parent = planned_output_dir
3652 .parent()
3653 .ok_or_else(|| anyhow::anyhow!("missing planned output parent"))?;
3654 std::fs::create_dir_all(output_parent)?;
3655 std::os::unix::fs::symlink(temp.path().join("missing-target"), &planned_output_dir)?;
3656
3657 assert!(!planned_output_dir.exists());
3658 let preflight = DownloadPreflight::inspect(&plan, &options, None)?;
3659
3660 assert!(preflight.requires_decision());
3661 assert_eq!(
3662 preflight
3663 .output_conflict
3664 .as_ref()
3665 .map(|conflict| &conflict.path),
3666 Some(&planned_output_dir)
3667 );
3668 Ok(())
3669 }
3670
3671 #[cfg(unix)]
3672 #[test]
3673 fn output_occupancy_reports_metadata_errors() -> anyhow::Result<()> {
3674 let temp = tempfile::tempdir()?;
3675 let restricted = temp.path().join("restricted");
3676 std_fs::create_dir(&restricted)?;
3677 let original_permissions = std_fs::metadata(&restricted)?.permissions();
3678 let mut denied_permissions = original_permissions.clone();
3679 denied_permissions.set_mode(0o000);
3680 std_fs::set_permissions(&restricted, denied_permissions)?;
3681
3682 let result = path_is_occupied(&restricted.join("Mock video"));
3683
3684 std_fs::set_permissions(&restricted, original_permissions)?;
3685 match result {
3686 Err(crate::Error::Io(error))
3687 if error.kind() == std::io::ErrorKind::PermissionDenied => {}
3688 Ok(occupied) => {
3689 anyhow::bail!("expected metadata permission error, got occupied={occupied}");
3690 }
3691 Err(error) => {
3692 anyhow::bail!("expected metadata permission error, got {error}");
3693 }
3694 }
3695 Ok(())
3696 }
3697
3698 #[test]
3699 fn download_preflight_reports_entry_overlap_from_archive() -> anyhow::Result<()> {
3700 let server = MockServer::start();
3701 let temp = tempfile::tempdir()?;
3702 let plan = test_plan(&server);
3703 let entry = &plan.entries[0];
3704 let archive = DownloadArchive::new(vec![DownloadArchiveRecord {
3705 content_key: "plan|different-entry-set".to_owned(),
3706 title: "Archived collection".to_owned(),
3707 output_dir: temp.path().join("downloads").join("Archived collection"),
3708 completed_at_unix: 42,
3709 entries: vec![DownloadArchiveEntryRecord {
3710 content_key: download_entry_content_key(entry),
3711 index: entry.index,
3712 aid: entry.aid,
3713 bvid: entry.bvid.clone(),
3714 cid: entry.cid,
3715 epid: entry.epid,
3716 title: entry.title.clone(),
3717 directory: temp.path().join("downloads").join("Archived collection"),
3718 files: Vec::new(),
3719 mux_output: None,
3720 }],
3721 }]);
3722
3723 let preflight =
3724 DownloadPreflight::inspect(&plan, &DownloadOptions::new(temp.path()), Some(&archive))?;
3725
3726 assert!(preflight.requires_decision());
3727 assert_eq!(preflight.archived_records.len(), 1);
3728 assert_eq!(preflight.archived_records[0].title, "Archived collection");
3729 Ok(())
3730 }
3731
3732 #[test]
3733 fn download_preflight_reports_entry_overlap_after_index_change() -> anyhow::Result<()> {
3734 let server = MockServer::start();
3735 let temp = tempfile::tempdir()?;
3736 let plan = test_plan(&server);
3737 let mut archived_entry = plan.entries[0].clone();
3738 archived_entry.index += 10;
3739 let archive = DownloadArchive::new(vec![DownloadArchiveRecord {
3740 content_key: "plan|different-entry-set".to_owned(),
3741 title: "Archived collection".to_owned(),
3742 output_dir: temp.path().join("downloads").join("Archived collection"),
3743 completed_at_unix: 42,
3744 entries: vec![DownloadArchiveEntryRecord {
3745 content_key: download_entry_content_key(&archived_entry),
3746 index: archived_entry.index,
3747 aid: archived_entry.aid,
3748 bvid: archived_entry.bvid.clone(),
3749 cid: archived_entry.cid,
3750 epid: archived_entry.epid,
3751 title: archived_entry.title,
3752 directory: temp.path().join("downloads").join("Archived collection"),
3753 files: Vec::new(),
3754 mux_output: None,
3755 }],
3756 }]);
3757
3758 let preflight =
3759 DownloadPreflight::inspect(&plan, &DownloadOptions::new(temp.path()), Some(&archive))?;
3760
3761 assert!(preflight.requires_decision());
3762 assert_eq!(preflight.archived_records.len(), 1);
3763 assert_eq!(preflight.archived_records[0].entries[0].index, 11);
3764 Ok(())
3765 }
3766
3767 #[test]
3768 fn download_preflight_matches_legacy_bvid_entry_key() -> anyhow::Result<()> {
3769 let server = MockServer::start();
3770 let temp = tempfile::tempdir()?;
3771 let plan = test_plan(&server);
3772 let mut archived_entry = plan.entries[0].clone();
3773 archived_entry.bvid = None;
3774 archived_entry.epid = Some(664_928);
3775 let archive = DownloadArchive::new(vec![DownloadArchiveRecord {
3776 content_key: "plan|legacy-entry-set".to_owned(),
3777 title: "Archived collection".to_owned(),
3778 output_dir: temp.path().join("downloads").join("Archived collection"),
3779 completed_at_unix: 42,
3780 entries: vec![DownloadArchiveEntryRecord {
3781 content_key: format!(
3782 "aid={};bvid={};cid={};epid={}",
3783 archived_entry.aid,
3784 archived_entry.bvid.as_deref().unwrap_or_default(),
3785 archived_entry.cid,
3786 archived_entry.epid.unwrap_or_default()
3787 ),
3788 index: archived_entry.index,
3789 aid: archived_entry.aid,
3790 bvid: archived_entry.bvid.clone(),
3791 cid: archived_entry.cid,
3792 epid: archived_entry.epid,
3793 title: archived_entry.title,
3794 directory: temp.path().join("downloads").join("Archived collection"),
3795 files: Vec::new(),
3796 mux_output: None,
3797 }],
3798 }]);
3799
3800 let preflight =
3801 DownloadPreflight::inspect(&plan, &DownloadOptions::new(temp.path()), Some(&archive))?;
3802
3803 assert!(preflight.requires_decision());
3804 assert_eq!(preflight.archived_records.len(), 1);
3805 Ok(())
3806 }
3807
3808 #[test]
3809 fn download_preflight_reports_same_output_archive_record() -> anyhow::Result<()> {
3810 let server = MockServer::start();
3811 let temp = tempfile::tempdir()?;
3812 let plan = test_plan(&server);
3813 let options = DownloadOptions::new(temp.path().join("downloads"));
3814 let planned_output_dir = default_plan_output_dir(&plan, &options);
3815 let archive = DownloadArchive::new(vec![DownloadArchiveRecord {
3816 content_key: "plan|different-content".to_owned(),
3817 title: "Different content".to_owned(),
3818 output_dir: planned_output_dir.join("."),
3819 completed_at_unix: 42,
3820 entries: Vec::new(),
3821 }]);
3822
3823 let preflight = DownloadPreflight::inspect(&plan, &options, Some(&archive))?;
3824
3825 assert!(preflight.requires_decision());
3826 assert!(preflight.output_conflict.is_none());
3827 assert_eq!(preflight.archived_records.len(), 1);
3828 assert_eq!(preflight.archived_records[0].title, "Different content");
3829 Ok(())
3830 }
3831
3832 #[test]
3833 fn download_preflight_round_trip_preserves_reserved_output_dirs() -> anyhow::Result<()> {
3834 let server = MockServer::start();
3835 let temp = tempfile::tempdir()?;
3836 let output_base = temp.path().join("downloads");
3837 let plan = test_plan(&server);
3838 let options = DownloadOptions::new(output_base.clone());
3839 let planned_output_dir = default_plan_output_dir(&plan, &options);
3840 std::fs::create_dir_all(&planned_output_dir)?;
3841 let reserved_output_dir = output_base.join("Mock video (2)");
3842 let archive = DownloadArchive::new(vec![DownloadArchiveRecord {
3843 content_key: "plan|unrelated".to_owned(),
3844 title: "Unrelated archived content".to_owned(),
3845 output_dir: reserved_output_dir,
3846 completed_at_unix: 42,
3847 entries: Vec::new(),
3848 }]);
3849 let preflight = DownloadPreflight::inspect(&plan, &options, Some(&archive))?;
3850 let raw = serde_json::to_string(&preflight)?;
3851
3852 let round_tripped: DownloadPreflight = serde_json::from_str(&raw)?;
3853
3854 assert!(raw.contains("reserved_output_dirs"));
3855 assert_eq!(
3856 round_tripped.output_dir_for_decision(DuplicateDecision::KeepBoth)?,
3857 output_base.join("Mock video (3)")
3858 );
3859 Ok(())
3860 }
3861
3862 #[tokio::test]
3863 async fn archive_preflight_keep_both_rejects_stale_archive_reservations() -> anyhow::Result<()>
3864 {
3865 let server = MockServer::start();
3866 let temp = tempfile::tempdir()?;
3867 let output_base = temp.path().join("downloads");
3868 let client = BiliClient::new(ClientConfig::default());
3869 let plan = test_plan(&server);
3870 let options = DownloadOptions::new(output_base.clone())
3871 .with_retry_policy(RetryPolicy::single_attempt())
3872 .with_mux(MuxOptions::Disabled);
3873 let planned_output_dir = default_plan_output_dir(&plan, &options);
3874 std::fs::create_dir_all(&planned_output_dir)?;
3875 let stale_preflight =
3876 DownloadPreflight::inspect(&plan, &options, Some(&DownloadArchive::default()))?;
3877 let mut archive = DownloadArchive::new(vec![DownloadArchiveRecord {
3878 content_key: "plan|unrelated".to_owned(),
3879 title: "Unrelated archived content".to_owned(),
3880 output_dir: output_base.join("Mock video (2)"),
3881 completed_at_unix: 42,
3882 entries: Vec::new(),
3883 }]);
3884
3885 let error = match client
3886 .download_plan_with_archive_preflight_decision(
3887 &plan,
3888 options,
3889 &mut archive,
3890 &stale_preflight,
3891 DuplicateDecision::KeepBoth,
3892 )
3893 .await
3894 {
3895 Ok(report) => anyhow::bail!(
3896 "stale preflight unexpectedly downloaded to {}",
3897 report.output_dir.display()
3898 ),
3899 Err(error) => error,
3900 };
3901
3902 assert!(matches!(
3903 error,
3904 crate::Error::InvalidInput(message)
3905 if message.contains("current download archive")
3906 ));
3907 assert!(!output_base.join("Mock video (2)").exists());
3908 Ok(())
3909 }
3910
3911 #[cfg(unix)]
3912 #[test]
3913 fn download_preflight_matches_symlink_parent_archive_output() -> anyhow::Result<()> {
3914 let server = MockServer::start();
3915 let temp = tempfile::tempdir()?;
3916 let plan = test_plan(&server);
3917 let options = DownloadOptions::new(temp.path().join("downloads"));
3918 let planned_output_dir = default_plan_output_dir(&plan, &options);
3919 let output_subdir = planned_output_dir.join("subdir");
3920 std_fs::create_dir_all(&output_subdir)?;
3921 let external_parent = temp.path().join("external");
3922 std_fs::create_dir_all(&external_parent)?;
3923 let link_to_output_subdir = external_parent.join("link");
3924 std::os::unix::fs::symlink(&output_subdir, &link_to_output_subdir)?;
3925 let archived_output_dir = link_to_output_subdir.join("..");
3926 let archive = DownloadArchive::new(vec![DownloadArchiveRecord {
3927 content_key: "plan|different-content".to_owned(),
3928 title: "Symlink parent content".to_owned(),
3929 output_dir: archived_output_dir,
3930 completed_at_unix: 42,
3931 entries: Vec::new(),
3932 }]);
3933
3934 let preflight = DownloadPreflight::inspect(&plan, &options, Some(&archive))?;
3935
3936 assert!(preflight.requires_decision());
3937 assert_eq!(preflight.archived_records.len(), 1);
3938 assert_eq!(
3939 preflight.archived_records[0].title,
3940 "Symlink parent content"
3941 );
3942 Ok(())
3943 }
3944
3945 #[tokio::test]
3946 async fn archive_decision_keep_both_uses_new_output_root() -> anyhow::Result<()> {
3947 let server = MockServer::start();
3948 server.mock(|when, then| {
3949 when.method(GET).path("/video.m4s");
3950 then.status(200).body("video");
3951 });
3952 server.mock(|when, then| {
3953 when.method(GET).path("/audio.m4s");
3954 then.status(200).body("audio");
3955 });
3956 server.mock(|when, then| {
3957 when.method(GET).path("/subtitle.ass");
3958 then.status(200).body("[Script Info]");
3959 });
3960 server.mock(|when, then| {
3961 when.method(GET).path("/danmaku.xml");
3962 then.status(200).body("<i/>");
3963 });
3964 let temp = tempfile::tempdir()?;
3965 let output_base = temp.path().join("downloads");
3966 let client = BiliClient::new(ClientConfig::default());
3967 let plan = test_plan(&server);
3968 let options = DownloadOptions::new(output_base.clone())
3969 .with_retry_policy(RetryPolicy::single_attempt())
3970 .with_mux(MuxOptions::Disabled);
3971 std::fs::create_dir_all(default_plan_output_dir(&plan, &options))?;
3972 let mut archive = DownloadArchive::new(vec![DownloadArchiveRecord {
3973 content_key: download_plan_content_key(&plan),
3974 title: plan.title.clone(),
3975 output_dir: default_plan_output_dir(&plan, &options),
3976 completed_at_unix: 42,
3977 entries: Vec::new(),
3978 }]);
3979
3980 let report = client
3981 .download_plan_with_archive_decision(
3982 &plan,
3983 options,
3984 &mut archive,
3985 DuplicateDecision::KeepBoth,
3986 )
3987 .await?;
3988
3989 assert_eq!(report.output_dir, output_base.join("Mock video (2)"));
3990 assert!(report.output_dir.exists());
3991 assert_eq!(archive.records.len(), 2);
3992 assert_eq!(
3993 archive.records[1].output_dir,
3994 comparable_output_path(&report.output_dir)
3995 );
3996 assert_eq!(archive.records[1].entries.len(), 1);
3997 Ok(())
3998 }
3999
4000 #[tokio::test]
4001 async fn archive_decision_keep_both_avoids_archive_only_output_root() -> anyhow::Result<()> {
4002 let server = MockServer::start();
4003 server.mock(|when, then| {
4004 when.method(GET).path("/video.m4s");
4005 then.status(200).body("video");
4006 });
4007 server.mock(|when, then| {
4008 when.method(GET).path("/audio.m4s");
4009 then.status(200).body("audio");
4010 });
4011 let temp = tempfile::tempdir()?;
4012 let output_base = temp.path().join("downloads");
4013 let client = BiliClient::new(ClientConfig::default());
4014 let plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
4015 let options = DownloadOptions::new(output_base.clone())
4016 .with_retry_policy(RetryPolicy::single_attempt())
4017 .with_danmaku(false)
4018 .with_mux(MuxOptions::Disabled);
4019 let planned_output_dir = default_plan_output_dir(&plan, &options);
4020 let planned_file_name = planned_output_dir
4021 .file_name()
4022 .ok_or_else(|| anyhow::anyhow!("missing planned output file name"))?;
4023 let archived_output_dir = planned_output_dir
4024 .parent()
4025 .ok_or_else(|| anyhow::anyhow!("missing planned output parent"))?
4026 .join(".")
4027 .join(planned_file_name);
4028 let mut archive = DownloadArchive::new(vec![DownloadArchiveRecord {
4029 content_key: download_plan_content_key(&plan),
4030 title: plan.title.clone(),
4031 output_dir: archived_output_dir.clone(),
4032 completed_at_unix: 42,
4033 entries: Vec::new(),
4034 }]);
4035
4036 let report = client
4037 .download_plan_with_archive_decision(
4038 &plan,
4039 options,
4040 &mut archive,
4041 DuplicateDecision::KeepBoth,
4042 )
4043 .await?;
4044
4045 assert!(!planned_output_dir.exists());
4046 assert_eq!(report.output_dir, output_base.join("Mock video (2)"));
4047 assert_eq!(archive.records.len(), 2);
4048 assert_eq!(archive.records[0].output_dir, archived_output_dir);
4049 assert_eq!(
4050 archive.records[1].output_dir,
4051 comparable_output_path(&report.output_dir)
4052 );
4053 Ok(())
4054 }
4055
4056 #[tokio::test]
4057 async fn archive_decision_keep_both_avoids_unrelated_archive_output_root() -> anyhow::Result<()>
4058 {
4059 let server = MockServer::start();
4060 server.mock(|when, then| {
4061 when.method(GET).path("/video.m4s");
4062 then.status(200).body("video");
4063 });
4064 server.mock(|when, then| {
4065 when.method(GET).path("/audio.m4s");
4066 then.status(200).body("audio");
4067 });
4068 let temp = tempfile::tempdir()?;
4069 let output_base = temp.path().join("downloads");
4070 let client = BiliClient::new(ClientConfig::default());
4071 let plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
4072 let options = DownloadOptions::new(output_base.clone())
4073 .with_retry_policy(RetryPolicy::single_attempt())
4074 .with_danmaku(false)
4075 .with_mux(MuxOptions::Disabled);
4076 std::fs::create_dir_all(default_plan_output_dir(&plan, &options))?;
4077 let unrelated_output_dir = output_base.join("Mock video (2)");
4078 let mut archive = DownloadArchive::new(vec![DownloadArchiveRecord {
4079 content_key: "plan|unrelated".to_owned(),
4080 title: "Unrelated archived content".to_owned(),
4081 output_dir: unrelated_output_dir.clone(),
4082 completed_at_unix: 42,
4083 entries: Vec::new(),
4084 }]);
4085
4086 let report = client
4087 .download_plan_with_archive_decision(
4088 &plan,
4089 options,
4090 &mut archive,
4091 DuplicateDecision::KeepBoth,
4092 )
4093 .await?;
4094
4095 assert_eq!(report.output_dir, output_base.join("Mock video (3)"));
4096 assert_eq!(archive.records.len(), 2);
4097 assert_eq!(archive.records[0].output_dir, unrelated_output_dir);
4098 assert_eq!(
4099 archive.records[1].output_dir,
4100 comparable_output_path(&report.output_dir)
4101 );
4102 Ok(())
4103 }
4104
4105 #[cfg(unix)]
4106 #[tokio::test]
4107 async fn archive_decision_keep_both_skips_broken_symlink_output_root() -> anyhow::Result<()> {
4108 let server = MockServer::start();
4109 server.mock(|when, then| {
4110 when.method(GET).path("/video.m4s");
4111 then.status(200).body("video");
4112 });
4113 server.mock(|when, then| {
4114 when.method(GET).path("/audio.m4s");
4115 then.status(200).body("audio");
4116 });
4117 let temp = tempfile::tempdir()?;
4118 let output_base = temp.path().join("downloads");
4119 let client = BiliClient::new(ClientConfig::default());
4120 let plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
4121 let options = DownloadOptions::new(output_base.clone())
4122 .with_retry_policy(RetryPolicy::single_attempt())
4123 .with_danmaku(false)
4124 .with_mux(MuxOptions::Disabled);
4125 let planned_output_dir = default_plan_output_dir(&plan, &options);
4126 let output_parent = planned_output_dir
4127 .parent()
4128 .ok_or_else(|| anyhow::anyhow!("missing planned output parent"))?;
4129 std::fs::create_dir_all(output_parent)?;
4130 std::os::unix::fs::symlink(temp.path().join("missing-target"), &planned_output_dir)?;
4131 let mut archive = DownloadArchive::default();
4132
4133 let report = client
4134 .download_plan_with_archive_decision(
4135 &plan,
4136 options,
4137 &mut archive,
4138 DuplicateDecision::KeepBoth,
4139 )
4140 .await?;
4141
4142 assert!(!planned_output_dir.exists());
4143 assert_eq!(report.output_dir, output_base.join("Mock video (2)"));
4144 assert_eq!(
4145 archive.records[0].output_dir,
4146 comparable_output_path(&report.output_dir)
4147 );
4148 Ok(())
4149 }
4150
4151 #[cfg(unix)]
4152 #[tokio::test]
4153 async fn archive_decision_replace_removes_broken_symlink_output_root() -> anyhow::Result<()> {
4154 let server = MockServer::start();
4155 server.mock(|when, then| {
4156 when.method(GET).path("/video.m4s");
4157 then.status(200).body("video");
4158 });
4159 server.mock(|when, then| {
4160 when.method(GET).path("/audio.m4s");
4161 then.status(200).body("audio");
4162 });
4163 let temp = tempfile::tempdir()?;
4164 let output_base = temp.path().join("downloads");
4165 let client = BiliClient::new(ClientConfig::default());
4166 let plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
4167 let options = DownloadOptions::new(output_base.clone())
4168 .with_retry_policy(RetryPolicy::single_attempt())
4169 .with_danmaku(false)
4170 .with_mux(MuxOptions::Disabled);
4171 let planned_output_dir = default_plan_output_dir(&plan, &options);
4172 let output_parent = planned_output_dir
4173 .parent()
4174 .ok_or_else(|| anyhow::anyhow!("missing planned output parent"))?;
4175 std::fs::create_dir_all(output_parent)?;
4176 std::os::unix::fs::symlink(temp.path().join("missing-target"), &planned_output_dir)?;
4177 let mut archive = DownloadArchive::default();
4178
4179 let report = client
4180 .download_plan_with_archive_decision(
4181 &plan,
4182 options,
4183 &mut archive,
4184 DuplicateDecision::Replace,
4185 )
4186 .await?;
4187
4188 assert_eq!(report.output_dir, planned_output_dir);
4189 assert!(tokio::fs::metadata(&planned_output_dir).await?.is_dir());
4190 assert_eq!(
4191 archive.records[0].output_dir,
4192 comparable_output_path(&report.output_dir)
4193 );
4194 Ok(())
4195 }
4196
4197 #[tokio::test]
4198 async fn archive_decision_replace_forces_fresh_writes() -> anyhow::Result<()> {
4199 let server = MockServer::start();
4200 server.mock(|when, then| {
4201 when.method(GET).path("/video.m4s");
4202 then.status(200).body("video");
4203 });
4204 server.mock(|when, then| {
4205 when.method(GET).path("/audio.m4s");
4206 then.status(200).body("audio");
4207 });
4208 let temp = tempfile::tempdir()?;
4209 let client = BiliClient::new(ClientConfig::default());
4210 let plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
4211 let options = DownloadOptions::new(temp.path().join("downloads"))
4212 .with_retry_policy(RetryPolicy::single_attempt())
4213 .with_danmaku(false)
4214 .with_mux(MuxOptions::Disabled);
4215 let entry_dir = test_entry_dir(&options.output_dir, &plan);
4216 std::fs::create_dir_all(&entry_dir)?;
4217 let video_path =
4218 entry_dir.join(media_file_name("video", &plan.entries[0].streams.videos[0]));
4219 std::fs::write(&video_path, "partial")?;
4220 let stale_sidecar = entry_dir.join("danmaku.xml");
4221 std::fs::write(&stale_sidecar, "<old/>")?;
4222 let planned_output_dir = default_plan_output_dir(&plan, &options);
4223 let planned_file_name = planned_output_dir
4224 .file_name()
4225 .ok_or_else(|| anyhow::anyhow!("missing planned output file name"))?;
4226 let equivalent_output_dir = planned_output_dir
4227 .parent()
4228 .ok_or_else(|| anyhow::anyhow!("missing planned output parent"))?
4229 .join(".")
4230 .join(planned_file_name);
4231 let stale_content_key = "plan|old-content".to_owned();
4232 let mut archive = DownloadArchive::new(vec![DownloadArchiveRecord {
4233 content_key: stale_content_key.clone(),
4234 title: "Old content".to_owned(),
4235 output_dir: equivalent_output_dir,
4236 completed_at_unix: 41,
4237 entries: Vec::new(),
4238 }]);
4239
4240 let report = client
4241 .download_plan_with_archive_decision(
4242 &plan,
4243 options,
4244 &mut archive,
4245 DuplicateDecision::Replace,
4246 )
4247 .await?;
4248
4249 assert_eq!(tokio::fs::read_to_string(video_path).await?, "video");
4250 assert!(!stale_sidecar.exists());
4251 assert_eq!(report.entries[0].files[0].resumed_from, 0);
4252 assert_eq!(archive.records.len(), 1);
4253 assert_eq!(
4254 archive.records[0].content_key,
4255 download_plan_content_key(&plan)
4256 );
4257 assert_ne!(archive.records[0].content_key, stale_content_key);
4258 Ok(())
4259 }
4260
4261 #[tokio::test]
4262 async fn archive_preflight_cancel_rejects_late_output_conflict() -> anyhow::Result<()> {
4263 let server = MockServer::start();
4264 let temp = tempfile::tempdir()?;
4265 let client = BiliClient::new(ClientConfig::default());
4266 let plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
4267 let options = DownloadOptions::new(temp.path().join("downloads"))
4268 .with_retry_policy(RetryPolicy::single_attempt())
4269 .with_danmaku(false)
4270 .with_mux(MuxOptions::Disabled);
4271 let mut archive = DownloadArchive::default();
4272 let preflight = DownloadPreflight::inspect(&plan, &options, Some(&archive))?;
4273 let marker = preflight.planned_output_dir.join("marker.txt");
4274 std::fs::create_dir_all(&preflight.planned_output_dir)?;
4275 std::fs::write(&marker, "external")?;
4276
4277 let result = client
4278 .download_plan_with_archive_preflight_decision(
4279 &plan,
4280 options,
4281 &mut archive,
4282 &preflight,
4283 DuplicateDecision::Cancel,
4284 )
4285 .await;
4286 let error = match result {
4287 Ok(report) => anyhow::bail!(
4288 "late output conflict must not be deleted by safe continue, got {report:?}"
4289 ),
4290 Err(error) => error,
4291 };
4292
4293 assert!(
4294 error
4295 .to_string()
4296 .contains("output directory appeared after duplicate preflight")
4297 );
4298 assert_eq!(std::fs::read_to_string(marker)?, "external");
4299 assert!(archive.records.is_empty());
4300 Ok(())
4301 }
4302
4303 #[tokio::test]
4304 async fn archive_preflight_replace_removes_late_output_conflict() -> anyhow::Result<()> {
4305 let server = MockServer::start();
4306 server.mock(|when, then| {
4307 when.method(GET).path("/video.m4s");
4308 then.status(200).body("video");
4309 });
4310 server.mock(|when, then| {
4311 when.method(GET).path("/audio.m4s");
4312 then.status(200).body("audio");
4313 });
4314 let temp = tempfile::tempdir()?;
4315 let client = BiliClient::new(ClientConfig::default());
4316 let plan = single_video_plan(format!("{}/video.m4s", server.base_url()));
4317 let options = DownloadOptions::new(temp.path().join("downloads"))
4318 .with_retry_policy(RetryPolicy::single_attempt())
4319 .with_danmaku(false)
4320 .with_mux(MuxOptions::Disabled);
4321 let entry_dir = test_entry_dir(&options.output_dir, &plan);
4322 let video_path =
4323 entry_dir.join(media_file_name("video", &plan.entries[0].streams.videos[0]));
4324 let stale_sidecar = entry_dir.join("stale.txt");
4325 let mut archive = DownloadArchive::default();
4326 let preflight = DownloadPreflight::inspect(&plan, &options, Some(&archive))?;
4327 std::fs::create_dir_all(&entry_dir)?;
4328 std::fs::write(&video_path, "partial")?;
4329 std::fs::write(&stale_sidecar, "stale")?;
4330
4331 let report = client
4332 .download_plan_with_archive_preflight_decision(
4333 &plan,
4334 options,
4335 &mut archive,
4336 &preflight,
4337 DuplicateDecision::Replace,
4338 )
4339 .await?;
4340
4341 assert_eq!(tokio::fs::read_to_string(video_path).await?, "video");
4342 assert!(!stale_sidecar.exists());
4343 assert_eq!(report.entries[0].files[0].resumed_from, 0);
4344 assert_eq!(archive.records.len(), 1);
4345 Ok(())
4346 }
4347
4348 #[test]
4349 fn download_archive_record_stores_absolute_paths() -> anyhow::Result<()> {
4350 let server = MockServer::start();
4351 let plan = test_plan(&server);
4352 let report = DownloadReport {
4353 title: plan.title.clone(),
4354 output_dir: Path::new("downloads").join("Mock video"),
4355 entries: vec![EntryDownloadReport {
4356 index: 1,
4357 title: "Main".to_owned(),
4358 directory: Path::new("downloads").join("Mock video").join("entry"),
4359 files: vec![DownloadedFile {
4360 kind: DownloadFileKind::Video,
4361 path: Path::new("downloads")
4362 .join("Mock video")
4363 .join("entry")
4364 .join("video.m4s"),
4365 bytes_written: 5,
4366 resumed_from: 0,
4367 }],
4368 mux: Some(super::MuxReport {
4369 output_path: Path::new("downloads")
4370 .join("Mock video")
4371 .join("entry")
4372 .join("main.mp4"),
4373 command: vec!["ffmpeg".to_owned()],
4374 }),
4375 }],
4376 };
4377 let mut archive = DownloadArchive::default();
4378
4379 archive.record_download(&plan, &report);
4380
4381 let record = &archive.records[0];
4382 assert!(record.output_dir.is_absolute());
4383 assert!(
4384 record
4385 .output_dir
4386 .ends_with(Path::new("downloads").join("Mock video"))
4387 );
4388 assert!(record.entries[0].directory.is_absolute());
4389 assert!(
4390 record.entries[0]
4391 .directory
4392 .ends_with(Path::new("downloads").join("Mock video").join("entry"))
4393 );
4394 assert!(record.entries[0].files[0].is_absolute());
4395 assert!(
4396 record.entries[0].files[0].ends_with(
4397 Path::new("downloads")
4398 .join("Mock video")
4399 .join("entry")
4400 .join("video.m4s")
4401 )
4402 );
4403 let mux_output = record.entries[0]
4404 .mux_output
4405 .as_ref()
4406 .ok_or_else(|| anyhow::anyhow!("missing mux output"))?;
4407 assert!(mux_output.is_absolute());
4408 assert!(
4409 mux_output.ends_with(
4410 Path::new("downloads")
4411 .join("Mock video")
4412 .join("entry")
4413 .join("main.mp4")
4414 )
4415 );
4416 Ok(())
4417 }
4418
4419 #[cfg(unix)]
4420 #[test]
4421 fn download_archive_record_resolves_symlink_parent_output_path() -> anyhow::Result<()> {
4422 let server = MockServer::start();
4423 let temp = tempfile::tempdir()?;
4424 let plan = test_plan(&server);
4425 let planned_output_dir = temp.path().join("downloads").join("Mock video");
4426 let output_subdir = planned_output_dir.join("subdir");
4427 std_fs::create_dir_all(&output_subdir)?;
4428 let external_parent = temp.path().join("external");
4429 std_fs::create_dir_all(&external_parent)?;
4430 let link_to_output_subdir = external_parent.join("link");
4431 std::os::unix::fs::symlink(&output_subdir, &link_to_output_subdir)?;
4432 let report = DownloadReport {
4433 title: plan.title.clone(),
4434 output_dir: link_to_output_subdir.join(".."),
4435 entries: Vec::new(),
4436 };
4437 let mut archive = DownloadArchive::default();
4438
4439 archive.record_download(&plan, &report);
4440
4441 assert_eq!(
4442 archive.records[0].output_dir,
4443 comparable_output_path(&planned_output_dir)
4444 );
4445 Ok(())
4446 }
4447
4448 #[test]
4449 fn download_archive_round_trips_without_urls() -> anyhow::Result<()> {
4450 let server = MockServer::start();
4451 let temp = tempfile::tempdir()?;
4452 let plan = test_plan(&server);
4453 let report = DownloadReport {
4454 title: plan.title.clone(),
4455 output_dir: temp.path().join("downloads").join("Mock video"),
4456 entries: vec![EntryDownloadReport {
4457 index: 1,
4458 title: "Main".to_owned(),
4459 directory: temp
4460 .path()
4461 .join("downloads")
4462 .join("Mock video")
4463 .join("entry"),
4464 files: vec![DownloadedFile {
4465 kind: DownloadFileKind::Video,
4466 path: temp
4467 .path()
4468 .join("downloads")
4469 .join("Mock video")
4470 .join("entry")
4471 .join("video.m4s"),
4472 bytes_written: 5,
4473 resumed_from: 0,
4474 }],
4475 mux: None,
4476 }],
4477 };
4478 let mut archive = DownloadArchive::default();
4479 archive.record_download(&plan, &report);
4480 let archive_path = temp.path().join("archive.json");
4481
4482 archive.save(&archive_path)?;
4483 let raw = std::fs::read_to_string(&archive_path)?;
4484 let loaded = DownloadArchive::load(&archive_path)?;
4485
4486 assert_eq!(loaded, archive);
4487 assert!(!raw.contains("https://"));
4488 assert!(!raw.contains("ACCESS"));
4489 Ok(())
4490 }
4491
4492 #[test]
4493 fn download_archive_save_replaces_existing_file() -> anyhow::Result<()> {
4494 let temp = tempfile::tempdir()?;
4495 let archive_path = temp.path().join("archive.json");
4496 std::fs::write(&archive_path, "{\"old\":true}")?;
4497
4498 DownloadArchive::default().save(&archive_path)?;
4499
4500 let raw = std::fs::read_to_string(&archive_path)?;
4501 assert!(raw.contains("\"records\""));
4502 assert!(!raw.contains("\"old\""));
4503 assert!(!archive_sidecar_path(&archive_path, ".bbdown-archive-backup").exists());
4504 Ok(())
4505 }
4506
4507 #[cfg(unix)]
4508 #[test]
4509 fn download_archive_save_preserves_symlink_target() -> anyhow::Result<()> {
4510 let temp = tempfile::tempdir()?;
4511 let shared_dir = temp.path().join("shared");
4512 std::fs::create_dir_all(&shared_dir)?;
4513 let archive_target = shared_dir.join("archive.json");
4514 let archive_link = temp.path().join("archive-link.json");
4515 std::fs::write(&archive_target, "{\"old\":true}")?;
4516 std::os::unix::fs::symlink(&archive_target, &archive_link)?;
4517
4518 DownloadArchive::default().save(&archive_link)?;
4519
4520 let raw = std::fs::read_to_string(&archive_target)?;
4521 assert!(
4522 std::fs::symlink_metadata(&archive_link)?
4523 .file_type()
4524 .is_symlink()
4525 );
4526 assert!(raw.contains("\"records\""));
4527 assert!(!raw.contains("\"old\""));
4528 assert!(!archive_sidecar_path(&archive_target, ".bbdown-archive-backup").exists());
4529 assert!(!archive_sidecar_path(&archive_link, ".bbdown-archive-backup").exists());
4530 Ok(())
4531 }
4532
4533 #[test]
4534 fn download_archive_save_rejects_directory_path() -> anyhow::Result<()> {
4535 let temp = tempfile::tempdir()?;
4536 let archive_path = temp.path().join("archive.json");
4537 std::fs::create_dir_all(&archive_path)?;
4538
4539 let error = match DownloadArchive::default().save(&archive_path) {
4540 Ok(()) => anyhow::bail!("directory archive path unexpectedly saved"),
4541 Err(error) => error,
4542 };
4543
4544 assert!(matches!(
4545 error,
4546 crate::Error::InvalidInput(message) if message.contains("directory")
4547 ));
4548 Ok(())
4549 }
4550
4551 #[cfg(unix)]
4552 #[test]
4553 fn download_archive_load_reports_metadata_errors() -> anyhow::Result<()> {
4554 let temp = tempfile::tempdir()?;
4555 let restricted = temp.path().join("restricted");
4556 std_fs::create_dir(&restricted)?;
4557 let original_permissions = std_fs::metadata(&restricted)?.permissions();
4558 let mut denied_permissions = original_permissions.clone();
4559 denied_permissions.set_mode(0o000);
4560 std_fs::set_permissions(&restricted, denied_permissions)?;
4561
4562 let result = DownloadArchive::load(restricted.join("archive.json"));
4563
4564 std_fs::set_permissions(&restricted, original_permissions)?;
4565 match result {
4566 Err(crate::Error::Io(error))
4567 if error.kind() == std::io::ErrorKind::PermissionDenied => {}
4568 Ok(archive) => {
4569 anyhow::bail!(
4570 "expected archive load permission error, got {} records",
4571 archive.records.len()
4572 );
4573 }
4574 Err(error) => {
4575 anyhow::bail!("expected archive load permission error, got {error}");
4576 }
4577 }
4578 Ok(())
4579 }
4580
4581 fn media_stream(id: u32, base_url: &str) -> MediaStream {
4582 MediaStream {
4583 id,
4584 base_url: base_url.to_owned(),
4585 backup_urls: Vec::new(),
4586 codecs: None,
4587 bandwidth: None,
4588 width: None,
4589 height: None,
4590 frame_rate: None,
4591 mime_type: Some("video/mp4".to_owned()),
4592 size: None,
4593 }
4594 }
4595
4596 fn test_plan(server: &MockServer) -> DownloadPlan {
4597 DownloadPlan {
4598 title: "Mock video".to_owned(),
4599 entries: vec![DownloadEntry {
4600 index: 1,
4601 aid: 170_001,
4602 bvid: Some("BV1xx411c7mD".to_owned()),
4603 cid: 2,
4604 epid: None,
4605 title: "Main".to_owned(),
4606 source: StreamSource::NormalWeb,
4607 streams: StreamSet {
4608 videos: vec![MediaStream {
4609 id: 80,
4610 base_url: format!("{}/video.m4s", server.base_url()),
4611 backup_urls: Vec::new(),
4612 codecs: None,
4613 bandwidth: None,
4614 width: None,
4615 height: None,
4616 frame_rate: None,
4617 mime_type: Some("video/mp4".to_owned()),
4618 size: None,
4619 }],
4620 audios: vec![MediaStream {
4621 id: 30280,
4622 base_url: format!("{}/audio.m4s", server.base_url()),
4623 backup_urls: Vec::new(),
4624 codecs: None,
4625 bandwidth: None,
4626 width: None,
4627 height: None,
4628 frame_rate: None,
4629 mime_type: Some("audio/mp4".to_owned()),
4630 size: None,
4631 }],
4632 flv_segments: Vec::new(),
4633 accept_quality: vec![80],
4634 qualities: vec![StreamQuality {
4635 id: 80,
4636 description: Some("1080P".to_owned()),
4637 }],
4638 duration_seconds: Some(3),
4639 },
4640 diagnostics: StreamDiagnostics::default(),
4641 subtitles: vec![SubtitleTrack {
4642 language: "en".to_owned(),
4643 language_doc: Some("English".to_owned()),
4644 url: format!("{}/subtitle.ass", server.base_url()),
4645 format: SubtitleFormat::Ass,
4646 }],
4647 danmaku: DanmakuTrack {
4648 cid: 2,
4649 xml_url: format!("{}/danmaku.xml", server.base_url()),
4650 },
4651 }],
4652 }
4653 }
4654
4655 fn single_video_plan(url: String) -> DownloadPlan {
4656 let mut plan = test_plan(&MockServer::start());
4657 plan.entries[0].streams.videos[0].base_url = url;
4658 plan.entries[0].streams.audios[0].base_url =
4659 companion_audio_url(&plan.entries[0].streams.videos[0].base_url);
4660 plan.entries[0].subtitles.clear();
4661 plan
4662 }
4663
4664 fn companion_audio_url(video_url: &str) -> String {
4665 let Ok(mut url) = url::Url::parse(video_url) else {
4666 return video_url.to_owned();
4667 };
4668 url.set_path("/audio.m4s");
4669 url.set_query(None);
4670 url.set_fragment(None);
4671 url.to_string()
4672 }
4673
4674 fn test_entry_dir(base: &Path, plan: &DownloadPlan) -> std::path::PathBuf {
4675 base.join(safe_file_name(&plan.title))
4676 .join(entry_dir_name(&plan.entries[0]))
4677 }
4678
4679 #[cfg(unix)]
4680 fn write_fake_ffmpeg(dir: &Path, body: &str) -> anyhow::Result<std::path::PathBuf> {
4681 let path = dir.join("fake-ffmpeg");
4682 std_fs::write(&path, format!("#!/bin/sh\n{body}\n"))?;
4683 let mut permissions = std_fs::metadata(&path)?.permissions();
4684 permissions.set_mode(0o755);
4685 std_fs::set_permissions(&path, permissions)?;
4686 Ok(path)
4687 }
4688
4689 #[cfg(unix)]
4690 fn fake_ffmpeg_creates_output_body() -> &'static str {
4691 "last=\nfor arg do last=$arg; done\nprintf 'muxed' > \"$last\"\nexit 0"
4692 }
4693}