Skip to main content

torq_core/
player.rs

1//! Open a torrent stream URL in a real video player.
2//!
3//! `open`/`xdg-open` would hand an http URL to the default *browser*, which
4//! chokes on mkv/webm. torq prefers an actual video player, in priority
5//! order, falling back to the platform opener only when none is installed.
6//! `config.player` can force a specific player (or `"browser"`).
7
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use std::sync::LazyLock;
11
12/// A resolved launch: argv (the URL is appended last) plus a display name.
13#[derive(Clone, Debug, PartialEq)]
14pub struct Resolved {
15    pub name: &'static str,
16    pub argv: Vec<String>,
17}
18
19/// Candidate players, most preferred first. `bins` are looked up on PATH;
20/// `mac_app` is a bundle whose executable lives at
21/// `<app>/Contents/MacOS/<exe>` (macOS only).
22struct 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
51/// Resolve which command plays the URL. Pure (no process spawns, no env
52/// reads beyond what is passed in) so the priority/fallback logic is
53/// unit-testable with fake PATHs and app dirs.
54pub 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        // A path is used verbatim; anything else is a player name.
65        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
139/// Last-resort opener: the platform's URL handler (a browser).
140fn 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
170/// Auto-detected default resolution, cached for the process lifetime.
171static 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
176/// Launch the stream URL in a player. Returns the player's display name on
177/// success. `override_player` (from config) wins when set.
178pub 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    /// Build a unique temp dir with executable `bins`; caller owns cleanup.
196    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}