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]
732 async fn playlists_round_trip_over_the_api() {
733 let (schema, rx, tmp) = test_schema();
734 let db_path = tmp.path().join("test.db");
735
736 let a = insert_test_track(&db_path, "Track A", "Artist", "Album");
737 let b = insert_test_track(&db_path, "Track B", "Artist", "Album");
738 let c = insert_test_track(&db_path, "Track C", "Artist", "Album");
739
740 let resp = schema
741 .execute(&format!(
742 "mutation {{ createPlaylist(name: \"Evening\", trackIds: [{a}, {b}]) \
743 {{ id name trackCount }} }}"
744 ))
745 .await;
746 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
747 let data = resp.data.into_json().unwrap();
748 assert_eq!(data["createPlaylist"]["name"], "Evening");
749 assert_eq!(data["createPlaylist"]["trackCount"], 2);
750 let id = data["createPlaylist"]["id"].as_i64().unwrap();
751
752 let resp = schema
753 .execute(&format!(
754 "mutation {{ addToPlaylist(id: {id}, trackIds: [{c}]) {{ ok }} }}"
755 ))
756 .await;
757 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
758
759 let resp = schema
760 .execute(&format!("{{ playlistTracks(id: {id}) {{ title }} }}"))
761 .await;
762 let data = resp.data.into_json().unwrap();
763 let titles: Vec<&str> = data["playlistTracks"]
764 .as_array()
765 .unwrap()
766 .iter()
767 .map(|t| t["title"].as_str().unwrap())
768 .collect();
769 assert_eq!(
770 titles,
771 ["Track A", "Track B", "Track C"],
772 "playlist order is kept"
773 );
774
775 let resp = schema
778 .execute(&format!(
779 "mutation {{ setPlaylistTracks(id: {id}, trackIds: [{c}, {a}]) {{ ok }} }}"
780 ))
781 .await;
782 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
783
784 let resp = schema
785 .execute(&format!(
786 "mutation {{ renamePlaylist(id: {id}, name: \"Late\") {{ ok }} }}"
787 ))
788 .await;
789 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
790
791 let resp = schema.execute("{ playlists { id name trackCount } }").await;
792 let data = resp.data.into_json().unwrap();
793 assert_eq!(data["playlists"][0]["name"], "Late");
794 assert_eq!(data["playlists"][0]["trackCount"], 2);
795
796 let resp = schema
798 .execute(&format!("mutation {{ playPlaylist(id: {id}) {{ ok }} }}"))
799 .await;
800 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
801 assert!(matches!(
802 rx.try_recv().unwrap(),
803 PlayerCommand::ClearPlaylist
804 ));
805 match rx.try_recv().unwrap() {
806 PlayerCommand::AddToPlaylist(items) => assert_eq!(items.len(), 2),
807 other => panic!("expected AddToPlaylist, got {:?}", other),
808 }
809 assert!(matches!(rx.try_recv().unwrap(), PlayerCommand::Play(_)));
810
811 let resp = schema
812 .execute(&format!("mutation {{ deletePlaylist(id: {id}) {{ ok }} }}"))
813 .await;
814 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
815 let resp = schema.execute("{ playlists { id } }").await;
816 let data = resp.data.into_json().unwrap();
817 assert!(data["playlists"].as_array().unwrap().is_empty());
818 }
819
820 #[tokio::test]
823 async fn saving_the_queue_keeps_only_what_the_library_knows() {
824 use koan_core::player::state::{ItemState, PlaylistItem};
825
826 let tmp = TempDir::new().unwrap();
827 let db_path = tmp.path().join("test.db");
828 let db = Database::open(&db_path).unwrap();
829 koan_core::db::schema::create_tables(&db.conn).unwrap();
830 drop(db);
831 let known = insert_test_track(&db_path, "Known", "Artist", "Album");
832
833 let state = SharedPlayerState::new();
834 let ch = CommandChannel::new();
835 let schema = build_schema(state.clone(), ch.tx.clone(), db_path, None);
836
837 let item = |title: &str, path: &str, db_id: Option<i64>| PlaylistItem {
838 playlist_entry_id: None,
839 id: QueueItemId::new(),
840 db_id,
841 path: std::path::PathBuf::from(path),
842 title: title.to_string(),
843 artist: "Artist".to_string(),
844 album_artist: "Artist".to_string(),
845 album: "Album".to_string(),
846 year: None,
847 codec: None,
848 track_number: None,
849 disc: None,
850 duration_ms: None,
851 state: ItemState::Ready,
852 };
853 state.add_items(vec![
854 item("Known", "/music/known.flac", Some(known)),
855 item("Stranger", "/elsewhere/stranger.flac", None),
858 ]);
859
860 let resp = schema
861 .execute("mutation { saveQueueAsPlaylist(name: \"Tonight\") { id trackCount } }")
862 .await;
863 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
864 let data = resp.data.into_json().unwrap();
865 assert_eq!(data["saveQueueAsPlaylist"]["trackCount"], 1);
866 }
867
868 #[tokio::test]
871 async fn queue_snapshot_has_version_and_status() {
872 let (schema, _rx, _tmp) = test_schema();
873
874 let resp = schema
875 .execute("{ queue { version entries { queueItemId status } hasPlaying queueCount } }")
876 .await;
877 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
878 let data = resp.data.into_json().unwrap();
879 assert_eq!(data["queue"]["version"], 0);
881 assert_eq!(data["queue"]["entries"].as_array().unwrap().len(), 0);
882 assert_eq!(data["queue"]["hasPlaying"], false);
883 assert_eq!(data["queue"]["queueCount"], 0);
884 }
885
886 #[tokio::test]
887 async fn queue_entries_have_status_and_download_progress() {
888 use koan_core::player::state::{ItemState, PlaylistItem};
889
890 let tmp = TempDir::new().unwrap();
892 let db_path = tmp.path().join("test.db");
893 let db = Database::open(&db_path).unwrap();
894 koan_core::db::schema::create_tables(&db.conn).unwrap();
895
896 let state = SharedPlayerState::new();
897 let ch = CommandChannel::new();
898 let schema = build_schema(state.clone(), ch.tx.clone(), db_path, None);
899
900 let item = PlaylistItem {
902 playlist_entry_id: None,
903 id: QueueItemId::new(),
904 db_id: None,
905 path: std::path::PathBuf::from("/tmp/test/windowlicker.flac"),
906 title: "Windowlicker".to_string(),
907 artist: "Aphex Twin".to_string(),
908 album_artist: "Aphex Twin".to_string(),
909 album: "Windowlicker EP".to_string(),
910 year: None,
911 codec: Some("FLAC".to_string()),
912 track_number: Some(1),
913 disc: Some(1),
914 duration_ms: Some(240000),
915 state: ItemState::Ready,
916 };
917 state.add_items(vec![item]);
918
919 let resp = schema
921 .execute(
922 "{ queue { version entries { queueItemId title status downloadProgress { downloaded total } isCurrent } finishedCount } }",
923 )
924 .await;
925 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
926 let data = resp.data.into_json().unwrap();
927 let entries = data["queue"]["entries"].as_array().unwrap();
928 assert_eq!(entries.len(), 1);
929 assert_eq!(entries[0]["title"], "Windowlicker");
930 assert_eq!(entries[0]["status"], "QUEUED");
932 assert_eq!(entries[0]["isCurrent"], false);
933 assert!(entries[0]["downloadProgress"].is_null());
935 }
936
937 #[tokio::test]
938 async fn viz_frame_returns_none_without_viz() {
939 let (schema, _rx, _tmp) = test_schema();
940
941 let resp = schema
942 .execute("{ vizFrame { spectrum peaks vuLevels beatEnergy } }")
943 .await;
944 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
945 let data = resp.data.into_json().unwrap();
946 assert!(data["vizFrame"].is_null());
947 }
948
949 #[tokio::test]
950 async fn viz_frame_returns_data_with_viz() {
951 let tmp = TempDir::new().unwrap();
953 let db_path = tmp.path().join("test.db");
954 let db = Database::open(&db_path).unwrap();
955 koan_core::db::schema::create_tables(&db.conn).unwrap();
956
957 let state = SharedPlayerState::new();
958 let ch = CommandChannel::new();
959 let viz = koan_core::audio::viz::VizSnapshot::new();
960
961 let mut spectrum = [0.0f32; 48];
963 spectrum[0] = 0.75;
964 viz.write(koan_core::audio::viz::VizFrame {
965 spectrum,
966 peaks: [0.0; 48],
967 vu_levels: [0.42, 0.38],
968 beat_energy: 0.6,
969 timestamp: std::time::Instant::now(),
970 waveform: Vec::new(),
971 });
972
973 let schema = build_schema(state, ch.tx.clone(), db_path, Some(viz));
974
975 let resp = schema
976 .execute("{ vizFrame { spectrum peaks vuLevels beatEnergy waveform } }")
977 .await;
978 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
979 let data = resp.data.into_json().unwrap();
980 let frame = &data["vizFrame"];
981 assert!(!frame.is_null());
982 let spectrum = frame["spectrum"].as_array().unwrap();
983 assert_eq!(spectrum.len(), 48);
984 assert!((spectrum[0].as_f64().unwrap() - 0.75).abs() < 0.01);
985 let vu = frame["vuLevels"].as_array().unwrap();
986 assert_eq!(vu.len(), 2);
987 assert!((vu[0].as_f64().unwrap() - 0.42).abs() < 0.01);
988 assert!((frame["beatEnergy"].as_f64().unwrap() - 0.6).abs() < 0.01);
989 let waveform = frame["waveform"].as_array().unwrap();
991 assert!(waveform.is_empty());
992 }
993
994 #[tokio::test]
995 async fn config_query() {
996 let (schema, _rx, _tmp) = test_schema();
997
998 let resp = schema
999 .execute(
1000 "{ config { libraryFolders replaygainMode targetFps artSize remoteEnabled graphqlPort } }",
1001 )
1002 .await;
1003 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1004 let data = resp.data.into_json().unwrap();
1005 let cfg = &data["config"];
1006 assert!(cfg["libraryFolders"].is_array());
1008 assert!(cfg["targetFps"].as_i64().unwrap() > 0);
1009 assert!(cfg["artSize"].as_i64().unwrap() > 0);
1010 }
1011
1012 #[tokio::test]
1013 async fn playlist_version_query() {
1014 let (schema, _rx, _tmp) = test_schema();
1015
1016 let resp = schema.execute("{ playlistVersion }").await;
1017 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1018 let data = resp.data.into_json().unwrap();
1019 assert_eq!(data["playlistVersion"], 0);
1020 }
1021
1022 #[tokio::test]
1023 async fn subscription_types_in_schema() {
1024 let (schema, _rx, _tmp) = test_schema();
1026
1027 let resp = schema
1028 .execute("{ __schema { subscriptionType { fields { name } } } }")
1029 .await;
1030 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1031 let data = resp.data.into_json().unwrap();
1032 let fields = data["__schema"]["subscriptionType"]["fields"]
1033 .as_array()
1034 .unwrap();
1035 let names: Vec<&str> = fields.iter().filter_map(|f| f["name"].as_str()).collect();
1036 assert!(
1037 names.contains(&"nowPlaying"),
1038 "missing nowPlaying subscription"
1039 );
1040 assert!(
1041 names.contains(&"queueUpdated"),
1042 "missing queueUpdated subscription"
1043 );
1044 assert!(names.contains(&"vizFrame"), "missing vizFrame subscription");
1045 }
1046 #[tokio::test]
1049 async fn nested_fan_out_opens_a_bounded_number_of_connections() {
1050 let (schema, handle, _rx, tmp) = instrumented_schema();
1051 seed_library(&tmp.path().join("test.db"), 10, 3, 4);
1052
1053 let before = handle.open_count();
1054 let resp = schema
1055 .execute(
1056 "{ artists(first: 10) { edges { node { name albumCount trackCount \
1057 albums(first: 10) { edges { node { title trackCount totalDurationMs \
1058 tracks(first: 10) { edges { node { title isFavourite } } } } } } } } } }",
1059 )
1060 .await;
1061 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1062
1063 let data = resp.data.into_json().unwrap();
1064 let artists = data["artists"]["edges"].as_array().unwrap();
1065 assert_eq!(artists.len(), 10, "query did not actually fan out");
1066 assert_eq!(artists[0]["node"]["albumCount"], 3);
1067 assert_eq!(artists[0]["node"]["trackCount"], 12);
1068
1069 let opened = handle.open_count() - before;
1072 assert!(opened <= 16, "opened {} connections", opened);
1073 }
1074
1075 #[tokio::test]
1076 async fn is_favourite_batches_into_one_query() {
1077 let (schema, handle, _rx, tmp) = instrumented_schema();
1078 seed_library(&tmp.path().join("test.db"), 1, 1, 100);
1079
1080 let before = handle.batch_count();
1081 let resp = schema
1082 .execute("{ tracks(first: 100) { edges { node { title isFavourite } } } }")
1083 .await;
1084 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1085 let data = resp.data.into_json().unwrap();
1086 assert_eq!(data["tracks"]["edges"].as_array().unwrap().len(), 100);
1087
1088 let batches = handle.batch_count() - before;
1093 assert!(
1094 batches < 10,
1095 "{batches} batches for 100 tracks — expected a handful, not one per row"
1096 );
1097 }
1098
1099 #[tokio::test]
1100 async fn tracks_without_first_returns_the_default_page() {
1101 let (schema, _handle, _rx, tmp) = instrumented_schema();
1102 seed_library(
1103 &tmp.path().join("test.db"),
1104 1,
1105 1,
1106 helpers::DEFAULT_PAGE + 20,
1107 );
1108
1109 let resp = schema
1110 .execute("{ tracks { edges { node { title } } pageInfo { hasNextPage } } }")
1111 .await;
1112 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1113 let data = resp.data.into_json().unwrap();
1114 assert_eq!(
1115 data["tracks"]["edges"].as_array().unwrap().len(),
1116 helpers::DEFAULT_PAGE
1117 );
1118 assert_eq!(data["tracks"]["pageInfo"]["hasNextPage"], true);
1119 }
1120
1121 #[tokio::test]
1122 async fn first_is_clamped_to_the_maximum_page() {
1123 let (schema, _handle, _rx, tmp) = instrumented_schema();
1124 seed_library(&tmp.path().join("test.db"), 1, 1, 10);
1125
1126 let resp = schema
1127 .execute("{ tracks(first: 100000) { edges { node { title } } } }")
1128 .await;
1129 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1130 let data = resp.data.into_json().unwrap();
1131 assert_eq!(data["tracks"]["edges"].as_array().unwrap().len(), 10);
1132 }
1133
1134 #[tokio::test]
1135 async fn negative_first_is_empty_not_a_panic() {
1136 let (schema, _handle, _rx, tmp) = instrumented_schema();
1137 seed_library(&tmp.path().join("test.db"), 2, 1, 3);
1138
1139 for query in [
1140 "{ tracks(first: -1) { edges { node { title } } } }",
1141 "{ artists(first: -1) { edges { node { name } } } }",
1142 "{ albums(first: -1) { edges { node { title } } } }",
1143 ] {
1144 let resp = schema.execute(query).await;
1145 assert!(resp.errors.is_empty(), "{}: {:?}", query, resp.errors);
1146 }
1147 }
1148
1149 #[tokio::test]
1150 async fn sort_arguments_are_honoured() {
1151 let (schema, _handle, _rx, tmp) = instrumented_schema();
1152 seed_library(&tmp.path().join("test.db"), 3, 1, 2);
1153
1154 let resp = schema
1155 .execute(
1156 "{ tracks(first: 100, sortBy: TITLE, sortDir: DESC) { edges { node { title } } } }",
1157 )
1158 .await;
1159 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1160 let data = resp.data.into_json().unwrap();
1161 let titles: Vec<String> = data["tracks"]["edges"]
1162 .as_array()
1163 .unwrap()
1164 .iter()
1165 .map(|e| e["node"]["title"].as_str().unwrap().to_string())
1166 .collect();
1167 let mut sorted = titles.clone();
1168 sorted.sort();
1169 sorted.reverse();
1170 assert_eq!(titles, sorted, "sortBy/sortDir were ignored");
1171 }
1172
1173 #[test]
1177 fn a_slow_query_does_not_stall_other_tasks() {
1178 use std::sync::atomic::{AtomicUsize, Ordering};
1179
1180 let rt = tokio::runtime::Builder::new_current_thread()
1181 .enable_time()
1182 .build()
1183 .unwrap();
1184 let (schema, _handle, _rx, tmp) = instrumented_schema();
1185 seed_library(&tmp.path().join("test.db"), 20, 5, 8);
1186
1187 rt.block_on(async move {
1188 let ticks = Arc::new(AtomicUsize::new(0));
1189 let counter = ticks.clone();
1190 let ticker = tokio::spawn(async move {
1191 loop {
1192 tokio::time::sleep(Duration::from_millis(1)).await;
1193 counter.fetch_add(1, Ordering::Relaxed);
1194 }
1195 });
1196
1197 let mut running = Vec::new();
1199 for _ in 0..4 {
1200 let schema = schema.clone();
1201 running.push(tokio::spawn(async move {
1202 schema
1203 .execute("{ fuzzySearch(query: \"track\") { id } }")
1204 .await
1205 }));
1206 }
1207 for handle in running {
1208 let resp = handle.await.unwrap();
1209 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1210 }
1211
1212 ticker.abort();
1213 assert!(
1214 ticks.load(Ordering::Relaxed) >= 2,
1215 "no other task ran while the queries were in flight"
1216 );
1217 });
1218 }
1219}