Skip to main content

phemios_core/hqplayer/
mod.rs

1//! Shared-folder HQPlayer output. The separate engine owns DSP and DAC access.
2mod paths;
3mod protocol;
4mod session;
5use protocol::Connection;
6use serde_json::{Value, json};
7use session::number;
8use std::sync::Mutex;
9/// Reserved output identifier, never confused with a native audio-device name.
10pub const OUTPUT: &str = "@hqplayer";
11struct Session {
12    connection: Connection,
13    tracks: Vec<String>,
14    uris: Vec<String>,
15    snapshot: Value,
16    volume_min: f64,
17    volume_max: f64,
18    volume_enabled: bool,
19    seek: u64,
20    seek_pending: Option<(u64, u64)>,
21    active: bool,
22}
23/// Verified source paths; private fields prevent arbitrary URI construction across the bridge.
24pub struct Selection {
25    tracks: Vec<String>,
26    uris: Vec<String>,
27    index: usize,
28}
29/// One socket owner serializes all protocol requests and acknowledged transport changes.
30#[derive(Default)]
31pub struct Controller {
32    session: Mutex<Option<Session>>,
33}
34impl Controller {
35    /// Explicit non-mutating connection probe. Never opens during ordinary navigation.
36    pub fn connect(&self, host: &str, port: u16) -> Result<Value, String> {
37        let mut guard = self.session.lock().map_err(|_| "HQPlayer occupé.")?;
38        if guard.as_ref().is_some_and(|s| s.active) {
39            return Err("Arrêtez la lecture HQPlayer avant de modifier sa connexion.".into());
40        }
41        *guard = None;
42        let mut connection = Connection::connect(host, port)?;
43        let info = connection.call("GetInfo", &[], false)?;
44        if !["product", "version"].iter().all(|key| {
45            info.get(*key)
46                .is_some_and(|v| !v.is_empty() && v.len() <= 256)
47        }) {
48            return Err("Cette adresse ne répond pas comme HQPlayer.".into());
49        }
50        let range = connection.call("VolumeRange", &[], false)?;
51        let min = number(&range, "min")?;
52        let max = number(&range, "max")?;
53        if min < -200. || max > 20. || min >= max {
54            return Err("Volume HQPlayer invalide.".into());
55        }
56        connection.call("Status", &[("subscribe", "0".into())], false)?;
57        *guard = Some(Session {
58            connection,
59            tracks: Vec::new(),
60            uris: Vec::new(),
61            snapshot: Value::Null,
62            volume_min: min,
63            volume_max: max,
64            volume_enabled: range.get("enabled").map(String::as_str) == Some("1"),
65            seek: 0,
66            seek_pending: None,
67            active: false,
68        });
69        Ok(
70            json!({"connected":true,"product":info.get("product"),"version":info.get("version"),"name":info.get("name")}),
71        )
72    }
73    /// Validate mappings before attempting a connection or saving an active profile.
74    pub fn validate_mapping(value: &Value) -> Result<(), String> {
75        paths::uri("/fixture", value).map(|_| ())
76    }
77    /// Expose verified availability without making a discovery network request.
78    pub fn connected(&self) -> bool {
79        self.session.lock().is_ok_and(|s| s.is_some())
80    }
81    /// Route audio controls only after an explicit selection has been accepted.
82    pub fn active(&self) -> bool {
83        self.session
84            .lock()
85            .is_ok_and(|s| s.as_ref().is_some_and(|s| s.active))
86    }
87    /// Stop only this controller's queue; unrelated HQPlayer playback is untouched.
88    pub fn halt(&self) -> Result<(), String> {
89        self.stop(false)
90    }
91    /// Stop an owned selection and drop its retained metadata without disconnecting the output.
92    pub fn clear(&self) -> Result<(), String> {
93        self.stop(true)
94    }
95    fn stop(&self, clear: bool) -> Result<(), String> {
96        let mut held = self.session.lock().map_err(|_| "HQPlayer occupé.")?;
97        if let Some(s) = held.as_mut()
98            && s.active
99        {
100            let result = s.connection.call("Stop", &[], true);
101            s.active = false;
102            if result.is_err() {
103                *held = None;
104            }
105            result?;
106        }
107        if clear && let Some(s) = held.as_mut() {
108            s.tracks.clear();
109            s.uris.clear();
110            s.snapshot = Value::Null;
111            s.seek = 0;
112            s.seek_pending = None;
113        }
114        Ok(())
115    }
116    /// Remove the optional endpoint after stopping an owned queue.
117    pub fn disconnect(&self) -> Result<(), String> {
118        let result = self.halt();
119        *self.session.lock().map_err(|_| "HQPlayer occupé.")? = None;
120        result
121    }
122    /// Prepare source-confined files without touching the external playlist; cancelled sessions stop retrieval.
123    pub fn prepare(
124        client: &crate::network::Client,
125        tracks: Vec<String>,
126        index: usize,
127        mappings: &Value,
128        current: impl Fn() -> bool,
129    ) -> Result<Selection, String> {
130        if !matches!(
131            client.account()["account"]["role"].as_str(),
132            Some("admin" | "user")
133        ) {
134            return Err("Votre compte ne permet pas la lecture.".into());
135        }
136        if tracks.is_empty() || tracks.len() > 4096 || index >= tracks.len() {
137            return Err("Sélection limitée à 4096 pistes.".into());
138        }
139        let mut uris = Vec::new();
140        for reference in &tracks {
141            if !current() {
142                return Err("Lecture annulée ou session remplacée.".into());
143            }
144            let detail = client.request_admitted(
145                "GET",
146                "/api/v1/track",
147                &[("ref", reference)],
148                None,
149                || {
150                    if current() {
151                        Ok(())
152                    } else {
153                        Err("Lecture annulée ou session remplacée.".into())
154                    }
155                },
156            )?;
157            if detail["reference"].as_str() != Some(reference) {
158                return Err("Identité de piste Aède invalide.".into());
159            }
160            uris.push(paths::uri(
161                detail["path"]
162                    .as_str()
163                    .ok_or("Chemin de piste Aède indisponible.")?,
164                mappings,
165            )?);
166        }
167        if !current() {
168            return Err("Lecture annulée ou session remplacée.".into());
169        }
170        if uris.iter().collect::<std::collections::HashSet<_>>().len() != uris.len() {
171            return Err(
172                "HQPlayer ne peut pas identifier plusieurs pistes partageant ce fichier.".into(),
173            );
174        }
175        Ok(Selection {
176            tracks,
177            uris,
178            index,
179        })
180    }
181    /// Publish only an authenticated selection, checking admission between protocol commands.
182    /// An in-flight command may finish; cancellation prevents subsequent queue/Play commands.
183    pub fn start(&self, selection: Selection, current: impl Fn() -> bool) -> Result<(), String> {
184        self.start_uris(selection.tracks, selection.uris, selection.index, current)
185    }
186    fn start_uris(
187        &self,
188        tracks: Vec<String>,
189        uris: Vec<String>,
190        index: usize,
191        current: impl Fn() -> bool,
192    ) -> Result<(), String> {
193        let mut held = self.session.lock().map_err(|_| "HQPlayer occupé.")?;
194        let s = held
195            .as_mut()
196            .ok_or("Testez la connexion HQPlayer dans les paramètres.")?;
197        let admit = || {
198            if current() {
199                Ok(())
200            } else {
201                Err("Lecture annulée ou session remplacée.".to_owned())
202            }
203        };
204        admit()?;
205        let result = (|| {
206            s.connection.call("Stop", &[], true)?;
207            for (i, uri) in uris.iter().enumerate() {
208                admit()?;
209                s.connection.call(
210                    "PlaylistAdd",
211                    &[
212                        ("uri", uri.clone()),
213                        ("queued", "0".into()),
214                        ("clear", if i == 0 { "1" } else { "0" }.into()),
215                        ("start", "0".into()),
216                        ("freewheel", "0".into()),
217                    ],
218                    true,
219                )?;
220            }
221            admit()?;
222            s.connection
223                .call("SelectTrack", &[("index", index.to_string())], true)?;
224            admit()?;
225            s.connection.call("Play", &[], true)?;
226            admit()?;
227            Ok::<(), String>(())
228        })();
229        if result.is_err() {
230            let _ = s.connection.call("Stop", &[], true);
231            *held = None;
232            return result;
233        }
234        s.tracks = tracks;
235        s.uris = uris;
236        s.active = true;
237        s.seek_pending = None;
238        s.snapshot = Value::Null;
239        Ok(())
240    }
241    /// Read the engine's actual track clock. Meter data and confirmed Aède history are unavailable.
242    pub fn snapshot(&self) -> Result<Value, String> {
243        let mut held = self.session.lock().map_err(|_| "HQPlayer occupé.")?;
244        let s = held.as_mut().ok_or("Connexion HQPlayer perdue.")?;
245        let result = s.read_state();
246        if result.is_err() {
247            let _ = s.connection.call("Stop", &[], true);
248            *held = None;
249        }
250        result
251    }
252    /// Typed controls cannot transmit arbitrary XML or open an arbitrary media URL.
253    pub fn control(&self, action: &str, value: Value) -> Result<Value, String> {
254        let mut held = self.session.lock().map_err(|_| "HQPlayer occupé.")?;
255        let s = held.as_mut().ok_or("Connexion HQPlayer perdue.")?;
256        match s.control(action, value) {
257            Ok(value) => Ok(value),
258            Err(session::ControlError::Rejected(message)) => Err(message.into()),
259            Err(session::ControlError::Transport(error)) => {
260                let _ = s.connection.call("Stop", &[], true);
261                *held = None;
262                Err(error)
263            }
264        }
265    }
266}
267#[cfg(test)]
268#[path = "tests.rs"]
269mod tests;
270
271#[cfg(test)]
272#[path = "clear_tests.rs"]
273mod clear_tests;
274
275#[cfg(test)]
276#[path = "start_tests.rs"]
277mod start_tests;
278
279#[cfg(test)]
280#[path = "control_tests.rs"]
281mod control_tests;