Skip to main content

ani_lib/
player.rs

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    /// Human-readable summary of the configured player (executable + kind).
110    /// Useful for debug logs and error messages.
111    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                // Ensure URL is passed last
127                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            // Android players receive a single intent URL and cannot be given
200            // an explicit `--sub-file`, so they need subtitles exposed as
201            // synthetic HLS renditions. Desktop players already receive
202            // subtitles via `--sub-file`, and wrapping a long subtitle file as
203            // a single oversized HLS segment produces unreliable cue timing
204            // in some HLS demuxers (see issue #18).
205            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        // Validate that the player executable exists before attempting to launch
231        // Only check if it's an absolute path or relative path with directory components
232        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        // The URL is logged at debug level so that long playlist URLs do not
252        // clutter the default log output, but the rest of the player command
253        // line (including the referer / user-agent switches) is logged at
254        // debug level for the same reason.
255        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            // ensure proper process detachment
295            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                    // Immediately detach the child process
308                    let _ = child.id(); // Ensure the process handle is consumed
309                    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    // Add cache settings for HLS relay streams
566    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}