Skip to main content

koan_core/
graphql_client.rs

1//! Lightweight GraphQL client for connecting to a `koan serve` instance.
2//!
3//! Uses blocking reqwest — call from a background thread when used from the TUI.
4
5use serde_json::Value;
6
7/// A GraphQL client that talks to a koan server.
8#[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    /// Execute a raw GraphQL query/mutation.
27    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    /// Get the stream URL for a track (for audio playback).
57    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    // -----------------------------------------------------------------------
63    // Typed helpers
64    // -----------------------------------------------------------------------
65
66    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    // -- Mutations --
190
191    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    /// Server URL (without /graphql path).
317    pub fn server_url(&self) -> &str {
318        self.url.trim_end_matches("/graphql")
319    }
320}
321
322// ---------------------------------------------------------------------------
323// Result types
324// ---------------------------------------------------------------------------
325
326#[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
407// ---------------------------------------------------------------------------
408// Parse helpers
409// ---------------------------------------------------------------------------
410
411fn 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}