use std::{
fmt,
io::{self, IsTerminal, Write},
path::{Path, PathBuf},
process::Stdio,
time::{SystemTime, UNIX_EPOCH},
};
use tokio::process::Command;
use tracing::{debug, info, warn};
use crate::{
AniError, Result, StreamLink, relay_stream, relay_stream_without_hls_subtitles,
requires_hls_relay,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PlayerKind {
Mpv,
Iina,
Vlc,
AndroidMpv,
AndroidVlc,
Syncplay,
Custom,
}
impl fmt::Display for PlayerKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let label = match self {
Self::Mpv => "mpv",
Self::Iina => "iina",
Self::Vlc => "vlc",
Self::AndroidMpv => "android-mpv",
Self::AndroidVlc => "android-vlc",
Self::Syncplay => "syncplay",
Self::Custom => "custom",
};
f.write_str(label)
}
}
#[derive(Clone, Debug)]
pub struct PlayerOptions {
pub executable: PathBuf,
pub kind: PlayerKind,
pub no_detach: bool,
pub exit_after_play: bool,
pub force_hls_relay: bool,
}
impl PlayerOptions {
pub fn default_player() -> Self {
if cfg!(target_os = "android") {
Self::default_android_mpv()
} else if cfg!(target_os = "macos") {
Self::default_iina()
} else {
Self::default_mpv()
}
}
pub fn default_mpv() -> Self {
let executable = std::env::var_os("ANI_CLI_PLAYER")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(if cfg!(windows) { "mpv.exe" } else { "mpv" }));
Self {
executable,
kind: PlayerKind::Mpv,
no_detach: env_bool("ANI_CLI_NO_DETACH"),
exit_after_play: env_bool("ANI_CLI_EXIT_AFTER_PLAY"),
force_hls_relay: false,
}
}
pub fn default_iina() -> Self {
let executable = std::env::var_os("ANI_CLI_PLAYER")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("iina"));
Self {
executable,
kind: PlayerKind::Iina,
no_detach: env_bool("ANI_CLI_NO_DETACH"),
exit_after_play: env_bool("ANI_CLI_EXIT_AFTER_PLAY"),
force_hls_relay: false,
}
}
pub fn default_android_mpv() -> Self {
Self {
executable: android_intent_launcher(),
kind: PlayerKind::AndroidMpv,
no_detach: true,
exit_after_play: env_bool("ANI_CLI_EXIT_AFTER_PLAY"),
force_hls_relay: false,
}
}
}
#[derive(Clone, Debug)]
pub struct Player {
options: PlayerOptions,
}
impl Player {
pub fn new(options: PlayerOptions) -> Self {
Self { options }
}
pub fn command_args(&self, stream: &StreamLink, title: &str) -> Vec<String> {
self.command_args_inner(stream, title, self.options.no_detach)
}
pub fn describe(&self) -> String {
format!(
"{} ({}) [no_detach={}, exit_after_play={}, force_hls_relay={}]",
self.options.executable.display(),
self.options.kind,
self.options.no_detach,
self.options.exit_after_play,
self.options.force_hls_relay,
)
}
fn command_args_inner(&self, stream: &StreamLink, title: &str, attached: bool) -> Vec<String> {
let referer = stream.headers.referer.as_deref().unwrap_or("");
match self.options.kind {
PlayerKind::Mpv => {
let mut args = mpv_options(stream, title, referer);
args.push(stream.url.clone());
args
}
PlayerKind::Iina => {
let mut args = vec!["--no-stdin".into()];
if attached {
args.push("--keep-running".into());
}
args.push(stream.url.clone());
args.push("--".into());
args.extend(mpv_options(stream, title, referer));
args
}
PlayerKind::Vlc => {
let mut args = vec!["--play-and-exit".into(), format!("--meta-title={title}")];
if !referer.is_empty() {
args.push(format!("--http-referrer={referer}"));
}
if let Some(agent) = stream.headers.extra.get("User-Agent") {
args.push(format!("--http-user-agent={agent}"));
}
for track in &stream.subtitles {
args.push(format!("--sub-file={}", track.url));
}
args.push(stream.url.clone());
args
}
PlayerKind::AndroidMpv => {
android_intent_args("is.xyz.mpv/.MPVActivity", &stream.url, title)
}
PlayerKind::AndroidVlc => android_intent_args(
"org.videolan.vlc/org.videolan.vlc.gui.video.VideoPlayerActivity",
&stream.url,
title,
),
PlayerKind::Syncplay => {
let mut args = vec![
stream.url.clone(),
"--".into(),
"--tls-verify=no".into(),
format!("--force-media-title={title}"),
];
if !referer.is_empty() {
args.push(format!("--referrer={referer}"));
}
append_mpv_headers(&mut args, stream);
for track in &stream.subtitles {
args.push(format!("--sub-file={}", track.url));
}
args
}
PlayerKind::Custom => vec![stream.url.clone()],
}
}
pub async fn play(&self, stream: &StreamLink, title: &str) -> Result<Option<i32>> {
info!(
title = %title,
player = %self.describe(),
stream_url = %stream.url,
hls = stream.hls,
subtitles = stream.subtitles.len(),
"playback requested",
);
if self.options.force_hls_relay
|| crate::requires_hls_relay(stream)
|| (self.is_android_player() && stream.hls)
{
debug!(
title = %title,
player = %self.options.kind.to_string(),
android_player = self.is_android_player(),
hls = stream.hls,
"stream requires the loopback HLS relay",
);
let (_relay, local) = if self.is_android_player() {
relay_stream(stream).await?
} else {
relay_stream_without_hls_subtitles(stream).await?
};
debug!(
title = %title,
local_url = %local.url,
"HLS relay is serving the rewritten stream URL to the player",
);
return self.play_inner(&local, title, true).await;
}
self.play_inner(stream, title, false).await
}
async fn play_inner(
&self,
stream: &StreamLink,
title: &str,
force_attached: bool,
) -> Result<Option<i32>> {
if self.is_android_player() {
return self.play_android(stream, title, force_attached).await;
}
let needs_validation = self.options.executable.components().count() > 1;
if needs_validation {
if !self.options.executable.exists() {
eprintln!(
"Player executable not found: {}. Please install the player or set ANI_CLI_PLAYER environment variable.",
self.options.executable.display()
);
return Err(AniError::PlayerNotFound {
executable: self.options.executable.display().to_string(),
});
}
} else {
use which::which;
if which(&self.options.executable).is_err() {
eprintln!(
"Player executable '{}' not found in PATH. Please install the player or set ANI_CLI_PLAYER environment variable.",
self.options.executable.display()
);
return Err(AniError::PlayerNotFound {
executable: self.options.executable.display().to_string(),
});
}
}
let mut command = Command::new(&self.options.executable);
let attached = self.options.no_detach || force_attached;
let args = if self.options.kind == PlayerKind::Vlc {
self.vlc_command_args(stream, title, attached).await?
} else {
self.command_args_inner(stream, title, attached)
};
info!(
title = %title,
executable = %self.options.executable.display(),
kind = %self.options.kind,
attached,
"launching external player",
);
debug!(
title = %title,
stream_url = %stream.url,
stream_hls = stream.hls,
args = ?args,
"full player command line",
);
command.args(args);
if attached {
match command.status().await {
Ok(status) => {
let code = status.code().unwrap_or(1);
info!(
title = %title,
exit_code = code,
success = status.success(),
"player exited",
);
if !status.success() && self.options.exit_after_play {
eprintln!("Player exited with {code}");
return Err(AniError::PlayerExitFailed);
}
Ok(Some(code))
}
Err(error) => {
warn!(
title = %title,
executable = %self.options.executable.display(),
error = %error,
"failed to launch player in attached mode",
);
eprintln!(
"Could not launch {}: {error}",
self.options.executable.display()
);
Err(AniError::PlayerLaunchFailed {
executable: self.options.executable.display().to_string(),
reason: format!("attached mode launch failed: {}", error),
})
}
}
} else {
command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
match command.spawn() {
Ok(child) => {
info!(
title = %title,
pid = child.id().unwrap_or(0),
executable = %self.options.executable.display(),
"player launched in the background",
);
let _ = child.id(); Ok(None)
}
Err(error) => {
warn!(
title = %title,
executable = %self.options.executable.display(),
error = %error,
"failed to launch player in detached mode",
);
eprintln!(
"Could not launch {}: {error}",
self.options.executable.display()
);
Err(AniError::PlayerLaunchFailed {
executable: self.options.executable.display().to_string(),
reason: format!("detached mode launch failed: {}", error),
})
}
}
}
}
async fn vlc_command_args(
&self,
stream: &StreamLink,
title: &str,
attached: bool,
) -> Result<Vec<String>> {
let referer = stream.headers.referer.as_deref().unwrap_or("");
let mut args = vec!["--play-and-exit".into(), format!("--meta-title={title}")];
if !referer.is_empty() {
args.push(format!("--http-referrer={referer}"));
}
if let Some(agent) = stream.headers.extra.get("User-Agent") {
args.push(format!("--http-user-agent={agent}"));
}
for subtitle_path in prepare_vlc_subtitles(stream).await? {
args.push(format!("--sub-file={subtitle_path}"));
}
if !attached {
args.push(stream.url.clone());
} else {
args.push(stream.url.clone());
}
Ok(args)
}
fn is_android_player(&self) -> bool {
matches!(
self.options.kind,
PlayerKind::AndroidMpv | PlayerKind::AndroidVlc
)
}
async fn play_android(
&self,
stream: &StreamLink,
title: &str,
relay_active: bool,
) -> Result<Option<i32>> {
let terminal = io::stdin().is_terminal();
if relay_active && !terminal {
return Err(AniError::PlayerAndroidTerminalRequired);
}
let launch_args = self.command_args_inner(stream, title, true);
info!(
title = %title,
executable = %self.options.executable.display(),
"launching Android activity for playback",
);
debug!(
title = %title,
stream_url = %stream.url,
args = ?launch_args,
"full Android intent command line",
);
let launch_result = Command::new(&self.options.executable)
.args(launch_args)
.status()
.await;
let code = match launch_result {
Ok(status) if status.success() => {
let code = status.code().unwrap_or(0);
info!(
title = %title,
exit_code = code,
"Android player activity returned successfully",
);
code
}
result => {
let primary_error = match &result {
Ok(status) => format!(
"Android activity launcher {} exited with {}",
self.options.executable.display(),
status.code().unwrap_or(1)
),
Err(error) => format!(
"could not launch Android player through {}: {error}",
self.options.executable.display()
),
};
warn!(
title = %title,
executable = %self.options.executable.display(),
error = %primary_error,
"primary Android launch failed; trying termux-open fallback",
);
match launch_android_url_fallback(&self.options.executable, &stream.url, stream.hls)
.await
{
Ok(code) => {
warn!(
title = %title,
"opened the stream through Android's default URL handler instead",
);
eprintln!(
"warning: {primary_error}; opened the stream through Android's default URL handler instead"
);
code
}
Err(fallback_error) => {
warn!(
title = %title,
error = %fallback_error,
"termux-open fallback also failed",
);
eprintln!("{primary_error}; {fallback_error}");
return Err(AniError::PlayerLaunchFailed {
executable: self.options.executable.display().to_string(),
reason: format!("Android launch failed: {}", fallback_error),
});
}
}
}
};
if terminal {
debug!(
title = %title,
"waiting for the Android player to finish before returning control to the TUI",
);
wait_for_android_player().await?;
}
Ok(Some(code))
}
}
fn android_intent_args(component: &str, url: &str, title: &str) -> Vec<String> {
vec![
"start".into(),
"--user".into(),
"0".into(),
"-a".into(),
"android.intent.action.VIEW".into(),
"-d".into(),
url.into(),
"-n".into(),
component.into(),
"--es".into(),
"title".into(),
title.into(),
]
}
fn android_intent_launcher() -> PathBuf {
if let Some(executable) = std::env::var_os("ANI_CLI_PLAYER") {
return PathBuf::from(executable);
}
let path = std::env::var_os("PATH").unwrap_or_default();
["termux-am-starter", "termux-am", "am"]
.iter()
.find_map(|name| find_in_path(name, &path))
.unwrap_or_else(|| PathBuf::from("termux-am-starter"))
}
fn find_in_path(executable: &str, path: &std::ffi::OsStr) -> Option<PathBuf> {
std::env::split_paths(path)
.map(|directory| directory.join(executable))
.find(|candidate| candidate.is_file())
}
async fn launch_android_url_fallback(
executable: &Path,
url: &str,
hls: bool,
) -> std::result::Result<i32, String> {
if !is_termux_activity_launcher(executable) {
return Err(
"the configured custom launcher failed and cannot use the automatic Termux fallback"
.into(),
);
}
let path = std::env::var_os("PATH").unwrap_or_default();
let mut open_error = None;
if let Some(opener) = find_in_path("termux-open", &path) {
let status = Command::new(&opener)
.args(["--view", "--content-type", android_media_type(hls), url])
.status()
.await;
match status {
Ok(status) if status.success() => return Ok(status.code().unwrap_or(0)),
Ok(status) => {
open_error = Some(format!(
"{} exited with {}",
opener.display(),
status.code().unwrap_or(1)
));
}
Err(error) => {
open_error = Some(format!("could not run {}: {error}", opener.display()));
}
}
}
let opener = find_in_path("termux-open-url", &path).ok_or_else(|| {
let prefix = open_error
.map(|error| format!("{error}; "))
.unwrap_or_default();
format!(
"{prefix}termux-open-url is unavailable; install or update the termux-tools package"
)
})?;
let status = Command::new(&opener)
.arg(url)
.status()
.await
.map_err(|error| format!("could not run {}: {error}", opener.display()))?;
if !status.success() {
return Err(format!(
"{} exited with {}",
opener.display(),
status.code().unwrap_or(1)
));
}
Ok(status.code().unwrap_or(0))
}
fn android_media_type(hls: bool) -> &'static str {
if hls {
"application/vnd.apple.mpegurl"
} else {
"video/mp4"
}
}
fn is_termux_activity_launcher(executable: &Path) -> bool {
executable
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| matches!(name, "termux-am-starter" | "termux-am" | "am"))
}
async fn wait_for_android_player() -> Result<()> {
tokio::task::spawn_blocking(|| {
println!(
"Opened the Android player. Return to Termux and press Enter after playback ends."
);
print!("Waiting for Android player... ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
Ok::<(), io::Error>(())
})
.await
.map_err(|error| {
eprintln!("Android playback prompt failed: {error}");
AniError::PlayerLaunchFailed {
executable: "android".to_string(),
reason: format!("playback prompt failed: {}", error),
}
})??;
Ok(())
}
async fn prepare_vlc_subtitles(stream: &StreamLink) -> Result<Vec<String>> {
if stream.subtitles.is_empty() {
return Ok(Vec::new());
}
let temp_dir = std::env::temp_dir().join(format!(
"ani-cli-rs-vlc-subtitles-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
tokio::fs::create_dir_all(&temp_dir).await?;
let mut local_paths = Vec::new();
for (index, track) in stream.subtitles.iter().enumerate() {
let path = temp_dir.join(format!(
"{index}.{}.{}",
sanitize_filename(&track.label),
subtitle_extension(&track.url)
));
let bytes = fetch_vlc_subtitle(track, &stream.headers).await?;
tokio::fs::write(&path, bytes).await?;
local_paths.push(path.to_string_lossy().into_owned());
}
Ok(local_paths)
}
async fn fetch_vlc_subtitle(track: &crate::SubtitleTrack, headers: &crate::RequestHeaders) -> Result<Vec<u8>> {
let client = reqwest::Client::builder().build()?;
let mut request = client.get(&track.url);
if let Some(referer) = &headers.referer {
request = request.header(reqwest::header::REFERER, referer);
}
if let Some(origin) = &headers.origin {
request = request.header(reqwest::header::ORIGIN, origin);
}
for (name, value) in &headers.extra {
request = request.header(name, value);
}
let response = request.send().await?;
if !response.status().is_success() {
return Err(AniError::SubtitleDownloadFailed {
track: track.label.clone(),
reason: format!("subtitle server returned {}", response.status()),
});
}
let bytes = response.bytes().await?;
if bytes.len() > 16 * 1024 * 1024 {
return Err(AniError::SubtitleSizeExceeded {
size: bytes.len(),
limit: 16 * 1024 * 1024,
});
}
Ok(bytes.to_vec())
}
fn sanitize_filename(value: &str) -> String {
let mut cleaned: String = value
.chars()
.map(|character| match character {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
c if c.is_control() => '_',
other => other,
})
.collect();
if cleaned.is_empty() {
cleaned = "subtitle".to_string();
}
cleaned
}
fn subtitle_extension(url: &str) -> &'static str {
let parsed = url::Url::parse(url).ok();
let extension = parsed.and_then(|url| {
Path::new(url.path())
.extension()
.and_then(|value| value.to_str())
.map(str::to_ascii_lowercase)
});
match extension.as_deref() {
Some("srt") => "srt",
Some("ass") => "ass",
Some("ssa") => "ssa",
Some("vtt") => "vtt",
_ => "vtt",
}
}
fn mpv_options(stream: &StreamLink, title: &str, referer: &str) -> Vec<String> {
let mut args = vec![
"--tls-verify=no".into(),
format!("--force-media-title={title}"),
];
if !referer.is_empty() {
args.push(format!("--referrer={referer}"));
}
append_mpv_headers(&mut args, stream);
for track in &stream.subtitles {
args.push(format!("--sub-file={}", track.url));
}
if let Some(track) = stream.subtitles.iter().find(|track| track.default) {
args.push(format!("--slang={}", track.label));
}
if stream.hls && requires_hls_relay(stream) {
args.push("--cache=yes".into());
args.push("--cache-secs=120".into());
args.push("--demuxer-max-bytes=512MiB".into());
args.push("--demuxer-max-back-bytes=256MiB".into());
}
args
}
fn append_mpv_headers(args: &mut Vec<String>, stream: &StreamLink) {
let mut headers = Vec::new();
if let Some(origin) = &stream.headers.origin
&& safe_header_value(origin)
{
headers.push(format!("Origin: {origin}"));
}
headers.extend(
stream
.headers
.extra
.iter()
.filter(|(name, value)| safe_header_value(name) && safe_header_value(value))
.map(|(name, value)| format!("{name}: {value}")),
);
if !headers.is_empty() {
args.push(format!("--http-header-fields={}", headers.join(",")));
}
}
fn safe_header_value(value: &str) -> bool {
!value.contains(['\r', '\n'])
}
fn env_bool(name: &str) -> bool {
std::env::var(name)
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{RequestHeaders, SubtitleTrack};
#[test]
fn mpv_arguments_preserve_referrer_as_one_argument() {
let player = Player::new(PlayerOptions {
executable: "mpv".into(),
kind: PlayerKind::Mpv,
no_detach: true,
exit_after_play: false,
force_hls_relay: false,
});
let stream = StreamLink {
url: "https://media/a.m3u8".into(),
resolution: "1080p".into(),
hls: true,
provider: "Default".into(),
downloadable: true,
headers: RequestHeaders {
referer: Some("https://ref.example".into()),
..Default::default()
},
subtitles: vec![],
};
assert!(
player
.command_args(&stream, "Anime Episode 1")
.contains(&"--referrer=https://ref.example".into())
);
}
#[test]
fn iina_arguments_put_stream_before_raw_mpv_options() {
let player = Player::new(PlayerOptions {
executable: "iina".into(),
kind: PlayerKind::Iina,
no_detach: false,
exit_after_play: false,
force_hls_relay: false,
});
let stream = StreamLink {
url: "https://media/a.m3u8".into(),
resolution: "1080p".into(),
hls: true,
provider: "Default".into(),
downloadable: true,
headers: RequestHeaders {
referer: Some("https://ref.example".into()),
origin: Some("https://origin.example".into()),
..Default::default()
},
subtitles: vec![SubtitleTrack {
label: "English".into(),
url: "https://media/subtitles.vtt".into(),
default: true,
}],
};
assert_eq!(
player.command_args(&stream, "Anime Episode 1"),
vec![
"--no-stdin",
"https://media/a.m3u8",
"--",
"--tls-verify=no",
"--force-media-title=Anime Episode 1",
"--referrer=https://ref.example",
"--http-header-fields=Origin: https://origin.example",
"--sub-file=https://media/subtitles.vtt",
"--slang=English",
]
);
}
#[test]
fn forced_attached_iina_keeps_cli_running() {
let player = Player::new(PlayerOptions {
executable: "iina".into(),
kind: PlayerKind::Iina,
no_detach: false,
exit_after_play: false,
force_hls_relay: false,
});
let stream = StreamLink {
url: "https://media/a.m3u8".into(),
resolution: "1080p".into(),
hls: true,
provider: "Default".into(),
downloadable: true,
headers: RequestHeaders::default(),
subtitles: vec![],
};
assert_eq!(
&player.command_args_inner(&stream, "Anime", true)[..3],
["--no-stdin", "--keep-running", "https://media/a.m3u8"]
);
}
#[test]
fn android_mpv_arguments_use_an_explicit_view_intent() {
let player = Player::new(PlayerOptions {
executable: "termux-am-starter".into(),
kind: PlayerKind::AndroidMpv,
no_detach: true,
exit_after_play: false,
force_hls_relay: false,
});
let stream = StreamLink {
url: "http://127.0.0.1:43123/stream-token".into(),
resolution: "1080p".into(),
hls: true,
provider: "Anikoto".into(),
downloadable: true,
headers: RequestHeaders::default(),
subtitles: vec![],
};
assert_eq!(
player.command_args(&stream, "Anime Episode 1"),
vec![
"start",
"--user",
"0",
"-a",
"android.intent.action.VIEW",
"-d",
"http://127.0.0.1:43123/stream-token",
"-n",
"is.xyz.mpv/.MPVActivity",
"--es",
"title",
"Anime Episode 1",
]
);
}
#[tokio::test]
async fn vlc_downloads_remote_subtitles_to_local_files() {
use wiremock::{Mock, MockServer, ResponseTemplate, matchers::{method, path}};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/subtitle.vtt"))
.respond_with(ResponseTemplate::new(200).set_body_string("WEBVTT\n\n00:00.000 --> 00:02.000\nHello from the subtitle."))
.mount(&server)
.await;
let stream = StreamLink {
url: "https://media.example/episode.m3u8".into(),
resolution: "720p".into(),
hls: false,
provider: "Anikoto".into(),
downloadable: true,
headers: RequestHeaders::default(),
subtitles: vec![SubtitleTrack {
label: "English".into(),
url: format!("{}/subtitle.vtt", server.uri()),
default: true,
}],
};
let local_subtitles = prepare_vlc_subtitles(&stream).await.unwrap();
assert_eq!(local_subtitles.len(), 1);
let local_path = Path::new(&local_subtitles[0]);
assert!(local_path.is_absolute());
assert!(!local_path.to_string_lossy().starts_with("http://"));
assert!(!local_path.to_string_lossy().starts_with("https://"));
assert!(tokio::fs::read_to_string(local_path).await.unwrap().contains("Hello from the subtitle"));
}
#[test]
fn android_vlc_arguments_target_the_android_app_not_terminal_vlc() {
let player = Player::new(PlayerOptions {
executable: "am".into(),
kind: PlayerKind::AndroidVlc,
no_detach: true,
exit_after_play: false,
force_hls_relay: false,
});
let stream = StreamLink {
url: "https://media.example/episode.m3u8".into(),
resolution: "720p".into(),
hls: true,
provider: "Anikoto".into(),
downloadable: true,
headers: RequestHeaders::default(),
subtitles: vec![],
};
assert!(
player.command_args(&stream, "Episode").contains(
&"org.videolan.vlc/org.videolan.vlc.gui.video.VideoPlayerActivity".into()
)
);
}
#[test]
fn android_launcher_lookup_prefers_the_first_available_candidate() {
let directory = tempfile::tempdir().unwrap();
std::fs::write(directory.path().join("termux-am"), "").unwrap();
let path = std::env::join_paths([directory.path()]).unwrap();
assert_eq!(
find_in_path("termux-am", &path),
Some(directory.path().join("termux-am"))
);
assert_eq!(find_in_path("termux-am-starter", &path), None);
}
#[test]
fn only_termux_activity_launchers_allow_the_url_opener_fallback() {
assert!(is_termux_activity_launcher(Path::new("termux-am-starter")));
assert!(is_termux_activity_launcher(Path::new(
"/data/data/com.termux/files/usr/bin/termux-am"
)));
assert!(is_termux_activity_launcher(Path::new("am")));
assert!(!is_termux_activity_launcher(Path::new(
"/data/local/tmp/custom-launcher"
)));
}
#[test]
fn android_fallback_uses_specific_media_types() {
assert_eq!(android_media_type(true), "application/vnd.apple.mpegurl");
assert_eq!(android_media_type(false), "video/mp4");
}
#[test]
fn platform_default_selects_expected_player() {
let options = PlayerOptions::default_player();
if cfg!(target_os = "android") {
assert_eq!(options.kind, PlayerKind::AndroidMpv);
} else if cfg!(target_os = "macos") {
assert_eq!(options.kind, PlayerKind::Iina);
if std::env::var_os("ANI_CLI_PLAYER").is_none() {
assert_eq!(options.executable, PathBuf::from("iina"));
}
} else {
assert_eq!(options.kind, PlayerKind::Mpv);
}
}
}