1use std::path::{Path, PathBuf};
9use std::process::Command;
10use std::sync::LazyLock;
11
12#[derive(Clone, Debug, PartialEq)]
14pub struct Resolved {
15 pub name: &'static str,
16 pub argv: Vec<String>,
17}
18
19struct Candidate {
23 name: &'static str,
24 bins: &'static [&'static str],
25 mac_app: Option<(&'static str, &'static str)>,
26}
27
28const CANDIDATES: &[Candidate] = &[
29 Candidate {
30 name: "VLC",
31 bins: &["vlc"],
32 mac_app: Some(("VLC.app", "VLC")),
33 },
34 Candidate {
35 name: "IINA",
36 bins: &["iina"],
37 mac_app: Some(("IINA.app", "iina")),
38 },
39 Candidate {
40 name: "mpv",
41 bins: &["mpv"],
42 mac_app: Some(("mpv.app", "mpv")),
43 },
44 Candidate {
45 name: "ffplay",
46 bins: &["ffplay"],
47 mac_app: None,
48 },
49];
50
51pub fn resolve(
55 override_player: Option<&str>,
56 mac: bool,
57 path_var: &str,
58 app_dirs: &[PathBuf],
59) -> Result<Resolved, String> {
60 if let Some(over) = override_player {
61 if over.eq_ignore_ascii_case("browser") {
62 return Ok(opener(mac));
63 }
64 if over.contains('/') {
66 return Ok(Resolved {
67 name: "configured player",
68 argv: vec![over.to_string()],
69 });
70 }
71 for c in CANDIDATES {
72 if c.name.eq_ignore_ascii_case(over) {
73 return locate(c, mac, path_var, app_dirs)
74 .ok_or_else(|| format!("player '{over}' is not installed"));
75 }
76 }
77 return Err(format!(
78 "unknown player '{over}' (try vlc, iina, mpv, ffplay, or browser)"
79 ));
80 }
81 for c in CANDIDATES {
82 if let Some(spec) = locate(c, mac, path_var, app_dirs) {
83 return Ok(spec);
84 }
85 }
86 Ok(opener(mac))
87}
88
89fn locate(c: &Candidate, mac: bool, path_var: &str, app_dirs: &[PathBuf]) -> Option<Resolved> {
90 if let Some(bin) = find_on_path(c.bins, path_var) {
91 return Some(Resolved {
92 name: c.name,
93 argv: vec![bin],
94 });
95 }
96 if mac
97 && let Some((app, exe)) = c.mac_app
98 {
99 for dir in app_dirs {
100 let p = dir.join(app).join("Contents").join("MacOS").join(exe);
101 if is_executable(&p) {
102 return Some(Resolved {
103 name: c.name,
104 argv: vec![p.to_string_lossy().into_owned()],
105 });
106 }
107 }
108 }
109 None
110}
111
112fn find_on_path(bins: &[&str], path_var: &str) -> Option<String> {
113 for dir in std::env::split_paths(path_var) {
114 for bin in bins {
115 let p = dir.join(bin);
116 if is_executable(&p) {
117 return Some(p.to_string_lossy().into_owned());
118 }
119 }
120 }
121 None
122}
123
124fn is_executable(p: &Path) -> bool {
125 #[cfg(unix)]
126 {
127 use std::os::unix::fs::PermissionsExt;
128 p.is_file()
129 && p.metadata()
130 .map(|m| m.permissions().mode() & 0o111 != 0)
131 .unwrap_or(false)
132 }
133 #[cfg(not(unix))]
134 {
135 p.is_file()
136 }
137}
138
139fn opener(mac: bool) -> Resolved {
141 if mac {
142 Resolved {
143 name: "QuickTime Player",
144 argv: vec!["open".into(), "-a".into(), "QuickTime Player".into()],
145 }
146 } else {
147 Resolved {
148 name: "browser",
149 argv: vec!["xdg-open".into()],
150 }
151 }
152}
153
154fn is_mac() -> bool {
155 cfg!(target_os = "macos")
156}
157
158fn path_var() -> String {
159 std::env::var("PATH").unwrap_or_default()
160}
161
162fn app_dirs() -> Vec<PathBuf> {
163 let mut dirs = vec![PathBuf::from("/Applications")];
164 if let Some(home) = std::env::var_os("HOME") {
165 dirs.push(PathBuf::from(home).join("Applications"));
166 }
167 dirs
168}
169
170static DEFAULT: LazyLock<Resolved> = LazyLock::new(|| {
172 resolve(None, is_mac(), &path_var(), &app_dirs())
173 .expect("auto-resolution always falls back to the opener")
174});
175
176pub fn open_in_player(url: &str, override_player: Option<&str>) -> Result<&'static str, String> {
179 let spec = match override_player {
180 Some(_) => resolve(override_player, is_mac(), &path_var(), &app_dirs())?,
181 None => DEFAULT.clone(),
182 };
183 let mut cmd = Command::new(&spec.argv[0]);
184 cmd.args(&spec.argv[1..]);
185 cmd.arg(url);
186 cmd.spawn()
187 .map_err(|e| format!("failed to start {}: {e}", spec.name))?;
188 Ok(spec.name)
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 fn bins_dir(bins: &[&str]) -> PathBuf {
197 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
198 let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
199 let dir = std::env::temp_dir().join(format!(
200 "torq-player-test-{}-{}",
201 std::process::id(),
202 n
203 ));
204 let _ = std::fs::remove_dir_all(&dir);
205 std::fs::create_dir_all(&dir).unwrap();
206 for b in bins {
207 let p = dir.join(b);
208 std::fs::write(&p, "#!/bin/sh\n").unwrap();
209 #[cfg(unix)]
210 {
211 use std::os::unix::fs::PermissionsExt;
212 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
213 }
214 }
215 dir
216 }
217
218 fn path_for(dir: &Path) -> String {
219 std::env::join_paths([dir]).unwrap().into_string().unwrap()
220 }
221
222 #[test]
223 fn priority_prefers_vlc_over_mpv() {
224 let dir = bins_dir(&["vlc", "mpv"]);
225 let r = resolve(None, false, &path_for(&dir), &[]).unwrap();
226 assert_eq!(r.name, "VLC");
227 assert_eq!(r.argv[0], dir.join("vlc").to_string_lossy());
228 }
229
230 #[test]
231 fn falls_back_to_next_available_player() {
232 let dir = bins_dir(&["mpv"]);
233 let r = resolve(None, false, &path_for(&dir), &[]).unwrap();
234 assert_eq!(r.name, "mpv");
235 assert_eq!(r.argv[0], dir.join("mpv").to_string_lossy());
236 }
237
238 #[test]
239 fn mac_app_bundle_is_found_when_no_binary() {
240 let dir = bins_dir(&[]);
241 let app = dir.join("VLC.app/Contents/MacOS/VLC");
242 std::fs::create_dir_all(app.parent().unwrap()).unwrap();
243 std::fs::write(&app, "#!/bin/sh\n").unwrap();
244 use std::os::unix::fs::PermissionsExt;
245 std::fs::set_permissions(&app, std::fs::Permissions::from_mode(0o755)).unwrap();
246 let r = resolve(None, true, "", std::slice::from_ref(&dir)).unwrap();
247 assert_eq!(r.name, "VLC");
248 assert_eq!(r.argv[0], app.to_string_lossy());
249 }
250
251 #[test]
252 fn no_player_falls_back_to_platform_opener() {
253 let dir = bins_dir(&[]);
254 let linux = resolve(None, false, &path_for(&dir), &[]).unwrap();
255 assert_eq!(linux.name, "browser");
256 assert_eq!(linux.argv, vec!["xdg-open".to_string()]);
257 let mac = resolve(None, true, "", &[]).unwrap();
258 assert_eq!(mac.name, "QuickTime Player");
259 assert_eq!(
260 mac.argv,
261 vec!["open".to_string(), "-a".to_string(), "QuickTime Player".to_string()]
262 );
263 }
264
265 #[test]
266 fn override_browser_forces_opener() {
267 let dir = bins_dir(&["vlc"]);
268 let r = resolve(Some("browser"), false, &path_for(&dir), &[]).unwrap();
269 assert_eq!(r.argv[0], "xdg-open");
270 let mac = resolve(Some("Browser"), true, "", &[]).unwrap();
271 assert_eq!(mac.name, "QuickTime Player");
272 }
273
274 #[test]
275 fn override_forces_installed_player_and_errors_otherwise() {
276 let dir = bins_dir(&["vlc"]);
277 assert!(resolve(Some("mpv"), false, &path_for(&dir), &[]).is_err());
278 let mpv_dir = bins_dir(&["vlc", "mpv"]);
279 let r = resolve(Some("mpv"), false, &path_for(&mpv_dir), &[]).unwrap();
280 assert_eq!(r.name, "mpv");
281 assert_eq!(r.argv[0], mpv_dir.join("mpv").to_string_lossy());
282 }
283
284 #[test]
285 fn override_path_is_used_verbatim() {
286 let r = resolve(Some("/opt/fake/player"), false, "", &[]).unwrap();
287 assert_eq!(r.argv, vec!["/opt/fake/player".to_string()]);
288 }
289
290 #[test]
291 fn unknown_override_errors() {
292 assert!(resolve(Some("totally-not-a-player"), false, "", &[]).is_err());
293 }
294}