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