Skip to main content

bbdown_core/
download.rs

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}