Skip to main content

ani_lib/
download.rs

1use std::{
2    io::{IsTerminal, Write},
3    path::{Path, PathBuf},
4    time::{Duration, Instant},
5};
6
7use futures_util::StreamExt;
8use reqwest::header;
9use tokio::{fs::OpenOptions, io::AsyncWriteExt, process::Command};
10
11use crate::{
12    AniError, RequestHeaders, Result, StreamLink, SubtitleTrack,
13    relay_stream_without_hls_subtitles, requires_hls_relay,
14};
15
16const MAX_SUBTITLE_BYTES: usize = 16 * 1024 * 1024;
17
18#[derive(Clone, Debug)]
19pub struct DownloadOptions {
20    pub directory: PathBuf,
21    pub filename: String,
22}
23
24pub async fn download_stream(stream: &StreamLink, options: &DownloadOptions) -> Result<PathBuf> {
25    if requires_hls_relay(stream) {
26        // Downloads never need subtitles exposed as synthetic HLS renditions:
27        // subtitle tracks are fetched separately (via the relayed sidecar
28        // URLs on `local.subtitles`) and muxed into the output with FFmpeg.
29        let (_relay, local) = relay_stream_without_hls_subtitles(stream).await?;
30        let target = download_stream_inner(&local, options).await?;
31        attach_subtitles(&local, &target).await?;
32        return Ok(target);
33    }
34    let target = download_stream_inner(stream, options).await?;
35    attach_subtitles(stream, &target).await?;
36    Ok(target)
37}
38
39#[derive(Debug)]
40struct DownloadedSubtitle {
41    path: PathBuf,
42    label: String,
43    default: bool,
44}
45
46async fn attach_subtitles(stream: &StreamLink, target: &Path) -> Result<()> {
47    if stream.subtitles.is_empty() {
48        return Ok(());
49    }
50    let mut subtitles = Vec::new();
51    for (index, track) in stream.subtitles.iter().enumerate() {
52        match download_subtitle_track(track, &stream.headers, target, index).await {
53            Ok(subtitle) => subtitles.push(subtitle),
54            Err(error) => eprintln!("Could not download subtitle '{}': {error}", track.label),
55        }
56    }
57    if subtitles.is_empty() {
58        eprintln!("No provider subtitle tracks could be downloaded.");
59        return Ok(());
60    }
61    if !program_available("ffmpeg").await {
62        eprintln!(
63            "FFmpeg is unavailable; saved {} subtitle track(s) beside the video.",
64            subtitles.len()
65        );
66        return Ok(());
67    }
68
69    let stem = target
70        .file_stem()
71        .and_then(|value| value.to_str())
72        .unwrap_or("episode");
73    let muxed = target.with_file_name(format!("{stem}.subtitled.part.mp4"));
74    let args = subtitle_mux_args(target, &subtitles, &muxed);
75    let status = Command::new("ffmpeg").args(&args).status().await;
76    match status {
77        Ok(status) if status.success() => {
78            if tokio::fs::try_exists(target).await? {
79                tokio::fs::remove_file(target).await?;
80            }
81            tokio::fs::rename(&muxed, target).await?;
82            for subtitle in subtitles {
83                let _ = tokio::fs::remove_file(subtitle.path).await;
84            }
85            eprintln!("Embedded provider subtitles into the downloaded MP4.");
86        }
87        Ok(status) => {
88            let _ = tokio::fs::remove_file(&muxed).await;
89            eprintln!(
90                "FFmpeg subtitle muxing exited with {}; subtitle sidecars were kept.",
91                status.code().unwrap_or(1)
92            );
93        }
94        Err(error) => {
95            let _ = tokio::fs::remove_file(&muxed).await;
96            eprintln!(
97                "Could not start FFmpeg for subtitle muxing ({error}); subtitle sidecars were kept."
98            );
99        }
100    }
101    Ok(())
102}
103
104async fn download_subtitle_track(
105    track: &SubtitleTrack,
106    headers: &RequestHeaders,
107    target: &Path,
108    index: usize,
109) -> Result<DownloadedSubtitle> {
110    let client = reqwest::Client::builder().build()?;
111    let mut request = client.get(&track.url);
112    if let Some(referer) = &headers.referer {
113        request = request.header(header::REFERER, referer);
114    }
115    if let Some(origin) = &headers.origin {
116        request = request.header(header::ORIGIN, origin);
117    }
118    for (name, value) in &headers.extra {
119        request = request.header(name, value);
120    }
121    let response = request.send().await?;
122    if !response.status().is_success() {
123        return Err(AniError::SubtitleDownloadFailed {
124            track: track.label.clone(),
125            reason: format!("subtitle server returned {}", response.status()),
126        });
127    }
128    if let Some(length) = response
129        .content_length()
130        .filter(|&length| length > MAX_SUBTITLE_BYTES as u64)
131    {
132        return Err(AniError::SubtitleSizeExceeded {
133            size: length as usize,
134            limit: MAX_SUBTITLE_BYTES,
135        });
136    }
137    let bytes = response.bytes().await?;
138    if bytes.len() > MAX_SUBTITLE_BYTES {
139        return Err(AniError::SubtitleSizeExceeded {
140            size: bytes.len(),
141            limit: MAX_SUBTITLE_BYTES,
142        });
143    }
144    let stem = target
145        .file_stem()
146        .and_then(|value| value.to_str())
147        .unwrap_or("episode");
148    let label = sanitize_filename(&track.label);
149    let extension = subtitle_extension(&track.url);
150    let path = target.with_file_name(format!("{stem}.{index}.{label}.{extension}"));
151    tokio::fs::write(&path, bytes).await?;
152    Ok(DownloadedSubtitle {
153        path,
154        label: track.label.clone(),
155        default: track.default,
156    })
157}
158
159fn subtitle_extension(url: &str) -> &'static str {
160    let extension = url::Url::parse(url).ok().and_then(|url| {
161        Path::new(url.path())
162            .extension()
163            .and_then(|value| value.to_str())
164            .map(str::to_ascii_lowercase)
165    });
166    match extension.as_deref() {
167        Some("srt") => "srt",
168        Some("ass") => "ass",
169        Some("ssa") => "ssa",
170        _ => "vtt",
171    }
172}
173
174fn subtitle_mux_args(
175    target: &Path,
176    subtitles: &[DownloadedSubtitle],
177    output: &Path,
178) -> Vec<String> {
179    let mut args = vec![
180        "-y".into(),
181        "-loglevel".into(),
182        "error".into(),
183        "-stats".into(),
184        "-i".into(),
185        target.to_string_lossy().into_owned(),
186    ];
187    for subtitle in subtitles {
188        args.extend(["-i".into(), subtitle.path.to_string_lossy().into_owned()]);
189    }
190    args.extend(["-map".into(), "0:v?".into(), "-map".into(), "0:a?".into()]);
191    for index in 0..subtitles.len() {
192        args.extend(["-map".into(), format!("{}:0", index + 1)]);
193    }
194    args.extend([
195        "-c:v".into(),
196        "copy".into(),
197        "-c:a".into(),
198        "copy".into(),
199        "-c:s".into(),
200        "mov_text".into(),
201    ]);
202    let default_index = subtitles.iter().position(|subtitle| subtitle.default);
203    for (index, subtitle) in subtitles.iter().enumerate() {
204        args.extend([
205            format!("-metadata:s:s:{index}"),
206            format!("title={}", subtitle.label),
207            format!("-metadata:s:s:{index}"),
208            format!("language={}", subtitle_language_code(&subtitle.label)),
209            format!("-disposition:s:{index}"),
210            if Some(index) == default_index || (default_index.is_none() && index == 0) {
211                "default".into()
212            } else {
213                "0".into()
214            },
215        ]);
216    }
217    args.push(output.to_string_lossy().into_owned());
218    args
219}
220
221fn subtitle_language_code(label: &str) -> &'static str {
222    let normalized = label.to_ascii_lowercase();
223    let label = normalized
224        .split(['(', '[', '-', '_'])
225        .next()
226        .unwrap_or(&normalized)
227        .trim();
228    match label {
229        "english" | "en" => "eng",
230        "polish" | "polski" | "pl" => "pol",
231        "spanish" | "español" | "espanol" | "es" => "spa",
232        "portuguese" | "português" | "portugues" | "pt" => "por",
233        "french" | "français" | "francais" | "fr" => "fra",
234        "german" | "deutsch" | "de" => "deu",
235        "italian" | "italiano" | "it" => "ita",
236        "japanese" | "日本語" | "ja" => "jpn",
237        "korean" | "한국어" | "ko" => "kor",
238        "chinese" | "中文" | "zh" => "zho",
239        "arabic" | "العربية" | "ar" => "ara",
240        "russian" | "русский" | "ru" => "rus",
241        "ukrainian" | "українська" | "uk" => "ukr",
242        "turkish" | "türkçe" | "turkce" | "tr" => "tur",
243        "indonesian" | "bahasa indonesia" | "id" => "ind",
244        "vietnamese" | "tiếng việt" | "tieng viet" | "vi" => "vie",
245        "thai" | "ไทย" | "th" => "tha",
246        "hindi" | "हिन्दी" | "hi" => "hin",
247        "dutch" | "nederlands" | "nl" => "nld",
248        "czech" | "čeština" | "cestina" | "cs" => "ces",
249        "romanian" | "română" | "romana" | "ro" => "ron",
250        "hungarian" | "magyar" | "hu" => "hun",
251        _ => "und",
252    }
253}
254
255async fn download_stream_inner(stream: &StreamLink, options: &DownloadOptions) -> Result<PathBuf> {
256    tokio::fs::create_dir_all(&options.directory).await?;
257    let filename = sanitize_filename(&options.filename);
258    let target = options.directory.join(format!("{filename}.mp4"));
259    if stream.hls {
260        download_hls(stream, &target).await?;
261        return Ok(target);
262    }
263
264    let partial = target.with_extension("mp4.part");
265    match run_tool("aria2c", &aria2_args(stream, &partial)).await {
266        ToolAttempt::Success => match finalize_partial(&partial, &target).await {
267            Ok(()) => return Ok(target),
268            Err(error) => eprintln!(
269                "aria2c finished but its output could not be finalized ({error}); falling back to the built-in downloader..."
270            ),
271        },
272        ToolAttempt::Failed(error) => {
273            eprintln!("aria2c failed ({error}); falling back to the built-in downloader...");
274        }
275        ToolAttempt::Unavailable => {}
276    }
277    download_direct(stream, &target).await?;
278    Ok(target)
279}
280
281async fn download_hls(stream: &StreamLink, target: &Path) -> Result<()> {
282    let mut failures = Vec::new();
283    if program_available("aria2c").await {
284        match run_tool("yt-dlp", &yt_dlp_args(stream, target, true)).await {
285            ToolAttempt::Success => return Ok(()),
286            ToolAttempt::Failed(error) => {
287                eprintln!("yt-dlp with aria2c failed ({error}); retrying with yt-dlp...");
288                failures.push(error);
289            }
290            ToolAttempt::Unavailable => {}
291        }
292    }
293
294    match run_tool("yt-dlp", &yt_dlp_args(stream, target, false)).await {
295        ToolAttempt::Success => return Ok(()),
296        ToolAttempt::Failed(error) => {
297            eprintln!("yt-dlp failed ({error}); falling back to FFmpeg...");
298            failures.push(error);
299        }
300        ToolAttempt::Unavailable => {}
301    }
302    match run_tool("ffmpeg", &ffmpeg_args(stream, target)).await {
303        ToolAttempt::Success => return Ok(()),
304        ToolAttempt::Failed(error) => failures.push(error),
305        ToolAttempt::Unavailable => {}
306    }
307
308    if failures.is_empty() {
309        Err(AniError::NoDownloadTool)
310    } else {
311        eprintln!("HLS download failures: {}", failures.join("; "));
312        Err(AniError::HlsDownloadFailed {
313            reason: failures.join("; "),
314        })
315    }
316}
317
318async fn program_available(program: &str) -> bool {
319    Command::new(program)
320        .arg("--version")
321        .output()
322        .await
323        .is_ok()
324}
325
326#[derive(Debug, PartialEq, Eq)]
327enum ToolAttempt {
328    Unavailable,
329    Success,
330    Failed(String),
331}
332
333async fn run_tool(program: &str, args: &[String]) -> ToolAttempt {
334    if !program_available(program).await {
335        return ToolAttempt::Unavailable;
336    }
337    eprintln!("Downloading with {program} (progress is reported by {program})...");
338    match Command::new(program).args(args).status().await {
339        Ok(status) if status.success() => ToolAttempt::Success,
340        Ok(status) => ToolAttempt::Failed(format!(
341            "{program} exited with {}",
342            status.code().unwrap_or(1)
343        )),
344        Err(error) => ToolAttempt::Failed(format!("could not start {program}: {error}")),
345    }
346}
347
348fn yt_dlp_args(stream: &StreamLink, target: &Path, aria2: bool) -> Vec<String> {
349    let mut args = Vec::new();
350    if aria2 {
351        args.extend(["--downloader".into(), "aria2c".into()]);
352        if let Some(config) = aria2_config_path() {
353            args.extend([
354                "--downloader-args".into(),
355                format!("aria2c:--conf-path=\"{}\"", config.to_string_lossy()),
356            ]);
357        }
358    }
359    append_yt_dlp_headers(&mut args, stream);
360    args.extend([
361        "--no-skip-unavailable-fragments".into(),
362        "--fragment-retries".into(),
363        "infinite".into(),
364        "--progress".into(),
365        "-N".into(),
366        "16".into(),
367        "-o".into(),
368        target.to_string_lossy().into_owned(),
369        stream.url.clone(),
370    ]);
371    args
372}
373
374fn append_yt_dlp_headers(args: &mut Vec<String>, stream: &StreamLink) {
375    if let Some(referer) = &stream.headers.referer {
376        args.extend(["--referer".into(), referer.clone()]);
377    }
378    if let Some(origin) = &stream.headers.origin {
379        args.extend(["--add-headers".into(), format!("Origin:{origin}")]);
380    }
381    for (name, value) in &stream.headers.extra {
382        args.extend(["--add-headers".into(), format!("{name}:{value}")]);
383    }
384}
385
386fn ffmpeg_args(stream: &StreamLink, target: &Path) -> Vec<String> {
387    let mut args = vec!["-y".into(), "-extension_picky".into(), "0".into()];
388    if let Some(referer) = &stream.headers.referer {
389        args.extend(["-referer".into(), referer.clone()]);
390    }
391    let mut headers = Vec::new();
392    if let Some(origin) = &stream.headers.origin {
393        headers.push(format!("Origin: {origin}"));
394    }
395    headers.extend(
396        stream
397            .headers
398            .extra
399            .iter()
400            .map(|(name, value)| format!("{name}: {value}")),
401    );
402    if !headers.is_empty() {
403        args.extend(["-headers".into(), format!("{}\r\n", headers.join("\r\n"))]);
404    }
405    args.extend([
406        "-loglevel".into(),
407        "error".into(),
408        "-stats".into(),
409        "-i".into(),
410        stream.url.clone(),
411        "-c".into(),
412        "copy".into(),
413        target.to_string_lossy().into_owned(),
414    ]);
415    args
416}
417
418fn aria2_args(stream: &StreamLink, partial: &Path) -> Vec<String> {
419    let directory = partial.parent().unwrap_or_else(|| Path::new("."));
420    let filename = partial
421        .file_name()
422        .unwrap_or(partial.as_os_str())
423        .to_string_lossy();
424    let connections = aria2_connection_count(stream);
425    let mut args = vec![
426        "--continue=true".into(),
427        format!("--max-connection-per-server={connections}"),
428        format!("--split={connections}"),
429        "--min-split-size=1M".into(),
430        "--file-allocation=none".into(),
431        "--auto-file-renaming=false".into(),
432        "--allow-overwrite=true".into(),
433        "--console-log-level=error".into(),
434        "--download-result=hide".into(),
435        "--summary-interval=0".into(),
436        format!("--dir={}", directory.to_string_lossy()),
437        format!("--out={filename}"),
438    ];
439    if let Some(config) = aria2_config_path() {
440        args.push(format!("--conf-path={}", config.to_string_lossy()));
441    }
442    if let Some(referer) = &stream.headers.referer {
443        args.push(format!("--referer={referer}"));
444    }
445    if let Some(origin) = &stream.headers.origin {
446        args.push(format!("--header=Origin: {origin}"));
447    }
448    args.extend(
449        stream
450            .headers
451            .extra
452            .iter()
453            .map(|(name, value)| format!("--header={name}: {value}")),
454    );
455    args.push(stream.url.clone());
456    args
457}
458
459fn aria2_config_path() -> Option<PathBuf> {
460    let profile = std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME"));
461    aria2_config_path_from(profile)
462}
463
464fn aria2_config_path_from(profile: Option<std::ffi::OsString>) -> Option<PathBuf> {
465    let path = PathBuf::from(profile?).join(".aria2").join("aria2.conf");
466    path.is_file().then_some(path)
467}
468
469fn aria2_connection_count(stream: &StreamLink) -> u8 {
470    let provider_is_mp4upload = stream.provider.to_ascii_lowercase().contains("mp4upload");
471    let host_is_mp4upload = url::Url::parse(&stream.url)
472        .ok()
473        .and_then(|url| url.host_str().map(str::to_owned))
474        .is_some_and(|host| host == "mp4upload.com" || host.ends_with(".mp4upload.com"));
475    if provider_is_mp4upload || host_is_mp4upload {
476        4
477    } else {
478        16
479    }
480}
481
482async fn download_direct(stream: &StreamLink, target: &Path) -> Result<()> {
483    let partial = target.with_extension("mp4.part");
484    let existing = tokio::fs::metadata(&partial)
485        .await
486        .map(|m| m.len())
487        .unwrap_or(0);
488    let client = reqwest::Client::builder().build()?;
489    let mut request = client.get(&stream.url);
490    if let Some(referer) = &stream.headers.referer {
491        request = request.header(header::REFERER, referer);
492    }
493    if let Some(origin) = &stream.headers.origin {
494        request = request.header(header::ORIGIN, origin);
495    }
496    for (name, value) in &stream.headers.extra {
497        request = request.header(name, value);
498    }
499    if existing > 0 {
500        request = request.header(header::RANGE, format!("bytes={existing}-"));
501    }
502    let response = request.send().await?;
503    if !response.status().is_success() {
504        return Err(AniError::DownloadFailed {
505            reason: format!("media server returned {}", response.status()),
506            source: None,
507        });
508    }
509    let append = existing > 0 && response.status() == reqwest::StatusCode::PARTIAL_CONTENT;
510    let initial = if append { existing } else { 0 };
511    let total = response_total(&response, initial);
512    let mut file = OpenOptions::new()
513        .create(true)
514        .write(true)
515        .append(append)
516        .truncate(!append)
517        .open(&partial)
518        .await?;
519    let mut progress = DownloadProgress::new(initial, total);
520    let mut chunks = response.bytes_stream();
521    while let Some(chunk) = chunks.next().await {
522        let chunk = chunk?;
523        file.write_all(&chunk).await?;
524        progress.advance(chunk.len() as u64);
525    }
526    file.flush().await?;
527    progress.finish();
528    drop(file);
529    finalize_partial(&partial, target).await?;
530    Ok(())
531}
532
533async fn finalize_partial(partial: &Path, target: &Path) -> Result<()> {
534    if tokio::fs::try_exists(target).await? {
535        tokio::fs::remove_file(target).await?;
536    }
537    tokio::fs::rename(partial, target).await?;
538    let mut control = partial.as_os_str().to_os_string();
539    control.push(".aria2");
540    let control = PathBuf::from(control);
541    if tokio::fs::try_exists(&control).await? {
542        tokio::fs::remove_file(control).await?;
543    }
544    Ok(())
545}
546
547fn response_total(response: &reqwest::Response, initial: u64) -> Option<u64> {
548    response
549        .headers()
550        .get(header::CONTENT_RANGE)
551        .and_then(|value| value.to_str().ok())
552        .and_then(|value| value.rsplit_once('/'))
553        .and_then(|(_, total)| total.parse().ok())
554        .or_else(|| response.content_length().map(|length| initial + length))
555}
556
557struct DownloadProgress {
558    initial: u64,
559    downloaded: u64,
560    total: Option<u64>,
561    started: Instant,
562    last_draw: Instant,
563    last_width: usize,
564    terminal: bool,
565    finished: bool,
566}
567
568impl DownloadProgress {
569    fn new(initial: u64, total: Option<u64>) -> Self {
570        let now = Instant::now();
571        let mut progress = Self {
572            initial,
573            downloaded: initial,
574            total,
575            started: now,
576            last_draw: now.checked_sub(Duration::from_secs(1)).unwrap_or(now),
577            last_width: 0,
578            terminal: std::io::stderr().is_terminal(),
579            finished: false,
580        };
581        progress.draw(false);
582        progress
583    }
584
585    fn advance(&mut self, bytes: u64) {
586        self.downloaded += bytes;
587        let interval = if self.terminal {
588            Duration::from_millis(125)
589        } else {
590            Duration::from_secs(5)
591        };
592        if self.last_draw.elapsed() >= interval {
593            self.draw(false);
594        }
595    }
596
597    fn finish(&mut self) {
598        self.finished = true;
599        self.draw(true);
600    }
601
602    fn draw(&mut self, finished: bool) {
603        let elapsed = self.started.elapsed();
604        let transferred = self.downloaded.saturating_sub(self.initial);
605        let speed = if elapsed.as_secs_f64() > 0.0 {
606            transferred as f64 / elapsed.as_secs_f64()
607        } else {
608            0.0
609        };
610        let line = progress_line(self.downloaded, self.total, speed, finished);
611        let padding = " ".repeat(self.last_width.saturating_sub(line.len()));
612        if self.terminal {
613            eprint!("\r{line}{padding}");
614            if finished {
615                eprintln!();
616            }
617            let _ = std::io::stderr().flush();
618        } else {
619            eprintln!("{line}");
620        }
621        self.last_width = line.len();
622        self.last_draw = Instant::now();
623    }
624}
625
626impl Drop for DownloadProgress {
627    fn drop(&mut self) {
628        if self.terminal && !self.finished {
629            eprintln!();
630        }
631    }
632}
633
634fn progress_line(downloaded: u64, total: Option<u64>, speed: f64, finished: bool) -> String {
635    let state = if finished {
636        "Downloaded"
637    } else {
638        "Downloading"
639    };
640    let speed_text = format!("{}/s", format_bytes(speed));
641    match total {
642        Some(total) if total > 0 => {
643            let percent = (downloaded as f64 / total as f64 * 100.0).min(100.0);
644            let remaining = total.saturating_sub(downloaded);
645            let eta = if speed > 0.0 {
646                format_duration(Duration::from_secs_f64(remaining as f64 / speed))
647            } else {
648                "--:--".into()
649            };
650            format!(
651                "{state}: {percent:5.1}%  {} / {}  {speed_text}  ETA {eta}",
652                format_bytes(downloaded as f64),
653                format_bytes(total as f64)
654            )
655        }
656        _ => format!("{state}: {}  {speed_text}", format_bytes(downloaded as f64)),
657    }
658}
659
660fn format_bytes(bytes: f64) -> String {
661    const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
662    let mut value = bytes.max(0.0);
663    let mut unit = 0;
664    while value >= 1024.0 && unit < UNITS.len() - 1 {
665        value /= 1024.0;
666        unit += 1;
667    }
668    if unit == 0 {
669        format!("{value:.0} {}", UNITS[unit])
670    } else {
671        format!("{value:.1} {}", UNITS[unit])
672    }
673}
674
675fn format_duration(duration: Duration) -> String {
676    let seconds = duration.as_secs();
677    let hours = seconds / 3600;
678    let minutes = seconds % 3600 / 60;
679    let seconds = seconds % 60;
680    if hours > 0 {
681        format!("{hours:02}:{minutes:02}:{seconds:02}")
682    } else {
683        format!("{minutes:02}:{seconds:02}")
684    }
685}
686
687fn sanitize_filename(value: &str) -> String {
688    let value: String = value
689        .chars()
690        .map(|c| if "<>:\"/\\|?*\0".contains(c) { '_' } else { c })
691        .collect();
692    let value = value.trim().trim_end_matches(['.', ' ']);
693    if value.is_empty() {
694        "episode".into()
695    } else {
696        value.into()
697    }
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703    use crate::RequestHeaders;
704
705    fn test_stream(hls: bool) -> StreamLink {
706        StreamLink {
707            url: "https://media.example/video".into(),
708            resolution: "1080p".into(),
709            hls,
710            provider: "Example".into(),
711            downloadable: true,
712            headers: RequestHeaders {
713                referer: Some("https://example.com/watch".into()),
714                origin: Some("https://example.com".into()),
715                extra: [("X-Test".into(), "value".into())].into(),
716            },
717            subtitles: vec![],
718        }
719    }
720
721    #[test]
722    fn sanitizes_windows_filename_characters() {
723        assert_eq!(sanitize_filename("A: B?"), "A_ B_");
724    }
725
726    #[test]
727    fn formats_progress_with_speed_and_eta() {
728        assert_eq!(
729            progress_line(512 * 1024, Some(1024 * 1024), 128.0 * 1024.0, false),
730            "Downloading:  50.0%  512.0 KiB / 1.0 MiB  128.0 KiB/s  ETA 00:04"
731        );
732    }
733
734    #[test]
735    fn formats_long_download_eta() {
736        assert_eq!(format_duration(Duration::from_secs(3723)), "01:02:03");
737    }
738
739    #[test]
740    fn aria2_direct_arguments_enable_parallel_resume_and_headers() {
741        let args = aria2_args(&test_stream(false), Path::new("downloads/episode.mp4.part"));
742
743        assert!(args.contains(&"--continue=true".into()));
744        assert!(args.contains(&"--max-connection-per-server=16".into()));
745        assert!(args.contains(&"--split=16".into()));
746        assert!(args.contains(&"--console-log-level=error".into()));
747        assert!(args.contains(&"--download-result=hide".into()));
748        assert!(args.contains(&"--summary-interval=0".into()));
749        assert!(args.contains(&"--referer=https://example.com/watch".into()));
750        assert!(args.contains(&"--header=Origin: https://example.com".into()));
751        assert!(args.contains(&"--header=X-Test: value".into()));
752        assert_eq!(
753            args.last().map(String::as_str),
754            Some("https://media.example/video")
755        );
756    }
757
758    #[test]
759    fn discovers_aria2_config_below_the_user_profile() {
760        let profile = tempfile::tempdir().unwrap();
761        let config_directory = profile.path().join(".aria2");
762        std::fs::create_dir(&config_directory).unwrap();
763        let config = config_directory.join("aria2.conf");
764        std::fs::write(&config, "max-download-limit=1M\n").unwrap();
765
766        assert_eq!(
767            aria2_config_path_from(Some(profile.path().as_os_str().to_owned())),
768            Some(config)
769        );
770    }
771
772    #[test]
773    fn aria2_limits_mp4upload_parallel_connections() {
774        let mut stream = test_stream(false);
775        stream.url = "https://a4.mp4upload.com:183/d/token/video.mp4".into();
776        stream.provider = "Mp4Upload".into();
777
778        let args = aria2_args(&stream, Path::new("episode.mp4.part"));
779
780        assert!(args.contains(&"--max-connection-per-server=4".into()));
781        assert!(args.contains(&"--split=4".into()));
782    }
783
784    #[test]
785    fn yt_dlp_can_delegate_hls_downloads_to_aria2() {
786        let args = yt_dlp_args(&test_stream(true), Path::new("episode.mp4"), true);
787
788        assert!(
789            args.windows(2)
790                .any(|args| args == ["--downloader", "aria2c"])
791        );
792        assert!(
793            args.windows(2)
794                .any(|args| args == ["--referer", "https://example.com/watch"])
795        );
796        assert!(
797            args.windows(2)
798                .any(|args| args == ["--add-headers", "Origin:https://example.com"])
799        );
800    }
801
802    #[test]
803    fn subtitle_mux_maps_tracks_and_selects_the_provider_default() {
804        let subtitles = vec![
805            DownloadedSubtitle {
806                path: "English.vtt".into(),
807                label: "English".into(),
808                default: false,
809            },
810            DownloadedSubtitle {
811                path: "Polish.ass".into(),
812                label: "Polish".into(),
813                default: true,
814            },
815        ];
816        let args = subtitle_mux_args(
817            Path::new("episode.mp4"),
818            &subtitles,
819            Path::new("episode.subtitled.part.mp4"),
820        );
821        assert!(args.windows(2).any(|args| args == ["-map", "1:0"]));
822        assert!(args.windows(2).any(|args| args == ["-map", "2:0"]));
823        assert!(
824            args.windows(2)
825                .any(|args| args == ["-disposition:s:1", "default"])
826        );
827        assert!(args.contains(&"-c:s".into()));
828        assert!(args.contains(&"mov_text".into()));
829        assert!(args.contains(&"language=eng".into()));
830        assert!(args.contains(&"language=pol".into()));
831    }
832
833    #[test]
834    fn subtitle_extensions_are_restricted_to_supported_text_formats() {
835        assert_eq!(
836            subtitle_extension("https://cdn.example/subtitle.srt?token=1"),
837            "srt"
838        );
839        assert_eq!(
840            subtitle_extension("https://cdn.example/subtitle.ass"),
841            "ass"
842        );
843        assert_eq!(subtitle_extension("http://127.0.0.1:1/r/token"), "vtt");
844    }
845
846    #[test]
847    fn subtitle_labels_map_to_mp4_language_codes() {
848        assert_eq!(subtitle_language_code("English (CC)"), "eng");
849        assert_eq!(subtitle_language_code("Português-BR"), "por");
850        assert_eq!(subtitle_language_code("Unknown Provider Label"), "und");
851    }
852}