1use std::{
2 fmt,
3 io::{self, IsTerminal, Write},
4 path::{Path, PathBuf},
5 process::Stdio,
6};
7
8use tokio::process::Command;
9use tracing::{debug, info, warn};
10
11use crate::{
12 AniError, Result, StreamLink, relay_stream, relay_stream_without_hls_subtitles,
13 requires_hls_relay,
14};
15
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub enum PlayerKind {
18 Mpv,
19 Iina,
20 Vlc,
21 AndroidMpv,
22 AndroidVlc,
23 Syncplay,
24 Custom,
25}
26
27impl fmt::Display for PlayerKind {
28 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29 let label = match self {
30 Self::Mpv => "mpv",
31 Self::Iina => "iina",
32 Self::Vlc => "vlc",
33 Self::AndroidMpv => "android-mpv",
34 Self::AndroidVlc => "android-vlc",
35 Self::Syncplay => "syncplay",
36 Self::Custom => "custom",
37 };
38 f.write_str(label)
39 }
40}
41
42#[derive(Clone, Debug)]
43pub struct PlayerOptions {
44 pub executable: PathBuf,
45 pub kind: PlayerKind,
46 pub no_detach: bool,
47 pub exit_after_play: bool,
48}
49
50impl PlayerOptions {
51 pub fn default_player() -> Self {
52 if cfg!(target_os = "android") {
53 Self::default_android_mpv()
54 } else if cfg!(target_os = "macos") {
55 Self::default_iina()
56 } else {
57 Self::default_mpv()
58 }
59 }
60
61 pub fn default_mpv() -> Self {
62 let executable = std::env::var_os("ANI_CLI_PLAYER")
63 .map(PathBuf::from)
64 .unwrap_or_else(|| PathBuf::from(if cfg!(windows) { "mpv.exe" } else { "mpv" }));
65 Self {
66 executable,
67 kind: PlayerKind::Mpv,
68 no_detach: env_bool("ANI_CLI_NO_DETACH"),
69 exit_after_play: env_bool("ANI_CLI_EXIT_AFTER_PLAY"),
70 }
71 }
72
73 pub fn default_iina() -> Self {
74 let executable = std::env::var_os("ANI_CLI_PLAYER")
75 .map(PathBuf::from)
76 .unwrap_or_else(|| PathBuf::from("iina"));
77 Self {
78 executable,
79 kind: PlayerKind::Iina,
80 no_detach: env_bool("ANI_CLI_NO_DETACH"),
81 exit_after_play: env_bool("ANI_CLI_EXIT_AFTER_PLAY"),
82 }
83 }
84
85 pub fn default_android_mpv() -> Self {
86 Self {
87 executable: android_intent_launcher(),
88 kind: PlayerKind::AndroidMpv,
89 no_detach: true,
90 exit_after_play: env_bool("ANI_CLI_EXIT_AFTER_PLAY"),
91 }
92 }
93}
94
95#[derive(Clone, Debug)]
96pub struct Player {
97 options: PlayerOptions,
98}
99
100impl Player {
101 pub fn new(options: PlayerOptions) -> Self {
102 Self { options }
103 }
104
105 pub fn command_args(&self, stream: &StreamLink, title: &str) -> Vec<String> {
106 self.command_args_inner(stream, title, self.options.no_detach)
107 }
108
109 pub fn describe(&self) -> String {
112 format!(
113 "{} ({}) [no_detach={}, exit_after_play={}]",
114 self.options.executable.display(),
115 self.options.kind,
116 self.options.no_detach,
117 self.options.exit_after_play,
118 )
119 }
120
121 fn command_args_inner(&self, stream: &StreamLink, title: &str, attached: bool) -> Vec<String> {
122 let referer = stream.headers.referer.as_deref().unwrap_or("");
123 match self.options.kind {
124 PlayerKind::Mpv => {
125 let mut args = mpv_options(stream, title, referer);
126 args.push(stream.url.clone());
128 args
129 }
130 PlayerKind::Iina => {
131 let mut args = vec!["--no-stdin".into()];
132 if attached {
133 args.push("--keep-running".into());
134 }
135 args.push(stream.url.clone());
136 args.push("--".into());
137 args.extend(mpv_options(stream, title, referer));
138 args
139 }
140 PlayerKind::Vlc => {
141 let mut args = vec!["--play-and-exit".into(), format!("--meta-title={title}")];
142 if !referer.is_empty() {
143 args.push(format!("--http-referrer={referer}"));
144 }
145 if let Some(agent) = stream.headers.extra.get("User-Agent") {
146 args.push(format!("--http-user-agent={agent}"));
147 }
148 for track in &stream.subtitles {
149 args.push(format!("--sub-file={}", track.url));
150 }
151 args.push(stream.url.clone());
152 args
153 }
154 PlayerKind::AndroidMpv => {
155 android_intent_args("is.xyz.mpv/.MPVActivity", &stream.url, title)
156 }
157 PlayerKind::AndroidVlc => android_intent_args(
158 "org.videolan.vlc/org.videolan.vlc.gui.video.VideoPlayerActivity",
159 &stream.url,
160 title,
161 ),
162 PlayerKind::Syncplay => {
163 let mut args = vec![
164 stream.url.clone(),
165 "--".into(),
166 "--tls-verify=no".into(),
167 format!("--force-media-title={title}"),
168 ];
169 if !referer.is_empty() {
170 args.push(format!("--referrer={referer}"));
171 }
172 append_mpv_headers(&mut args, stream);
173 for track in &stream.subtitles {
174 args.push(format!("--sub-file={}", track.url));
175 }
176 args
177 }
178 PlayerKind::Custom => vec![stream.url.clone()],
179 }
180 }
181
182 pub async fn play(&self, stream: &StreamLink, title: &str) -> Result<Option<i32>> {
183 info!(
184 title = %title,
185 player = %self.describe(),
186 stream_url = %stream.url,
187 hls = stream.hls,
188 subtitles = stream.subtitles.len(),
189 "playback requested",
190 );
191 if requires_hls_relay(stream) || (self.is_android_player() && stream.hls) {
192 debug!(
193 title = %title,
194 player = %self.options.kind.to_string(),
195 android_player = self.is_android_player(),
196 hls = stream.hls,
197 "stream requires the loopback HLS relay",
198 );
199 let (_relay, local) = if self.is_android_player() {
206 relay_stream(stream).await?
207 } else {
208 relay_stream_without_hls_subtitles(stream).await?
209 };
210 debug!(
211 title = %title,
212 local_url = %local.url,
213 "HLS relay is serving the rewritten stream URL to the player",
214 );
215 return self.play_inner(&local, title, true).await;
216 }
217 self.play_inner(stream, title, false).await
218 }
219
220 async fn play_inner(
221 &self,
222 stream: &StreamLink,
223 title: &str,
224 force_attached: bool,
225 ) -> Result<Option<i32>> {
226 if self.is_android_player() {
227 return self.play_android(stream, title, force_attached).await;
228 }
229
230 let needs_validation = self.options.executable.components().count() > 1;
233 if needs_validation && !self.options.executable.exists() {
234 eprintln!(
235 "Player executable not found: {}. Please install the player or set ANI_CLI_PLAYER environment variable.",
236 self.options.executable.display()
237 );
238 return Err(AniError::PlayerNotFound);
239 }
240
241 let mut command = Command::new(&self.options.executable);
242 let attached = self.options.no_detach || force_attached;
243 let args = self.command_args_inner(stream, title, attached);
244 info!(
245 title = %title,
246 executable = %self.options.executable.display(),
247 kind = %self.options.kind,
248 attached,
249 "launching external player",
250 );
251 debug!(
256 title = %title,
257 stream_url = %stream.url,
258 stream_hls = stream.hls,
259 args = ?args,
260 "full player command line",
261 );
262 command.args(args);
263 if attached {
264 match command.status().await {
265 Ok(status) => {
266 let code = status.code().unwrap_or(1);
267 info!(
268 title = %title,
269 exit_code = code,
270 success = status.success(),
271 "player exited",
272 );
273 if !status.success() && self.options.exit_after_play {
274 eprintln!("Player exited with {code}");
275 return Err(AniError::PlayerExitFailed);
276 }
277 Ok(Some(code))
278 }
279 Err(error) => {
280 warn!(
281 title = %title,
282 executable = %self.options.executable.display(),
283 error = %error,
284 "failed to launch player in attached mode",
285 );
286 eprintln!(
287 "Could not launch {}: {error}",
288 self.options.executable.display()
289 );
290 Err(AniError::PlayerLaunchFailed)
291 }
292 }
293 } else {
294 command
296 .stdin(Stdio::null())
297 .stdout(Stdio::null())
298 .stderr(Stdio::null());
299 match command.spawn() {
300 Ok(child) => {
301 info!(
302 title = %title,
303 pid = child.id().unwrap_or(0),
304 executable = %self.options.executable.display(),
305 "player launched in the background",
306 );
307 let _ = child.id(); Ok(None)
310 }
311 Err(error) => {
312 warn!(
313 title = %title,
314 executable = %self.options.executable.display(),
315 error = %error,
316 "failed to launch player in detached mode",
317 );
318 eprintln!(
319 "Could not launch {}: {error}",
320 self.options.executable.display()
321 );
322 Err(AniError::PlayerLaunchFailed)
323 }
324 }
325 }
326 }
327
328 fn is_android_player(&self) -> bool {
329 matches!(
330 self.options.kind,
331 PlayerKind::AndroidMpv | PlayerKind::AndroidVlc
332 )
333 }
334
335 async fn play_android(
336 &self,
337 stream: &StreamLink,
338 title: &str,
339 relay_active: bool,
340 ) -> Result<Option<i32>> {
341 let terminal = io::stdin().is_terminal();
342 if relay_active && !terminal {
343 return Err(AniError::PlayerAndroidTerminalRequired);
344 }
345
346 let launch_args = self.command_args_inner(stream, title, true);
347 info!(
348 title = %title,
349 executable = %self.options.executable.display(),
350 "launching Android activity for playback",
351 );
352 debug!(
353 title = %title,
354 stream_url = %stream.url,
355 args = ?launch_args,
356 "full Android intent command line",
357 );
358 let launch_result = Command::new(&self.options.executable)
359 .args(launch_args)
360 .status()
361 .await;
362 let code = match launch_result {
363 Ok(status) if status.success() => {
364 let code = status.code().unwrap_or(0);
365 info!(
366 title = %title,
367 exit_code = code,
368 "Android player activity returned successfully",
369 );
370 code
371 }
372 result => {
373 let primary_error = match &result {
374 Ok(status) => format!(
375 "Android activity launcher {} exited with {}",
376 self.options.executable.display(),
377 status.code().unwrap_or(1)
378 ),
379 Err(error) => format!(
380 "could not launch Android player through {}: {error}",
381 self.options.executable.display()
382 ),
383 };
384 warn!(
385 title = %title,
386 executable = %self.options.executable.display(),
387 error = %primary_error,
388 "primary Android launch failed; trying termux-open fallback",
389 );
390 match launch_android_url_fallback(&self.options.executable, &stream.url, stream.hls)
391 .await
392 {
393 Ok(code) => {
394 warn!(
395 title = %title,
396 "opened the stream through Android's default URL handler instead",
397 );
398 eprintln!(
399 "warning: {primary_error}; opened the stream through Android's default URL handler instead"
400 );
401 code
402 }
403 Err(fallback_error) => {
404 warn!(
405 title = %title,
406 error = %fallback_error,
407 "termux-open fallback also failed",
408 );
409 eprintln!("{primary_error}; {fallback_error}");
410 return Err(AniError::PlayerLaunchFailed);
411 }
412 }
413 }
414 };
415
416 if terminal {
417 debug!(
418 title = %title,
419 "waiting for the Android player to finish before returning control to the TUI",
420 );
421 wait_for_android_player().await?;
422 }
423 Ok(Some(code))
424 }
425}
426
427fn android_intent_args(component: &str, url: &str, title: &str) -> Vec<String> {
428 vec![
429 "start".into(),
430 "--user".into(),
431 "0".into(),
432 "-a".into(),
433 "android.intent.action.VIEW".into(),
434 "-d".into(),
435 url.into(),
436 "-n".into(),
437 component.into(),
438 "--es".into(),
439 "title".into(),
440 title.into(),
441 ]
442}
443
444fn android_intent_launcher() -> PathBuf {
445 if let Some(executable) = std::env::var_os("ANI_CLI_PLAYER") {
446 return PathBuf::from(executable);
447 }
448 let path = std::env::var_os("PATH").unwrap_or_default();
449 ["termux-am-starter", "termux-am", "am"]
450 .iter()
451 .find_map(|name| find_in_path(name, &path))
452 .unwrap_or_else(|| PathBuf::from("termux-am-starter"))
453}
454
455fn find_in_path(executable: &str, path: &std::ffi::OsStr) -> Option<PathBuf> {
456 std::env::split_paths(path)
457 .map(|directory| directory.join(executable))
458 .find(|candidate| candidate.is_file())
459}
460
461async fn launch_android_url_fallback(
462 executable: &Path,
463 url: &str,
464 hls: bool,
465) -> std::result::Result<i32, String> {
466 if !is_termux_activity_launcher(executable) {
467 return Err(
468 "the configured custom launcher failed and cannot use the automatic Termux fallback"
469 .into(),
470 );
471 }
472 let path = std::env::var_os("PATH").unwrap_or_default();
473 let mut open_error = None;
474 if let Some(opener) = find_in_path("termux-open", &path) {
475 let status = Command::new(&opener)
476 .args(["--view", "--content-type", android_media_type(hls), url])
477 .status()
478 .await;
479 match status {
480 Ok(status) if status.success() => return Ok(status.code().unwrap_or(0)),
481 Ok(status) => {
482 open_error = Some(format!(
483 "{} exited with {}",
484 opener.display(),
485 status.code().unwrap_or(1)
486 ));
487 }
488 Err(error) => {
489 open_error = Some(format!("could not run {}: {error}", opener.display()));
490 }
491 }
492 }
493 let opener = find_in_path("termux-open-url", &path).ok_or_else(|| {
494 let prefix = open_error
495 .map(|error| format!("{error}; "))
496 .unwrap_or_default();
497 format!(
498 "{prefix}termux-open-url is unavailable; install or update the termux-tools package"
499 )
500 })?;
501 let status = Command::new(&opener)
502 .arg(url)
503 .status()
504 .await
505 .map_err(|error| format!("could not run {}: {error}", opener.display()))?;
506 if !status.success() {
507 return Err(format!(
508 "{} exited with {}",
509 opener.display(),
510 status.code().unwrap_or(1)
511 ));
512 }
513 Ok(status.code().unwrap_or(0))
514}
515
516fn android_media_type(hls: bool) -> &'static str {
517 if hls {
518 "application/vnd.apple.mpegurl"
519 } else {
520 "video/mp4"
521 }
522}
523
524fn is_termux_activity_launcher(executable: &Path) -> bool {
525 executable
526 .file_name()
527 .and_then(|name| name.to_str())
528 .is_some_and(|name| matches!(name, "termux-am-starter" | "termux-am" | "am"))
529}
530
531async fn wait_for_android_player() -> Result<()> {
532 tokio::task::spawn_blocking(|| {
533 println!(
534 "Opened the Android player. Return to Termux and press Enter after playback ends."
535 );
536 print!("Waiting for Android player... ");
537 io::stdout().flush()?;
538 let mut input = String::new();
539 io::stdin().read_line(&mut input)?;
540 Ok::<(), io::Error>(())
541 })
542 .await
543 .map_err(|error| {
544 eprintln!("Android playback prompt failed: {error}");
545 AniError::PlayerLaunchFailed
546 })??;
547 Ok(())
548}
549
550fn mpv_options(stream: &StreamLink, title: &str, referer: &str) -> Vec<String> {
551 let mut args = vec![
552 "--tls-verify=no".into(),
553 format!("--force-media-title={title}"),
554 ];
555 if !referer.is_empty() {
556 args.push(format!("--referrer={referer}"));
557 }
558 append_mpv_headers(&mut args, stream);
559 for track in &stream.subtitles {
560 args.push(format!("--sub-file={}", track.url));
561 }
562 if let Some(track) = stream.subtitles.iter().find(|track| track.default) {
563 args.push(format!("--slang={}", track.label));
564 }
565 if stream.hls && crate::anikoto::requires_hls_relay(stream) {
567 args.push("--cache=yes".into());
568 args.push("--cache-secs=120".into());
569 args.push("--demuxer-max-bytes=512MiB".into());
570 args.push("--demuxer-max-back-bytes=256MiB".into());
571 }
572 args
573}
574
575fn append_mpv_headers(args: &mut Vec<String>, stream: &StreamLink) {
576 let mut headers = Vec::new();
577 if let Some(origin) = &stream.headers.origin
578 && safe_header_value(origin)
579 {
580 headers.push(format!("Origin: {origin}"));
581 }
582 headers.extend(
583 stream
584 .headers
585 .extra
586 .iter()
587 .filter(|(name, value)| safe_header_value(name) && safe_header_value(value))
588 .map(|(name, value)| format!("{name}: {value}")),
589 );
590 if !headers.is_empty() {
591 args.push(format!("--http-header-fields={}", headers.join(",")));
592 }
593}
594
595fn safe_header_value(value: &str) -> bool {
596 !value.contains(['\r', '\n'])
597}
598
599fn env_bool(name: &str) -> bool {
600 std::env::var(name)
601 .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
602 .unwrap_or(false)
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608 use crate::{RequestHeaders, SubtitleTrack};
609 #[test]
610 fn mpv_arguments_preserve_referrer_as_one_argument() {
611 let player = Player::new(PlayerOptions {
612 executable: "mpv".into(),
613 kind: PlayerKind::Mpv,
614 no_detach: true,
615 exit_after_play: false,
616 });
617 let stream = StreamLink {
618 url: "https://media/a.m3u8".into(),
619 resolution: "1080p".into(),
620 hls: true,
621 provider: "Default".into(),
622 downloadable: true,
623 headers: RequestHeaders {
624 referer: Some("https://ref.example".into()),
625 ..Default::default()
626 },
627 subtitles: vec![],
628 };
629 assert!(
630 player
631 .command_args(&stream, "Anime Episode 1")
632 .contains(&"--referrer=https://ref.example".into())
633 );
634 }
635
636 #[test]
637 fn iina_arguments_put_stream_before_raw_mpv_options() {
638 let player = Player::new(PlayerOptions {
639 executable: "iina".into(),
640 kind: PlayerKind::Iina,
641 no_detach: false,
642 exit_after_play: false,
643 });
644 let stream = StreamLink {
645 url: "https://media/a.m3u8".into(),
646 resolution: "1080p".into(),
647 hls: true,
648 provider: "Default".into(),
649 downloadable: true,
650 headers: RequestHeaders {
651 referer: Some("https://ref.example".into()),
652 origin: Some("https://origin.example".into()),
653 ..Default::default()
654 },
655 subtitles: vec![SubtitleTrack {
656 label: "English".into(),
657 url: "https://media/subtitles.vtt".into(),
658 default: true,
659 }],
660 };
661
662 assert_eq!(
663 player.command_args(&stream, "Anime Episode 1"),
664 vec![
665 "--no-stdin",
666 "https://media/a.m3u8",
667 "--",
668 "--tls-verify=no",
669 "--force-media-title=Anime Episode 1",
670 "--referrer=https://ref.example",
671 "--http-header-fields=Origin: https://origin.example",
672 "--sub-file=https://media/subtitles.vtt",
673 "--slang=English",
674 ]
675 );
676 }
677
678 #[test]
679 fn forced_attached_iina_keeps_cli_running() {
680 let player = Player::new(PlayerOptions {
681 executable: "iina".into(),
682 kind: PlayerKind::Iina,
683 no_detach: false,
684 exit_after_play: false,
685 });
686 let stream = StreamLink {
687 url: "https://media/a.m3u8".into(),
688 resolution: "1080p".into(),
689 hls: true,
690 provider: "Default".into(),
691 downloadable: true,
692 headers: RequestHeaders::default(),
693 subtitles: vec![],
694 };
695
696 assert_eq!(
697 &player.command_args_inner(&stream, "Anime", true)[..3],
698 ["--no-stdin", "--keep-running", "https://media/a.m3u8"]
699 );
700 }
701
702 #[test]
703 fn android_mpv_arguments_use_an_explicit_view_intent() {
704 let player = Player::new(PlayerOptions {
705 executable: "termux-am-starter".into(),
706 kind: PlayerKind::AndroidMpv,
707 no_detach: true,
708 exit_after_play: false,
709 });
710 let stream = StreamLink {
711 url: "http://127.0.0.1:43123/stream-token".into(),
712 resolution: "1080p".into(),
713 hls: true,
714 provider: "Anikoto".into(),
715 downloadable: true,
716 headers: RequestHeaders::default(),
717 subtitles: vec![],
718 };
719
720 assert_eq!(
721 player.command_args(&stream, "Anime Episode 1"),
722 vec![
723 "start",
724 "--user",
725 "0",
726 "-a",
727 "android.intent.action.VIEW",
728 "-d",
729 "http://127.0.0.1:43123/stream-token",
730 "-n",
731 "is.xyz.mpv/.MPVActivity",
732 "--es",
733 "title",
734 "Anime Episode 1",
735 ]
736 );
737 }
738
739 #[test]
740 fn android_vlc_arguments_target_the_android_app_not_terminal_vlc() {
741 let player = Player::new(PlayerOptions {
742 executable: "am".into(),
743 kind: PlayerKind::AndroidVlc,
744 no_detach: true,
745 exit_after_play: false,
746 });
747 let stream = StreamLink {
748 url: "https://media.example/episode.m3u8".into(),
749 resolution: "720p".into(),
750 hls: true,
751 provider: "Anikoto".into(),
752 downloadable: true,
753 headers: RequestHeaders::default(),
754 subtitles: vec![],
755 };
756
757 assert!(
758 player.command_args(&stream, "Episode").contains(
759 &"org.videolan.vlc/org.videolan.vlc.gui.video.VideoPlayerActivity".into()
760 )
761 );
762 }
763
764 #[test]
765 fn android_launcher_lookup_prefers_the_first_available_candidate() {
766 let directory = tempfile::tempdir().unwrap();
767 std::fs::write(directory.path().join("termux-am"), "").unwrap();
768 let path = std::env::join_paths([directory.path()]).unwrap();
769
770 assert_eq!(
771 find_in_path("termux-am", &path),
772 Some(directory.path().join("termux-am"))
773 );
774 assert_eq!(find_in_path("termux-am-starter", &path), None);
775 }
776
777 #[test]
778 fn only_termux_activity_launchers_allow_the_url_opener_fallback() {
779 assert!(is_termux_activity_launcher(Path::new("termux-am-starter")));
780 assert!(is_termux_activity_launcher(Path::new(
781 "/data/data/com.termux/files/usr/bin/termux-am"
782 )));
783 assert!(is_termux_activity_launcher(Path::new("am")));
784 assert!(!is_termux_activity_launcher(Path::new(
785 "/data/local/tmp/custom-launcher"
786 )));
787 }
788
789 #[test]
790 fn android_fallback_uses_specific_media_types() {
791 assert_eq!(android_media_type(true), "application/vnd.apple.mpegurl");
792 assert_eq!(android_media_type(false), "video/mp4");
793 }
794
795 #[test]
796 fn platform_default_selects_expected_player() {
797 let options = PlayerOptions::default_player();
798 if cfg!(target_os = "android") {
799 assert_eq!(options.kind, PlayerKind::AndroidMpv);
800 } else if cfg!(target_os = "macos") {
801 assert_eq!(options.kind, PlayerKind::Iina);
802 if std::env::var_os("ANI_CLI_PLAYER").is_none() {
803 assert_eq!(options.executable, PathBuf::from("iina"));
804 }
805 } else {
806 assert_eq!(options.kind, PlayerKind::Mpv);
807 }
808 }
809}