1mod paths;
3mod protocol;
4mod session;
5use protocol::Connection;
6use serde_json::{Value, json};
7use session::number;
8use std::sync::Mutex;
9pub 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}
23pub struct Selection {
25 tracks: Vec<String>,
26 uris: Vec<String>,
27 index: usize,
28}
29#[derive(Default)]
31pub struct Controller {
32 session: Mutex<Option<Session>>,
33}
34impl Controller {
35 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 pub fn validate_mapping(value: &Value) -> Result<(), String> {
75 paths::uri("/fixture", value).map(|_| ())
76 }
77 pub fn connected(&self) -> bool {
79 self.session.lock().is_ok_and(|s| s.is_some())
80 }
81 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 pub fn halt(&self) -> Result<(), String> {
89 self.stop(false)
90 }
91 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 pub fn disconnect(&self) -> Result<(), String> {
118 let result = self.halt();
119 *self.session.lock().map_err(|_| "HQPlayer occupé.")? = None;
120 result
121 }
122 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 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 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 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;