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!("Player executable not found: {}. Please install the player or set ANI_CLI_PLAYER environment variable.", self.options.executable.display());
235 return Err(AniError::PlayerNotFound);
236 }
237
238 let mut command = Command::new(&self.options.executable);
239 let attached = self.options.no_detach || force_attached;
240 let args = self.command_args_inner(stream, title, attached);
241 info!(
242 title = %title,
243 executable = %self.options.executable.display(),
244 kind = %self.options.kind,
245 attached,
246 "launching external player",
247 );
248 debug!(
253 title = %title,
254 stream_url = %stream.url,
255 stream_hls = stream.hls,
256 args = ?args,
257 "full player command line",
258 );
259 command.args(args);
260 if attached {
261 match command.status().await {
262 Ok(status) => {
263 let code = status.code().unwrap_or(1);
264 info!(
265 title = %title,
266 exit_code = code,
267 success = status.success(),
268 "player exited",
269 );
270 if !status.success() && self.options.exit_after_play {
271 eprintln!("Player exited with {code}");
272 return Err(AniError::PlayerExitFailed);
273 }
274 Ok(Some(code))
275 }
276 Err(error) => {
277 warn!(
278 title = %title,
279 executable = %self.options.executable.display(),
280 error = %error,
281 "failed to launch player in attached mode",
282 );
283 eprintln!("Could not launch {}: {error}", self.options.executable.display());
284 Err(AniError::PlayerLaunchFailed)
285 }
286 }
287 } else {
288 command
290 .stdin(Stdio::null())
291 .stdout(Stdio::null())
292 .stderr(Stdio::null());
293 match command.spawn() {
294 Ok(child) => {
295 info!(
296 title = %title,
297 pid = child.id().unwrap_or(0),
298 executable = %self.options.executable.display(),
299 "player launched in the background",
300 );
301 let _ = child.id(); Ok(None)
304 }
305 Err(error) => {
306 warn!(
307 title = %title,
308 executable = %self.options.executable.display(),
309 error = %error,
310 "failed to launch player in detached mode",
311 );
312 eprintln!("Could not launch {}: {error}", self.options.executable.display());
313 Err(AniError::PlayerLaunchFailed)
314 }
315 }
316 }
317 }
318
319 fn is_android_player(&self) -> bool {
320 matches!(
321 self.options.kind,
322 PlayerKind::AndroidMpv | PlayerKind::AndroidVlc
323 )
324 }
325
326 async fn play_android(
327 &self,
328 stream: &StreamLink,
329 title: &str,
330 relay_active: bool,
331 ) -> Result<Option<i32>> {
332 let terminal = io::stdin().is_terminal();
333 if relay_active && !terminal {
334 return Err(AniError::PlayerAndroidTerminalRequired);
335 }
336
337 let launch_args = self.command_args_inner(stream, title, true);
338 info!(
339 title = %title,
340 executable = %self.options.executable.display(),
341 "launching Android activity for playback",
342 );
343 debug!(
344 title = %title,
345 stream_url = %stream.url,
346 args = ?launch_args,
347 "full Android intent command line",
348 );
349 let launch_result = Command::new(&self.options.executable)
350 .args(launch_args)
351 .status()
352 .await;
353 let code = match launch_result {
354 Ok(status) if status.success() => {
355 let code = status.code().unwrap_or(0);
356 info!(
357 title = %title,
358 exit_code = code,
359 "Android player activity returned successfully",
360 );
361 code
362 }
363 result => {
364 let primary_error = match &result {
365 Ok(status) => format!(
366 "Android activity launcher {} exited with {}",
367 self.options.executable.display(),
368 status.code().unwrap_or(1)
369 ),
370 Err(error) => format!(
371 "could not launch Android player through {}: {error}",
372 self.options.executable.display()
373 ),
374 };
375 warn!(
376 title = %title,
377 executable = %self.options.executable.display(),
378 error = %primary_error,
379 "primary Android launch failed; trying termux-open fallback",
380 );
381 match launch_android_url_fallback(&self.options.executable, &stream.url, stream.hls)
382 .await
383 {
384 Ok(code) => {
385 warn!(
386 title = %title,
387 "opened the stream through Android's default URL handler instead",
388 );
389 eprintln!(
390 "warning: {primary_error}; opened the stream through Android's default URL handler instead"
391 );
392 code
393 }
394 Err(fallback_error) => {
395 warn!(
396 title = %title,
397 error = %fallback_error,
398 "termux-open fallback also failed",
399 );
400 eprintln!("{primary_error}; {fallback_error}");
401 return Err(AniError::PlayerLaunchFailed);
402 }
403 }
404 }
405 };
406
407 if terminal {
408 debug!(
409 title = %title,
410 "waiting for the Android player to finish before returning control to the TUI",
411 );
412 wait_for_android_player().await?;
413 }
414 Ok(Some(code))
415 }
416}
417
418fn android_intent_args(component: &str, url: &str, title: &str) -> Vec<String> {
419 vec![
420 "start".into(),
421 "--user".into(),
422 "0".into(),
423 "-a".into(),
424 "android.intent.action.VIEW".into(),
425 "-d".into(),
426 url.into(),
427 "-n".into(),
428 component.into(),
429 "--es".into(),
430 "title".into(),
431 title.into(),
432 ]
433}
434
435fn android_intent_launcher() -> PathBuf {
436 if let Some(executable) = std::env::var_os("ANI_CLI_PLAYER") {
437 return PathBuf::from(executable);
438 }
439 let path = std::env::var_os("PATH").unwrap_or_default();
440 ["termux-am-starter", "termux-am", "am"]
441 .iter()
442 .find_map(|name| find_in_path(name, &path))
443 .unwrap_or_else(|| PathBuf::from("termux-am-starter"))
444}
445
446fn find_in_path(executable: &str, path: &std::ffi::OsStr) -> Option<PathBuf> {
447 std::env::split_paths(path)
448 .map(|directory| directory.join(executable))
449 .find(|candidate| candidate.is_file())
450}
451
452async fn launch_android_url_fallback(
453 executable: &Path,
454 url: &str,
455 hls: bool,
456) -> std::result::Result<i32, String> {
457 if !is_termux_activity_launcher(executable) {
458 return Err(
459 "the configured custom launcher failed and cannot use the automatic Termux fallback"
460 .into(),
461 );
462 }
463 let path = std::env::var_os("PATH").unwrap_or_default();
464 let mut open_error = None;
465 if let Some(opener) = find_in_path("termux-open", &path) {
466 let status = Command::new(&opener)
467 .args(["--view", "--content-type", android_media_type(hls), url])
468 .status()
469 .await;
470 match status {
471 Ok(status) if status.success() => return Ok(status.code().unwrap_or(0)),
472 Ok(status) => {
473 open_error = Some(format!(
474 "{} exited with {}",
475 opener.display(),
476 status.code().unwrap_or(1)
477 ));
478 }
479 Err(error) => {
480 open_error = Some(format!("could not run {}: {error}", opener.display()));
481 }
482 }
483 }
484 let opener = find_in_path("termux-open-url", &path).ok_or_else(|| {
485 let prefix = open_error
486 .map(|error| format!("{error}; "))
487 .unwrap_or_default();
488 format!(
489 "{prefix}termux-open-url is unavailable; install or update the termux-tools package"
490 )
491 })?;
492 let status = Command::new(&opener)
493 .arg(url)
494 .status()
495 .await
496 .map_err(|error| format!("could not run {}: {error}", opener.display()))?;
497 if !status.success() {
498 return Err(format!(
499 "{} exited with {}",
500 opener.display(),
501 status.code().unwrap_or(1)
502 ));
503 }
504 Ok(status.code().unwrap_or(0))
505}
506
507fn android_media_type(hls: bool) -> &'static str {
508 if hls {
509 "application/vnd.apple.mpegurl"
510 } else {
511 "video/mp4"
512 }
513}
514
515fn is_termux_activity_launcher(executable: &Path) -> bool {
516 executable
517 .file_name()
518 .and_then(|name| name.to_str())
519 .is_some_and(|name| matches!(name, "termux-am-starter" | "termux-am" | "am"))
520}
521
522async fn wait_for_android_player() -> Result<()> {
523 tokio::task::spawn_blocking(|| {
524 println!(
525 "Opened the Android player. Return to Termux and press Enter after playback ends."
526 );
527 print!("Waiting for Android player... ");
528 io::stdout().flush()?;
529 let mut input = String::new();
530 io::stdin().read_line(&mut input)?;
531 Ok::<(), io::Error>(())
532 })
533 .await
534 .map_err(|error| {
535 eprintln!("Android playback prompt failed: {error}");
536 AniError::PlayerLaunchFailed
537 })??;
538 Ok(())
539}
540
541fn mpv_options(stream: &StreamLink, title: &str, referer: &str) -> Vec<String> {
542 let mut args = vec![
543 "--tls-verify=no".into(),
544 format!("--force-media-title={title}"),
545 ];
546 if !referer.is_empty() {
547 args.push(format!("--referrer={referer}"));
548 }
549 append_mpv_headers(&mut args, stream);
550 for track in &stream.subtitles {
551 args.push(format!("--sub-file={}", track.url));
552 }
553 if let Some(track) = stream.subtitles.iter().find(|track| track.default) {
554 args.push(format!("--slang={}", track.label));
555 }
556 if stream.hls && crate::anikoto::requires_hls_relay(stream) {
558 args.push("--cache=yes".into());
559 args.push("--cache-secs=120".into());
560 args.push("--demuxer-max-bytes=512MiB".into());
561 args.push("--demuxer-max-back-bytes=256MiB".into());
562 }
563 args
564}
565
566fn append_mpv_headers(args: &mut Vec<String>, stream: &StreamLink) {
567 let mut headers = Vec::new();
568 if let Some(origin) = &stream.headers.origin
569 && safe_header_value(origin)
570 {
571 headers.push(format!("Origin: {origin}"));
572 }
573 headers.extend(
574 stream
575 .headers
576 .extra
577 .iter()
578 .filter(|(name, value)| safe_header_value(name) && safe_header_value(value))
579 .map(|(name, value)| format!("{name}: {value}")),
580 );
581 if !headers.is_empty() {
582 args.push(format!("--http-header-fields={}", headers.join(",")));
583 }
584}
585
586fn safe_header_value(value: &str) -> bool {
587 !value.contains(['\r', '\n'])
588}
589
590fn env_bool(name: &str) -> bool {
591 std::env::var(name)
592 .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
593 .unwrap_or(false)
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599 use crate::{RequestHeaders, SubtitleTrack};
600 #[test]
601 fn mpv_arguments_preserve_referrer_as_one_argument() {
602 let player = Player::new(PlayerOptions {
603 executable: "mpv".into(),
604 kind: PlayerKind::Mpv,
605 no_detach: true,
606 exit_after_play: false,
607 });
608 let stream = StreamLink {
609 url: "https://media/a.m3u8".into(),
610 resolution: "1080p".into(),
611 hls: true,
612 provider: "Default".into(),
613 downloadable: true,
614 headers: RequestHeaders {
615 referer: Some("https://ref.example".into()),
616 ..Default::default()
617 },
618 subtitles: vec![],
619 };
620 assert!(
621 player
622 .command_args(&stream, "Anime Episode 1")
623 .contains(&"--referrer=https://ref.example".into())
624 );
625 }
626
627 #[test]
628 fn iina_arguments_put_stream_before_raw_mpv_options() {
629 let player = Player::new(PlayerOptions {
630 executable: "iina".into(),
631 kind: PlayerKind::Iina,
632 no_detach: false,
633 exit_after_play: false,
634 });
635 let stream = StreamLink {
636 url: "https://media/a.m3u8".into(),
637 resolution: "1080p".into(),
638 hls: true,
639 provider: "Default".into(),
640 downloadable: true,
641 headers: RequestHeaders {
642 referer: Some("https://ref.example".into()),
643 origin: Some("https://origin.example".into()),
644 ..Default::default()
645 },
646 subtitles: vec![SubtitleTrack {
647 label: "English".into(),
648 url: "https://media/subtitles.vtt".into(),
649 default: true,
650 }],
651 };
652
653 assert_eq!(
654 player.command_args(&stream, "Anime Episode 1"),
655 vec![
656 "--no-stdin",
657 "https://media/a.m3u8",
658 "--",
659 "--tls-verify=no",
660 "--force-media-title=Anime Episode 1",
661 "--referrer=https://ref.example",
662 "--http-header-fields=Origin: https://origin.example",
663 "--sub-file=https://media/subtitles.vtt",
664 "--slang=English",
665 ]
666 );
667 }
668
669 #[test]
670 fn forced_attached_iina_keeps_cli_running() {
671 let player = Player::new(PlayerOptions {
672 executable: "iina".into(),
673 kind: PlayerKind::Iina,
674 no_detach: false,
675 exit_after_play: false,
676 });
677 let stream = StreamLink {
678 url: "https://media/a.m3u8".into(),
679 resolution: "1080p".into(),
680 hls: true,
681 provider: "Default".into(),
682 downloadable: true,
683 headers: RequestHeaders::default(),
684 subtitles: vec![],
685 };
686
687 assert_eq!(
688 &player.command_args_inner(&stream, "Anime", true)[..3],
689 ["--no-stdin", "--keep-running", "https://media/a.m3u8"]
690 );
691 }
692
693 #[test]
694 fn android_mpv_arguments_use_an_explicit_view_intent() {
695 let player = Player::new(PlayerOptions {
696 executable: "termux-am-starter".into(),
697 kind: PlayerKind::AndroidMpv,
698 no_detach: true,
699 exit_after_play: false,
700 });
701 let stream = StreamLink {
702 url: "http://127.0.0.1:43123/stream-token".into(),
703 resolution: "1080p".into(),
704 hls: true,
705 provider: "Anikoto".into(),
706 downloadable: true,
707 headers: RequestHeaders::default(),
708 subtitles: vec![],
709 };
710
711 assert_eq!(
712 player.command_args(&stream, "Anime Episode 1"),
713 vec![
714 "start",
715 "--user",
716 "0",
717 "-a",
718 "android.intent.action.VIEW",
719 "-d",
720 "http://127.0.0.1:43123/stream-token",
721 "-n",
722 "is.xyz.mpv/.MPVActivity",
723 "--es",
724 "title",
725 "Anime Episode 1",
726 ]
727 );
728 }
729
730 #[test]
731 fn android_vlc_arguments_target_the_android_app_not_terminal_vlc() {
732 let player = Player::new(PlayerOptions {
733 executable: "am".into(),
734 kind: PlayerKind::AndroidVlc,
735 no_detach: true,
736 exit_after_play: false,
737 });
738 let stream = StreamLink {
739 url: "https://media.example/episode.m3u8".into(),
740 resolution: "720p".into(),
741 hls: true,
742 provider: "Anikoto".into(),
743 downloadable: true,
744 headers: RequestHeaders::default(),
745 subtitles: vec![],
746 };
747
748 assert!(
749 player.command_args(&stream, "Episode").contains(
750 &"org.videolan.vlc/org.videolan.vlc.gui.video.VideoPlayerActivity".into()
751 )
752 );
753 }
754
755 #[test]
756 fn android_launcher_lookup_prefers_the_first_available_candidate() {
757 let directory = tempfile::tempdir().unwrap();
758 std::fs::write(directory.path().join("termux-am"), "").unwrap();
759 let path = std::env::join_paths([directory.path()]).unwrap();
760
761 assert_eq!(
762 find_in_path("termux-am", &path),
763 Some(directory.path().join("termux-am"))
764 );
765 assert_eq!(find_in_path("termux-am-starter", &path), None);
766 }
767
768 #[test]
769 fn only_termux_activity_launchers_allow_the_url_opener_fallback() {
770 assert!(is_termux_activity_launcher(Path::new("termux-am-starter")));
771 assert!(is_termux_activity_launcher(Path::new(
772 "/data/data/com.termux/files/usr/bin/termux-am"
773 )));
774 assert!(is_termux_activity_launcher(Path::new("am")));
775 assert!(!is_termux_activity_launcher(Path::new(
776 "/data/local/tmp/custom-launcher"
777 )));
778 }
779
780 #[test]
781 fn android_fallback_uses_specific_media_types() {
782 assert_eq!(android_media_type(true), "application/vnd.apple.mpegurl");
783 assert_eq!(android_media_type(false), "video/mp4");
784 }
785
786 #[test]
787 fn platform_default_selects_expected_player() {
788 let options = PlayerOptions::default_player();
789 if cfg!(target_os = "android") {
790 assert_eq!(options.kind, PlayerKind::AndroidMpv);
791 } else if cfg!(target_os = "macos") {
792 assert_eq!(options.kind, PlayerKind::Iina);
793 if std::env::var_os("ANI_CLI_PLAYER").is_none() {
794 assert_eq!(options.executable, PathBuf::from("iina"));
795 }
796 } else {
797 assert_eq!(options.kind, PlayerKind::Mpv);
798 }
799 }
800}