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 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}