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    time::{SystemTime, UNIX_EPOCH},
7};
8
9use tokio::process::Command;
10use tracing::{debug, info, warn};
11
12use crate::{
13    AniError, Result, StreamLink, relay_stream, relay_stream_without_hls_subtitles,
14    requires_hls_relay,
15};
16
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub enum PlayerKind {
19    Mpv,
20    Iina,
21    Vlc,
22    AndroidMpv,
23    AndroidVlc,
24    Syncplay,
25    Custom,
26}
27
28impl fmt::Display for PlayerKind {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        let label = match self {
31            Self::Mpv => "mpv",
32            Self::Iina => "iina",
33            Self::Vlc => "vlc",
34            Self::AndroidMpv => "android-mpv",
35            Self::AndroidVlc => "android-vlc",
36            Self::Syncplay => "syncplay",
37            Self::Custom => "custom",
38        };
39        f.write_str(label)
40    }
41}
42
43#[derive(Clone, Debug)]
44pub struct PlayerOptions {
45    pub executable: PathBuf,
46    pub kind: PlayerKind,
47    pub no_detach: bool,
48    pub exit_after_play: bool,
49    pub force_hls_relay: bool,
50}
51
52impl PlayerOptions {
53    pub fn default_player() -> Self {
54        if cfg!(target_os = "android") {
55            Self::default_android_mpv()
56        } else if cfg!(target_os = "macos") {
57            Self::default_iina()
58        } else {
59            Self::default_mpv()
60        }
61    }
62
63    pub fn default_mpv() -> Self {
64        let executable = std::env::var_os("ANI_CLI_PLAYER")
65            .map(PathBuf::from)
66            .unwrap_or_else(|| PathBuf::from(if cfg!(windows) { "mpv.exe" } else { "mpv" }));
67        Self {
68            executable,
69            kind: PlayerKind::Mpv,
70            no_detach: env_bool("ANI_CLI_NO_DETACH"),
71            exit_after_play: env_bool("ANI_CLI_EXIT_AFTER_PLAY"),
72            force_hls_relay: false,
73        }
74    }
75
76    pub fn default_iina() -> Self {
77        let executable = std::env::var_os("ANI_CLI_PLAYER")
78            .map(PathBuf::from)
79            .unwrap_or_else(|| PathBuf::from("iina"));
80        Self {
81            executable,
82            kind: PlayerKind::Iina,
83            no_detach: env_bool("ANI_CLI_NO_DETACH"),
84            exit_after_play: env_bool("ANI_CLI_EXIT_AFTER_PLAY"),
85            force_hls_relay: false,
86        }
87    }
88
89    pub fn default_android_mpv() -> Self {
90        Self {
91            executable: android_intent_launcher(),
92            kind: PlayerKind::AndroidMpv,
93            no_detach: true,
94            exit_after_play: env_bool("ANI_CLI_EXIT_AFTER_PLAY"),
95            force_hls_relay: false,
96        }
97    }
98}
99
100#[derive(Clone, Debug)]
101pub struct Player {
102    options: PlayerOptions,
103}
104
105impl Player {
106    pub fn new(options: PlayerOptions) -> Self {
107        Self { options }
108    }
109
110    pub fn command_args(&self, stream: &StreamLink, title: &str) -> Vec<String> {
111        self.command_args_inner(stream, title, self.options.no_detach)
112    }
113
114    /// Human-readable summary of the configured player (executable + kind).
115    /// Useful for debug logs and error messages.
116    pub fn describe(&self) -> String {
117        format!(
118            "{} ({}) [no_detach={}, exit_after_play={}, force_hls_relay={}]",
119            self.options.executable.display(),
120            self.options.kind,
121            self.options.no_detach,
122            self.options.exit_after_play,
123            self.options.force_hls_relay,
124        )
125    }
126
127    fn command_args_inner(&self, stream: &StreamLink, title: &str, attached: bool) -> Vec<String> {
128        let referer = stream.headers.referer.as_deref().unwrap_or("");
129        match self.options.kind {
130            PlayerKind::Mpv => {
131                let mut args = mpv_options(stream, title, referer);
132                // Ensure URL is passed last
133                args.push(stream.url.clone());
134                args
135            }
136            PlayerKind::Iina => {
137                let mut args = vec!["--no-stdin".into()];
138                if attached {
139                    args.push("--keep-running".into());
140                }
141                args.push(stream.url.clone());
142                args.push("--".into());
143                args.extend(mpv_options(stream, title, referer));
144                args
145            }
146            PlayerKind::Vlc => {
147                let mut args = vec!["--play-and-exit".into(), format!("--meta-title={title}")];
148                if !referer.is_empty() {
149                    args.push(format!("--http-referrer={referer}"));
150                }
151                if let Some(agent) = stream.headers.extra.get("User-Agent") {
152                    args.push(format!("--http-user-agent={agent}"));
153                }
154                for track in &stream.subtitles {
155                    args.push(format!("--sub-file={}", track.url));
156                }
157                args.push(stream.url.clone());
158                args
159            }
160            PlayerKind::AndroidMpv => {
161                android_intent_args("is.xyz.mpv/.MPVActivity", &stream.url, title)
162            }
163            PlayerKind::AndroidVlc => android_intent_args(
164                "org.videolan.vlc/org.videolan.vlc.gui.video.VideoPlayerActivity",
165                &stream.url,
166                title,
167            ),
168            PlayerKind::Syncplay => {
169                let mut args = vec![
170                    stream.url.clone(),
171                    "--".into(),
172                    "--tls-verify=no".into(),
173                    format!("--force-media-title={title}"),
174                ];
175                if !referer.is_empty() {
176                    args.push(format!("--referrer={referer}"));
177                }
178                append_mpv_headers(&mut args, stream);
179                for track in &stream.subtitles {
180                    args.push(format!("--sub-file={}", track.url));
181                }
182                args
183            }
184            PlayerKind::Custom => vec![stream.url.clone()],
185        }
186    }
187
188    pub async fn play(&self, stream: &StreamLink, title: &str) -> Result<Option<i32>> {
189        info!(
190            title = %title,
191            player = %self.describe(),
192            stream_url = %stream.url,
193            hls = stream.hls,
194            subtitles = stream.subtitles.len(),
195            "playback requested",
196        );
197        if self.options.force_hls_relay
198            || crate::requires_hls_relay(stream)
199            || (self.is_android_player() && stream.hls)
200        {
201            debug!(
202                title = %title,
203                player = %self.options.kind.to_string(),
204                android_player = self.is_android_player(),
205                hls = stream.hls,
206                "stream requires the loopback HLS relay",
207            );
208            // Android players receive a single intent URL and cannot be given
209            // an explicit `--sub-file`, so they need subtitles exposed as
210            // synthetic HLS renditions. Desktop players already receive
211            // subtitles via `--sub-file`, and wrapping a long subtitle file as
212            // a single oversized HLS segment produces unreliable cue timing
213            // in some HLS demuxers (see issue #18).
214            let (_relay, local) = if self.is_android_player() {
215                relay_stream(stream).await?
216            } else {
217                relay_stream_without_hls_subtitles(stream).await?
218            };
219            debug!(
220                title = %title,
221                local_url = %local.url,
222                "HLS relay is serving the rewritten stream URL to the player",
223            );
224            return self.play_inner(&local, title, true).await;
225        }
226        self.play_inner(stream, title, false).await
227    }
228
229    async fn play_inner(
230        &self,
231        stream: &StreamLink,
232        title: &str,
233        force_attached: bool,
234    ) -> Result<Option<i32>> {
235        if self.is_android_player() {
236            return self.play_android(stream, title, force_attached).await;
237        }
238
239        // Validate that the player executable exists before attempting to launch
240        // For simple names (e.g., "mpv"), check if they exist in PATH
241        let needs_validation = self.options.executable.components().count() > 1;
242        if needs_validation {
243            if !self.options.executable.exists() {
244                eprintln!(
245                    "Player executable not found: {}. Please install the player or set ANI_CLI_PLAYER environment variable.",
246                    self.options.executable.display()
247                );
248                return Err(AniError::PlayerNotFound {
249                    executable: self.options.executable.display().to_string(),
250                });
251            }
252        } else {
253            // For simple names, check if they can be found in PATH
254            use which::which;
255            if which(&self.options.executable).is_err() {
256                eprintln!(
257                    "Player executable '{}' not found in PATH. Please install the player or set ANI_CLI_PLAYER environment variable.",
258                    self.options.executable.display()
259                );
260                return Err(AniError::PlayerNotFound {
261                    executable: self.options.executable.display().to_string(),
262                });
263            }
264        }
265
266        let mut command = Command::new(&self.options.executable);
267        let attached = self.options.no_detach || force_attached;
268        let args = if self.options.kind == PlayerKind::Vlc {
269            self.vlc_command_args(stream, title, attached).await?
270        } else {
271            self.command_args_inner(stream, title, attached)
272        };
273        info!(
274            title = %title,
275            executable = %self.options.executable.display(),
276            kind = %self.options.kind,
277            attached,
278            "launching external player",
279        );
280        // The URL is logged at debug level so that long playlist URLs do not
281        // clutter the default log output, but the rest of the player command
282        // line (including the referer / user-agent switches) is logged at
283        // debug level for the same reason.
284        debug!(
285            title = %title,
286            stream_url = %stream.url,
287            stream_hls = stream.hls,
288            args = ?args,
289            "full player command line",
290        );
291        command.args(args);
292        if attached {
293            match command.status().await {
294                Ok(status) => {
295                    let code = status.code().unwrap_or(1);
296                    info!(
297                        title = %title,
298                        exit_code = code,
299                        success = status.success(),
300                        "player exited",
301                    );
302                    if !status.success() && self.options.exit_after_play {
303                        eprintln!("Player exited with {code}");
304                        return Err(AniError::PlayerExitFailed);
305                    }
306                    Ok(Some(code))
307                }
308                Err(error) => {
309                    warn!(
310                        title = %title,
311                        executable = %self.options.executable.display(),
312                        error = %error,
313                        "failed to launch player in attached mode",
314                    );
315                    eprintln!(
316                        "Could not launch {}: {error}",
317                        self.options.executable.display()
318                    );
319                    Err(AniError::PlayerLaunchFailed {
320                        executable: self.options.executable.display().to_string(),
321                        reason: format!("attached mode launch failed: {}", error),
322                    })
323                }
324            }
325        } else {
326            // ensure proper process detachment
327            command
328                .stdin(Stdio::null())
329                .stdout(Stdio::null())
330                .stderr(Stdio::null());
331            match command.spawn() {
332                Ok(child) => {
333                    info!(
334                        title = %title,
335                        pid = child.id().unwrap_or(0),
336                        executable = %self.options.executable.display(),
337                        "player launched in the background",
338                    );
339                    // Immediately detach the child process
340                    let _ = child.id(); // Ensure the process handle is consumed
341                    Ok(None)
342                }
343                Err(error) => {
344                    warn!(
345                        title = %title,
346                        executable = %self.options.executable.display(),
347                        error = %error,
348                        "failed to launch player in detached mode",
349                    );
350                    eprintln!(
351                        "Could not launch {}: {error}",
352                        self.options.executable.display()
353                    );
354                    Err(AniError::PlayerLaunchFailed {
355                        executable: self.options.executable.display().to_string(),
356                        reason: format!("detached mode launch failed: {}", error),
357                    })
358                }
359            }
360        }
361    }
362
363    async fn vlc_command_args(
364        &self,
365        stream: &StreamLink,
366        title: &str,
367        attached: bool,
368    ) -> Result<Vec<String>> {
369        let referer = stream.headers.referer.as_deref().unwrap_or("");
370        let mut args = vec!["--play-and-exit".into(), format!("--meta-title={title}")];
371        if !referer.is_empty() {
372            args.push(format!("--http-referrer={referer}"));
373        }
374        if let Some(agent) = stream.headers.extra.get("User-Agent") {
375            args.push(format!("--http-user-agent={agent}"));
376        }
377        for subtitle_path in prepare_vlc_subtitles(stream).await? {
378            args.push(format!("--sub-file={subtitle_path}"));
379        }
380        if !attached {
381            args.push(stream.url.clone());
382        } else {
383            args.push(stream.url.clone());
384        }
385        Ok(args)
386    }
387
388    fn is_android_player(&self) -> bool {
389        matches!(
390            self.options.kind,
391            PlayerKind::AndroidMpv | PlayerKind::AndroidVlc
392        )
393    }
394
395    async fn play_android(
396        &self,
397        stream: &StreamLink,
398        title: &str,
399        relay_active: bool,
400    ) -> Result<Option<i32>> {
401        let terminal = io::stdin().is_terminal();
402        if relay_active && !terminal {
403            return Err(AniError::PlayerAndroidTerminalRequired);
404        }
405
406        let launch_args = self.command_args_inner(stream, title, true);
407        info!(
408            title = %title,
409            executable = %self.options.executable.display(),
410            "launching Android activity for playback",
411        );
412        debug!(
413            title = %title,
414            stream_url = %stream.url,
415            args = ?launch_args,
416            "full Android intent command line",
417        );
418        let launch_result = Command::new(&self.options.executable)
419            .args(launch_args)
420            .status()
421            .await;
422        let code = match launch_result {
423            Ok(status) if status.success() => {
424                let code = status.code().unwrap_or(0);
425                info!(
426                    title = %title,
427                    exit_code = code,
428                    "Android player activity returned successfully",
429                );
430                code
431            }
432            result => {
433                let primary_error = match &result {
434                    Ok(status) => format!(
435                        "Android activity launcher {} exited with {}",
436                        self.options.executable.display(),
437                        status.code().unwrap_or(1)
438                    ),
439                    Err(error) => format!(
440                        "could not launch Android player through {}: {error}",
441                        self.options.executable.display()
442                    ),
443                };
444                warn!(
445                    title = %title,
446                    executable = %self.options.executable.display(),
447                    error = %primary_error,
448                    "primary Android launch failed; trying termux-open fallback",
449                );
450                match launch_android_url_fallback(&self.options.executable, &stream.url, stream.hls)
451                    .await
452                {
453                    Ok(code) => {
454                        warn!(
455                            title = %title,
456                            "opened the stream through Android's default URL handler instead",
457                        );
458                        eprintln!(
459                            "warning: {primary_error}; opened the stream through Android's default URL handler instead"
460                        );
461                        code
462                    }
463                    Err(fallback_error) => {
464                        warn!(
465                            title = %title,
466                            error = %fallback_error,
467                            "termux-open fallback also failed",
468                        );
469                        eprintln!("{primary_error}; {fallback_error}");
470                        return Err(AniError::PlayerLaunchFailed {
471                            executable: self.options.executable.display().to_string(),
472                            reason: format!("Android launch failed: {}", fallback_error),
473                        });
474                    }
475                }
476            }
477        };
478
479        if terminal {
480            debug!(
481                title = %title,
482                "waiting for the Android player to finish before returning control to the TUI",
483            );
484            wait_for_android_player().await?;
485        }
486        Ok(Some(code))
487    }
488}
489
490fn android_intent_args(component: &str, url: &str, title: &str) -> Vec<String> {
491    vec![
492        "start".into(),
493        "--user".into(),
494        "0".into(),
495        "-a".into(),
496        "android.intent.action.VIEW".into(),
497        "-d".into(),
498        url.into(),
499        "-n".into(),
500        component.into(),
501        "--es".into(),
502        "title".into(),
503        title.into(),
504    ]
505}
506
507fn android_intent_launcher() -> PathBuf {
508    if let Some(executable) = std::env::var_os("ANI_CLI_PLAYER") {
509        return PathBuf::from(executable);
510    }
511    let path = std::env::var_os("PATH").unwrap_or_default();
512    ["termux-am-starter", "termux-am", "am"]
513        .iter()
514        .find_map(|name| find_in_path(name, &path))
515        .unwrap_or_else(|| PathBuf::from("termux-am-starter"))
516}
517
518fn find_in_path(executable: &str, path: &std::ffi::OsStr) -> Option<PathBuf> {
519    std::env::split_paths(path)
520        .map(|directory| directory.join(executable))
521        .find(|candidate| candidate.is_file())
522}
523
524async fn launch_android_url_fallback(
525    executable: &Path,
526    url: &str,
527    hls: bool,
528) -> std::result::Result<i32, String> {
529    if !is_termux_activity_launcher(executable) {
530        return Err(
531            "the configured custom launcher failed and cannot use the automatic Termux fallback"
532                .into(),
533        );
534    }
535    let path = std::env::var_os("PATH").unwrap_or_default();
536    let mut open_error = None;
537    if let Some(opener) = find_in_path("termux-open", &path) {
538        let status = Command::new(&opener)
539            .args(["--view", "--content-type", android_media_type(hls), url])
540            .status()
541            .await;
542        match status {
543            Ok(status) if status.success() => return Ok(status.code().unwrap_or(0)),
544            Ok(status) => {
545                open_error = Some(format!(
546                    "{} exited with {}",
547                    opener.display(),
548                    status.code().unwrap_or(1)
549                ));
550            }
551            Err(error) => {
552                open_error = Some(format!("could not run {}: {error}", opener.display()));
553            }
554        }
555    }
556    let opener = find_in_path("termux-open-url", &path).ok_or_else(|| {
557        let prefix = open_error
558            .map(|error| format!("{error}; "))
559            .unwrap_or_default();
560        format!(
561            "{prefix}termux-open-url is unavailable; install or update the termux-tools package"
562        )
563    })?;
564    let status = Command::new(&opener)
565        .arg(url)
566        .status()
567        .await
568        .map_err(|error| format!("could not run {}: {error}", opener.display()))?;
569    if !status.success() {
570        return Err(format!(
571            "{} exited with {}",
572            opener.display(),
573            status.code().unwrap_or(1)
574        ));
575    }
576    Ok(status.code().unwrap_or(0))
577}
578
579fn android_media_type(hls: bool) -> &'static str {
580    if hls {
581        "application/vnd.apple.mpegurl"
582    } else {
583        "video/mp4"
584    }
585}
586
587fn is_termux_activity_launcher(executable: &Path) -> bool {
588    executable
589        .file_name()
590        .and_then(|name| name.to_str())
591        .is_some_and(|name| matches!(name, "termux-am-starter" | "termux-am" | "am"))
592}
593
594async fn wait_for_android_player() -> Result<()> {
595    tokio::task::spawn_blocking(|| {
596        println!(
597            "Opened the Android player. Return to Termux and press Enter after playback ends."
598        );
599        print!("Waiting for Android player... ");
600        io::stdout().flush()?;
601        let mut input = String::new();
602        io::stdin().read_line(&mut input)?;
603        Ok::<(), io::Error>(())
604    })
605    .await
606    .map_err(|error| {
607        eprintln!("Android playback prompt failed: {error}");
608        AniError::PlayerLaunchFailed {
609            executable: "android".to_string(),
610            reason: format!("playback prompt failed: {}", error),
611        }
612    })??;
613    Ok(())
614}
615
616async fn prepare_vlc_subtitles(stream: &StreamLink) -> Result<Vec<String>> {
617    if stream.subtitles.is_empty() {
618        return Ok(Vec::new());
619    }
620
621    let temp_dir = std::env::temp_dir().join(format!(
622        "ani-cli-rs-vlc-subtitles-{}-{}",
623        std::process::id(),
624        SystemTime::now()
625            .duration_since(UNIX_EPOCH)
626            .unwrap_or_default()
627            .as_nanos()
628    ));
629    tokio::fs::create_dir_all(&temp_dir).await?;
630
631    let mut local_paths = Vec::new();
632    for (index, track) in stream.subtitles.iter().enumerate() {
633        let path = temp_dir.join(format!(
634            "{index}.{}.{}",
635            sanitize_filename(&track.label),
636            subtitle_extension(&track.url)
637        ));
638        let bytes = fetch_vlc_subtitle(track, &stream.headers).await?;
639        tokio::fs::write(&path, bytes).await?;
640        local_paths.push(path.to_string_lossy().into_owned());
641    }
642
643    Ok(local_paths)
644}
645
646async fn fetch_vlc_subtitle(
647    track: &crate::SubtitleTrack,
648    headers: &crate::RequestHeaders,
649) -> Result<Vec<u8>> {
650    let client = reqwest::Client::builder().build()?;
651    let mut request = client.get(&track.url);
652    if let Some(referer) = &headers.referer {
653        request = request.header(reqwest::header::REFERER, referer);
654    }
655    if let Some(origin) = &headers.origin {
656        request = request.header(reqwest::header::ORIGIN, origin);
657    }
658    for (name, value) in &headers.extra {
659        request = request.header(name, value);
660    }
661
662    let response = request.send().await?;
663    if !response.status().is_success() {
664        return Err(AniError::SubtitleDownloadFailed {
665            track: track.label.clone(),
666            reason: format!("subtitle server returned {}", response.status()),
667        });
668    }
669
670    let bytes = response.bytes().await?;
671    if bytes.len() > 16 * 1024 * 1024 {
672        return Err(AniError::SubtitleSizeExceeded {
673            size: bytes.len(),
674            limit: 16 * 1024 * 1024,
675        });
676    }
677
678    Ok(bytes.to_vec())
679}
680
681fn sanitize_filename(value: &str) -> String {
682    let mut cleaned: String = value
683        .chars()
684        .map(|character| match character {
685            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
686            c if c.is_control() => '_',
687            other => other,
688        })
689        .collect();
690    if cleaned.is_empty() {
691        cleaned = "subtitle".to_string();
692    }
693    cleaned
694}
695
696fn subtitle_extension(url: &str) -> &'static str {
697    let parsed = url::Url::parse(url).ok();
698    let extension = parsed.and_then(|url| {
699        Path::new(url.path())
700            .extension()
701            .and_then(|value| value.to_str())
702            .map(str::to_ascii_lowercase)
703    });
704    match extension.as_deref() {
705        Some("srt") => "srt",
706        Some("ass") => "ass",
707        Some("ssa") => "ssa",
708        Some("vtt") => "vtt",
709        _ => "vtt",
710    }
711}
712
713fn mpv_options(stream: &StreamLink, title: &str, referer: &str) -> Vec<String> {
714    let mut args = vec![
715        "--tls-verify=no".into(),
716        format!("--force-media-title={title}"),
717    ];
718    if !referer.is_empty() {
719        args.push(format!("--referrer={referer}"));
720    }
721    append_mpv_headers(&mut args, stream);
722    for track in &stream.subtitles {
723        args.push(format!("--sub-file={}", track.url));
724    }
725    if let Some(track) = stream.subtitles.iter().find(|track| track.default) {
726        args.push(format!("--slang={}", track.label));
727    }
728    // Add cache settings for HLS relay streams
729    if stream.hls && requires_hls_relay(stream) {
730        args.push("--cache=yes".into());
731        args.push("--cache-secs=120".into());
732        args.push("--demuxer-max-bytes=512MiB".into());
733        args.push("--demuxer-max-back-bytes=256MiB".into());
734    }
735    args
736}
737
738fn append_mpv_headers(args: &mut Vec<String>, stream: &StreamLink) {
739    let mut headers = Vec::new();
740    if let Some(origin) = &stream.headers.origin
741        && safe_header_value(origin)
742    {
743        headers.push(format!("Origin: {origin}"));
744    }
745    headers.extend(
746        stream
747            .headers
748            .extra
749            .iter()
750            .filter(|(name, value)| safe_header_value(name) && safe_header_value(value))
751            .map(|(name, value)| format!("{name}: {value}")),
752    );
753    if !headers.is_empty() {
754        args.push(format!("--http-header-fields={}", headers.join(",")));
755    }
756}
757
758fn safe_header_value(value: &str) -> bool {
759    !value.contains(['\r', '\n'])
760}
761
762fn env_bool(name: &str) -> bool {
763    std::env::var(name)
764        .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
765        .unwrap_or(false)
766}
767
768#[cfg(test)]
769mod tests {
770    use super::*;
771    use crate::{RequestHeaders, SubtitleTrack};
772    #[test]
773    fn mpv_arguments_preserve_referrer_as_one_argument() {
774        let player = Player::new(PlayerOptions {
775            executable: "mpv".into(),
776            kind: PlayerKind::Mpv,
777            no_detach: true,
778            exit_after_play: false,
779            force_hls_relay: false,
780        });
781        let stream = StreamLink {
782            url: "https://media/a.m3u8".into(),
783            resolution: "1080p".into(),
784            hls: true,
785            provider: "Default".into(),
786            downloadable: true,
787            headers: RequestHeaders {
788                referer: Some("https://ref.example".into()),
789                ..Default::default()
790            },
791            subtitles: vec![],
792        };
793        assert!(
794            player
795                .command_args(&stream, "Anime Episode 1")
796                .contains(&"--referrer=https://ref.example".into())
797        );
798    }
799
800    #[test]
801    fn iina_arguments_put_stream_before_raw_mpv_options() {
802        let player = Player::new(PlayerOptions {
803            executable: "iina".into(),
804            kind: PlayerKind::Iina,
805            no_detach: false,
806            exit_after_play: false,
807            force_hls_relay: false,
808        });
809        let stream = StreamLink {
810            url: "https://media/a.m3u8".into(),
811            resolution: "1080p".into(),
812            hls: true,
813            provider: "Default".into(),
814            downloadable: true,
815            headers: RequestHeaders {
816                referer: Some("https://ref.example".into()),
817                origin: Some("https://origin.example".into()),
818                ..Default::default()
819            },
820            subtitles: vec![SubtitleTrack {
821                label: "English".into(),
822                url: "https://media/subtitles.vtt".into(),
823                default: true,
824            }],
825        };
826
827        assert_eq!(
828            player.command_args(&stream, "Anime Episode 1"),
829            vec![
830                "--no-stdin",
831                "https://media/a.m3u8",
832                "--",
833                "--tls-verify=no",
834                "--force-media-title=Anime Episode 1",
835                "--referrer=https://ref.example",
836                "--http-header-fields=Origin: https://origin.example",
837                "--sub-file=https://media/subtitles.vtt",
838                "--slang=English",
839            ]
840        );
841    }
842
843    #[test]
844    fn forced_attached_iina_keeps_cli_running() {
845        let player = Player::new(PlayerOptions {
846            executable: "iina".into(),
847            kind: PlayerKind::Iina,
848            no_detach: false,
849            exit_after_play: false,
850            force_hls_relay: false,
851        });
852        let stream = StreamLink {
853            url: "https://media/a.m3u8".into(),
854            resolution: "1080p".into(),
855            hls: true,
856            provider: "Default".into(),
857            downloadable: true,
858            headers: RequestHeaders::default(),
859            subtitles: vec![],
860        };
861
862        assert_eq!(
863            &player.command_args_inner(&stream, "Anime", true)[..3],
864            ["--no-stdin", "--keep-running", "https://media/a.m3u8"]
865        );
866    }
867
868    #[test]
869    fn android_mpv_arguments_use_an_explicit_view_intent() {
870        let player = Player::new(PlayerOptions {
871            executable: "termux-am-starter".into(),
872            kind: PlayerKind::AndroidMpv,
873            no_detach: true,
874            exit_after_play: false,
875            force_hls_relay: false,
876        });
877        let stream = StreamLink {
878            url: "http://127.0.0.1:43123/stream-token".into(),
879            resolution: "1080p".into(),
880            hls: true,
881            provider: "Anikoto".into(),
882            downloadable: true,
883            headers: RequestHeaders::default(),
884            subtitles: vec![],
885        };
886
887        assert_eq!(
888            player.command_args(&stream, "Anime Episode 1"),
889            vec![
890                "start",
891                "--user",
892                "0",
893                "-a",
894                "android.intent.action.VIEW",
895                "-d",
896                "http://127.0.0.1:43123/stream-token",
897                "-n",
898                "is.xyz.mpv/.MPVActivity",
899                "--es",
900                "title",
901                "Anime Episode 1",
902            ]
903        );
904    }
905
906    #[tokio::test]
907    async fn vlc_downloads_remote_subtitles_to_local_files() {
908        use wiremock::{
909            Mock, MockServer, ResponseTemplate,
910            matchers::{method, path},
911        };
912
913        let server = MockServer::start().await;
914        Mock::given(method("GET"))
915            .and(path("/subtitle.vtt"))
916            .respond_with(
917                ResponseTemplate::new(200)
918                    .set_body_string("WEBVTT\n\n00:00.000 --> 00:02.000\nHello from the subtitle."),
919            )
920            .mount(&server)
921            .await;
922
923        let stream = StreamLink {
924            url: "https://media.example/episode.m3u8".into(),
925            resolution: "720p".into(),
926            hls: false,
927            provider: "Anikoto".into(),
928            downloadable: true,
929            headers: RequestHeaders::default(),
930            subtitles: vec![SubtitleTrack {
931                label: "English".into(),
932                url: format!("{}/subtitle.vtt", server.uri()),
933                default: true,
934            }],
935        };
936
937        let local_subtitles = prepare_vlc_subtitles(&stream).await.unwrap();
938
939        assert_eq!(local_subtitles.len(), 1);
940        let local_path = Path::new(&local_subtitles[0]);
941        assert!(local_path.is_absolute());
942        assert!(!local_path.to_string_lossy().starts_with("http://"));
943        assert!(!local_path.to_string_lossy().starts_with("https://"));
944        assert!(
945            tokio::fs::read_to_string(local_path)
946                .await
947                .unwrap()
948                .contains("Hello from the subtitle")
949        );
950    }
951
952    #[test]
953    fn android_vlc_arguments_target_the_android_app_not_terminal_vlc() {
954        let player = Player::new(PlayerOptions {
955            executable: "am".into(),
956            kind: PlayerKind::AndroidVlc,
957            no_detach: true,
958            exit_after_play: false,
959            force_hls_relay: false,
960        });
961        let stream = StreamLink {
962            url: "https://media.example/episode.m3u8".into(),
963            resolution: "720p".into(),
964            hls: true,
965            provider: "Anikoto".into(),
966            downloadable: true,
967            headers: RequestHeaders::default(),
968            subtitles: vec![],
969        };
970
971        assert!(
972            player.command_args(&stream, "Episode").contains(
973                &"org.videolan.vlc/org.videolan.vlc.gui.video.VideoPlayerActivity".into()
974            )
975        );
976    }
977
978    #[test]
979    fn android_launcher_lookup_prefers_the_first_available_candidate() {
980        let directory = tempfile::tempdir().unwrap();
981        std::fs::write(directory.path().join("termux-am"), "").unwrap();
982        let path = std::env::join_paths([directory.path()]).unwrap();
983
984        assert_eq!(
985            find_in_path("termux-am", &path),
986            Some(directory.path().join("termux-am"))
987        );
988        assert_eq!(find_in_path("termux-am-starter", &path), None);
989    }
990
991    #[test]
992    fn only_termux_activity_launchers_allow_the_url_opener_fallback() {
993        assert!(is_termux_activity_launcher(Path::new("termux-am-starter")));
994        assert!(is_termux_activity_launcher(Path::new(
995            "/data/data/com.termux/files/usr/bin/termux-am"
996        )));
997        assert!(is_termux_activity_launcher(Path::new("am")));
998        assert!(!is_termux_activity_launcher(Path::new(
999            "/data/local/tmp/custom-launcher"
1000        )));
1001    }
1002
1003    #[test]
1004    fn android_fallback_uses_specific_media_types() {
1005        assert_eq!(android_media_type(true), "application/vnd.apple.mpegurl");
1006        assert_eq!(android_media_type(false), "video/mp4");
1007    }
1008
1009    #[test]
1010    fn platform_default_selects_expected_player() {
1011        let options = PlayerOptions::default_player();
1012        if cfg!(target_os = "android") {
1013            assert_eq!(options.kind, PlayerKind::AndroidMpv);
1014        } else if cfg!(target_os = "macos") {
1015            assert_eq!(options.kind, PlayerKind::Iina);
1016            if std::env::var_os("ANI_CLI_PLAYER").is_none() {
1017                assert_eq!(options.executable, PathBuf::from("iina"));
1018            }
1019        } else {
1020            assert_eq!(options.kind, PlayerKind::Mpv);
1021        }
1022    }
1023}