1mod helpers;
2mod jobs;
3mod loaders;
4mod mutations;
5mod queries;
6mod server;
7mod subscriptions;
8mod types;
9
10use std::ops::Deref;
11use std::path::PathBuf;
12use std::sync::Arc;
13use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
14use std::time::Duration;
15
16use async_graphql::dataloader::DataLoader;
17use async_graphql::{Context, Schema};
18use crossbeam_channel::{Sender, TrySendError};
19use koan_core::audio::viz::VizSnapshot;
20use koan_core::db::connection::Database;
21use koan_core::player::commands::PlayerCommand;
22use koan_core::player::state::{QueueItemId, SharedPlayerState};
23use uuid::Uuid;
24
25use koan_core::auth::Role;
26use loaders::DbLoader;
27use mutations::MutationRoot;
28use queries::QueryRoot;
29pub use server::{
30 ApiServerOpts, cmd_serve, cmd_serve_daemon, execute_in_process, start_api_background,
31};
32use subscriptions::SubscriptionRoot;
33
34use crate::auth::AuthUser;
35
36const BATCH_WINDOW: Duration = Duration::from_millis(10);
45
46const ACQUIRE_TIMEOUT: Duration = Duration::from_secs(10);
50
51struct DbPool {
59 path: PathBuf,
60 idle_tx: Sender<Database>,
63 idle_rx: crossbeam_channel::Receiver<Database>,
64 live: AtomicUsize,
66 opens: AtomicUsize,
69 batches: AtomicUsize,
72 initialised: AtomicBool,
74 max: usize,
75}
76
77impl DbPool {
78 fn new(path: PathBuf) -> Arc<Self> {
79 let max = std::thread::available_parallelism()
81 .map(|n| n.get())
82 .unwrap_or(4)
83 .clamp(4, 16);
84 let (idle_tx, idle_rx) = crossbeam_channel::bounded(max);
85 Arc::new(Self {
86 path,
87 idle_tx,
88 idle_rx,
89 live: AtomicUsize::new(0),
90 opens: AtomicUsize::new(0),
91 batches: AtomicUsize::new(0),
92 initialised: AtomicBool::new(false),
93 max,
94 })
95 }
96
97 fn open_new(&self) -> Result<Database, koan_core::db::connection::DbError> {
99 self.opens.fetch_add(1, Ordering::Relaxed);
100 if self.initialised.load(Ordering::Acquire) {
101 Database::open_existing(&self.path)
102 } else {
103 let db = Database::open(&self.path)?;
104 self.initialised.store(true, Ordering::Release);
105 Ok(db)
106 }
107 }
108
109 fn acquire(self: &Arc<Self>) -> async_graphql::Result<PooledDb> {
110 if let Ok(db) = self.idle_rx.try_recv() {
111 return Ok(PooledDb {
112 db: Some(db),
113 pool: self.clone(),
114 });
115 }
116
117 let mut live = self.live.load(Ordering::Relaxed);
118 while live < self.max {
119 match self.live.compare_exchange_weak(
120 live,
121 live + 1,
122 Ordering::AcqRel,
123 Ordering::Relaxed,
124 ) {
125 Ok(_) => {
126 return match self.open_new() {
127 Ok(db) => Ok(PooledDb {
128 db: Some(db),
129 pool: self.clone(),
130 }),
131 Err(e) => {
132 self.live.fetch_sub(1, Ordering::AcqRel);
133 Err(internal_error("db open", e))
134 }
135 };
136 }
137 Err(actual) => live = actual,
138 }
139 }
140
141 self.idle_rx
142 .recv_timeout(ACQUIRE_TIMEOUT)
143 .map(|db| PooledDb {
144 db: Some(db),
145 pool: self.clone(),
146 })
147 .map_err(|_| async_graphql::Error::new("database busy"))
148 }
149}
150
151struct PooledDb {
153 db: Option<Database>,
154 pool: Arc<DbPool>,
155}
156
157impl Deref for PooledDb {
158 type Target = Database;
159
160 fn deref(&self) -> &Database {
161 self.db.as_ref().expect("connection taken before drop")
162 }
163}
164
165impl Drop for PooledDb {
166 fn drop(&mut self) {
167 if let Some(db) = self.db.take()
168 && let Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) =
169 self.pool.idle_tx.try_send(db)
170 {
171 self.pool.live.fetch_sub(1, Ordering::AcqRel);
172 }
173 }
174}
175
176#[derive(Clone)]
181struct DbHandle {
182 pool: Arc<DbPool>,
183}
184
185impl DbHandle {
186 fn new(path: PathBuf) -> Self {
187 Self {
188 pool: DbPool::new(path),
189 }
190 }
191
192 fn acquire(&self) -> async_graphql::Result<PooledDb> {
193 self.pool.acquire()
194 }
195
196 fn open_detached(&self) -> Result<Database, koan_core::db::connection::DbError> {
199 self.pool.open_new()
200 }
201
202 fn note_batch(&self) {
203 self.pool.batches.fetch_add(1, Ordering::Relaxed);
204 }
205
206 #[cfg_attr(not(test), allow(dead_code))]
208 fn open_count(&self) -> usize {
209 self.pool.opens.load(Ordering::Relaxed)
210 }
211
212 #[cfg_attr(not(test), allow(dead_code))]
214 fn batch_count(&self) -> usize {
215 self.pool.batches.load(Ordering::Relaxed)
216 }
217}
218
219async fn with_db<T, F>(ctx: &Context<'_>, f: F) -> async_graphql::Result<T>
225where
226 F: FnOnce(&Database) -> async_graphql::Result<T> + Send + 'static,
227 T: Send + 'static,
228{
229 let handle = ctx.data::<DbHandle>()?.clone();
230 blocking(move || {
231 let db = handle.acquire()?;
232 f(&db)
233 })
234 .await
235}
236
237async fn blocking<T, F>(f: F) -> async_graphql::Result<T>
240where
241 F: FnOnce() -> async_graphql::Result<T> + Send + 'static,
242 T: Send + 'static,
243{
244 tokio::task::spawn_blocking(f)
245 .await
246 .map_err(|e| internal_error("blocking task", e))?
247}
248
249pub(super) fn internal_error(context: &str, e: impl std::fmt::Display) -> async_graphql::Error {
254 log::error!("graphql {}: {}", context, e);
255 async_graphql::Error::new("internal error")
256}
257
258pub type KoanSchema = Schema<QueryRoot, MutationRoot, SubscriptionRoot>;
263
264pub fn build_schema(
265 state: Arc<SharedPlayerState>,
266 cmd_tx: Sender<PlayerCommand>,
267 db_path: PathBuf,
268 viz: Option<Arc<VizSnapshot>>,
269) -> KoanSchema {
270 build_schema_with(DbHandle::new(db_path), state, cmd_tx, viz)
271}
272
273fn build_schema_with(
274 handle: DbHandle,
275 state: Arc<SharedPlayerState>,
276 cmd_tx: Sender<PlayerCommand>,
277 viz: Option<Arc<VizSnapshot>>,
278) -> KoanSchema {
279 let loader = DataLoader::new(DbLoader::new(handle.clone()), tokio::spawn).delay(BATCH_WINDOW);
282 loader.enable_all_cache(false);
283
284 let mut builder = Schema::build(QueryRoot, MutationRoot, SubscriptionRoot)
285 .data(handle)
286 .data(loader)
287 .data(jobs::JobRegistry::default())
288 .data(state)
289 .data(cmd_tx);
290 if let Some(viz) = viz {
291 builder = builder.data(viz);
292 }
293 builder.limit_depth(12).limit_complexity(2000).finish()
296}
297
298fn parse_queue_item_id(s: &str) -> async_graphql::Result<QueueItemId> {
303 Uuid::parse_str(s)
304 .map(QueueItemId)
305 .map_err(|e| async_graphql::Error::new(format!("invalid queue item ID '{}': {}", s, e)))
306}
307
308const CMD_SEND_TIMEOUT: Duration = Duration::from_millis(250);
315
316fn send_cmd(ctx: &Context<'_>, cmd: PlayerCommand) -> async_graphql::Result<()> {
317 let tx = ctx.data::<Sender<PlayerCommand>>()?;
318 send_cmd_via(tx, cmd)
319}
320
321fn send_cmd_via(tx: &Sender<PlayerCommand>, cmd: PlayerCommand) -> async_graphql::Result<()> {
322 tx.send_timeout(cmd, CMD_SEND_TIMEOUT)
323 .map_err(|_| async_graphql::Error::new("player busy — command not accepted"))
324}
325
326fn get_auth_user(ctx: &Context<'_>) -> AuthUser {
329 ctx.data::<AuthUser>()
330 .cloned()
331 .unwrap_or_else(|_| AuthUser::anonymous_admin())
332}
333
334fn require_role(ctx: &Context<'_>, required: Role) -> async_graphql::Result<()> {
337 let user = get_auth_user(ctx);
338 if user.role.has_permission(required) {
339 Ok(())
340 } else {
341 Err(async_graphql::Error::new(format!(
342 "forbidden: requires {} role, you have {}",
343 required, user.role
344 )))
345 }
346}
347
348#[cfg(test)]
353mod tests {
354 use super::*;
355 use koan_core::db::connection::Database;
356 use koan_core::db::queries;
357 use koan_core::player::commands::CommandChannel;
358 use tempfile::TempDir;
359
360 fn isolate_config() {
367 static DIR: std::sync::OnceLock<TempDir> = std::sync::OnceLock::new();
368 koan_core::config::set_config_dir(DIR.get_or_init(|| TempDir::new().unwrap()).path());
369 }
370
371 fn test_schema() -> (
372 KoanSchema,
373 crossbeam_channel::Receiver<PlayerCommand>,
374 TempDir,
375 ) {
376 isolate_config();
377 let tmp = TempDir::new().unwrap();
378 let db_path = tmp.path().join("test.db");
379 let db = Database::open(&db_path).unwrap();
380 koan_core::db::schema::create_tables(&db.conn).unwrap();
381
382 let state = SharedPlayerState::new();
383 let ch = CommandChannel::new();
384 let tx = ch.tx.clone();
385 let rx = ch.rx.clone();
386
387 let schema = build_schema(state, tx, db_path, None);
388 (schema, rx, tmp)
389 }
390
391 fn instrumented_schema() -> (
393 KoanSchema,
394 DbHandle,
395 crossbeam_channel::Receiver<PlayerCommand>,
396 TempDir,
397 ) {
398 let tmp = TempDir::new().unwrap();
399 let db_path = tmp.path().join("test.db");
400 let db = Database::open(&db_path).unwrap();
401 koan_core::db::schema::create_tables(&db.conn).unwrap();
402
403 let state = SharedPlayerState::new();
404 let ch = CommandChannel::new();
405 let handle = DbHandle::new(db_path);
406 let schema = build_schema_with(handle.clone(), state, ch.tx.clone(), None);
407 (schema, handle, ch.rx.clone(), tmp)
408 }
409
410 fn seed_library(db_path: &std::path::Path, artists: usize, albums: usize, tracks: usize) {
412 let db = Database::open(db_path).unwrap();
413 for a in 0..artists {
414 for al in 0..albums {
415 for t in 0..tracks {
416 let meta = queries::TrackMeta {
417 title: format!("Track {:03}-{}-{:03}", a, al, t),
418 artist: format!("Artist {:03}", a),
419 album_artist: Some(format!("Artist {:03}", a)),
420 album: format!("Album {:03}-{}", a, al),
421 track_number: Some(t as i32),
422 disc: Some(1),
423 date: Some("2024".into()),
424 genre: Some("Electronic".into()),
425 duration_ms: Some(240_000),
426 path: Some(format!("/tmp/koan-test/{}/{}/{}.flac", a, al, t)),
427 codec: Some("FLAC".into()),
428 sample_rate: Some(44100),
429 bit_depth: Some(16),
430 channels: Some(2),
431 bitrate: Some(1411),
432 size_bytes: Some(42_000_000),
433 mtime: Some(1700000000),
434 source: "local".into(),
435 remote_id: None,
436 remote_url: None,
437 album_remote_id: None,
438 artist_remote_id: None,
439 mbid: None,
440 album_added_at: None,
441 label: None,
442 };
443 queries::upsert_track(&db.conn, &meta).unwrap();
444 }
445 }
446 }
447 }
448
449 fn insert_test_track(db_path: &std::path::Path, title: &str, artist: &str, album: &str) -> i64 {
450 let db = Database::open(db_path).unwrap();
451 let meta = queries::TrackMeta {
452 title: title.to_string(),
453 artist: artist.to_string(),
454 album_artist: Some(artist.to_string()),
455 album: album.to_string(),
456 track_number: Some(1),
457 disc: Some(1),
458 date: Some("2024".into()),
459 genre: Some("Electronic".into()),
460 duration_ms: Some(240000),
461 path: Some(format!(
462 "/tmp/test/{}.flac",
463 title.to_lowercase().replace(' ', "_")
464 )),
465 codec: Some("FLAC".into()),
466 sample_rate: Some(44100),
467 bit_depth: Some(16),
468 channels: Some(2),
469 bitrate: Some(1411),
470 size_bytes: Some(42_000_000),
471 mtime: Some(1700000000),
472 source: "local".into(),
473 remote_id: None,
474 remote_url: None,
475 album_remote_id: None,
476 artist_remote_id: None,
477 mbid: None,
478 album_added_at: None,
479 label: None,
480 };
481 queries::upsert_track(&db.conn, &meta).unwrap()
482 }
483
484 #[test]
485 fn schema_builds() {
486 let (_schema, _rx, _tmp) = test_schema();
487 }
488
489 #[tokio::test]
490 async fn library_stats_query() {
491 let (schema, _rx, tmp) = test_schema();
492 let db_path = tmp.path().join("test.db");
493 insert_test_track(&db_path, "Track1", "Artist1", "Album1");
494
495 let resp = schema
496 .execute("{ libraryStats { totalTracks totalAlbums totalArtists } }")
497 .await;
498 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
499 let data = resp.data.into_json().unwrap();
500 assert_eq!(data["libraryStats"]["totalTracks"], 1);
501 assert_eq!(data["libraryStats"]["totalAlbums"], 1);
502 assert_eq!(data["libraryStats"]["totalArtists"], 1);
503 }
504
505 #[tokio::test]
506 async fn artists_query() {
507 let (schema, _rx, tmp) = test_schema();
508 let db_path = tmp.path().join("test.db");
509 insert_test_track(&db_path, "T1", "Aphex Twin", "Drukqs");
510 insert_test_track(&db_path, "T2", "Boards of Canada", "MHTRTC");
511
512 let resp = schema
513 .execute("{ artists { edges { node { id name } } } }")
514 .await;
515 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
516 let data = resp.data.into_json().unwrap();
517 let edges = data["artists"]["edges"].as_array().unwrap();
518 assert_eq!(edges.len(), 2);
519 }
520
521 #[tokio::test]
522 async fn tracks_search() {
523 let (schema, _rx, tmp) = test_schema();
524 let db_path = tmp.path().join("test.db");
525 insert_test_track(&db_path, "Windowlicker", "Aphex Twin", "Windowlicker EP");
526 insert_test_track(&db_path, "Roygbiv", "Boards of Canada", "MHTRTC");
527
528 let resp = schema
529 .execute(r#"{ tracks(search: "Aphex") { edges { node { id title artist } } } }"#)
530 .await;
531 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
532 let data = resp.data.into_json().unwrap();
533 let edges = data["tracks"]["edges"].as_array().unwrap();
534 assert_eq!(edges.len(), 1);
535 assert_eq!(edges[0]["node"]["title"], "Windowlicker");
536 }
537
538 #[tokio::test]
539 async fn now_playing_stopped() {
540 let (schema, _rx, _tmp) = test_schema();
541 let resp = schema
542 .execute("{ nowPlaying { state positionMs track { title } } }")
543 .await;
544 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
545 let data = resp.data.into_json().unwrap();
546 assert_eq!(data["nowPlaying"]["state"], "STOPPED");
547 }
548
549 #[tokio::test]
550 async fn pause_mutation() {
551 let (schema, rx, _tmp) = test_schema();
552 let resp = schema.execute("mutation { pause { ok message } }").await;
553 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
554 let data = resp.data.into_json().unwrap();
555 assert_eq!(data["pause"]["ok"], true);
556 let cmd = rx.try_recv().unwrap();
557 assert!(matches!(cmd, PlayerCommand::Pause));
558 }
559
560 #[tokio::test]
561 async fn nested_artist_albums_tracks() {
562 let (schema, _rx, tmp) = test_schema();
563 let db_path = tmp.path().join("test.db");
564 insert_test_track(&db_path, "Vordhosbn", "Aphex Twin", "Drukqs");
565 insert_test_track(&db_path, "Avril 14th", "Aphex Twin", "Drukqs");
566
567 let resp = schema
568 .execute(
569 r#"{ artists(search: "Aphex") {
570 edges { node {
571 name
572 albums { edges { node {
573 title
574 tracks { edges { node { title } } }
575 } } }
576 } }
577 } }"#,
578 )
579 .await;
580 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
581 let data = resp.data.into_json().unwrap();
582 let artist = &data["artists"]["edges"][0]["node"];
583 assert_eq!(artist["name"], "Aphex Twin");
584 let album = &artist["albums"]["edges"][0]["node"];
585 assert_eq!(album["title"], "Drukqs");
586 let tracks = album["tracks"]["edges"].as_array().unwrap();
587 assert_eq!(tracks.len(), 2);
588 }
589
590 #[tokio::test]
591 async fn pagination_has_next() {
592 let (schema, _rx, tmp) = test_schema();
593 let db_path = tmp.path().join("test.db");
594 for i in 0..5 {
595 insert_test_track(
596 &db_path,
597 &format!("Track{}", i),
598 "Artist",
599 &format!("Album{}", i),
600 );
601 }
602
603 let resp = schema
604 .execute(
605 r#"{ artists(first: 1) {
606 edges { node { name } cursor }
607 pageInfo { hasNextPage endCursor }
608 } }"#,
609 )
610 .await;
611 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
612 let data = resp.data.into_json().unwrap();
613 assert_eq!(data["artists"]["edges"].as_array().unwrap().len(), 1);
616 }
617
618 #[tokio::test]
619 async fn clear_queue_mutation() {
620 let (schema, rx, _tmp) = test_schema();
621 let resp = schema
622 .execute("mutation { clearQueue { ok message } }")
623 .await;
624 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
625 let cmd = rx.try_recv().unwrap();
626 assert!(matches!(cmd, PlayerCommand::ClearPlaylist));
627 }
628
629 #[tokio::test]
630 async fn enqueue_mutation_adds_to_queue() {
631 let (schema, rx, tmp) = test_schema();
632 let db_path = tmp.path().join("test.db");
633
634 let track_id = insert_test_track(&db_path, "Windowlicker", "Aphex Twin", "Windowlicker EP");
636
637 let query = format!(
639 "mutation {{ addToQueue(trackIds: [{}]) {{ ok message addedCount queueItemIds }} }}",
640 track_id
641 );
642 let resp = schema.execute(&query).await;
643 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
644
645 let data = resp.data.into_json().unwrap();
646 assert_eq!(data["addToQueue"]["ok"], true);
647 assert_eq!(data["addToQueue"]["addedCount"], 1);
648
649 let queue_ids = data["addToQueue"]["queueItemIds"].as_array().unwrap();
650 assert_eq!(queue_ids.len(), 1, "should return one queue item ID");
651
652 let cmd = rx.try_recv().unwrap();
655 match cmd {
656 PlayerCommand::AddToPlaylist(items) => {
657 assert_eq!(items.len(), 1);
658 assert_eq!(items[0].title, "Windowlicker");
659 assert_eq!(items[0].artist, "Aphex Twin");
660 assert_eq!(items[0].album, "Windowlicker EP");
661 }
662 other => panic!("expected AddToPlaylist, got {:?}", other),
663 }
664
665 let play_cmd = rx.try_recv().unwrap();
667 assert!(
668 matches!(play_cmd, PlayerCommand::Play(_)),
669 "expected Play command for auto-play, got {:?}",
670 play_cmd
671 );
672 }
673
674 #[tokio::test]
675 async fn replace_queue_mutation_clears_and_enqueues() {
676 let (schema, rx, tmp) = test_schema();
677 let db_path = tmp.path().join("test.db");
678
679 let id1 = insert_test_track(&db_path, "Track A", "Artist", "Album");
680 let id2 = insert_test_track(&db_path, "Track B", "Artist", "Album");
681
682 let query = format!(
683 "mutation {{ replaceQueue(trackIds: [{}, {}]) {{ ok addedCount queueItemIds }} }}",
684 id1, id2
685 );
686 let resp = schema.execute(&query).await;
687 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
688
689 let data = resp.data.into_json().unwrap();
690 assert_eq!(data["replaceQueue"]["addedCount"], 2);
691
692 match rx.try_recv().unwrap() {
696 PlayerCommand::ReplacePlaylist { items, start } => {
697 assert_eq!(items.len(), 2);
698 assert_eq!(start, 0, "defaults to the first track");
699 }
700 other => panic!("expected ReplacePlaylist, got {:?}", other),
701 }
702 assert!(rx.try_recv().is_err(), "no follow-up commands");
703 }
704
705 #[tokio::test]
706 async fn replace_queue_starts_where_it_was_asked_to() {
707 let (schema, rx, tmp) = test_schema();
708 let db_path = tmp.path().join("test.db");
709
710 let id1 = insert_test_track(&db_path, "Track A", "Artist", "Album");
711 let id2 = insert_test_track(&db_path, "Track B", "Artist", "Album");
712
713 let resp = schema
714 .execute(&format!(
715 "mutation {{ replaceQueue(trackIds: [{}, {}], startAt: 1) {{ ok }} }}",
716 id1, id2
717 ))
718 .await;
719 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
720
721 match rx.try_recv().unwrap() {
722 PlayerCommand::ReplacePlaylist { start, .. } => assert_eq!(start, 1),
723 other => panic!("expected ReplacePlaylist, got {:?}", other),
724 }
725 }
726
727 #[tokio::test]
730 async fn queue_snapshot_has_version_and_status() {
731 let (schema, _rx, _tmp) = test_schema();
732
733 let resp = schema
734 .execute("{ queue { version entries { queueItemId status } hasPlaying queueCount } }")
735 .await;
736 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
737 let data = resp.data.into_json().unwrap();
738 assert_eq!(data["queue"]["version"], 0);
740 assert_eq!(data["queue"]["entries"].as_array().unwrap().len(), 0);
741 assert_eq!(data["queue"]["hasPlaying"], false);
742 assert_eq!(data["queue"]["queueCount"], 0);
743 }
744
745 #[tokio::test]
746 async fn queue_entries_have_status_and_download_progress() {
747 use koan_core::player::state::{LoadState, PlaylistItem};
748
749 let tmp = TempDir::new().unwrap();
751 let db_path = tmp.path().join("test.db");
752 let db = Database::open(&db_path).unwrap();
753 koan_core::db::schema::create_tables(&db.conn).unwrap();
754
755 let state = SharedPlayerState::new();
756 let ch = CommandChannel::new();
757 let schema = build_schema(state.clone(), ch.tx.clone(), db_path, None);
758
759 let item = PlaylistItem {
761 id: QueueItemId::new(),
762 db_id: None,
763 path: std::path::PathBuf::from("/tmp/test/windowlicker.flac"),
764 title: "Windowlicker".to_string(),
765 artist: "Aphex Twin".to_string(),
766 album_artist: "Aphex Twin".to_string(),
767 album: "Windowlicker EP".to_string(),
768 year: None,
769 codec: Some("FLAC".to_string()),
770 track_number: Some(1),
771 disc: Some(1),
772 duration_ms: Some(240000),
773 load_state: LoadState::Ready,
774 };
775 state.add_items(vec![item]);
776
777 let resp = schema
779 .execute(
780 "{ queue { version entries { queueItemId title status downloadProgress { downloaded total } isCurrent } finishedCount } }",
781 )
782 .await;
783 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
784 let data = resp.data.into_json().unwrap();
785 let entries = data["queue"]["entries"].as_array().unwrap();
786 assert_eq!(entries.len(), 1);
787 assert_eq!(entries[0]["title"], "Windowlicker");
788 assert_eq!(entries[0]["status"], "QUEUED");
790 assert_eq!(entries[0]["isCurrent"], false);
791 assert!(entries[0]["downloadProgress"].is_null());
793 }
794
795 #[tokio::test]
796 async fn viz_frame_returns_none_without_viz() {
797 let (schema, _rx, _tmp) = test_schema();
798
799 let resp = schema
800 .execute("{ vizFrame { spectrum peaks vuLevels beatEnergy } }")
801 .await;
802 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
803 let data = resp.data.into_json().unwrap();
804 assert!(data["vizFrame"].is_null());
805 }
806
807 #[tokio::test]
808 async fn viz_frame_returns_data_with_viz() {
809 let tmp = TempDir::new().unwrap();
811 let db_path = tmp.path().join("test.db");
812 let db = Database::open(&db_path).unwrap();
813 koan_core::db::schema::create_tables(&db.conn).unwrap();
814
815 let state = SharedPlayerState::new();
816 let ch = CommandChannel::new();
817 let viz = koan_core::audio::viz::VizSnapshot::new();
818
819 let mut spectrum = [0.0f32; 48];
821 spectrum[0] = 0.75;
822 viz.write(koan_core::audio::viz::VizFrame {
823 spectrum,
824 peaks: [0.0; 48],
825 vu_levels: [0.42, 0.38],
826 beat_energy: 0.6,
827 timestamp: std::time::Instant::now(),
828 waveform: Vec::new(),
829 });
830
831 let schema = build_schema(state, ch.tx.clone(), db_path, Some(viz));
832
833 let resp = schema
834 .execute("{ vizFrame { spectrum peaks vuLevels beatEnergy waveform } }")
835 .await;
836 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
837 let data = resp.data.into_json().unwrap();
838 let frame = &data["vizFrame"];
839 assert!(!frame.is_null());
840 let spectrum = frame["spectrum"].as_array().unwrap();
841 assert_eq!(spectrum.len(), 48);
842 assert!((spectrum[0].as_f64().unwrap() - 0.75).abs() < 0.01);
843 let vu = frame["vuLevels"].as_array().unwrap();
844 assert_eq!(vu.len(), 2);
845 assert!((vu[0].as_f64().unwrap() - 0.42).abs() < 0.01);
846 assert!((frame["beatEnergy"].as_f64().unwrap() - 0.6).abs() < 0.01);
847 let waveform = frame["waveform"].as_array().unwrap();
849 assert!(waveform.is_empty());
850 }
851
852 #[tokio::test]
853 async fn config_query() {
854 let (schema, _rx, _tmp) = test_schema();
855
856 let resp = schema
857 .execute(
858 "{ config { libraryFolders replaygainMode targetFps artSize remoteEnabled graphqlPort } }",
859 )
860 .await;
861 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
862 let data = resp.data.into_json().unwrap();
863 let cfg = &data["config"];
864 assert!(cfg["libraryFolders"].is_array());
866 assert!(cfg["targetFps"].as_i64().unwrap() > 0);
867 assert!(cfg["artSize"].as_i64().unwrap() > 0);
868 }
869
870 #[tokio::test]
871 async fn playlist_version_query() {
872 let (schema, _rx, _tmp) = test_schema();
873
874 let resp = schema.execute("{ playlistVersion }").await;
875 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
876 let data = resp.data.into_json().unwrap();
877 assert_eq!(data["playlistVersion"], 0);
878 }
879
880 #[tokio::test]
881 async fn subscription_types_in_schema() {
882 let (schema, _rx, _tmp) = test_schema();
884
885 let resp = schema
886 .execute("{ __schema { subscriptionType { fields { name } } } }")
887 .await;
888 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
889 let data = resp.data.into_json().unwrap();
890 let fields = data["__schema"]["subscriptionType"]["fields"]
891 .as_array()
892 .unwrap();
893 let names: Vec<&str> = fields.iter().filter_map(|f| f["name"].as_str()).collect();
894 assert!(
895 names.contains(&"nowPlaying"),
896 "missing nowPlaying subscription"
897 );
898 assert!(
899 names.contains(&"queueUpdated"),
900 "missing queueUpdated subscription"
901 );
902 assert!(names.contains(&"vizFrame"), "missing vizFrame subscription");
903 }
904 #[tokio::test]
907 async fn nested_fan_out_opens_a_bounded_number_of_connections() {
908 let (schema, handle, _rx, tmp) = instrumented_schema();
909 seed_library(&tmp.path().join("test.db"), 10, 3, 4);
910
911 let before = handle.open_count();
912 let resp = schema
913 .execute(
914 "{ artists(first: 10) { edges { node { name albumCount trackCount \
915 albums(first: 10) { edges { node { title trackCount totalDurationMs \
916 tracks(first: 10) { edges { node { title isFavourite } } } } } } } } } }",
917 )
918 .await;
919 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
920
921 let data = resp.data.into_json().unwrap();
922 let artists = data["artists"]["edges"].as_array().unwrap();
923 assert_eq!(artists.len(), 10, "query did not actually fan out");
924 assert_eq!(artists[0]["node"]["albumCount"], 3);
925 assert_eq!(artists[0]["node"]["trackCount"], 12);
926
927 let opened = handle.open_count() - before;
930 assert!(opened <= 16, "opened {} connections", opened);
931 }
932
933 #[tokio::test]
934 async fn is_favourite_batches_into_one_query() {
935 let (schema, handle, _rx, tmp) = instrumented_schema();
936 seed_library(&tmp.path().join("test.db"), 1, 1, 100);
937
938 let before = handle.batch_count();
939 let resp = schema
940 .execute("{ tracks(first: 100) { edges { node { title isFavourite } } } }")
941 .await;
942 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
943 let data = resp.data.into_json().unwrap();
944 assert_eq!(data["tracks"]["edges"].as_array().unwrap().len(), 100);
945
946 let batches = handle.batch_count() - before;
951 assert!(
952 batches < 10,
953 "{batches} batches for 100 tracks — expected a handful, not one per row"
954 );
955 }
956
957 #[tokio::test]
958 async fn tracks_without_first_returns_the_default_page() {
959 let (schema, _handle, _rx, tmp) = instrumented_schema();
960 seed_library(
961 &tmp.path().join("test.db"),
962 1,
963 1,
964 helpers::DEFAULT_PAGE + 20,
965 );
966
967 let resp = schema
968 .execute("{ tracks { edges { node { title } } pageInfo { hasNextPage } } }")
969 .await;
970 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
971 let data = resp.data.into_json().unwrap();
972 assert_eq!(
973 data["tracks"]["edges"].as_array().unwrap().len(),
974 helpers::DEFAULT_PAGE
975 );
976 assert_eq!(data["tracks"]["pageInfo"]["hasNextPage"], true);
977 }
978
979 #[tokio::test]
980 async fn first_is_clamped_to_the_maximum_page() {
981 let (schema, _handle, _rx, tmp) = instrumented_schema();
982 seed_library(&tmp.path().join("test.db"), 1, 1, 10);
983
984 let resp = schema
985 .execute("{ tracks(first: 100000) { edges { node { title } } } }")
986 .await;
987 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
988 let data = resp.data.into_json().unwrap();
989 assert_eq!(data["tracks"]["edges"].as_array().unwrap().len(), 10);
990 }
991
992 #[tokio::test]
993 async fn negative_first_is_empty_not_a_panic() {
994 let (schema, _handle, _rx, tmp) = instrumented_schema();
995 seed_library(&tmp.path().join("test.db"), 2, 1, 3);
996
997 for query in [
998 "{ tracks(first: -1) { edges { node { title } } } }",
999 "{ artists(first: -1) { edges { node { name } } } }",
1000 "{ albums(first: -1) { edges { node { title } } } }",
1001 ] {
1002 let resp = schema.execute(query).await;
1003 assert!(resp.errors.is_empty(), "{}: {:?}", query, resp.errors);
1004 }
1005 }
1006
1007 #[tokio::test]
1008 async fn sort_arguments_are_honoured() {
1009 let (schema, _handle, _rx, tmp) = instrumented_schema();
1010 seed_library(&tmp.path().join("test.db"), 3, 1, 2);
1011
1012 let resp = schema
1013 .execute(
1014 "{ tracks(first: 100, sortBy: TITLE, sortDir: DESC) { edges { node { title } } } }",
1015 )
1016 .await;
1017 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1018 let data = resp.data.into_json().unwrap();
1019 let titles: Vec<String> = data["tracks"]["edges"]
1020 .as_array()
1021 .unwrap()
1022 .iter()
1023 .map(|e| e["node"]["title"].as_str().unwrap().to_string())
1024 .collect();
1025 let mut sorted = titles.clone();
1026 sorted.sort();
1027 sorted.reverse();
1028 assert_eq!(titles, sorted, "sortBy/sortDir were ignored");
1029 }
1030
1031 #[test]
1035 fn a_slow_query_does_not_stall_other_tasks() {
1036 use std::sync::atomic::{AtomicUsize, Ordering};
1037
1038 let rt = tokio::runtime::Builder::new_current_thread()
1039 .enable_time()
1040 .build()
1041 .unwrap();
1042 let (schema, _handle, _rx, tmp) = instrumented_schema();
1043 seed_library(&tmp.path().join("test.db"), 20, 5, 8);
1044
1045 rt.block_on(async move {
1046 let ticks = Arc::new(AtomicUsize::new(0));
1047 let counter = ticks.clone();
1048 let ticker = tokio::spawn(async move {
1049 loop {
1050 tokio::time::sleep(Duration::from_millis(1)).await;
1051 counter.fetch_add(1, Ordering::Relaxed);
1052 }
1053 });
1054
1055 let mut running = Vec::new();
1057 for _ in 0..4 {
1058 let schema = schema.clone();
1059 running.push(tokio::spawn(async move {
1060 schema
1061 .execute("{ fuzzySearch(query: \"track\") { id } }")
1062 .await
1063 }));
1064 }
1065 for handle in running {
1066 let resp = handle.await.unwrap();
1067 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1068 }
1069
1070 ticker.abort();
1071 assert!(
1072 ticks.load(Ordering::Relaxed) >= 2,
1073 "no other task ran while the queries were in flight"
1074 );
1075 });
1076 }
1077}