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 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    // -- Mutations --
189
190    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    /// Server URL (without /graphql path).
316    pub fn server_url(&self) -> &str {
317        self.url.trim_end_matches("/graphql")
318    }
319}
320
321// ---------------------------------------------------------------------------
322// Result types
323// ---------------------------------------------------------------------------
324
325#[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
405// ---------------------------------------------------------------------------
406// Parse helpers
407// ---------------------------------------------------------------------------
408
409fn 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}