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 {
22 name: &'static str,
23 bins: &'static [&'static str],
24 mac_app: Option<(&'static str, &'static str)>,
25}
26
27const CANDIDATES: &[Candidate] = &[Candidate {
28 name: "VLC",
29 bins: &["vlc"],
30 mac_app: Some(("VLC.app", "VLC")),
31}];
32
33pub fn resolve(
37 override_player: Option<&str>,
38 mac: bool,
39 path_var: &str,
40 app_dirs: &[PathBuf],
41) -> Result<Resolved, String> {
42 if let Some(over) = override_player {
43 if over.contains('/') {
45 return Ok(Resolved {
46 name: "VLC",
47 argv: vec![over.to_string()],
48 });
49 }
50 if over.eq_ignore_ascii_case("vlc") {
51 return locate(&CANDIDATES[0], mac, path_var, app_dirs)
52 .ok_or_else(|| "VLC is not installed".to_string());
53 }
54 return Err(format!(
55 "only VLC is supported for playback (set player = \"vlc\" or a path to the vlc binary); got '{over}'"
56 ));
57 }
58 locate(&CANDIDATES[0], mac, path_var, app_dirs).ok_or_else(|| {
59 "VLC is not installed — torq requires VLC to play streams \
60 (macOS: brew install --cask vlc; Linux: your package manager's vlc)"
61 .to_string()
62 })
63}
64
65fn locate(c: &Candidate, mac: bool, path_var: &str, app_dirs: &[PathBuf]) -> Option<Resolved> {
66 if let Some(bin) = find_on_path(c.bins, path_var) {
67 return Some(Resolved {
68 name: c.name,
69 argv: vec![bin],
70 });
71 }
72 if mac
73 && let Some((app, exe)) = c.mac_app
74 {
75 for dir in app_dirs {
76 let p = dir.join(app).join("Contents").join("MacOS").join(exe);
77 if is_executable(&p) {
78 return Some(Resolved {
79 name: c.name,
80 argv: vec![p.to_string_lossy().into_owned()],
81 });
82 }
83 }
84 }
85 None
86}
87
88fn find_on_path(bins: &[&str], path_var: &str) -> Option<String> {
89 for dir in std::env::split_paths(path_var) {
90 for bin in bins {
91 let p = dir.join(bin);
92 if is_executable(&p) {
93 return Some(p.to_string_lossy().into_owned());
94 }
95 }
96 }
97 None
98}
99
100fn is_executable(p: &Path) -> bool {
101 #[cfg(unix)]
102 {
103 use std::os::unix::fs::PermissionsExt;
104 p.is_file()
105 && p.metadata()
106 .map(|m| m.permissions().mode() & 0o111 != 0)
107 .unwrap_or(false)
108 }
109 #[cfg(not(unix))]
110 {
111 p.is_file()
112 }
113}
114
115fn is_mac() -> bool {
116 cfg!(target_os = "macos")
117}
118
119fn path_var() -> String {
120 std::env::var("PATH").unwrap_or_default()
121}
122
123fn app_dirs() -> Vec<PathBuf> {
124 let mut dirs = vec![PathBuf::from("/Applications")];
125 if let Some(home) = std::env::var_os("HOME") {
126 dirs.push(PathBuf::from(home).join("Applications"));
127 }
128 dirs
129}
130
131static DEFAULT: LazyLock<Result<Resolved, String>> = LazyLock::new(|| {
133 resolve(None, is_mac(), &path_var(), &app_dirs())
134});
135
136pub fn open_in_player(url: &str, override_player: Option<&str>) -> Result<&'static str, String> {
139 let spec = match override_player {
140 Some(_) => resolve(override_player, is_mac(), &path_var(), &app_dirs())?,
141 None => DEFAULT.clone()?,
142 };
143 let mut cmd = Command::new(&spec.argv[0]);
144 cmd.args(&spec.argv[1..]);
145 cmd.arg(url);
146 cmd.spawn()
147 .map_err(|e| format!("failed to start {}: {e}", spec.name))?;
148 Ok(spec.name)
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 fn bins_dir(bins: &[&str]) -> PathBuf {
157 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
158 let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
159 let dir = std::env::temp_dir().join(format!(
160 "torq-player-test-{}-{}",
161 std::process::id(),
162 n
163 ));
164 let _ = std::fs::remove_dir_all(&dir);
165 std::fs::create_dir_all(&dir).unwrap();
166 for b in bins {
167 let p = dir.join(b);
168 std::fs::write(&p, "#!/bin/sh\n").unwrap();
169 #[cfg(unix)]
170 {
171 use std::os::unix::fs::PermissionsExt;
172 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
173 }
174 }
175 dir
176 }
177
178 fn path_for(dir: &Path) -> String {
179 std::env::join_paths([dir]).unwrap().into_string().unwrap()
180 }
181
182 fn mac_app_dir(dir: &Path) -> PathBuf {
183 let app = dir.join("VLC.app/Contents/MacOS/VLC");
184 std::fs::create_dir_all(app.parent().unwrap()).unwrap();
185 std::fs::write(&app, "#!/bin/sh\n").unwrap();
186 #[cfg(unix)]
187 {
188 use std::os::unix::fs::PermissionsExt;
189 std::fs::set_permissions(&app, std::fs::Permissions::from_mode(0o755)).unwrap();
190 }
191 app
192 }
193
194 #[test]
195 fn detects_vlc_from_path() {
196 let dir = bins_dir(&["vlc", "mpv"]);
197 let r = resolve(None, false, &path_for(&dir), &[]).unwrap();
198 assert_eq!(r.name, "VLC");
199 assert_eq!(r.argv[0], dir.join("vlc").to_string_lossy());
200 }
201
202 #[test]
203 fn mac_app_bundle_is_found_when_no_binary() {
204 let dir = bins_dir(&[]);
205 let app = mac_app_dir(&dir);
206 let r = resolve(None, true, "", std::slice::from_ref(&dir)).unwrap();
207 assert_eq!(r.name, "VLC");
208 assert_eq!(r.argv[0], app.to_string_lossy());
209 }
210
211 #[test]
212 fn missing_vlc_errors_with_clear_message() {
213 let dir = bins_dir(&["mpv"]);
214 let err = resolve(None, false, &path_for(&dir), &[]).unwrap_err();
215 assert!(err.contains("VLC is not installed"), "{err}");
216 assert!(err.contains("requires VLC"), "{err}");
217 }
218
219 #[test]
220 fn override_vlc_works_and_others_are_rejected() {
221 let dir = bins_dir(&["vlc"]);
222 let r = resolve(Some("vlc"), false, &path_for(&dir), &[]).unwrap();
223 assert_eq!(r.name, "VLC");
224 assert!(resolve(Some("iina"), false, &path_for(&dir), &[]).is_err());
225 assert!(resolve(Some("mpv"), false, &path_for(&dir), &[]).is_err());
226 assert!(resolve(Some("browser"), false, &path_for(&dir), &[]).is_err());
227 assert!(resolve(Some("vlc"), false, "", &[]).is_err());
229 }
230
231 #[test]
232 fn override_path_is_used_verbatim() {
233 let r = resolve(Some("/opt/vlc/vlc"), false, "", &[]).unwrap();
234 assert_eq!(r.argv, vec!["/opt/vlc/vlc".to_string()]);
235 }
236
237 #[test]
238 fn unknown_override_errors() {
239 assert!(resolve(Some("totally-not-a-player"), false, "", &[]).is_err());
240 }
241}