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(track: &crate::SubtitleTrack, headers: &crate::RequestHeaders) -> Result<Vec<u8>> {
647    let client = reqwest::Client::builder().build()?;
648    let mut request = client.get(&track.url);
649    if let Some(referer) = &headers.referer {
650        request = request.header(reqwest::header::REFERER, referer);
651    }
652    if let Some(origin) = &headers.origin {
653        request = request.header(reqwest::header::ORIGIN, origin);
654    }
655    for (name, value) in &headers.extra {
656        request = request.header(name, value);
657    }
658
659    let response = request.send().await?;
660    if !response.status().is_success() {
661        return Err(AniError::SubtitleDownloadFailed {
662            track: track.label.clone(),
663            reason: format!("subtitle server returned {}", response.status()),
664        });
665    }
666
667    let bytes = response.bytes().await?;
668    if bytes.len() > 16 * 1024 * 1024 {
669        return Err(AniError::SubtitleSizeExceeded {
670            size: bytes.len(),
671            limit: 16 * 1024 * 1024,
672        });
673    }
674
675    Ok(bytes.to_vec())
676}
677
678fn sanitize_filename(value: &str) -> String {
679    let mut cleaned: String = value
680        .chars()
681        .map(|character| match character {
682            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
683            c if c.is_control() => '_',
684            other => other,
685        })
686        .collect();
687    if cleaned.is_empty() {
688        cleaned = "subtitle".to_string();
689    }
690    cleaned
691}
692
693fn subtitle_extension(url: &str) -> &'static str {
694    let parsed = url::Url::parse(url).ok();
695    let extension = parsed.and_then(|url| {
696        Path::new(url.path())
697            .extension()
698            .and_then(|value| value.to_str())
699            .map(str::to_ascii_lowercase)
700    });
701    match extension.as_deref() {
702        Some("srt") => "srt",
703        Some("ass") => "ass",
704        Some("ssa") => "ssa",
705        Some("vtt") => "vtt",
706        _ => "vtt",
707    }
708}
709
710fn mpv_options(stream: &StreamLink, title: &str, referer: &str) -> Vec<String> {
711    let mut args = vec![
712        "--tls-verify=no".into(),
713        format!("--force-media-title={title}"),
714    ];
715    if !referer.is_empty() {
716        args.push(format!("--referrer={referer}"));
717    }
718    append_mpv_headers(&mut args, stream);
719    for track in &stream.subtitles {
720        args.push(format!("--sub-file={}", track.url));
721    }
722    if let Some(track) = stream.subtitles.iter().find(|track| track.default) {
723        args.push(format!("--slang={}", track.label));
724    }
725    // Add cache settings for HLS relay streams
726    if stream.hls && requires_hls_relay(stream) {
727        args.push("--cache=yes".into());
728        args.push("--cache-secs=120".into());
729        args.push("--demuxer-max-bytes=512MiB".into());
730        args.push("--demuxer-max-back-bytes=256MiB".into());
731    }
732    args
733}
734
735fn append_mpv_headers(args: &mut Vec<String>, stream: &StreamLink) {
736    let mut headers = Vec::new();
737    if let Some(origin) = &stream.headers.origin
738        && safe_header_value(origin)
739    {
740        headers.push(format!("Origin: {origin}"));
741    }
742    headers.extend(
743        stream
744            .headers
745            .extra
746            .iter()
747            .filter(|(name, value)| safe_header_value(name) && safe_header_value(value))
748            .map(|(name, value)| format!("{name}: {value}")),
749    );
750    if !headers.is_empty() {
751        args.push(format!("--http-header-fields={}", headers.join(",")));
752    }
753}
754
755fn safe_header_value(value: &str) -> bool {
756    !value.contains(['\r', '\n'])
757}
758
759fn env_bool(name: &str) -> bool {
760    std::env::var(name)
761        .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
762        .unwrap_or(false)
763}
764
765#[cfg(test)]
766mod tests {
767    use super::*;
768    use crate::{RequestHeaders, SubtitleTrack};
769    #[test]
770    fn mpv_arguments_preserve_referrer_as_one_argument() {
771        let player = Player::new(PlayerOptions {
772            executable: "mpv".into(),
773            kind: PlayerKind::Mpv,
774            no_detach: true,
775            exit_after_play: false,
776            force_hls_relay: false,
777        });
778        let stream = StreamLink {
779            url: "https://media/a.m3u8".into(),
780            resolution: "1080p".into(),
781            hls: true,
782            provider: "Default".into(),
783            downloadable: true,
784            headers: RequestHeaders {
785                referer: Some("https://ref.example".into()),
786                ..Default::default()
787            },
788            subtitles: vec![],
789        };
790        assert!(
791            player
792                .command_args(&stream, "Anime Episode 1")
793                .contains(&"--referrer=https://ref.example".into())
794        );
795    }
796
797    #[test]
798    fn iina_arguments_put_stream_before_raw_mpv_options() {
799        let player = Player::new(PlayerOptions {
800            executable: "iina".into(),
801            kind: PlayerKind::Iina,
802            no_detach: false,
803            exit_after_play: false,
804            force_hls_relay: false,
805        });
806        let stream = StreamLink {
807            url: "https://media/a.m3u8".into(),
808            resolution: "1080p".into(),
809            hls: true,
810            provider: "Default".into(),
811            downloadable: true,
812            headers: RequestHeaders {
813                referer: Some("https://ref.example".into()),
814                origin: Some("https://origin.example".into()),
815                ..Default::default()
816            },
817            subtitles: vec![SubtitleTrack {
818                label: "English".into(),
819                url: "https://media/subtitles.vtt".into(),
820                default: true,
821            }],
822        };
823
824        assert_eq!(
825            player.command_args(&stream, "Anime Episode 1"),
826            vec![
827                "--no-stdin",
828                "https://media/a.m3u8",
829                "--",
830                "--tls-verify=no",
831                "--force-media-title=Anime Episode 1",
832                "--referrer=https://ref.example",
833                "--http-header-fields=Origin: https://origin.example",
834                "--sub-file=https://media/subtitles.vtt",
835                "--slang=English",
836            ]
837        );
838    }
839
840    #[test]
841    fn forced_attached_iina_keeps_cli_running() {
842        let player = Player::new(PlayerOptions {
843            executable: "iina".into(),
844            kind: PlayerKind::Iina,
845            no_detach: false,
846            exit_after_play: false,
847            force_hls_relay: false,
848        });
849        let stream = StreamLink {
850            url: "https://media/a.m3u8".into(),
851            resolution: "1080p".into(),
852            hls: true,
853            provider: "Default".into(),
854            downloadable: true,
855            headers: RequestHeaders::default(),
856            subtitles: vec![],
857        };
858
859        assert_eq!(
860            &player.command_args_inner(&stream, "Anime", true)[..3],
861            ["--no-stdin", "--keep-running", "https://media/a.m3u8"]
862        );
863    }
864
865    #[test]
866    fn android_mpv_arguments_use_an_explicit_view_intent() {
867        let player = Player::new(PlayerOptions {
868            executable: "termux-am-starter".into(),
869            kind: PlayerKind::AndroidMpv,
870            no_detach: true,
871            exit_after_play: false,
872            force_hls_relay: false,
873        });
874        let stream = StreamLink {
875            url: "http://127.0.0.1:43123/stream-token".into(),
876            resolution: "1080p".into(),
877            hls: true,
878            provider: "Anikoto".into(),
879            downloadable: true,
880            headers: RequestHeaders::default(),
881            subtitles: vec![],
882        };
883
884        assert_eq!(
885            player.command_args(&stream, "Anime Episode 1"),
886            vec![
887                "start",
888                "--user",
889                "0",
890                "-a",
891                "android.intent.action.VIEW",
892                "-d",
893                "http://127.0.0.1:43123/stream-token",
894                "-n",
895                "is.xyz.mpv/.MPVActivity",
896                "--es",
897                "title",
898                "Anime Episode 1",
899            ]
900        );
901    }
902
903    #[tokio::test]
904    async fn vlc_downloads_remote_subtitles_to_local_files() {
905        use wiremock::{Mock, MockServer, ResponseTemplate, matchers::{method, path}};
906
907        let server = MockServer::start().await;
908        Mock::given(method("GET"))
909            .and(path("/subtitle.vtt"))
910            .respond_with(ResponseTemplate::new(200).set_body_string("WEBVTT\n\n00:00.000 --> 00:02.000\nHello from the subtitle."))
911            .mount(&server)
912            .await;
913
914        let stream = StreamLink {
915            url: "https://media.example/episode.m3u8".into(),
916            resolution: "720p".into(),
917            hls: false,
918            provider: "Anikoto".into(),
919            downloadable: true,
920            headers: RequestHeaders::default(),
921            subtitles: vec![SubtitleTrack {
922                label: "English".into(),
923                url: format!("{}/subtitle.vtt", server.uri()),
924                default: true,
925            }],
926        };
927
928        let local_subtitles = prepare_vlc_subtitles(&stream).await.unwrap();
929
930        assert_eq!(local_subtitles.len(), 1);
931        let local_path = Path::new(&local_subtitles[0]);
932        assert!(local_path.is_absolute());
933        assert!(!local_path.to_string_lossy().starts_with("http://"));
934        assert!(!local_path.to_string_lossy().starts_with("https://"));
935        assert!(tokio::fs::read_to_string(local_path).await.unwrap().contains("Hello from the subtitle"));
936    }
937
938    #[test]
939    fn android_vlc_arguments_target_the_android_app_not_terminal_vlc() {
940        let player = Player::new(PlayerOptions {
941            executable: "am".into(),
942            kind: PlayerKind::AndroidVlc,
943            no_detach: true,
944            exit_after_play: false,
945            force_hls_relay: false,
946        });
947        let stream = StreamLink {
948            url: "https://media.example/episode.m3u8".into(),
949            resolution: "720p".into(),
950            hls: true,
951            provider: "Anikoto".into(),
952            downloadable: true,
953            headers: RequestHeaders::default(),
954            subtitles: vec![],
955        };
956
957        assert!(
958            player.command_args(&stream, "Episode").contains(
959                &"org.videolan.vlc/org.videolan.vlc.gui.video.VideoPlayerActivity".into()
960            )
961        );
962    }
963
964    #[test]
965    fn android_launcher_lookup_prefers_the_first_available_candidate() {
966        let directory = tempfile::tempdir().unwrap();
967        std::fs::write(directory.path().join("termux-am"), "").unwrap();
968        let path = std::env::join_paths([directory.path()]).unwrap();
969
970        assert_eq!(
971            find_in_path("termux-am", &path),
972            Some(directory.path().join("termux-am"))
973        );
974        assert_eq!(find_in_path("termux-am-starter", &path), None);
975    }
976
977    #[test]
978    fn only_termux_activity_launchers_allow_the_url_opener_fallback() {
979        assert!(is_termux_activity_launcher(Path::new("termux-am-starter")));
980        assert!(is_termux_activity_launcher(Path::new(
981            "/data/data/com.termux/files/usr/bin/termux-am"
982        )));
983        assert!(is_termux_activity_launcher(Path::new("am")));
984        assert!(!is_termux_activity_launcher(Path::new(
985            "/data/local/tmp/custom-launcher"
986        )));
987    }
988
989    #[test]
990    fn android_fallback_uses_specific_media_types() {
991        assert_eq!(android_media_type(true), "application/vnd.apple.mpegurl");
992        assert_eq!(android_media_type(false), "video/mp4");
993    }
994
995    #[test]
996    fn platform_default_selects_expected_player() {
997        let options = PlayerOptions::default_player();
998        if cfg!(target_os = "android") {
999            assert_eq!(options.kind, PlayerKind::AndroidMpv);
1000        } else if cfg!(target_os = "macos") {
1001            assert_eq!(options.kind, PlayerKind::Iina);
1002            if std::env::var_os("ANI_CLI_PLAYER").is_none() {
1003                assert_eq!(options.executable, PathBuf::from("iina"));
1004            }
1005        } else {
1006            assert_eq!(options.kind, PlayerKind::Mpv);
1007        }
1008    }
1009}