1use std::sync::Arc;
6
7use parking_lot::Mutex;
8use reqwest::StatusCode;
9use serde_json::Value;
10
11use crate::config::Config;
12
13#[derive(Clone)]
18pub struct GraphQLClient {
19 url: String,
20 http: reqwest::blocking::Client,
21 session: Option<Arc<Session>>,
22 play_modes: Arc<std::sync::atomic::AtomicBool>,
26 sleep_timer: Arc<std::sync::atomic::AtomicBool>,
28}
29
30struct Session {
33 tokens: Mutex<Tokens>,
34 on_rotate: Box<dyn Fn(&str) + Send + Sync>,
37}
38
39struct Tokens {
40 access: Option<String>,
41 refresh: String,
42 refused: Option<String>,
45}
46
47impl GraphQLClient {
48 pub fn new(server_url: &str) -> Self {
49 let url = format!("{}/graphql", server_url.trim_end_matches('/'));
50 Self {
51 url,
52 http: reqwest::blocking::Client::builder()
53 .timeout(std::time::Duration::from_secs(30))
54 .build()
55 .expect("failed to build HTTP client"),
56 session: None,
57 play_modes: Arc::new(std::sync::atomic::AtomicBool::new(true)),
58 sleep_timer: Arc::new(std::sync::atomic::AtomicBool::new(true)),
59 }
60 }
61
62 pub fn from_config(server_url: &str) -> Self {
65 let client = Self::new(server_url);
66 let cfg = Config::load().unwrap_or_default();
67 if cfg.auth.refresh_token.is_empty()
68 || cfg.auth.server.trim_end_matches('/') != client.server_url()
69 {
70 return client;
71 }
72 client.with_session(cfg.auth.refresh_token, |token| {
73 if let Err(e) = Config::persist(|cfg| cfg.auth.refresh_token = token.to_owned()) {
74 log::warn!("could not store the rotated refresh token: {e}");
75 }
76 })
77 }
78
79 pub fn with_session(
82 mut self,
83 refresh_token: impl Into<String>,
84 on_rotate: impl Fn(&str) + Send + Sync + 'static,
85 ) -> Self {
86 self.session = Some(Arc::new(Session {
87 tokens: Mutex::new(Tokens {
88 access: None,
89 refresh: refresh_token.into(),
90 refused: None,
91 }),
92 on_rotate: Box::new(on_rotate),
93 }));
94 self
95 }
96
97 pub fn has_session(&self) -> bool {
99 self.session.is_some()
100 }
101
102 pub fn execute(&self, query: &str, variables: Option<Value>) -> Result<Value, GraphQLError> {
107 let mut body = serde_json::json!({ "query": query });
108 if let Some(vars) = variables {
109 body["variables"] = vars;
110 }
111
112 let access = self
113 .session
114 .as_ref()
115 .and_then(|s| s.tokens.lock().access.clone());
116 let mut resp = self.post(&body, access.as_deref())?;
117 if resp.status() == StatusCode::UNAUTHORIZED
118 && let Some(session) = &self.session
119 {
120 let fresh = self.refresh(session, access.as_deref())?;
121 resp = self.post(&body, Some(&fresh))?;
122 }
123
124 let status = resp.status();
125 if status == StatusCode::UNAUTHORIZED {
126 return Err(GraphQLError::Unauthorized(reason(resp)));
127 }
128 if !status.is_success() {
129 return Err(GraphQLError::Http(format!("{status}: {}", reason(resp))));
130 }
131
132 let resp: Value = resp.json().map_err(|e| GraphQLError::Http(e.to_string()))?;
133
134 if let Some(errors) = resp.get("errors")
135 && let Some(arr) = errors.as_array()
136 && !arr.is_empty()
137 {
138 let msg = arr[0]
139 .get("message")
140 .and_then(|m| m.as_str())
141 .unwrap_or("unknown error");
142 return Err(GraphQLError::Query(msg.to_string()));
143 }
144
145 Ok(resp.get("data").cloned().unwrap_or(Value::Null))
146 }
147
148 fn post(
149 &self,
150 body: &Value,
151 access: Option<&str>,
152 ) -> Result<reqwest::blocking::Response, GraphQLError> {
153 let mut req = self.http.post(&self.url).json(body);
154 if let Some(token) = access {
155 req = req.bearer_auth(token);
156 }
157 req.send().map_err(|e| GraphQLError::Http(e.to_string()))
158 }
159
160 fn refresh(&self, session: &Session, stale: Option<&str>) -> Result<String, GraphQLError> {
167 let mut tokens = session.tokens.lock();
168 if let Some(access) = &tokens.access
169 && Some(access.as_str()) != stale
170 {
171 return Ok(access.clone());
172 }
173 if let Some(reason) = &tokens.refused {
174 return Err(GraphQLError::Unauthorized(reason.clone()));
175 }
176
177 let resp = self
178 .http
179 .post(format!("{}/auth/refresh", self.server_url()))
180 .json(&serde_json::json!({ "refresh_token": tokens.refresh }))
181 .send()
182 .map_err(|e| GraphQLError::Http(e.to_string()))?;
183 if resp.status() == StatusCode::UNAUTHORIZED {
184 let refused = format!("the stored sign-in was refused: {}", reason(resp));
185 tokens.access = None;
186 tokens.refused = Some(refused.clone());
187 return Err(GraphQLError::Unauthorized(refused));
188 }
189 if !resp.status().is_success() {
190 let status = resp.status();
191 return Err(GraphQLError::Http(format!(
192 "/auth/refresh {status}: {}",
193 reason(resp)
194 )));
195 }
196
197 let body: Value = resp.json().map_err(|e| GraphQLError::Http(e.to_string()))?;
198 let (Some(access), Some(refresh)) = (
199 body["access_token"].as_str(),
200 body["refresh_token"].as_str(),
201 ) else {
202 return Err(GraphQLError::Http(
203 "malformed /auth/refresh response".into(),
204 ));
205 };
206 tokens.access = Some(access.to_owned());
207 tokens.refresh = refresh.to_owned();
208 (session.on_rotate)(refresh);
209 Ok(access.to_owned())
210 }
211
212 pub fn now_playing(&self) -> Result<NowPlaying, GraphQLError> {
220 use std::sync::atomic::Ordering;
221 const TRACK: &str = "track { trackId title artist album codec sampleRate bitDepth \
222 bitrateKbps channels durationMs }";
223 let ask = |modes: bool, sleep: bool| {
224 let modes = if modes { "shuffle repeat " } else { "" };
225 let sleep = if sleep {
226 "sleep { endsAtMs endOf } "
227 } else {
228 ""
229 };
230 self.execute(
231 &format!(
232 "{{ nowPlaying {{ state positionMs durationMs queueItemId {modes}{sleep}{TRACK} }} }}"
233 ),
234 None,
235 )
236 };
237 let data = loop {
238 let (modes, sleep) = (
239 self.play_modes.load(Ordering::Relaxed),
240 self.sleep_timer.load(Ordering::Relaxed),
241 );
242 match ask(modes, sleep) {
243 Err(GraphQLError::Query(e)) if sleep && e.contains("sleep") => {
244 log::info!("server predates the sleep timer: {e}");
245 self.sleep_timer.store(false, Ordering::Relaxed);
246 }
247 Err(GraphQLError::Query(e))
248 if modes && (e.contains("shuffle") || e.contains("repeat")) =>
249 {
250 log::info!("server predates play modes: {e}");
251 self.play_modes.store(false, Ordering::Relaxed);
252 }
253 other => break other?,
254 }
255 };
256 let np = &data["nowPlaying"];
257 Ok(NowPlaying {
258 state: np["state"].as_str().unwrap_or("STOPPED").to_string(),
259 position_ms: np["positionMs"].as_u64().unwrap_or(0),
260 duration_ms: np["durationMs"].as_u64(),
261 queue_item_id: np["queueItemId"].as_str().map(String::from),
262 mode: crate::player::state::PlayMode {
263 shuffle: np["shuffle"].as_bool().unwrap_or(false),
264 repeat: np["repeat"]
265 .as_str()
266 .and_then(|r| crate::player::state::Repeat::parse(&r.to_lowercase()))
267 .unwrap_or_default(),
268 },
269 sleep: match (
270 np["sleep"]["endsAtMs"].as_u64(),
271 np["sleep"]["endOf"].as_str(),
272 ) {
273 (Some(unix_ms), _) => Some(crate::player::state::Sleep::At { unix_ms }),
274 (None, Some("TRACK")) => Some(crate::player::state::Sleep::EndOfTrack),
275 (None, Some("RECORD")) => Some(crate::player::state::Sleep::EndOfRecord),
276 _ => None,
277 },
278 track: np.get("track").and_then(|t| {
279 if t.is_null() {
280 return None;
281 }
282 Some(NowPlayingTrack {
283 track_id: t["trackId"].as_str().map(String::from),
284 title: t["title"].as_str().unwrap_or("").to_string(),
285 artist: t["artist"].as_str().unwrap_or("").to_string(),
286 album: t["album"].as_str().unwrap_or("").to_string(),
287 codec: t["codec"].as_str().unwrap_or("").to_string(),
288 sample_rate: t["sampleRate"].as_u64().unwrap_or(0) as u32,
289 bit_depth: t["bitDepth"].as_u64().map(|v| v as u16),
290 bitrate_kbps: t["bitrateKbps"].as_u64().map(|v| v as u32),
291 channels: t["channels"].as_u64().unwrap_or(0) as u16,
292 duration_ms: t["durationMs"].as_u64().unwrap_or(0),
293 })
294 }),
295 })
296 }
297
298 pub fn queue(&self) -> Result<Vec<QueueEntry>, GraphQLError> {
299 let data = self.execute(
300 "{ queue { queueItemId trackId title artist album codec trackNumber disc durationMs isCurrent } }",
301 None,
302 )?;
303 let entries = data["queue"]
304 .as_array()
305 .map(|arr| {
306 arr.iter()
307 .map(|e| QueueEntry {
308 queue_item_id: e["queueItemId"].as_str().unwrap_or("").to_string(),
309 track_id: e["trackId"].as_str().map(String::from),
310 title: e["title"].as_str().unwrap_or("").to_string(),
311 artist: e["artist"].as_str().unwrap_or("").to_string(),
312 album: e["album"].as_str().unwrap_or("").to_string(),
313 codec: e["codec"].as_str().map(String::from),
314 track_number: e["trackNumber"].as_i64(),
315 disc: e["disc"].as_i64(),
316 duration_ms: e["durationMs"].as_u64(),
317 is_current: e["isCurrent"].as_bool().unwrap_or(false),
318 })
319 .collect()
320 })
321 .unwrap_or_default();
322 Ok(entries)
323 }
324
325 pub fn pause(&self) -> Result<(), GraphQLError> {
328 self.execute("mutation { pause { ok } }", None)?;
329 Ok(())
330 }
331
332 pub fn resume(&self) -> Result<(), GraphQLError> {
333 self.execute("mutation { resume { ok } }", None)?;
334 Ok(())
335 }
336
337 pub fn stop(&self) -> Result<(), GraphQLError> {
338 self.execute("mutation { stop { ok } }", None)?;
339 Ok(())
340 }
341
342 pub fn next(&self) -> Result<(), GraphQLError> {
343 self.execute("mutation { next { ok } }", None)?;
344 Ok(())
345 }
346
347 pub fn previous(&self) -> Result<(), GraphQLError> {
348 self.execute("mutation { previous { ok } }", None)?;
349 Ok(())
350 }
351
352 pub fn set_shuffle(&self, on: bool) -> Result<(), GraphQLError> {
353 self.execute(
354 "mutation($on: Boolean!) { setPlayMode(shuffle: $on) { ok } }",
355 Some(serde_json::json!({ "on": on })),
356 )?;
357 Ok(())
358 }
359
360 pub fn set_sleep_timer(
362 &self,
363 timer: Option<crate::player::state::SleepTimer>,
364 ) -> Result<(), GraphQLError> {
365 use crate::player::state::SleepTimer;
366 let (query, vars) = match timer {
367 None => ("mutation { cancelSleepTimer { ok } }", None),
368 Some(SleepTimer::After { minutes }) => (
369 "mutation($m: Int!) { setSleepTimer(minutes: $m) { ok } }",
370 Some(serde_json::json!({ "m": minutes })),
371 ),
372 Some(SleepTimer::EndOfTrack) => {
373 ("mutation { setSleepTimer(endOf: TRACK) { ok } }", None)
374 }
375 Some(SleepTimer::EndOfRecord) => {
376 ("mutation { setSleepTimer(endOf: RECORD) { ok } }", None)
377 }
378 };
379 self.execute(query, vars)?;
380 Ok(())
381 }
382
383 pub fn set_repeat(&self, repeat: crate::player::state::Repeat) -> Result<(), GraphQLError> {
384 self.execute(
385 "mutation($repeat: Repeat!) { setPlayMode(repeat: $repeat) { ok } }",
386 Some(serde_json::json!({ "repeat": repeat.as_str().to_uppercase() })),
387 )?;
388 Ok(())
389 }
390
391 pub fn seek(&self, position_ms: u64) -> Result<(), GraphQLError> {
392 self.execute(
393 "mutation($positionMs: Int!) { seek(positionMs: $positionMs) { ok } }",
394 Some(serde_json::json!({ "positionMs": position_ms })),
395 )?;
396 Ok(())
397 }
398
399 pub fn play(&self, queue_item_id: &str) -> Result<(), GraphQLError> {
400 self.execute(
401 "mutation($queueItemId: String!) { play(queueItemId: $queueItemId) { ok } }",
402 Some(serde_json::json!({ "queueItemId": queue_item_id })),
403 )?;
404 Ok(())
405 }
406
407 pub fn clear_queue(&self) -> Result<(), GraphQLError> {
408 self.execute("mutation { clearQueue { ok } }", None)?;
409 Ok(())
410 }
411
412 pub fn library_stats(&self) -> Result<Value, GraphQLError> {
413 self.execute(
414 "{ libraryStats { totalTracks totalArtists totalAlbums localTracks remoteTracks cachedTracks } }",
415 None,
416 )
417 }
418
419 pub fn server_url(&self) -> &str {
421 self.url.trim_end_matches("/graphql")
422 }
423}
424
425#[derive(Debug, thiserror::Error)]
430pub enum GraphQLError {
431 #[error("http error: {0}")]
432 Http(String),
433 #[error("query error: {0}")]
434 Query(String),
435 #[error("unauthorised: {0}")]
436 Unauthorized(String),
437}
438
439fn reason(resp: reqwest::blocking::Response) -> String {
442 let status = resp.status();
443 let text = resp.text().unwrap_or_default();
444 let message = serde_json::from_str::<Value>(&text)
445 .ok()
446 .and_then(|v| v["message"].as_str().map(str::to_owned))
447 .unwrap_or(text);
448 if message.trim().is_empty() {
449 status.to_string()
450 } else {
451 message.trim().to_owned()
452 }
453}
454
455#[derive(Debug, Clone)]
456pub struct NowPlaying {
457 pub state: String,
458 pub position_ms: u64,
459 pub duration_ms: Option<u64>,
460 pub queue_item_id: Option<String>,
461 pub mode: crate::player::state::PlayMode,
462 pub sleep: Option<crate::player::state::Sleep>,
463 pub track: Option<NowPlayingTrack>,
464}
465
466#[derive(Debug, Clone)]
467pub struct NowPlayingTrack {
468 pub track_id: Option<String>,
471 pub title: String,
472 pub artist: String,
473 pub album: String,
474 pub codec: String,
475 pub sample_rate: u32,
476 pub bit_depth: Option<u16>,
477 pub bitrate_kbps: Option<u32>,
478 pub channels: u16,
479 pub duration_ms: u64,
480}
481
482#[derive(Debug, Clone)]
483pub struct QueueEntry {
484 pub queue_item_id: String,
485 pub track_id: Option<String>,
486 pub title: String,
487 pub artist: String,
488 pub album: String,
489 pub codec: Option<String>,
490 pub track_number: Option<i64>,
491 pub disc: Option<i64>,
492 pub duration_ms: Option<u64>,
493 pub is_current: bool,
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499
500 #[test]
501 fn client_constructs_url() {
502 let c = GraphQLClient::new("http://localhost:4000");
503 assert_eq!(c.url, "http://localhost:4000/graphql");
504 }
505
506 #[test]
507 fn client_trailing_slash() {
508 let c = GraphQLClient::new("http://localhost:4000/");
509 assert_eq!(c.url, "http://localhost:4000/graphql");
510 }
511
512 struct Seen {
514 path: String,
515 bearer: Option<String>,
516 body: String,
517 }
518
519 fn serve(
522 respond: impl Fn(&Seen) -> (u16, &'static str) + Send + 'static,
523 ) -> (String, Arc<Mutex<Vec<String>>>) {
524 use std::io::{BufRead, BufReader, Read, Write};
525
526 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
527 let base = format!("http://{}", listener.local_addr().unwrap());
528 let log = Arc::new(Mutex::new(Vec::new()));
529 let seen_log = log.clone();
530 std::thread::spawn(move || {
531 for stream in listener.incoming() {
532 let Ok(mut stream) = stream else { return };
533 let mut reader = BufReader::new(stream.try_clone().unwrap());
534 let mut line = String::new();
535 reader.read_line(&mut line).unwrap();
536 let path = line.split_whitespace().nth(1).unwrap_or("").to_owned();
537 let (mut bearer, mut length) = (None, 0);
538 loop {
539 line.clear();
540 reader.read_line(&mut line).unwrap();
541 let header = line.trim_end();
542 if header.is_empty() {
543 break;
544 }
545 let (name, value) = header.split_once(": ").unwrap_or((header, ""));
546 match name.to_ascii_lowercase().as_str() {
547 "authorization" => {
548 bearer = value.strip_prefix("Bearer ").map(str::to_owned)
549 }
550 "content-length" => length = value.parse().unwrap_or(0),
551 _ => {}
552 }
553 }
554 let mut body = vec![0; length];
555 reader.read_exact(&mut body).unwrap();
556 let seen = Seen {
557 path,
558 bearer,
559 body: String::from_utf8_lossy(&body).into_owned(),
560 };
561 seen_log.lock().push(seen.path.clone());
562 let (code, reply) = respond(&seen);
563 write!(
564 stream,
565 "HTTP/1.1 {code} X\r\nContent-Type: application/json\r\n\
566 Content-Length: {}\r\nConnection: close\r\n\r\n{reply}",
567 reply.len()
568 )
569 .unwrap();
570 }
571 });
572 (base, log)
573 }
574
575 fn signed_in_server(seen: &Seen) -> (u16, &'static str) {
577 match seen.path.as_str() {
578 "/graphql" if seen.bearer.as_deref() == Some("access-2") => {
579 (200, r#"{"data":{"ok":true}}"#)
580 }
581 "/auth/refresh" if seen.body.contains("refresh-1") => (
582 200,
583 r#"{"access_token":"access-2","refresh_token":"refresh-2"}"#,
584 ),
585 "/auth/refresh" => (401, r#"{"message":"invalid or expired refresh token"}"#),
586 _ => (401, "missing or invalid Authorization header"),
587 }
588 }
589
590 #[test]
591 fn refused_request_refreshes_once_and_retries() {
592 let (base, log) = serve(signed_in_server);
593 let rotated = Arc::new(Mutex::new(Vec::new()));
594 let stored = rotated.clone();
595 let client = GraphQLClient::new(&base)
596 .with_session("refresh-1", move |t| stored.lock().push(t.to_owned()));
597
598 let data = client.execute("{ ok }", None).unwrap();
599 assert_eq!(data["ok"], true);
600 assert_eq!(*rotated.lock(), ["refresh-2"]);
601
602 client.clone().execute("{ ok }", None).unwrap();
604 assert_eq!(
605 *log.lock(),
606 ["/graphql", "/auth/refresh", "/graphql", "/graphql"]
607 );
608 }
609
610 #[test]
611 fn refused_refresh_is_unauthorised() {
612 let (base, log) = serve(signed_in_server);
613 let client = GraphQLClient::new(&base).with_session("revoked", |_| {});
614
615 for _ in 0..2 {
616 match client.execute("{ ok }", None) {
617 Err(GraphQLError::Unauthorized(msg)) => {
618 assert!(msg.contains("invalid or expired refresh token"), "{msg}")
619 }
620 other => panic!("expected Unauthorized, got {other:?}"),
621 }
622 }
623 assert_eq!(*log.lock(), ["/graphql", "/auth/refresh", "/graphql"]);
625 }
626
627 #[test]
628 fn unauthorised_without_a_session_says_why() {
629 let (base, log) = serve(signed_in_server);
630
631 match GraphQLClient::new(&base).execute("{ ok }", None) {
632 Err(GraphQLError::Unauthorized(msg)) => {
633 assert_eq!(msg, "missing or invalid Authorization header")
634 }
635 other => panic!("expected Unauthorized, got {other:?}"),
636 }
637 assert_eq!(*log.lock(), ["/graphql"]);
638 }
639}