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