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