1use serde_json::Value;
6
7#[derive(Debug, Clone)]
9pub struct GraphQLClient {
10 url: String,
11 http: reqwest::blocking::Client,
12}
13
14impl GraphQLClient {
15 pub fn new(server_url: &str) -> Self {
16 let url = format!("{}/graphql", server_url.trim_end_matches('/'));
17 Self {
18 url,
19 http: reqwest::blocking::Client::builder()
20 .timeout(std::time::Duration::from_secs(30))
21 .build()
22 .expect("failed to build HTTP client"),
23 }
24 }
25
26 pub fn execute(&self, query: &str, variables: Option<Value>) -> Result<Value, GraphQLError> {
28 let mut body = serde_json::json!({ "query": query });
29 if let Some(vars) = variables {
30 body["variables"] = vars;
31 }
32
33 let resp: Value = self
34 .http
35 .post(&self.url)
36 .json(&body)
37 .send()
38 .map_err(|e| GraphQLError::Http(e.to_string()))?
39 .json()
40 .map_err(|e| GraphQLError::Http(e.to_string()))?;
41
42 if let Some(errors) = resp.get("errors")
43 && let Some(arr) = errors.as_array()
44 && !arr.is_empty()
45 {
46 let msg = arr[0]
47 .get("message")
48 .and_then(|m| m.as_str())
49 .unwrap_or("unknown error");
50 return Err(GraphQLError::Query(msg.to_string()));
51 }
52
53 Ok(resp.get("data").cloned().unwrap_or(Value::Null))
54 }
55
56 pub fn stream_url(&self, track_id: i64) -> String {
58 let base = self.url.trim_end_matches("/graphql");
59 format!("{}/rest/stream?id={}", base, track_id)
60 }
61
62 pub fn now_playing(&self) -> Result<NowPlaying, GraphQLError> {
67 let data = self.execute(
68 "{ nowPlaying { state positionMs durationMs queueItemId \
69 track { title artist album codec sampleRate bitDepth bitrateKbps channels durationMs } } }",
70 None,
71 )?;
72 let np = &data["nowPlaying"];
73 Ok(NowPlaying {
74 state: np["state"].as_str().unwrap_or("STOPPED").to_string(),
75 position_ms: np["positionMs"].as_u64().unwrap_or(0),
76 duration_ms: np["durationMs"].as_u64(),
77 queue_item_id: np["queueItemId"].as_str().map(String::from),
78 track: np.get("track").and_then(|t| {
79 if t.is_null() {
80 return None;
81 }
82 Some(NowPlayingTrack {
83 title: t["title"].as_str().unwrap_or("").to_string(),
84 artist: t["artist"].as_str().unwrap_or("").to_string(),
85 album: t["album"].as_str().unwrap_or("").to_string(),
86 codec: t["codec"].as_str().unwrap_or("").to_string(),
87 sample_rate: t["sampleRate"].as_u64().unwrap_or(0) as u32,
88 bit_depth: t["bitDepth"].as_u64().map(|v| v as u16),
89 bitrate_kbps: t["bitrateKbps"].as_u64().map(|v| v as u32),
90 channels: t["channels"].as_u64().unwrap_or(0) as u16,
91 duration_ms: t["durationMs"].as_u64().unwrap_or(0),
92 })
93 }),
94 })
95 }
96
97 pub fn queue(&self) -> Result<Vec<QueueEntry>, GraphQLError> {
98 let data = self.execute(
99 "{ queue { queueItemId title artist album codec trackNumber disc durationMs isCurrent } }",
100 None,
101 )?;
102 let entries = data["queue"]
103 .as_array()
104 .map(|arr| {
105 arr.iter()
106 .map(|e| QueueEntry {
107 queue_item_id: e["queueItemId"].as_str().unwrap_or("").to_string(),
108 title: e["title"].as_str().unwrap_or("").to_string(),
109 artist: e["artist"].as_str().unwrap_or("").to_string(),
110 album: e["album"].as_str().unwrap_or("").to_string(),
111 codec: e["codec"].as_str().map(String::from),
112 track_number: e["trackNumber"].as_i64(),
113 disc: e["disc"].as_i64(),
114 duration_ms: e["durationMs"].as_u64(),
115 is_current: e["isCurrent"].as_bool().unwrap_or(false),
116 })
117 .collect()
118 })
119 .unwrap_or_default();
120 Ok(entries)
121 }
122
123 pub fn search(&self, query: &str, limit: u32) -> Result<Vec<TrackResult>, GraphQLError> {
124 let data = self.execute(
125 "query($search: String!, $first: Int) { tracks(search: $search, first: $first) { edges { node { id title artist album albumId artistId disc trackNumber durationMs codec genre source } } } }",
126 Some(serde_json::json!({ "search": query, "first": limit })),
127 )?;
128 parse_track_edges(&data["tracks"])
129 }
130
131 pub fn artists(&self) -> Result<Vec<ArtistResult>, GraphQLError> {
132 let data = self.execute("{ artists { edges { node { id name } } } }", None)?;
133 let edges = data["artists"]["edges"].as_array();
134 Ok(edges
135 .map(|arr| {
136 arr.iter()
137 .map(|e| {
138 let n = &e["node"];
139 ArtistResult {
140 id: n["id"].as_i64().unwrap_or(0),
141 name: n["name"].as_str().unwrap_or("").to_string(),
142 }
143 })
144 .collect()
145 })
146 .unwrap_or_default())
147 }
148
149 pub fn albums_for_artist(&self, artist_id: i64) -> Result<Vec<AlbumResult>, GraphQLError> {
150 let data = self.execute(
151 "query($artistId: Int!) { albums(artistId: $artistId) { edges { node { id title artistName date codec } } } }",
152 Some(serde_json::json!({ "artistId": artist_id })),
153 )?;
154 parse_album_edges(&data["albums"])
155 }
156
157 pub fn tracks_for_album(&self, album_id: i64) -> Result<Vec<TrackResult>, GraphQLError> {
158 let data = self.execute(
159 "query($albumId: Int!) { tracks(albumId: $albumId) { edges { node { id title artist album albumId artistId disc trackNumber durationMs codec genre source } } } }",
160 Some(serde_json::json!({ "albumId": album_id })),
161 )?;
162 parse_track_edges(&data["tracks"])
163 }
164
165 pub fn fuzzy_search(
166 &self,
167 query: &str,
168 kind: &str,
169 limit: u32,
170 ) -> Result<Vec<FuzzyMatch>, GraphQLError> {
171 let data = self.execute(
172 "query($query: String!, $kind: FuzzySearchKind!, $limit: Int) { fuzzySearch(query: $query, kind: $kind, limit: $limit) { id name rank kind } }",
173 Some(serde_json::json!({ "query": query, "kind": kind, "limit": limit })),
174 )?;
175 Ok(data["fuzzySearch"]
176 .as_array()
177 .map(|arr| {
178 arr.iter()
179 .map(|e| FuzzyMatch {
180 id: e["id"].as_i64().unwrap_or(0),
181 name: e["name"].as_str().unwrap_or("").to_string(),
182 rank: e["rank"].as_i64().unwrap_or(0) as i32,
183 })
184 .collect()
185 })
186 .unwrap_or_default())
187 }
188
189 pub fn pause(&self) -> Result<(), GraphQLError> {
192 self.execute("mutation { pause { ok } }", None)?;
193 Ok(())
194 }
195
196 pub fn resume(&self) -> Result<(), GraphQLError> {
197 self.execute("mutation { resume { ok } }", None)?;
198 Ok(())
199 }
200
201 pub fn stop(&self) -> Result<(), GraphQLError> {
202 self.execute("mutation { stop { ok } }", None)?;
203 Ok(())
204 }
205
206 pub fn next(&self) -> Result<(), GraphQLError> {
207 self.execute("mutation { next { ok } }", None)?;
208 Ok(())
209 }
210
211 pub fn previous(&self) -> Result<(), GraphQLError> {
212 self.execute("mutation { previous { ok } }", None)?;
213 Ok(())
214 }
215
216 pub fn seek(&self, position_ms: u64) -> Result<(), GraphQLError> {
217 self.execute(
218 "mutation($positionMs: Int!) { seek(positionMs: $positionMs) { ok } }",
219 Some(serde_json::json!({ "positionMs": position_ms })),
220 )?;
221 Ok(())
222 }
223
224 pub fn play(&self, queue_item_id: &str) -> Result<(), GraphQLError> {
225 self.execute(
226 "mutation($queueItemId: String!) { play(queueItemId: $queueItemId) { ok } }",
227 Some(serde_json::json!({ "queueItemId": queue_item_id })),
228 )?;
229 Ok(())
230 }
231
232 pub fn add_to_queue(&self, track_ids: &[i64]) -> Result<Vec<String>, GraphQLError> {
233 let data = self.execute(
234 "mutation($trackIds: [Int!]!) { addToQueue(trackIds: $trackIds) { ok addedCount queueItemIds } }",
235 Some(serde_json::json!({ "trackIds": track_ids })),
236 )?;
237 Ok(data["addToQueue"]["queueItemIds"]
238 .as_array()
239 .map(|arr| {
240 arr.iter()
241 .filter_map(|v| v.as_str().map(String::from))
242 .collect()
243 })
244 .unwrap_or_default())
245 }
246
247 pub fn replace_queue(&self, track_ids: &[i64]) -> Result<Vec<String>, GraphQLError> {
248 let data = self.execute(
249 "mutation($trackIds: [Int!]!) { replaceQueue(trackIds: $trackIds) { ok addedCount queueItemIds } }",
250 Some(serde_json::json!({ "trackIds": track_ids })),
251 )?;
252 Ok(data["replaceQueue"]["queueItemIds"]
253 .as_array()
254 .map(|arr| {
255 arr.iter()
256 .filter_map(|v| v.as_str().map(String::from))
257 .collect()
258 })
259 .unwrap_or_default())
260 }
261
262 pub fn clear_queue(&self) -> Result<(), GraphQLError> {
263 self.execute("mutation { clearQueue { ok } }", None)?;
264 Ok(())
265 }
266
267 pub fn favourite(&self, track_id: i64) -> Result<(), GraphQLError> {
268 self.execute(
269 "mutation($trackId: Int!) { favourite(trackId: $trackId) { id } }",
270 Some(serde_json::json!({ "trackId": track_id })),
271 )?;
272 Ok(())
273 }
274
275 pub fn unfavourite(&self, track_id: i64) -> Result<(), GraphQLError> {
276 self.execute(
277 "mutation($trackId: Int!) { unfavourite(trackId: $trackId) { id } }",
278 Some(serde_json::json!({ "trackId": track_id })),
279 )?;
280 Ok(())
281 }
282
283 pub fn save_snapshot(&self, name: &str) -> Result<(), GraphQLError> {
284 self.execute(
285 "mutation($name: String!) { saveSnapshot(name: $name) { ok } }",
286 Some(serde_json::json!({ "name": name })),
287 )?;
288 Ok(())
289 }
290
291 pub fn restore_snapshot(&self, name: &str) -> Result<(), GraphQLError> {
292 self.execute(
293 "mutation($name: String!) { restoreSnapshot(name: $name) { ok } }",
294 Some(serde_json::json!({ "name": name })),
295 )?;
296 Ok(())
297 }
298
299 pub fn enable_radio(&self) -> Result<(), GraphQLError> {
300 self.execute("mutation { enableRadio { ok } }", None)?;
301 Ok(())
302 }
303
304 pub fn disable_radio(&self) -> Result<(), GraphQLError> {
305 self.execute("mutation { disableRadio { ok } }", None)?;
306 Ok(())
307 }
308
309 pub fn library_stats(&self) -> Result<Value, GraphQLError> {
310 self.execute(
311 "{ libraryStats { totalTracks totalArtists totalAlbums localTracks remoteTracks cachedTracks } }",
312 None,
313 )
314 }
315
316 pub fn server_url(&self) -> &str {
318 self.url.trim_end_matches("/graphql")
319 }
320}
321
322#[derive(Debug, thiserror::Error)]
327pub enum GraphQLError {
328 #[error("http error: {0}")]
329 Http(String),
330 #[error("query error: {0}")]
331 Query(String),
332}
333
334#[derive(Debug, Clone)]
335pub struct NowPlaying {
336 pub state: String,
337 pub position_ms: u64,
338 pub duration_ms: Option<u64>,
339 pub queue_item_id: Option<String>,
340 pub track: Option<NowPlayingTrack>,
341}
342
343#[derive(Debug, Clone)]
344pub struct NowPlayingTrack {
345 pub title: String,
346 pub artist: String,
347 pub album: String,
348 pub codec: String,
349 pub sample_rate: u32,
350 pub bit_depth: Option<u16>,
351 pub bitrate_kbps: Option<u32>,
352 pub channels: u16,
353 pub duration_ms: u64,
354}
355
356#[derive(Debug, Clone)]
357pub struct QueueEntry {
358 pub queue_item_id: String,
359 pub title: String,
360 pub artist: String,
361 pub album: String,
362 pub codec: Option<String>,
363 pub track_number: Option<i64>,
364 pub disc: Option<i64>,
365 pub duration_ms: Option<u64>,
366 pub is_current: bool,
367}
368
369#[derive(Debug, Clone)]
370pub struct TrackResult {
371 pub id: i64,
372 pub title: String,
373 pub artist: String,
374 pub album: String,
375 pub album_id: Option<i64>,
376 pub artist_id: Option<i64>,
377 pub disc: Option<i32>,
378 pub track_number: Option<i32>,
379 pub duration_ms: Option<i64>,
380 pub codec: Option<String>,
381 pub genre: Option<String>,
382 pub source: String,
383}
384
385#[derive(Debug, Clone)]
386pub struct ArtistResult {
387 pub id: i64,
388 pub name: String,
389}
390
391#[derive(Debug, Clone)]
392pub struct AlbumResult {
393 pub id: i64,
394 pub title: String,
395 pub artist_name: String,
396 pub date: Option<String>,
397 pub codec: Option<String>,
398}
399
400#[derive(Debug, Clone)]
401pub struct FuzzyMatch {
402 pub id: i64,
403 pub name: String,
404 pub rank: i32,
405}
406
407fn parse_track_edges(connection: &Value) -> Result<Vec<TrackResult>, GraphQLError> {
412 Ok(connection["edges"]
413 .as_array()
414 .map(|arr| {
415 arr.iter()
416 .map(|e| {
417 let n = &e["node"];
418 TrackResult {
419 id: n["id"].as_i64().unwrap_or(0),
420 title: n["title"].as_str().unwrap_or("").to_string(),
421 artist: n["artist"].as_str().unwrap_or("").to_string(),
422 album: n["album"].as_str().unwrap_or("").to_string(),
423 album_id: n["albumId"].as_i64(),
424 artist_id: n["artistId"].as_i64(),
425 disc: n["disc"].as_i64().map(|v| v as i32),
426 track_number: n["trackNumber"].as_i64().map(|v| v as i32),
427 duration_ms: n["durationMs"].as_i64(),
428 codec: n["codec"].as_str().map(String::from),
429 genre: n["genre"].as_str().map(String::from),
430 source: n["source"].as_str().unwrap_or("local").to_string(),
431 }
432 })
433 .collect()
434 })
435 .unwrap_or_default())
436}
437
438fn parse_album_edges(connection: &Value) -> Result<Vec<AlbumResult>, GraphQLError> {
439 Ok(connection["edges"]
440 .as_array()
441 .map(|arr| {
442 arr.iter()
443 .map(|e| {
444 let n = &e["node"];
445 AlbumResult {
446 id: n["id"].as_i64().unwrap_or(0),
447 title: n["title"].as_str().unwrap_or("").to_string(),
448 artist_name: n["artistName"].as_str().unwrap_or("").to_string(),
449 date: n["date"].as_str().map(String::from),
450 codec: n["codec"].as_str().map(String::from),
451 }
452 })
453 .collect()
454 })
455 .unwrap_or_default())
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 #[test]
463 fn client_constructs_url() {
464 let c = GraphQLClient::new("http://localhost:4000");
465 assert_eq!(c.url, "http://localhost:4000/graphql");
466 }
467
468 #[test]
469 fn client_trailing_slash() {
470 let c = GraphQLClient::new("http://localhost:4000/");
471 assert_eq!(c.url, "http://localhost:4000/graphql");
472 }
473
474 #[test]
475 fn stream_url_format() {
476 let c = GraphQLClient::new("http://localhost:4000");
477 assert_eq!(c.stream_url(42), "http://localhost:4000/rest/stream?id=42");
478 }
479}