Skip to main content

koan_server/graphql/
mod.rs

1mod helpers;
2mod mutations;
3mod queries;
4mod server;
5mod subscriptions;
6mod types;
7
8use std::path::PathBuf;
9use std::sync::Arc;
10
11use async_graphql::{Context, Schema};
12use crossbeam_channel::Sender;
13use koan_core::audio::viz::VizSnapshot;
14use koan_core::db::connection::Database;
15use koan_core::player::commands::PlayerCommand;
16use koan_core::player::state::{QueueItemId, SharedPlayerState};
17use uuid::Uuid;
18
19use koan_core::auth::Role;
20use mutations::MutationRoot;
21use queries::QueryRoot;
22pub use server::{
23    ApiServerOpts, cmd_serve, cmd_serve_daemon, execute_in_process, start_api_background,
24};
25use subscriptions::SubscriptionRoot;
26
27use crate::auth::AuthUser;
28
29// ---------------------------------------------------------------------------
30// DB handle wrapper (so we can put it in Context)
31// ---------------------------------------------------------------------------
32
33#[derive(Clone)]
34struct DbHandle {
35    path: PathBuf,
36}
37
38impl DbHandle {
39    fn open(&self) -> async_graphql::Result<Database> {
40        Database::open(&self.path)
41            .map_err(|e| async_graphql::Error::new(format!("db error: {}", e)))
42    }
43}
44
45// ---------------------------------------------------------------------------
46// Schema builder
47// ---------------------------------------------------------------------------
48
49pub type KoanSchema = Schema<QueryRoot, MutationRoot, SubscriptionRoot>;
50
51pub fn build_schema(
52    state: Arc<SharedPlayerState>,
53    cmd_tx: Sender<PlayerCommand>,
54    db_path: PathBuf,
55    viz: Option<Arc<VizSnapshot>>,
56) -> KoanSchema {
57    let mut builder = Schema::build(QueryRoot, MutationRoot, SubscriptionRoot)
58        .data(DbHandle { path: db_path })
59        .data(state)
60        .data(cmd_tx);
61    if let Some(viz) = viz {
62        builder = builder.data(viz);
63    }
64    builder.finish()
65}
66
67// ---------------------------------------------------------------------------
68// Shared helpers used by queries + mutations
69// ---------------------------------------------------------------------------
70
71fn parse_queue_item_id(s: &str) -> async_graphql::Result<QueueItemId> {
72    Uuid::parse_str(s)
73        .map(QueueItemId)
74        .map_err(|e| async_graphql::Error::new(format!("invalid queue item ID '{}': {}", s, e)))
75}
76
77fn send_cmd(ctx: &Context<'_>, cmd: PlayerCommand) -> async_graphql::Result<()> {
78    let tx = ctx.data::<Sender<PlayerCommand>>()?;
79    tx.send(cmd)
80        .map_err(|e| async_graphql::Error::new(format!("send error: {}", e)))
81}
82
83/// Extract the authenticated user from GraphQL context.
84/// Returns anonymous admin if no user is present (auth disabled or in-process).
85fn get_auth_user(ctx: &Context<'_>) -> AuthUser {
86    ctx.data::<AuthUser>()
87        .cloned()
88        .unwrap_or_else(|_| AuthUser::anonymous_admin())
89}
90
91/// Check that the current user has at least the required role.
92/// Returns an error suitable for GraphQL if the check fails.
93fn require_role(ctx: &Context<'_>, required: Role) -> async_graphql::Result<()> {
94    let user = get_auth_user(ctx);
95    if user.role.has_permission(required) {
96        Ok(())
97    } else {
98        Err(async_graphql::Error::new(format!(
99            "forbidden: requires {} role, you have {}",
100            required, user.role
101        )))
102    }
103}
104
105// ---------------------------------------------------------------------------
106// Tests
107// ---------------------------------------------------------------------------
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use koan_core::db::connection::Database;
113    use koan_core::db::queries;
114    use koan_core::player::commands::CommandChannel;
115    use tempfile::TempDir;
116
117    fn test_schema() -> (
118        KoanSchema,
119        crossbeam_channel::Receiver<PlayerCommand>,
120        TempDir,
121    ) {
122        let tmp = TempDir::new().unwrap();
123        let db_path = tmp.path().join("test.db");
124        let db = Database::open(&db_path).unwrap();
125        koan_core::db::schema::create_tables(&db.conn).unwrap();
126
127        let state = SharedPlayerState::new();
128        let ch = CommandChannel::new();
129        let tx = ch.tx.clone();
130        let rx = ch.rx.clone();
131
132        let schema = build_schema(state, tx, db_path, None);
133        (schema, rx, tmp)
134    }
135
136    fn insert_test_track(db_path: &std::path::Path, title: &str, artist: &str, album: &str) -> i64 {
137        let db = Database::open(db_path).unwrap();
138        let meta = queries::TrackMeta {
139            title: title.to_string(),
140            artist: artist.to_string(),
141            album_artist: Some(artist.to_string()),
142            album: album.to_string(),
143            track_number: Some(1),
144            disc: Some(1),
145            date: Some("2024".into()),
146            genre: Some("Electronic".into()),
147            duration_ms: Some(240000),
148            path: Some(format!(
149                "/tmp/test/{}.flac",
150                title.to_lowercase().replace(' ', "_")
151            )),
152            codec: Some("FLAC".into()),
153            sample_rate: Some(44100),
154            bit_depth: Some(16),
155            channels: Some(2),
156            bitrate: Some(1411),
157            size_bytes: Some(42_000_000),
158            mtime: Some(1700000000),
159            source: "local".into(),
160            remote_id: None,
161            remote_url: None,
162            label: None,
163        };
164        queries::upsert_track(&db.conn, &meta).unwrap()
165    }
166
167    #[test]
168    fn schema_builds() {
169        let (_schema, _rx, _tmp) = test_schema();
170    }
171
172    #[tokio::test]
173    async fn library_stats_query() {
174        let (schema, _rx, tmp) = test_schema();
175        let db_path = tmp.path().join("test.db");
176        insert_test_track(&db_path, "Track1", "Artist1", "Album1");
177
178        let resp = schema
179            .execute("{ libraryStats { totalTracks totalAlbums totalArtists } }")
180            .await;
181        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
182        let data = resp.data.into_json().unwrap();
183        assert_eq!(data["libraryStats"]["totalTracks"], 1);
184        assert_eq!(data["libraryStats"]["totalAlbums"], 1);
185        assert_eq!(data["libraryStats"]["totalArtists"], 1);
186    }
187
188    #[tokio::test]
189    async fn artists_query() {
190        let (schema, _rx, tmp) = test_schema();
191        let db_path = tmp.path().join("test.db");
192        insert_test_track(&db_path, "T1", "Aphex Twin", "Drukqs");
193        insert_test_track(&db_path, "T2", "Boards of Canada", "MHTRTC");
194
195        let resp = schema
196            .execute("{ artists { edges { node { id name } } } }")
197            .await;
198        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
199        let data = resp.data.into_json().unwrap();
200        let edges = data["artists"]["edges"].as_array().unwrap();
201        assert_eq!(edges.len(), 2);
202    }
203
204    #[tokio::test]
205    async fn tracks_search() {
206        let (schema, _rx, tmp) = test_schema();
207        let db_path = tmp.path().join("test.db");
208        insert_test_track(&db_path, "Windowlicker", "Aphex Twin", "Windowlicker EP");
209        insert_test_track(&db_path, "Roygbiv", "Boards of Canada", "MHTRTC");
210
211        let resp = schema
212            .execute(r#"{ tracks(search: "Aphex") { edges { node { id title artist } } } }"#)
213            .await;
214        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
215        let data = resp.data.into_json().unwrap();
216        let edges = data["tracks"]["edges"].as_array().unwrap();
217        assert_eq!(edges.len(), 1);
218        assert_eq!(edges[0]["node"]["title"], "Windowlicker");
219    }
220
221    #[tokio::test]
222    async fn now_playing_stopped() {
223        let (schema, _rx, _tmp) = test_schema();
224        let resp = schema
225            .execute("{ nowPlaying { state positionMs track { title } } }")
226            .await;
227        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
228        let data = resp.data.into_json().unwrap();
229        assert_eq!(data["nowPlaying"]["state"], "STOPPED");
230    }
231
232    #[tokio::test]
233    async fn pause_mutation() {
234        let (schema, rx, _tmp) = test_schema();
235        let resp = schema.execute("mutation { pause { ok message } }").await;
236        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
237        let data = resp.data.into_json().unwrap();
238        assert_eq!(data["pause"]["ok"], true);
239        let cmd = rx.try_recv().unwrap();
240        assert!(matches!(cmd, PlayerCommand::Pause));
241    }
242
243    #[tokio::test]
244    async fn nested_artist_albums_tracks() {
245        let (schema, _rx, tmp) = test_schema();
246        let db_path = tmp.path().join("test.db");
247        insert_test_track(&db_path, "Vordhosbn", "Aphex Twin", "Drukqs");
248        insert_test_track(&db_path, "Avril 14th", "Aphex Twin", "Drukqs");
249
250        let resp = schema
251            .execute(
252                r#"{ artists(search: "Aphex") {
253                    edges { node {
254                        name
255                        albums { edges { node {
256                            title
257                            tracks { edges { node { title } } }
258                        } } }
259                    } }
260                } }"#,
261            )
262            .await;
263        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
264        let data = resp.data.into_json().unwrap();
265        let artist = &data["artists"]["edges"][0]["node"];
266        assert_eq!(artist["name"], "Aphex Twin");
267        let album = &artist["albums"]["edges"][0]["node"];
268        assert_eq!(album["title"], "Drukqs");
269        let tracks = album["tracks"]["edges"].as_array().unwrap();
270        assert_eq!(tracks.len(), 2);
271    }
272
273    #[tokio::test]
274    async fn pagination_has_next() {
275        let (schema, _rx, tmp) = test_schema();
276        let db_path = tmp.path().join("test.db");
277        for i in 0..5 {
278            insert_test_track(
279                &db_path,
280                &format!("Track{}", i),
281                "Artist",
282                &format!("Album{}", i),
283            );
284        }
285
286        let resp = schema
287            .execute(
288                r#"{ artists(first: 1) {
289                    edges { node { name } cursor }
290                    pageInfo { hasNextPage endCursor }
291                } }"#,
292            )
293            .await;
294        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
295        let data = resp.data.into_json().unwrap();
296        // Only 1 artist ("Artist"), so hasNextPage should be false
297        // since all 5 tracks are by the same artist.
298        assert_eq!(data["artists"]["edges"].as_array().unwrap().len(), 1);
299    }
300
301    #[tokio::test]
302    async fn clear_queue_mutation() {
303        let (schema, rx, _tmp) = test_schema();
304        let resp = schema
305            .execute("mutation { clearQueue { ok message } }")
306            .await;
307        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
308        let cmd = rx.try_recv().unwrap();
309        assert!(matches!(cmd, PlayerCommand::ClearPlaylist));
310    }
311
312    #[tokio::test]
313    async fn enqueue_mutation_adds_to_queue() {
314        let (schema, rx, tmp) = test_schema();
315        let db_path = tmp.path().join("test.db");
316
317        // Insert a track into the DB.
318        let track_id = insert_test_track(&db_path, "Windowlicker", "Aphex Twin", "Windowlicker EP");
319
320        // Execute the addToQueue mutation.
321        let query = format!(
322            "mutation {{ addToQueue(trackIds: [{}]) {{ ok message addedCount queueItemIds }} }}",
323            track_id
324        );
325        let resp = schema.execute(&query).await;
326        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
327
328        let data = resp.data.into_json().unwrap();
329        assert_eq!(data["addToQueue"]["ok"], true);
330        assert_eq!(data["addToQueue"]["addedCount"], 1);
331
332        let queue_ids = data["addToQueue"]["queueItemIds"].as_array().unwrap();
333        assert_eq!(queue_ids.len(), 1, "should return one queue item ID");
334
335        // Verify the PlayerCommand was sent through the channel.
336        // The mutation sends AddToPlaylist and then Play (auto-play when stopped).
337        let cmd = rx.try_recv().unwrap();
338        match cmd {
339            PlayerCommand::AddToPlaylist(items) => {
340                assert_eq!(items.len(), 1);
341                assert_eq!(items[0].title, "Windowlicker");
342                assert_eq!(items[0].artist, "Aphex Twin");
343                assert_eq!(items[0].album, "Windowlicker EP");
344            }
345            other => panic!("expected AddToPlaylist, got {:?}", other),
346        }
347
348        // Auto-play command should follow.
349        let play_cmd = rx.try_recv().unwrap();
350        assert!(
351            matches!(play_cmd, PlayerCommand::Play(_)),
352            "expected Play command for auto-play, got {:?}",
353            play_cmd
354        );
355    }
356
357    #[tokio::test]
358    async fn replace_queue_mutation_clears_and_enqueues() {
359        let (schema, rx, tmp) = test_schema();
360        let db_path = tmp.path().join("test.db");
361
362        let id1 = insert_test_track(&db_path, "Track A", "Artist", "Album");
363        let id2 = insert_test_track(&db_path, "Track B", "Artist", "Album");
364
365        let query = format!(
366            "mutation {{ replaceQueue(trackIds: [{}, {}]) {{ ok addedCount queueItemIds }} }}",
367            id1, id2
368        );
369        let resp = schema.execute(&query).await;
370        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
371
372        let data = resp.data.into_json().unwrap();
373        assert_eq!(data["replaceQueue"]["addedCount"], 2);
374
375        // Should send: ClearPlaylist, AddToPlaylist, Play
376        let cmd1 = rx.try_recv().unwrap();
377        assert!(
378            matches!(cmd1, PlayerCommand::ClearPlaylist),
379            "first command should be ClearPlaylist"
380        );
381
382        let cmd2 = rx.try_recv().unwrap();
383        match cmd2 {
384            PlayerCommand::AddToPlaylist(items) => {
385                assert_eq!(items.len(), 2);
386            }
387            other => panic!("expected AddToPlaylist, got {:?}", other),
388        }
389
390        let cmd3 = rx.try_recv().unwrap();
391        assert!(
392            matches!(cmd3, PlayerCommand::Play(_)),
393            "third command should be Play"
394        );
395    }
396
397    // ---- Phase 1 tests: queue snapshot, viz, config, playlist version, subscriptions ----
398
399    #[tokio::test]
400    async fn queue_snapshot_has_version_and_status() {
401        let (schema, _rx, _tmp) = test_schema();
402
403        let resp = schema
404            .execute("{ queue { version entries { queueItemId status } hasPlaying queueCount } }")
405            .await;
406        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
407        let data = resp.data.into_json().unwrap();
408        // Empty queue.
409        assert_eq!(data["queue"]["version"], 0);
410        assert_eq!(data["queue"]["entries"].as_array().unwrap().len(), 0);
411        assert_eq!(data["queue"]["hasPlaying"], false);
412        assert_eq!(data["queue"]["queueCount"], 0);
413    }
414
415    #[tokio::test]
416    async fn queue_entries_have_status_and_download_progress() {
417        use koan_core::player::state::{LoadState, PlaylistItem};
418
419        // Build schema with a shared state we can manipulate directly.
420        let tmp = TempDir::new().unwrap();
421        let db_path = tmp.path().join("test.db");
422        let db = Database::open(&db_path).unwrap();
423        koan_core::db::schema::create_tables(&db.conn).unwrap();
424
425        let state = SharedPlayerState::new();
426        let ch = CommandChannel::new();
427        let schema = build_schema(state.clone(), ch.tx.clone(), db_path, None);
428
429        // Directly add items to the playlist (simulating what the player thread does).
430        let item = PlaylistItem {
431            id: QueueItemId::new(),
432            db_id: None,
433            path: std::path::PathBuf::from("/tmp/test/windowlicker.flac"),
434            title: "Windowlicker".to_string(),
435            artist: "Aphex Twin".to_string(),
436            album_artist: "Aphex Twin".to_string(),
437            album: "Windowlicker EP".to_string(),
438            year: None,
439            codec: Some("FLAC".to_string()),
440            track_number: Some(1),
441            disc: Some(1),
442            duration_ms: Some(240000),
443            load_state: LoadState::Ready,
444        };
445        state.add_items(vec![item]);
446
447        // Query the queue — should have one entry with QUEUED status.
448        let resp = schema
449            .execute(
450                "{ queue { version entries { queueItemId title status downloadProgress { downloaded total } isCurrent } finishedCount } }",
451            )
452            .await;
453        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
454        let data = resp.data.into_json().unwrap();
455        let entries = data["queue"]["entries"].as_array().unwrap();
456        assert_eq!(entries.len(), 1);
457        assert_eq!(entries[0]["title"], "Windowlicker");
458        // Without a cursor set, all entries are QUEUED.
459        assert_eq!(entries[0]["status"], "QUEUED");
460        assert_eq!(entries[0]["isCurrent"], false);
461        // Local track — no download progress.
462        assert!(entries[0]["downloadProgress"].is_null());
463    }
464
465    #[tokio::test]
466    async fn viz_frame_returns_none_without_viz() {
467        let (schema, _rx, _tmp) = test_schema();
468
469        let resp = schema
470            .execute("{ vizFrame { spectrum peaks vuLevels beatEnergy } }")
471            .await;
472        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
473        let data = resp.data.into_json().unwrap();
474        assert!(data["vizFrame"].is_null());
475    }
476
477    #[tokio::test]
478    async fn viz_frame_returns_data_with_viz() {
479        // Build schema with a VizSnapshot.
480        let tmp = TempDir::new().unwrap();
481        let db_path = tmp.path().join("test.db");
482        let db = Database::open(&db_path).unwrap();
483        koan_core::db::schema::create_tables(&db.conn).unwrap();
484
485        let state = SharedPlayerState::new();
486        let ch = CommandChannel::new();
487        let viz = koan_core::audio::viz::VizSnapshot::new();
488
489        // Write some test data.
490        let mut spectrum = [0.0f32; 48];
491        spectrum[0] = 0.75;
492        viz.write(koan_core::audio::viz::VizFrame {
493            spectrum,
494            peaks: [0.0; 48],
495            vu_levels: [0.42, 0.38],
496            beat_energy: 0.6,
497            timestamp: std::time::Instant::now(),
498            waveform: Vec::new(),
499        });
500
501        let schema = build_schema(state, ch.tx.clone(), db_path, Some(viz));
502
503        let resp = schema
504            .execute("{ vizFrame { spectrum peaks vuLevels beatEnergy waveform } }")
505            .await;
506        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
507        let data = resp.data.into_json().unwrap();
508        let frame = &data["vizFrame"];
509        assert!(!frame.is_null());
510        let spectrum = frame["spectrum"].as_array().unwrap();
511        assert_eq!(spectrum.len(), 48);
512        assert!((spectrum[0].as_f64().unwrap() - 0.75).abs() < 0.01);
513        let vu = frame["vuLevels"].as_array().unwrap();
514        assert_eq!(vu.len(), 2);
515        assert!((vu[0].as_f64().unwrap() - 0.42).abs() < 0.01);
516        assert!((frame["beatEnergy"].as_f64().unwrap() - 0.6).abs() < 0.01);
517        // Waveform empty — we didn't request includeWaveform.
518        let waveform = frame["waveform"].as_array().unwrap();
519        assert!(waveform.is_empty());
520    }
521
522    #[tokio::test]
523    async fn config_query() {
524        let (schema, _rx, _tmp) = test_schema();
525
526        let resp = schema
527            .execute(
528                "{ config { libraryFolders replaygainMode targetFps artSize remoteEnabled graphqlPort } }",
529            )
530            .await;
531        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
532        let data = resp.data.into_json().unwrap();
533        let cfg = &data["config"];
534        // Defaults from Config::default().
535        assert!(cfg["libraryFolders"].is_array());
536        assert!(cfg["targetFps"].as_i64().unwrap() > 0);
537        assert!(cfg["artSize"].as_i64().unwrap() > 0);
538    }
539
540    #[tokio::test]
541    async fn playlist_version_query() {
542        let (schema, _rx, _tmp) = test_schema();
543
544        let resp = schema.execute("{ playlistVersion }").await;
545        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
546        let data = resp.data.into_json().unwrap();
547        assert_eq!(data["playlistVersion"], 0);
548    }
549
550    #[tokio::test]
551    async fn subscription_types_in_schema() {
552        // Verify that subscriptions are registered by introspecting the schema.
553        let (schema, _rx, _tmp) = test_schema();
554
555        let resp = schema
556            .execute("{ __schema { subscriptionType { fields { name } } } }")
557            .await;
558        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
559        let data = resp.data.into_json().unwrap();
560        let fields = data["__schema"]["subscriptionType"]["fields"]
561            .as_array()
562            .unwrap();
563        let names: Vec<&str> = fields.iter().filter_map(|f| f["name"].as_str()).collect();
564        assert!(
565            names.contains(&"nowPlaying"),
566            "missing nowPlaying subscription"
567        );
568        assert!(
569            names.contains(&"queueUpdated"),
570            "missing queueUpdated subscription"
571        );
572        assert!(names.contains(&"vizFrame"), "missing vizFrame subscription");
573    }
574}