1use std::{
2 fmt,
3 io::{self, IsTerminal, Write},
4 path::{Path, PathBuf},
5 process::Stdio,
6 time::{SystemTime, UNIX_EPOCH},
7};
8
9use tokio::process::Command;
10use tracing::{debug, info, warn};
11
12use crate::{
13 AniError, Result, StreamLink, relay_stream, relay_stream_without_hls_subtitles,
14 requires_hls_relay,
15};
16
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub enum PlayerKind {
19 Mpv,
20 Iina,
21 Vlc,
22 AndroidMpv,
23 AndroidVlc,
24 Syncplay,
25 Custom,
26}
27
28impl fmt::Display for PlayerKind {
29 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30 let label = match self {
31 Self::Mpv => "mpv",
32 Self::Iina => "iina",
33 Self::Vlc => "vlc",
34 Self::AndroidMpv => "android-mpv",
35 Self::AndroidVlc => "android-vlc",
36 Self::Syncplay => "syncplay",
37 Self::Custom => "custom",
38 };
39 f.write_str(label)
40 }
41}
42
43#[derive(Clone, Debug)]
44pub struct PlayerOptions {
45 pub executable: PathBuf,
46 pub kind: PlayerKind,
47 pub no_detach: bool,
48 pub exit_after_play: bool,
49 pub force_hls_relay: bool,
50}
51
52impl PlayerOptions {
53 pub fn default_player() -> Self {
54 if cfg!(target_os = "android") {
55 Self::default_android_mpv()
56 } else if cfg!(target_os = "macos") {
57 Self::default_iina()
58 } else {
59 Self::default_mpv()
60 }
61 }
62
63 pub fn default_mpv() -> Self {
64 let executable = std::env::var_os("ANI_CLI_PLAYER")
65 .map(PathBuf::from)
66 .unwrap_or_else(|| PathBuf::from(if cfg!(windows) { "mpv.exe" } else { "mpv" }));
67 Self {
68 executable,
69 kind: PlayerKind::Mpv,
70 no_detach: env_bool("ANI_CLI_NO_DETACH"),
71 exit_after_play: env_bool("ANI_CLI_EXIT_AFTER_PLAY"),
72 force_hls_relay: false,
73 }
74 }
75
76 pub fn default_iina() -> Self {
77 let executable = std::env::var_os("ANI_CLI_PLAYER")
78 .map(PathBuf::from)
79 .unwrap_or_else(|| PathBuf::from("iina"));
80 Self {
81 executable,
82 kind: PlayerKind::Iina,
83 no_detach: env_bool("ANI_CLI_NO_DETACH"),
84 exit_after_play: env_bool("ANI_CLI_EXIT_AFTER_PLAY"),
85 force_hls_relay: false,
86 }
87 }
88
89 pub fn default_android_mpv() -> Self {
90 Self {
91 executable: android_intent_launcher(),
92 kind: PlayerKind::AndroidMpv,
93 no_detach: true,
94 exit_after_play: env_bool("ANI_CLI_EXIT_AFTER_PLAY"),
95 force_hls_relay: false,
96 }
97 }
98}
99
100#[derive(Clone, Debug)]
101pub struct Player {
102 options: PlayerOptions,
103}
104
105impl Player {
106 pub fn new(options: PlayerOptions) -> Self {
107 Self { options }
108 }
109
110 pub fn command_args(&self, stream: &StreamLink, title: &str) -> Vec<String> {
111 self.command_args_inner(stream, title, self.options.no_detach)
112 }
113
114 pub fn describe(&self) -> String {
117 format!(
118 "{} ({}) [no_detach={}, exit_after_play={}, force_hls_relay={}]",
119 self.options.executable.display(),
120 self.options.kind,
121 self.options.no_detach,
122 self.options.exit_after_play,
123 self.options.force_hls_relay,
124 )
125 }
126
127 fn command_args_inner(&self, stream: &StreamLink, title: &str, attached: bool) -> Vec<String> {
128 let referer = stream.headers.referer.as_deref().unwrap_or("");
129 match self.options.kind {
130 PlayerKind::Mpv => {
131 let mut args = mpv_options(stream, title, referer);
132 args.push(stream.url.clone());
134 args
135 }
136 PlayerKind::Iina => {
137 let mut args = vec!["--no-stdin".into()];
138 if attached {
139 args.push("--keep-running".into());
140 }
141 args.push(stream.url.clone());
142 args.push("--".into());
143 args.extend(mpv_options(stream, title, referer));
144 args
145 }
146 PlayerKind::Vlc => {
147 let mut args = vec!["--play-and-exit".into(), format!("--meta-title={title}")];
148 if !referer.is_empty() {
149 args.push(format!("--http-referrer={referer}"));
150 }
151 if let Some(agent) = stream.headers.extra.get("User-Agent") {
152 args.push(format!("--http-user-agent={agent}"));
153 }
154 for track in &stream.subtitles {
155 args.push(format!("--sub-file={}", track.url));
156 }
157 args.push(stream.url.clone());
158 args
159 }
160 PlayerKind::AndroidMpv => {
161 android_intent_args("is.xyz.mpv/.MPVActivity", &stream.url, title)
162 }
163 PlayerKind::AndroidVlc => android_intent_args(
164 "org.videolan.vlc/org.videolan.vlc.gui.video.VideoPlayerActivity",
165 &stream.url,
166 title,
167 ),
168 PlayerKind::Syncplay => {
169 let mut args = vec![
170 stream.url.clone(),
171 "--".into(),
172 "--tls-verify=no".into(),
173 format!("--force-media-title={title}"),
174 ];
175 if !referer.is_empty() {
176 args.push(format!("--referrer={referer}"));
177 }
178 append_mpv_headers(&mut args, stream);
179 for track in &stream.subtitles {
180 args.push(format!("--sub-file={}", track.url));
181 }
182 args
183 }
184 PlayerKind::Custom => vec![stream.url.clone()],
185 }
186 }
187
188 pub async fn play(&self, stream: &StreamLink, title: &str) -> Result<Option<i32>> {
189 info!(
190 title = %title,
191 player = %self.describe(),
192 stream_url = %stream.url,
193 hls = stream.hls,
194 subtitles = stream.subtitles.len(),
195 "playback requested",
196 );
197 if self.options.force_hls_relay
198 || crate::requires_hls_relay(stream)
199 || (self.is_android_player() && stream.hls)
200 {
201 debug!(
202 title = %title,
203 player = %self.options.kind.to_string(),
204 android_player = self.is_android_player(),
205 hls = stream.hls,
206 "stream requires the loopback HLS relay",
207 );
208 let (_relay, local) = if self.is_android_player() {
215 relay_stream(stream).await?
216 } else {
217 relay_stream_without_hls_subtitles(stream).await?
218 };
219 debug!(
220 title = %title,
221 local_url = %local.url,
222 "HLS relay is serving the rewritten stream URL to the player",
223 );
224 return self.play_inner(&local, title, true).await;
225 }
226 self.play_inner(stream, title, false).await
227 }
228
229 async fn play_inner(
230 &self,
231 stream: &StreamLink,
232 title: &str,
233 force_attached: bool,
234 ) -> Result<Option<i32>> {
235 if self.is_android_player() {
236 return self.play_android(stream, title, force_attached).await;
237 }
238
239 let needs_validation = self.options.executable.components().count() > 1;
242 if needs_validation {
243 if !self.options.executable.exists() {
244 eprintln!(
245 "Player executable not found: {}. Please install the player or set ANI_CLI_PLAYER environment variable.",
246 self.options.executable.display()
247 );
248 return Err(AniError::PlayerNotFound {
249 executable: self.options.executable.display().to_string(),
250 });
251 }
252 } else {
253 use which::which;
255 if which(&self.options.executable).is_err() {
256 eprintln!(
257 "Player executable '{}' not found in PATH. Please install the player or set ANI_CLI_PLAYER environment variable.",
258 self.options.executable.display()
259 );
260 return Err(AniError::PlayerNotFound {
261 executable: self.options.executable.display().to_string(),
262 });
263 }
264 }
265
266 let mut command = Command::new(&self.options.executable);
267 let attached = self.options.no_detach || force_attached;
268 let args = if self.options.kind == PlayerKind::Vlc {
269 self.vlc_command_args(stream, title, attached).await?
270 } else {
271 self.command_args_inner(stream, title, attached)
272 };
273 info!(
274 title = %title,
275 executable = %self.options.executable.display(),
276 kind = %self.options.kind,
277 attached,
278 "launching external player",
279 );
280 debug!(
285 title = %title,
286 stream_url = %stream.url,
287 stream_hls = stream.hls,
288 args = ?args,
289 "full player command line",
290 );
291 command.args(args);
292 if attached {
293 match command.status().await {
294 Ok(status) => {
295 let code = status.code().unwrap_or(1);
296 info!(
297 title = %title,
298 exit_code = code,
299 success = status.success(),
300 "player exited",
301 );
302 if !status.success() && self.options.exit_after_play {
303 eprintln!("Player exited with {code}");
304 return Err(AniError::PlayerExitFailed);
305 }
306 Ok(Some(code))
307 }
308 Err(error) => {
309 warn!(
310 title = %title,
311 executable = %self.options.executable.display(),
312 error = %error,
313 "failed to launch player in attached mode",
314 );
315 eprintln!(
316 "Could not launch {}: {error}",
317 self.options.executable.display()
318 );
319 Err(AniError::PlayerLaunchFailed {
320 executable: self.options.executable.display().to_string(),
321 reason: format!("attached mode launch failed: {}", error),
322 })
323 }
324 }
325 } else {
326 command
328 .stdin(Stdio::null())
329 .stdout(Stdio::null())
330 .stderr(Stdio::null());
331 match command.spawn() {
332 Ok(child) => {
333 info!(
334 title = %title,
335 pid = child.id().unwrap_or(0),
336 executable = %self.options.executable.display(),
337 "player launched in the background",
338 );
339 let _ = child.id(); Ok(None)
342 }
343 Err(error) => {
344 warn!(
345 title = %title,
346 executable = %self.options.executable.display(),
347 error = %error,
348 "failed to launch player in detached mode",
349 );
350 eprintln!(
351 "Could not launch {}: {error}",
352 self.options.executable.display()
353 );
354 Err(AniError::PlayerLaunchFailed {
355 executable: self.options.executable.display().to_string(),
356 reason: format!("detached mode launch failed: {}", error),
357 })
358 }
359 }
360 }
361 }
362
363 async fn vlc_command_args(
364 &self,
365 stream: &StreamLink,
366 title: &str,
367 attached: bool,
368 ) -> Result<Vec<String>> {
369 let referer = stream.headers.referer.as_deref().unwrap_or("");
370 let mut args = vec!["--play-and-exit".into(), format!("--meta-title={title}")];
371 if !referer.is_empty() {
372 args.push(format!("--http-referrer={referer}"));
373 }
374 if let Some(agent) = stream.headers.extra.get("User-Agent") {
375 args.push(format!("--http-user-agent={agent}"));
376 }
377 for subtitle_path in prepare_vlc_subtitles(stream).await? {
378 args.push(format!("--sub-file={subtitle_path}"));
379 }
380 if !attached {
381 args.push(stream.url.clone());
382 } else {
383 args.push(stream.url.clone());
384 }
385 Ok(args)
386 }
387
388 fn is_android_player(&self) -> bool {
389 matches!(
390 self.options.kind,
391 PlayerKind::AndroidMpv | PlayerKind::AndroidVlc
392 )
393 }
394
395 async fn play_android(
396 &self,
397 stream: &StreamLink,
398 title: &str,
399 relay_active: bool,
400 ) -> Result<Option<i32>> {
401 let terminal = io::stdin().is_terminal();
402 if relay_active && !terminal {
403 return Err(AniError::PlayerAndroidTerminalRequired);
404 }
405
406 let launch_args = self.command_args_inner(stream, title, true);
407 info!(
408 title = %title,
409 executable = %self.options.executable.display(),
410 "launching Android activity for playback",
411 );
412 debug!(
413 title = %title,
414 stream_url = %stream.url,
415 args = ?launch_args,
416 "full Android intent command line",
417 );
418 let launch_result = Command::new(&self.options.executable)
419 .args(launch_args)
420 .status()
421 .await;
422 let code = match launch_result {
423 Ok(status) if status.success() => {
424 let code = status.code().unwrap_or(0);
425 info!(
426 title = %title,
427 exit_code = code,
428 "Android player activity returned successfully",
429 );
430 code
431 }
432 result => {
433 let primary_error = match &result {
434 Ok(status) => format!(
435 "Android activity launcher {} exited with {}",
436 self.options.executable.display(),
437 status.code().unwrap_or(1)
438 ),
439 Err(error) => format!(
440 "could not launch Android player through {}: {error}",
441 self.options.executable.display()
442 ),
443 };
444 warn!(
445 title = %title,
446 executable = %self.options.executable.display(),
447 error = %primary_error,
448 "primary Android launch failed; trying termux-open fallback",
449 );
450 match launch_android_url_fallback(&self.options.executable, &stream.url, stream.hls)
451 .await
452 {
453 Ok(code) => {
454 warn!(
455 title = %title,
456 "opened the stream through Android's default URL handler instead",
457 );
458 eprintln!(
459 "warning: {primary_error}; opened the stream through Android's default URL handler instead"
460 );
461 code
462 }
463 Err(fallback_error) => {
464 warn!(
465 title = %title,
466 error = %fallback_error,
467 "termux-open fallback also failed",
468 );
469 eprintln!("{primary_error}; {fallback_error}");
470 return Err(AniError::PlayerLaunchFailed {
471 executable: self.options.executable.display().to_string(),
472 reason: format!("Android launch failed: {}", fallback_error),
473 });
474 }
475 }
476 }
477 };
478
479 if terminal {
480 debug!(
481 title = %title,
482 "waiting for the Android player to finish before returning control to the TUI",
483 );
484 wait_for_android_player().await?;
485 }
486 Ok(Some(code))
487 }
488}
489
490fn android_intent_args(component: &str, url: &str, title: &str) -> Vec<String> {
491 vec![
492 "start".into(),
493 "--user".into(),
494 "0".into(),
495 "-a".into(),
496 "android.intent.action.VIEW".into(),
497 "-d".into(),
498 url.into(),
499 "-n".into(),
500 component.into(),
501 "--es".into(),
502 "title".into(),
503 title.into(),
504 ]
505}
506
507fn android_intent_launcher() -> PathBuf {
508 if let Some(executable) = std::env::var_os("ANI_CLI_PLAYER") {
509 return PathBuf::from(executable);
510 }
511 let path = std::env::var_os("PATH").unwrap_or_default();
512 ["termux-am-starter", "termux-am", "am"]
513 .iter()
514 .find_map(|name| find_in_path(name, &path))
515 .unwrap_or_else(|| PathBuf::from("termux-am-starter"))
516}
517
518fn find_in_path(executable: &str, path: &std::ffi::OsStr) -> Option<PathBuf> {
519 std::env::split_paths(path)
520 .map(|directory| directory.join(executable))
521 .find(|candidate| candidate.is_file())
522}
523
524async fn launch_android_url_fallback(
525 executable: &Path,
526 url: &str,
527 hls: bool,
528) -> std::result::Result<i32, String> {
529 if !is_termux_activity_launcher(executable) {
530 return Err(
531 "the configured custom launcher failed and cannot use the automatic Termux fallback"
532 .into(),
533 );
534 }
535 let path = std::env::var_os("PATH").unwrap_or_default();
536 let mut open_error = None;
537 if let Some(opener) = find_in_path("termux-open", &path) {
538 let status = Command::new(&opener)
539 .args(["--view", "--content-type", android_media_type(hls), url])
540 .status()
541 .await;
542 match status {
543 Ok(status) if status.success() => return Ok(status.code().unwrap_or(0)),
544 Ok(status) => {
545 open_error = Some(format!(
546 "{} exited with {}",
547 opener.display(),
548 status.code().unwrap_or(1)
549 ));
550 }
551 Err(error) => {
552 open_error = Some(format!("could not run {}: {error}", opener.display()));
553 }
554 }
555 }
556 let opener = find_in_path("termux-open-url", &path).ok_or_else(|| {
557 let prefix = open_error
558 .map(|error| format!("{error}; "))
559 .unwrap_or_default();
560 format!(
561 "{prefix}termux-open-url is unavailable; install or update the termux-tools package"
562 )
563 })?;
564 let status = Command::new(&opener)
565 .arg(url)
566 .status()
567 .await
568 .map_err(|error| format!("could not run {}: {error}", opener.display()))?;
569 if !status.success() {
570 return Err(format!(
571 "{} exited with {}",
572 opener.display(),
573 status.code().unwrap_or(1)
574 ));
575 }
576 Ok(status.code().unwrap_or(0))
577}
578
579fn android_media_type(hls: bool) -> &'static str {
580 if hls {
581 "application/vnd.apple.mpegurl"
582 } else {
583 "video/mp4"
584 }
585}
586
587fn is_termux_activity_launcher(executable: &Path) -> bool {
588 executable
589 .file_name()
590 .and_then(|name| name.to_str())
591 .is_some_and(|name| matches!(name, "termux-am-starter" | "termux-am" | "am"))
592}
593
594async fn wait_for_android_player() -> Result<()> {
595 tokio::task::spawn_blocking(|| {
596 println!(
597 "Opened the Android player. Return to Termux and press Enter after playback ends."
598 );
599 print!("Waiting for Android player... ");
600 io::stdout().flush()?;
601 let mut input = String::new();
602 io::stdin().read_line(&mut input)?;
603 Ok::<(), io::Error>(())
604 })
605 .await
606 .map_err(|error| {
607 eprintln!("Android playback prompt failed: {error}");
608 AniError::PlayerLaunchFailed {
609 executable: "android".to_string(),
610 reason: format!("playback prompt failed: {}", error),
611 }
612 })??;
613 Ok(())
614}
615
616async fn prepare_vlc_subtitles(stream: &StreamLink) -> Result<Vec<String>> {
617 if stream.subtitles.is_empty() {
618 return Ok(Vec::new());
619 }
620
621 let temp_dir = std::env::temp_dir().join(format!(
622 "ani-cli-rs-vlc-subtitles-{}-{}",
623 std::process::id(),
624 SystemTime::now()
625 .duration_since(UNIX_EPOCH)
626 .unwrap_or_default()
627 .as_nanos()
628 ));
629 tokio::fs::create_dir_all(&temp_dir).await?;
630
631 let mut local_paths = Vec::new();
632 for (index, track) in stream.subtitles.iter().enumerate() {
633 let path = temp_dir.join(format!(
634 "{index}.{}.{}",
635 sanitize_filename(&track.label),
636 subtitle_extension(&track.url)
637 ));
638 let bytes = fetch_vlc_subtitle(track, &stream.headers).await?;
639 tokio::fs::write(&path, bytes).await?;
640 local_paths.push(path.to_string_lossy().into_owned());
641 }
642
643 Ok(local_paths)
644}
645
646async fn fetch_vlc_subtitle(
647 track: &crate::SubtitleTrack,
648 headers: &crate::RequestHeaders,
649) -> Result<Vec<u8>> {
650 let client = reqwest::Client::builder().build()?;
651 let mut request = client.get(&track.url);
652 if let Some(referer) = &headers.referer {
653 request = request.header(reqwest::header::REFERER, referer);
654 }
655 if let Some(origin) = &headers.origin {
656 request = request.header(reqwest::header::ORIGIN, origin);
657 }
658 for (name, value) in &headers.extra {
659 request = request.header(name, value);
660 }
661
662 let response = request.send().await?;
663 if !response.status().is_success() {
664 return Err(AniError::SubtitleDownloadFailed {
665 track: track.label.clone(),
666 reason: format!("subtitle server returned {}", response.status()),
667 });
668 }
669
670 let bytes = response.bytes().await?;
671 if bytes.len() > 16 * 1024 * 1024 {
672 return Err(AniError::SubtitleSizeExceeded {
673 size: bytes.len(),
674 limit: 16 * 1024 * 1024,
675 });
676 }
677
678 Ok(bytes.to_vec())
679}
680
681fn sanitize_filename(value: &str) -> String {
682 let mut cleaned: String = value
683 .chars()
684 .map(|character| match character {
685 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
686 c if c.is_control() => '_',
687 other => other,
688 })
689 .collect();
690 if cleaned.is_empty() {
691 cleaned = "subtitle".to_string();
692 }
693 cleaned
694}
695
696fn subtitle_extension(url: &str) -> &'static str {
697 let parsed = url::Url::parse(url).ok();
698 let extension = parsed.and_then(|url| {
699 Path::new(url.path())
700 .extension()
701 .and_then(|value| value.to_str())
702 .map(str::to_ascii_lowercase)
703 });
704 match extension.as_deref() {
705 Some("srt") => "srt",
706 Some("ass") => "ass",
707 Some("ssa") => "ssa",
708 Some("vtt") => "vtt",
709 _ => "vtt",
710 }
711}
712
713fn mpv_options(stream: &StreamLink, title: &str, referer: &str) -> Vec<String> {
714 let mut args = vec![
715 "--tls-verify=no".into(),
716 format!("--force-media-title={title}"),
717 ];
718 if !referer.is_empty() {
719 args.push(format!("--referrer={referer}"));
720 }
721 append_mpv_headers(&mut args, stream);
722 for track in &stream.subtitles {
723 args.push(format!("--sub-file={}", track.url));
724 }
725 if let Some(track) = stream.subtitles.iter().find(|track| track.default) {
726 args.push(format!("--slang={}", track.label));
727 }
728 if stream.hls && requires_hls_relay(stream) {
730 args.push("--cache=yes".into());
731 args.push("--cache-secs=120".into());
732 args.push("--demuxer-max-bytes=512MiB".into());
733 args.push("--demuxer-max-back-bytes=256MiB".into());
734 }
735 args
736}
737
738fn append_mpv_headers(args: &mut Vec<String>, stream: &StreamLink) {
739 let mut headers = Vec::new();
740 if let Some(origin) = &stream.headers.origin
741 && safe_header_value(origin)
742 {
743 headers.push(format!("Origin: {origin}"));
744 }
745 headers.extend(
746 stream
747 .headers
748 .extra
749 .iter()
750 .filter(|(name, value)| safe_header_value(name) && safe_header_value(value))
751 .map(|(name, value)| format!("{name}: {value}")),
752 );
753 if !headers.is_empty() {
754 args.push(format!("--http-header-fields={}", headers.join(",")));
755 }
756}
757
758fn safe_header_value(value: &str) -> bool {
759 !value.contains(['\r', '\n'])
760}
761
762fn env_bool(name: &str) -> bool {
763 std::env::var(name)
764 .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
765 .unwrap_or(false)
766}
767
768#[cfg(test)]
769mod tests {
770 use super::*;
771 use crate::{RequestHeaders, SubtitleTrack};
772 #[test]
773 fn mpv_arguments_preserve_referrer_as_one_argument() {
774 let player = Player::new(PlayerOptions {
775 executable: "mpv".into(),
776 kind: PlayerKind::Mpv,
777 no_detach: true,
778 exit_after_play: false,
779 force_hls_relay: false,
780 });
781 let stream = StreamLink {
782 url: "https://media/a.m3u8".into(),
783 resolution: "1080p".into(),
784 hls: true,
785 provider: "Default".into(),
786 downloadable: true,
787 headers: RequestHeaders {
788 referer: Some("https://ref.example".into()),
789 ..Default::default()
790 },
791 subtitles: vec![],
792 };
793 assert!(
794 player
795 .command_args(&stream, "Anime Episode 1")
796 .contains(&"--referrer=https://ref.example".into())
797 );
798 }
799
800 #[test]
801 fn iina_arguments_put_stream_before_raw_mpv_options() {
802 let player = Player::new(PlayerOptions {
803 executable: "iina".into(),
804 kind: PlayerKind::Iina,
805 no_detach: false,
806 exit_after_play: false,
807 force_hls_relay: false,
808 });
809 let stream = StreamLink {
810 url: "https://media/a.m3u8".into(),
811 resolution: "1080p".into(),
812 hls: true,
813 provider: "Default".into(),
814 downloadable: true,
815 headers: RequestHeaders {
816 referer: Some("https://ref.example".into()),
817 origin: Some("https://origin.example".into()),
818 ..Default::default()
819 },
820 subtitles: vec![SubtitleTrack {
821 label: "English".into(),
822 url: "https://media/subtitles.vtt".into(),
823 default: true,
824 }],
825 };
826
827 assert_eq!(
828 player.command_args(&stream, "Anime Episode 1"),
829 vec![
830 "--no-stdin",
831 "https://media/a.m3u8",
832 "--",
833 "--tls-verify=no",
834 "--force-media-title=Anime Episode 1",
835 "--referrer=https://ref.example",
836 "--http-header-fields=Origin: https://origin.example",
837 "--sub-file=https://media/subtitles.vtt",
838 "--slang=English",
839 ]
840 );
841 }
842
843 #[test]
844 fn forced_attached_iina_keeps_cli_running() {
845 let player = Player::new(PlayerOptions {
846 executable: "iina".into(),
847 kind: PlayerKind::Iina,
848 no_detach: false,
849 exit_after_play: false,
850 force_hls_relay: false,
851 });
852 let stream = StreamLink {
853 url: "https://media/a.m3u8".into(),
854 resolution: "1080p".into(),
855 hls: true,
856 provider: "Default".into(),
857 downloadable: true,
858 headers: RequestHeaders::default(),
859 subtitles: vec![],
860 };
861
862 assert_eq!(
863 &player.command_args_inner(&stream, "Anime", true)[..3],
864 ["--no-stdin", "--keep-running", "https://media/a.m3u8"]
865 );
866 }
867
868 #[test]
869 fn android_mpv_arguments_use_an_explicit_view_intent() {
870 let player = Player::new(PlayerOptions {
871 executable: "termux-am-starter".into(),
872 kind: PlayerKind::AndroidMpv,
873 no_detach: true,
874 exit_after_play: false,
875 force_hls_relay: false,
876 });
877 let stream = StreamLink {
878 url: "http://127.0.0.1:43123/stream-token".into(),
879 resolution: "1080p".into(),
880 hls: true,
881 provider: "Anikoto".into(),
882 downloadable: true,
883 headers: RequestHeaders::default(),
884 subtitles: vec![],
885 };
886
887 assert_eq!(
888 player.command_args(&stream, "Anime Episode 1"),
889 vec![
890 "start",
891 "--user",
892 "0",
893 "-a",
894 "android.intent.action.VIEW",
895 "-d",
896 "http://127.0.0.1:43123/stream-token",
897 "-n",
898 "is.xyz.mpv/.MPVActivity",
899 "--es",
900 "title",
901 "Anime Episode 1",
902 ]
903 );
904 }
905
906 #[tokio::test]
907 async fn vlc_downloads_remote_subtitles_to_local_files() {
908 use wiremock::{
909 Mock, MockServer, ResponseTemplate,
910 matchers::{method, path},
911 };
912
913 let server = MockServer::start().await;
914 Mock::given(method("GET"))
915 .and(path("/subtitle.vtt"))
916 .respond_with(
917 ResponseTemplate::new(200)
918 .set_body_string("WEBVTT\n\n00:00.000 --> 00:02.000\nHello from the subtitle."),
919 )
920 .mount(&server)
921 .await;
922
923 let stream = StreamLink {
924 url: "https://media.example/episode.m3u8".into(),
925 resolution: "720p".into(),
926 hls: false,
927 provider: "Anikoto".into(),
928 downloadable: true,
929 headers: RequestHeaders::default(),
930 subtitles: vec![SubtitleTrack {
931 label: "English".into(),
932 url: format!("{}/subtitle.vtt", server.uri()),
933 default: true,
934 }],
935 };
936
937 let local_subtitles = prepare_vlc_subtitles(&stream).await.unwrap();
938
939 assert_eq!(local_subtitles.len(), 1);
940 let local_path = Path::new(&local_subtitles[0]);
941 assert!(local_path.is_absolute());
942 assert!(!local_path.to_string_lossy().starts_with("http://"));
943 assert!(!local_path.to_string_lossy().starts_with("https://"));
944 assert!(
945 tokio::fs::read_to_string(local_path)
946 .await
947 .unwrap()
948 .contains("Hello from the subtitle")
949 );
950 }
951
952 #[test]
953 fn android_vlc_arguments_target_the_android_app_not_terminal_vlc() {
954 let player = Player::new(PlayerOptions {
955 executable: "am".into(),
956 kind: PlayerKind::AndroidVlc,
957 no_detach: true,
958 exit_after_play: false,
959 force_hls_relay: false,
960 });
961 let stream = StreamLink {
962 url: "https://media.example/episode.m3u8".into(),
963 resolution: "720p".into(),
964 hls: true,
965 provider: "Anikoto".into(),
966 downloadable: true,
967 headers: RequestHeaders::default(),
968 subtitles: vec![],
969 };
970
971 assert!(
972 player.command_args(&stream, "Episode").contains(
973 &"org.videolan.vlc/org.videolan.vlc.gui.video.VideoPlayerActivity".into()
974 )
975 );
976 }
977
978 #[test]
979 fn android_launcher_lookup_prefers_the_first_available_candidate() {
980 let directory = tempfile::tempdir().unwrap();
981 std::fs::write(directory.path().join("termux-am"), "").unwrap();
982 let path = std::env::join_paths([directory.path()]).unwrap();
983
984 assert_eq!(
985 find_in_path("termux-am", &path),
986 Some(directory.path().join("termux-am"))
987 );
988 assert_eq!(find_in_path("termux-am-starter", &path), None);
989 }
990
991 #[test]
992 fn only_termux_activity_launchers_allow_the_url_opener_fallback() {
993 assert!(is_termux_activity_launcher(Path::new("termux-am-starter")));
994 assert!(is_termux_activity_launcher(Path::new(
995 "/data/data/com.termux/files/usr/bin/termux-am"
996 )));
997 assert!(is_termux_activity_launcher(Path::new("am")));
998 assert!(!is_termux_activity_launcher(Path::new(
999 "/data/local/tmp/custom-launcher"
1000 )));
1001 }
1002
1003 #[test]
1004 fn android_fallback_uses_specific_media_types() {
1005 assert_eq!(android_media_type(true), "application/vnd.apple.mpegurl");
1006 assert_eq!(android_media_type(false), "video/mp4");
1007 }
1008
1009 #[test]
1010 fn platform_default_selects_expected_player() {
1011 let options = PlayerOptions::default_player();
1012 if cfg!(target_os = "android") {
1013 assert_eq!(options.kind, PlayerKind::AndroidMpv);
1014 } else if cfg!(target_os = "macos") {
1015 assert_eq!(options.kind, PlayerKind::Iina);
1016 if std::env::var_os("ANI_CLI_PLAYER").is_none() {
1017 assert_eq!(options.executable, PathBuf::from("iina"));
1018 }
1019 } else {
1020 assert_eq!(options.kind, PlayerKind::Mpv);
1021 }
1022 }
1023}