Skip to main content

torq_core/
player.rs

1//! Open a torrent stream URL in VLC.
2//!
3//! VLC is the only supported player for now: it is the one player whose
4//! HTTP streaming behavior is known-good against torq's mid-download
5//! ranges. `config.player` can still point at a specific VLC binary (or
6//! `"vlc"`), but non-VLC players are rejected.
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/// The one supported player. `bins` are looked up on PATH; `mac_app` is the
20/// macOS bundle whose executable lives at `<app>/Contents/MacOS/<exe>`.
21struct 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
33/// Resolve which command plays the URL. Pure (no process spawns, no env
34/// reads beyond what is passed in) so the resolution logic is unit-testable
35/// with fake PATHs and app dirs.
36pub 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        // A path is used verbatim (it is expected to be a VLC binary).
44        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
131/// Auto-detected VLC resolution, cached for the process lifetime.
132static DEFAULT: LazyLock<Result<Resolved, String>> = LazyLock::new(|| {
133    resolve(None, is_mac(), &path_var(), &app_dirs())
134});
135
136/// Launch the stream URL in VLC. Returns "VLC" on success.
137/// `override_player` (from config) wins when set.
138pub 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    /// Build a unique temp dir with executable `bins`; caller owns cleanup.
156    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        // A named player that is simply missing also errors.
228        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}