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!("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        // The URL is logged at debug level so that long playlist URLs do not
249        // clutter the default log output, but the rest of the player command
250        // line (including the referer / user-agent switches) is logged at
251        // debug level for the same reason.
252        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            // ensure proper process detachment
289            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                    // Immediately detach the child process
302                    let _ = child.id(); // Ensure the process handle is consumed
303                    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    // Add cache settings for HLS relay streams
557    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}