use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::LazyLock;
#[derive(Clone, Debug, PartialEq)]
pub struct Resolved {
pub name: &'static str,
pub argv: Vec<String>,
}
struct Candidate {
name: &'static str,
bins: &'static [&'static str],
mac_app: Option<(&'static str, &'static str)>,
}
const CANDIDATES: &[Candidate] = &[
Candidate {
name: "VLC",
bins: &["vlc"],
mac_app: Some(("VLC.app", "VLC")),
},
Candidate {
name: "IINA",
bins: &["iina"],
mac_app: Some(("IINA.app", "iina")),
},
Candidate {
name: "mpv",
bins: &["mpv"],
mac_app: Some(("mpv.app", "mpv")),
},
Candidate {
name: "ffplay",
bins: &["ffplay"],
mac_app: None,
},
];
pub fn resolve(
override_player: Option<&str>,
mac: bool,
path_var: &str,
app_dirs: &[PathBuf],
) -> Result<Resolved, String> {
if let Some(over) = override_player {
if over.eq_ignore_ascii_case("browser") {
return Ok(opener(mac));
}
if over.contains('/') {
return Ok(Resolved {
name: "configured player",
argv: vec![over.to_string()],
});
}
for c in CANDIDATES {
if c.name.eq_ignore_ascii_case(over) {
return locate(c, mac, path_var, app_dirs)
.ok_or_else(|| format!("player '{over}' is not installed"));
}
}
return Err(format!(
"unknown player '{over}' (try vlc, iina, mpv, ffplay, or browser)"
));
}
for c in CANDIDATES {
if let Some(spec) = locate(c, mac, path_var, app_dirs) {
return Ok(spec);
}
}
Ok(opener(mac))
}
fn locate(c: &Candidate, mac: bool, path_var: &str, app_dirs: &[PathBuf]) -> Option<Resolved> {
if let Some(bin) = find_on_path(c.bins, path_var) {
return Some(Resolved {
name: c.name,
argv: vec![bin],
});
}
if mac
&& let Some((app, exe)) = c.mac_app
{
for dir in app_dirs {
let p = dir.join(app).join("Contents").join("MacOS").join(exe);
if is_executable(&p) {
return Some(Resolved {
name: c.name,
argv: vec![p.to_string_lossy().into_owned()],
});
}
}
}
None
}
fn find_on_path(bins: &[&str], path_var: &str) -> Option<String> {
for dir in std::env::split_paths(path_var) {
for bin in bins {
let p = dir.join(bin);
if is_executable(&p) {
return Some(p.to_string_lossy().into_owned());
}
}
}
None
}
fn is_executable(p: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
p.is_file()
&& p.metadata()
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
{
p.is_file()
}
}
fn opener(mac: bool) -> Resolved {
if mac {
Resolved {
name: "QuickTime Player",
argv: vec!["open".into(), "-a".into(), "QuickTime Player".into()],
}
} else {
Resolved {
name: "browser",
argv: vec!["xdg-open".into()],
}
}
}
fn is_mac() -> bool {
cfg!(target_os = "macos")
}
fn path_var() -> String {
std::env::var("PATH").unwrap_or_default()
}
fn app_dirs() -> Vec<PathBuf> {
let mut dirs = vec![PathBuf::from("/Applications")];
if let Some(home) = std::env::var_os("HOME") {
dirs.push(PathBuf::from(home).join("Applications"));
}
dirs
}
static DEFAULT: LazyLock<Resolved> = LazyLock::new(|| {
resolve(None, is_mac(), &path_var(), &app_dirs())
.expect("auto-resolution always falls back to the opener")
});
pub fn open_in_player(url: &str, override_player: Option<&str>) -> Result<&'static str, String> {
let spec = match override_player {
Some(_) => resolve(override_player, is_mac(), &path_var(), &app_dirs())?,
None => DEFAULT.clone(),
};
let mut cmd = Command::new(&spec.argv[0]);
cmd.args(&spec.argv[1..]);
cmd.arg(url);
cmd.spawn()
.map_err(|e| format!("failed to start {}: {e}", spec.name))?;
Ok(spec.name)
}
#[cfg(test)]
mod tests {
use super::*;
fn bins_dir(bins: &[&str]) -> PathBuf {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"torq-player-test-{}-{}",
std::process::id(),
n
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for b in bins {
let p = dir.join(b);
std::fs::write(&p, "#!/bin/sh\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
}
}
dir
}
fn path_for(dir: &Path) -> String {
std::env::join_paths([dir]).unwrap().into_string().unwrap()
}
#[test]
fn priority_prefers_vlc_over_mpv() {
let dir = bins_dir(&["vlc", "mpv"]);
let r = resolve(None, false, &path_for(&dir), &[]).unwrap();
assert_eq!(r.name, "VLC");
assert_eq!(r.argv[0], dir.join("vlc").to_string_lossy());
}
#[test]
fn falls_back_to_next_available_player() {
let dir = bins_dir(&["mpv"]);
let r = resolve(None, false, &path_for(&dir), &[]).unwrap();
assert_eq!(r.name, "mpv");
assert_eq!(r.argv[0], dir.join("mpv").to_string_lossy());
}
#[test]
fn mac_app_bundle_is_found_when_no_binary() {
let dir = bins_dir(&[]);
let app = dir.join("VLC.app/Contents/MacOS/VLC");
std::fs::create_dir_all(app.parent().unwrap()).unwrap();
std::fs::write(&app, "#!/bin/sh\n").unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&app, std::fs::Permissions::from_mode(0o755)).unwrap();
let r = resolve(None, true, "", std::slice::from_ref(&dir)).unwrap();
assert_eq!(r.name, "VLC");
assert_eq!(r.argv[0], app.to_string_lossy());
}
#[test]
fn no_player_falls_back_to_platform_opener() {
let dir = bins_dir(&[]);
let linux = resolve(None, false, &path_for(&dir), &[]).unwrap();
assert_eq!(linux.name, "browser");
assert_eq!(linux.argv, vec!["xdg-open".to_string()]);
let mac = resolve(None, true, "", &[]).unwrap();
assert_eq!(mac.name, "QuickTime Player");
assert_eq!(
mac.argv,
vec!["open".to_string(), "-a".to_string(), "QuickTime Player".to_string()]
);
}
#[test]
fn override_browser_forces_opener() {
let dir = bins_dir(&["vlc"]);
let r = resolve(Some("browser"), false, &path_for(&dir), &[]).unwrap();
assert_eq!(r.argv[0], "xdg-open");
let mac = resolve(Some("Browser"), true, "", &[]).unwrap();
assert_eq!(mac.name, "QuickTime Player");
}
#[test]
fn override_forces_installed_player_and_errors_otherwise() {
let dir = bins_dir(&["vlc"]);
assert!(resolve(Some("mpv"), false, &path_for(&dir), &[]).is_err());
let mpv_dir = bins_dir(&["vlc", "mpv"]);
let r = resolve(Some("mpv"), false, &path_for(&mpv_dir), &[]).unwrap();
assert_eq!(r.name, "mpv");
assert_eq!(r.argv[0], mpv_dir.join("mpv").to_string_lossy());
}
#[test]
fn override_path_is_used_verbatim() {
let r = resolve(Some("/opt/fake/player"), false, "", &[]).unwrap();
assert_eq!(r.argv, vec!["/opt/fake/player".to_string()]);
}
#[test]
fn unknown_override_errors() {
assert!(resolve(Some("totally-not-a-player"), false, "", &[]).is_err());
}
}