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 test_schema() -> (
361 KoanSchema,
362 crossbeam_channel::Receiver<PlayerCommand>,
363 TempDir,
364 ) {
365 let tmp = TempDir::new().unwrap();
366 let db_path = tmp.path().join("test.db");
367 let db = Database::open(&db_path).unwrap();
368 koan_core::db::schema::create_tables(&db.conn).unwrap();
369
370 let state = SharedPlayerState::new();
371 let ch = CommandChannel::new();
372 let tx = ch.tx.clone();
373 let rx = ch.rx.clone();
374
375 let schema = build_schema(state, tx, db_path, None);
376 (schema, rx, tmp)
377 }
378
379 fn instrumented_schema() -> (
381 KoanSchema,
382 DbHandle,
383 crossbeam_channel::Receiver<PlayerCommand>,
384 TempDir,
385 ) {
386 let tmp = TempDir::new().unwrap();
387 let db_path = tmp.path().join("test.db");
388 let db = Database::open(&db_path).unwrap();
389 koan_core::db::schema::create_tables(&db.conn).unwrap();
390
391 let state = SharedPlayerState::new();
392 let ch = CommandChannel::new();
393 let handle = DbHandle::new(db_path);
394 let schema = build_schema_with(handle.clone(), state, ch.tx.clone(), None);
395 (schema, handle, ch.rx.clone(), tmp)
396 }
397
398 fn seed_library(db_path: &std::path::Path, artists: usize, albums: usize, tracks: usize) {
400 let db = Database::open(db_path).unwrap();
401 for a in 0..artists {
402 for al in 0..albums {
403 for t in 0..tracks {
404 let meta = queries::TrackMeta {
405 title: format!("Track {:03}-{}-{:03}", a, al, t),
406 artist: format!("Artist {:03}", a),
407 album_artist: Some(format!("Artist {:03}", a)),
408 album: format!("Album {:03}-{}", a, al),
409 track_number: Some(t as i32),
410 disc: Some(1),
411 date: Some("2024".into()),
412 genre: Some("Electronic".into()),
413 duration_ms: Some(240_000),
414 path: Some(format!("/tmp/koan-test/{}/{}/{}.flac", a, al, t)),
415 codec: Some("FLAC".into()),
416 sample_rate: Some(44100),
417 bit_depth: Some(16),
418 channels: Some(2),
419 bitrate: Some(1411),
420 size_bytes: Some(42_000_000),
421 mtime: Some(1700000000),
422 source: "local".into(),
423 remote_id: None,
424 remote_url: None,
425 album_added_at: None,
426 label: None,
427 };
428 queries::upsert_track(&db.conn, &meta).unwrap();
429 }
430 }
431 }
432 }
433
434 fn insert_test_track(db_path: &std::path::Path, title: &str, artist: &str, album: &str) -> i64 {
435 let db = Database::open(db_path).unwrap();
436 let meta = queries::TrackMeta {
437 title: title.to_string(),
438 artist: artist.to_string(),
439 album_artist: Some(artist.to_string()),
440 album: album.to_string(),
441 track_number: Some(1),
442 disc: Some(1),
443 date: Some("2024".into()),
444 genre: Some("Electronic".into()),
445 duration_ms: Some(240000),
446 path: Some(format!(
447 "/tmp/test/{}.flac",
448 title.to_lowercase().replace(' ', "_")
449 )),
450 codec: Some("FLAC".into()),
451 sample_rate: Some(44100),
452 bit_depth: Some(16),
453 channels: Some(2),
454 bitrate: Some(1411),
455 size_bytes: Some(42_000_000),
456 mtime: Some(1700000000),
457 source: "local".into(),
458 remote_id: None,
459 remote_url: None,
460 album_added_at: None,
461 label: None,
462 };
463 queries::upsert_track(&db.conn, &meta).unwrap()
464 }
465
466 #[test]
467 fn schema_builds() {
468 let (_schema, _rx, _tmp) = test_schema();
469 }
470
471 #[tokio::test]
472 async fn library_stats_query() {
473 let (schema, _rx, tmp) = test_schema();
474 let db_path = tmp.path().join("test.db");
475 insert_test_track(&db_path, "Track1", "Artist1", "Album1");
476
477 let resp = schema
478 .execute("{ libraryStats { totalTracks totalAlbums totalArtists } }")
479 .await;
480 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
481 let data = resp.data.into_json().unwrap();
482 assert_eq!(data["libraryStats"]["totalTracks"], 1);
483 assert_eq!(data["libraryStats"]["totalAlbums"], 1);
484 assert_eq!(data["libraryStats"]["totalArtists"], 1);
485 }
486
487 #[tokio::test]
488 async fn artists_query() {
489 let (schema, _rx, tmp) = test_schema();
490 let db_path = tmp.path().join("test.db");
491 insert_test_track(&db_path, "T1", "Aphex Twin", "Drukqs");
492 insert_test_track(&db_path, "T2", "Boards of Canada", "MHTRTC");
493
494 let resp = schema
495 .execute("{ artists { edges { node { id name } } } }")
496 .await;
497 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
498 let data = resp.data.into_json().unwrap();
499 let edges = data["artists"]["edges"].as_array().unwrap();
500 assert_eq!(edges.len(), 2);
501 }
502
503 #[tokio::test]
504 async fn tracks_search() {
505 let (schema, _rx, tmp) = test_schema();
506 let db_path = tmp.path().join("test.db");
507 insert_test_track(&db_path, "Windowlicker", "Aphex Twin", "Windowlicker EP");
508 insert_test_track(&db_path, "Roygbiv", "Boards of Canada", "MHTRTC");
509
510 let resp = schema
511 .execute(r#"{ tracks(search: "Aphex") { edges { node { id title artist } } } }"#)
512 .await;
513 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
514 let data = resp.data.into_json().unwrap();
515 let edges = data["tracks"]["edges"].as_array().unwrap();
516 assert_eq!(edges.len(), 1);
517 assert_eq!(edges[0]["node"]["title"], "Windowlicker");
518 }
519
520 #[tokio::test]
521 async fn now_playing_stopped() {
522 let (schema, _rx, _tmp) = test_schema();
523 let resp = schema
524 .execute("{ nowPlaying { state positionMs track { title } } }")
525 .await;
526 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
527 let data = resp.data.into_json().unwrap();
528 assert_eq!(data["nowPlaying"]["state"], "STOPPED");
529 }
530
531 #[tokio::test]
532 async fn pause_mutation() {
533 let (schema, rx, _tmp) = test_schema();
534 let resp = schema.execute("mutation { pause { ok message } }").await;
535 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
536 let data = resp.data.into_json().unwrap();
537 assert_eq!(data["pause"]["ok"], true);
538 let cmd = rx.try_recv().unwrap();
539 assert!(matches!(cmd, PlayerCommand::Pause));
540 }
541
542 #[tokio::test]
543 async fn nested_artist_albums_tracks() {
544 let (schema, _rx, tmp) = test_schema();
545 let db_path = tmp.path().join("test.db");
546 insert_test_track(&db_path, "Vordhosbn", "Aphex Twin", "Drukqs");
547 insert_test_track(&db_path, "Avril 14th", "Aphex Twin", "Drukqs");
548
549 let resp = schema
550 .execute(
551 r#"{ artists(search: "Aphex") {
552 edges { node {
553 name
554 albums { edges { node {
555 title
556 tracks { edges { node { title } } }
557 } } }
558 } }
559 } }"#,
560 )
561 .await;
562 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
563 let data = resp.data.into_json().unwrap();
564 let artist = &data["artists"]["edges"][0]["node"];
565 assert_eq!(artist["name"], "Aphex Twin");
566 let album = &artist["albums"]["edges"][0]["node"];
567 assert_eq!(album["title"], "Drukqs");
568 let tracks = album["tracks"]["edges"].as_array().unwrap();
569 assert_eq!(tracks.len(), 2);
570 }
571
572 #[tokio::test]
573 async fn pagination_has_next() {
574 let (schema, _rx, tmp) = test_schema();
575 let db_path = tmp.path().join("test.db");
576 for i in 0..5 {
577 insert_test_track(
578 &db_path,
579 &format!("Track{}", i),
580 "Artist",
581 &format!("Album{}", i),
582 );
583 }
584
585 let resp = schema
586 .execute(
587 r#"{ artists(first: 1) {
588 edges { node { name } cursor }
589 pageInfo { hasNextPage endCursor }
590 } }"#,
591 )
592 .await;
593 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
594 let data = resp.data.into_json().unwrap();
595 assert_eq!(data["artists"]["edges"].as_array().unwrap().len(), 1);
598 }
599
600 #[tokio::test]
601 async fn clear_queue_mutation() {
602 let (schema, rx, _tmp) = test_schema();
603 let resp = schema
604 .execute("mutation { clearQueue { ok message } }")
605 .await;
606 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
607 let cmd = rx.try_recv().unwrap();
608 assert!(matches!(cmd, PlayerCommand::ClearPlaylist));
609 }
610
611 #[tokio::test]
612 async fn enqueue_mutation_adds_to_queue() {
613 let (schema, rx, tmp) = test_schema();
614 let db_path = tmp.path().join("test.db");
615
616 let track_id = insert_test_track(&db_path, "Windowlicker", "Aphex Twin", "Windowlicker EP");
618
619 let query = format!(
621 "mutation {{ addToQueue(trackIds: [{}]) {{ ok message addedCount queueItemIds }} }}",
622 track_id
623 );
624 let resp = schema.execute(&query).await;
625 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
626
627 let data = resp.data.into_json().unwrap();
628 assert_eq!(data["addToQueue"]["ok"], true);
629 assert_eq!(data["addToQueue"]["addedCount"], 1);
630
631 let queue_ids = data["addToQueue"]["queueItemIds"].as_array().unwrap();
632 assert_eq!(queue_ids.len(), 1, "should return one queue item ID");
633
634 let cmd = rx.try_recv().unwrap();
637 match cmd {
638 PlayerCommand::AddToPlaylist(items) => {
639 assert_eq!(items.len(), 1);
640 assert_eq!(items[0].title, "Windowlicker");
641 assert_eq!(items[0].artist, "Aphex Twin");
642 assert_eq!(items[0].album, "Windowlicker EP");
643 }
644 other => panic!("expected AddToPlaylist, got {:?}", other),
645 }
646
647 let play_cmd = rx.try_recv().unwrap();
649 assert!(
650 matches!(play_cmd, PlayerCommand::Play(_)),
651 "expected Play command for auto-play, got {:?}",
652 play_cmd
653 );
654 }
655
656 #[tokio::test]
657 async fn replace_queue_mutation_clears_and_enqueues() {
658 let (schema, rx, tmp) = test_schema();
659 let db_path = tmp.path().join("test.db");
660
661 let id1 = insert_test_track(&db_path, "Track A", "Artist", "Album");
662 let id2 = insert_test_track(&db_path, "Track B", "Artist", "Album");
663
664 let query = format!(
665 "mutation {{ replaceQueue(trackIds: [{}, {}]) {{ ok addedCount queueItemIds }} }}",
666 id1, id2
667 );
668 let resp = schema.execute(&query).await;
669 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
670
671 let data = resp.data.into_json().unwrap();
672 assert_eq!(data["replaceQueue"]["addedCount"], 2);
673
674 match rx.try_recv().unwrap() {
678 PlayerCommand::ReplacePlaylist { items, start } => {
679 assert_eq!(items.len(), 2);
680 assert_eq!(start, 0, "defaults to the first track");
681 }
682 other => panic!("expected ReplacePlaylist, got {:?}", other),
683 }
684 assert!(rx.try_recv().is_err(), "no follow-up commands");
685 }
686
687 #[tokio::test]
688 async fn replace_queue_starts_where_it_was_asked_to() {
689 let (schema, rx, tmp) = test_schema();
690 let db_path = tmp.path().join("test.db");
691
692 let id1 = insert_test_track(&db_path, "Track A", "Artist", "Album");
693 let id2 = insert_test_track(&db_path, "Track B", "Artist", "Album");
694
695 let resp = schema
696 .execute(&format!(
697 "mutation {{ replaceQueue(trackIds: [{}, {}], startAt: 1) {{ ok }} }}",
698 id1, id2
699 ))
700 .await;
701 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
702
703 match rx.try_recv().unwrap() {
704 PlayerCommand::ReplacePlaylist { start, .. } => assert_eq!(start, 1),
705 other => panic!("expected ReplacePlaylist, got {:?}", other),
706 }
707 }
708
709 #[tokio::test]
712 async fn queue_snapshot_has_version_and_status() {
713 let (schema, _rx, _tmp) = test_schema();
714
715 let resp = schema
716 .execute("{ queue { version entries { queueItemId status } hasPlaying queueCount } }")
717 .await;
718 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
719 let data = resp.data.into_json().unwrap();
720 assert_eq!(data["queue"]["version"], 0);
722 assert_eq!(data["queue"]["entries"].as_array().unwrap().len(), 0);
723 assert_eq!(data["queue"]["hasPlaying"], false);
724 assert_eq!(data["queue"]["queueCount"], 0);
725 }
726
727 #[tokio::test]
728 async fn queue_entries_have_status_and_download_progress() {
729 use koan_core::player::state::{LoadState, PlaylistItem};
730
731 let tmp = TempDir::new().unwrap();
733 let db_path = tmp.path().join("test.db");
734 let db = Database::open(&db_path).unwrap();
735 koan_core::db::schema::create_tables(&db.conn).unwrap();
736
737 let state = SharedPlayerState::new();
738 let ch = CommandChannel::new();
739 let schema = build_schema(state.clone(), ch.tx.clone(), db_path, None);
740
741 let item = PlaylistItem {
743 id: QueueItemId::new(),
744 db_id: None,
745 path: std::path::PathBuf::from("/tmp/test/windowlicker.flac"),
746 title: "Windowlicker".to_string(),
747 artist: "Aphex Twin".to_string(),
748 album_artist: "Aphex Twin".to_string(),
749 album: "Windowlicker EP".to_string(),
750 year: None,
751 codec: Some("FLAC".to_string()),
752 track_number: Some(1),
753 disc: Some(1),
754 duration_ms: Some(240000),
755 load_state: LoadState::Ready,
756 };
757 state.add_items(vec![item]);
758
759 let resp = schema
761 .execute(
762 "{ queue { version entries { queueItemId title status downloadProgress { downloaded total } isCurrent } finishedCount } }",
763 )
764 .await;
765 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
766 let data = resp.data.into_json().unwrap();
767 let entries = data["queue"]["entries"].as_array().unwrap();
768 assert_eq!(entries.len(), 1);
769 assert_eq!(entries[0]["title"], "Windowlicker");
770 assert_eq!(entries[0]["status"], "QUEUED");
772 assert_eq!(entries[0]["isCurrent"], false);
773 assert!(entries[0]["downloadProgress"].is_null());
775 }
776
777 #[tokio::test]
778 async fn viz_frame_returns_none_without_viz() {
779 let (schema, _rx, _tmp) = test_schema();
780
781 let resp = schema
782 .execute("{ vizFrame { spectrum peaks vuLevels beatEnergy } }")
783 .await;
784 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
785 let data = resp.data.into_json().unwrap();
786 assert!(data["vizFrame"].is_null());
787 }
788
789 #[tokio::test]
790 async fn viz_frame_returns_data_with_viz() {
791 let tmp = TempDir::new().unwrap();
793 let db_path = tmp.path().join("test.db");
794 let db = Database::open(&db_path).unwrap();
795 koan_core::db::schema::create_tables(&db.conn).unwrap();
796
797 let state = SharedPlayerState::new();
798 let ch = CommandChannel::new();
799 let viz = koan_core::audio::viz::VizSnapshot::new();
800
801 let mut spectrum = [0.0f32; 48];
803 spectrum[0] = 0.75;
804 viz.write(koan_core::audio::viz::VizFrame {
805 spectrum,
806 peaks: [0.0; 48],
807 vu_levels: [0.42, 0.38],
808 beat_energy: 0.6,
809 timestamp: std::time::Instant::now(),
810 waveform: Vec::new(),
811 });
812
813 let schema = build_schema(state, ch.tx.clone(), db_path, Some(viz));
814
815 let resp = schema
816 .execute("{ vizFrame { spectrum peaks vuLevels beatEnergy waveform } }")
817 .await;
818 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
819 let data = resp.data.into_json().unwrap();
820 let frame = &data["vizFrame"];
821 assert!(!frame.is_null());
822 let spectrum = frame["spectrum"].as_array().unwrap();
823 assert_eq!(spectrum.len(), 48);
824 assert!((spectrum[0].as_f64().unwrap() - 0.75).abs() < 0.01);
825 let vu = frame["vuLevels"].as_array().unwrap();
826 assert_eq!(vu.len(), 2);
827 assert!((vu[0].as_f64().unwrap() - 0.42).abs() < 0.01);
828 assert!((frame["beatEnergy"].as_f64().unwrap() - 0.6).abs() < 0.01);
829 let waveform = frame["waveform"].as_array().unwrap();
831 assert!(waveform.is_empty());
832 }
833
834 #[tokio::test]
835 async fn config_query() {
836 let (schema, _rx, _tmp) = test_schema();
837
838 let resp = schema
839 .execute(
840 "{ config { libraryFolders replaygainMode targetFps artSize remoteEnabled graphqlPort } }",
841 )
842 .await;
843 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
844 let data = resp.data.into_json().unwrap();
845 let cfg = &data["config"];
846 assert!(cfg["libraryFolders"].is_array());
848 assert!(cfg["targetFps"].as_i64().unwrap() > 0);
849 assert!(cfg["artSize"].as_i64().unwrap() > 0);
850 }
851
852 #[tokio::test]
853 async fn playlist_version_query() {
854 let (schema, _rx, _tmp) = test_schema();
855
856 let resp = schema.execute("{ playlistVersion }").await;
857 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
858 let data = resp.data.into_json().unwrap();
859 assert_eq!(data["playlistVersion"], 0);
860 }
861
862 #[tokio::test]
863 async fn subscription_types_in_schema() {
864 let (schema, _rx, _tmp) = test_schema();
866
867 let resp = schema
868 .execute("{ __schema { subscriptionType { fields { name } } } }")
869 .await;
870 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
871 let data = resp.data.into_json().unwrap();
872 let fields = data["__schema"]["subscriptionType"]["fields"]
873 .as_array()
874 .unwrap();
875 let names: Vec<&str> = fields.iter().filter_map(|f| f["name"].as_str()).collect();
876 assert!(
877 names.contains(&"nowPlaying"),
878 "missing nowPlaying subscription"
879 );
880 assert!(
881 names.contains(&"queueUpdated"),
882 "missing queueUpdated subscription"
883 );
884 assert!(names.contains(&"vizFrame"), "missing vizFrame subscription");
885 }
886 #[tokio::test]
889 async fn nested_fan_out_opens_a_bounded_number_of_connections() {
890 let (schema, handle, _rx, tmp) = instrumented_schema();
891 seed_library(&tmp.path().join("test.db"), 10, 3, 4);
892
893 let before = handle.open_count();
894 let resp = schema
895 .execute(
896 "{ artists(first: 10) { edges { node { name albumCount trackCount \
897 albums(first: 10) { edges { node { title trackCount totalDurationMs \
898 tracks(first: 10) { edges { node { title isFavourite } } } } } } } } } }",
899 )
900 .await;
901 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
902
903 let data = resp.data.into_json().unwrap();
904 let artists = data["artists"]["edges"].as_array().unwrap();
905 assert_eq!(artists.len(), 10, "query did not actually fan out");
906 assert_eq!(artists[0]["node"]["albumCount"], 3);
907 assert_eq!(artists[0]["node"]["trackCount"], 12);
908
909 let opened = handle.open_count() - before;
912 assert!(opened <= 16, "opened {} connections", opened);
913 }
914
915 #[tokio::test]
916 async fn is_favourite_batches_into_one_query() {
917 let (schema, handle, _rx, tmp) = instrumented_schema();
918 seed_library(&tmp.path().join("test.db"), 1, 1, 100);
919
920 let before = handle.batch_count();
921 let resp = schema
922 .execute("{ tracks(first: 100) { edges { node { title isFavourite } } } }")
923 .await;
924 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
925 let data = resp.data.into_json().unwrap();
926 assert_eq!(data["tracks"]["edges"].as_array().unwrap().len(), 100);
927
928 let batches = handle.batch_count() - before;
933 assert!(
934 batches < 10,
935 "{batches} batches for 100 tracks — expected a handful, not one per row"
936 );
937 }
938
939 #[tokio::test]
940 async fn tracks_without_first_returns_the_default_page() {
941 let (schema, _handle, _rx, tmp) = instrumented_schema();
942 seed_library(
943 &tmp.path().join("test.db"),
944 1,
945 1,
946 helpers::DEFAULT_PAGE + 20,
947 );
948
949 let resp = schema
950 .execute("{ tracks { edges { node { title } } pageInfo { hasNextPage } } }")
951 .await;
952 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
953 let data = resp.data.into_json().unwrap();
954 assert_eq!(
955 data["tracks"]["edges"].as_array().unwrap().len(),
956 helpers::DEFAULT_PAGE
957 );
958 assert_eq!(data["tracks"]["pageInfo"]["hasNextPage"], true);
959 }
960
961 #[tokio::test]
962 async fn first_is_clamped_to_the_maximum_page() {
963 let (schema, _handle, _rx, tmp) = instrumented_schema();
964 seed_library(&tmp.path().join("test.db"), 1, 1, 10);
965
966 let resp = schema
967 .execute("{ tracks(first: 100000) { edges { node { title } } } }")
968 .await;
969 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
970 let data = resp.data.into_json().unwrap();
971 assert_eq!(data["tracks"]["edges"].as_array().unwrap().len(), 10);
972 }
973
974 #[tokio::test]
975 async fn negative_first_is_empty_not_a_panic() {
976 let (schema, _handle, _rx, tmp) = instrumented_schema();
977 seed_library(&tmp.path().join("test.db"), 2, 1, 3);
978
979 for query in [
980 "{ tracks(first: -1) { edges { node { title } } } }",
981 "{ artists(first: -1) { edges { node { name } } } }",
982 "{ albums(first: -1) { edges { node { title } } } }",
983 ] {
984 let resp = schema.execute(query).await;
985 assert!(resp.errors.is_empty(), "{}: {:?}", query, resp.errors);
986 }
987 }
988
989 #[tokio::test]
990 async fn sort_arguments_are_honoured() {
991 let (schema, _handle, _rx, tmp) = instrumented_schema();
992 seed_library(&tmp.path().join("test.db"), 3, 1, 2);
993
994 let resp = schema
995 .execute(
996 "{ tracks(first: 100, sortBy: TITLE, sortDir: DESC) { edges { node { title } } } }",
997 )
998 .await;
999 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1000 let data = resp.data.into_json().unwrap();
1001 let titles: Vec<String> = data["tracks"]["edges"]
1002 .as_array()
1003 .unwrap()
1004 .iter()
1005 .map(|e| e["node"]["title"].as_str().unwrap().to_string())
1006 .collect();
1007 let mut sorted = titles.clone();
1008 sorted.sort();
1009 sorted.reverse();
1010 assert_eq!(titles, sorted, "sortBy/sortDir were ignored");
1011 }
1012
1013 #[test]
1017 fn a_slow_query_does_not_stall_other_tasks() {
1018 use std::sync::atomic::{AtomicUsize, Ordering};
1019
1020 let rt = tokio::runtime::Builder::new_current_thread()
1021 .enable_time()
1022 .build()
1023 .unwrap();
1024 let (schema, _handle, _rx, tmp) = instrumented_schema();
1025 seed_library(&tmp.path().join("test.db"), 20, 5, 8);
1026
1027 rt.block_on(async move {
1028 let ticks = Arc::new(AtomicUsize::new(0));
1029 let counter = ticks.clone();
1030 let ticker = tokio::spawn(async move {
1031 loop {
1032 tokio::time::sleep(Duration::from_millis(1)).await;
1033 counter.fetch_add(1, Ordering::Relaxed);
1034 }
1035 });
1036
1037 let mut running = Vec::new();
1039 for _ in 0..4 {
1040 let schema = schema.clone();
1041 running.push(tokio::spawn(async move {
1042 schema
1043 .execute("{ fuzzySearch(query: \"track\") { id } }")
1044 .await
1045 }));
1046 }
1047 for handle in running {
1048 let resp = handle.await.unwrap();
1049 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1050 }
1051
1052 ticker.abort();
1053 assert!(
1054 ticks.load(Ordering::Relaxed) >= 2,
1055 "no other task ran while the queries were in flight"
1056 );
1057 });
1058 }
1059}