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