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