Skip to main content

koan_server/graphql/
mod.rs

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
36// ---------------------------------------------------------------------------
37// Connection pool
38// ---------------------------------------------------------------------------
39
40/// How long the dataloader gathers keys before running a batch.
41///
42/// The default 1ms closes the window while a wide selection set is still
43/// registering keys, splitting one query into several.
44const BATCH_WINDOW: Duration = Duration::from_millis(10);
45
46/// How long a resolver waits for a free connection before giving up. Long
47/// enough to ride out a scan chunk, short enough that a wedged writer surfaces
48/// as an error rather than a hung request.
49const ACQUIRE_TIMEOUT: Duration = Duration::from_secs(10);
50
51/// A fixed set of SQLite connections handed out per query, not per field.
52///
53/// `Database::open` runs the DDL batch, the migrations and a WAL checkpoint, so
54/// opening one per resolver field costs tens of statements before the actual
55/// query runs. Connections are opened lazily up to `max` and returned on drop.
56/// Deliberately not a single `Mutex<Connection>`: WAL gives concurrent readers,
57/// and one slow query must not block every other client.
58struct DbPool {
59    path: PathBuf,
60    /// Returned connections wait here. Bounded at `max`, so `try_send` on the
61    /// return path cannot block or fail for lack of room.
62    idle_tx: Sender<Database>,
63    idle_rx: crossbeam_channel::Receiver<Database>,
64    /// Connections in existence (idle plus checked out).
65    live: AtomicUsize,
66    /// Total connections ever opened. Instrumentation only — the fan-out tests
67    /// assert this stays bounded as a query's breadth grows.
68    opens: AtomicUsize,
69    /// Dataloader batches run. Instrumentation only — the N+1 tests assert one
70    /// batch serves a whole selection set.
71    batches: AtomicUsize,
72    /// Whether the schema and migrations have been applied to this path.
73    initialised: AtomicBool,
74    max: usize,
75}
76
77impl DbPool {
78    fn new(path: PathBuf) -> Arc<Self> {
79        // SQLite readers scale with cores; past that they only queue on the OS.
80        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    /// The first connection applies the schema; every later one skips it.
98    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
151/// A connection checked out of the pool, returned when the guard drops.
152struct 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// ---------------------------------------------------------------------------
177// DB handle wrapper (so we can put it in Context)
178// ---------------------------------------------------------------------------
179
180#[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    /// A connection outside the pool, for work that runs for minutes and must
197    /// not deny a connection to request-path resolvers.
198    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    /// Connections opened since the schema was built.
207    #[cfg_attr(not(test), allow(dead_code))]
208    fn open_count(&self) -> usize {
209        self.pool.opens.load(Ordering::Relaxed)
210    }
211
212    /// Dataloader batches run since the schema was built.
213    #[cfg_attr(not(test), allow(dead_code))]
214    fn batch_count(&self) -> usize {
215        self.pool.batches.load(Ordering::Relaxed)
216    }
217}
218
219/// Run a rusqlite closure on the blocking pool with a pooled connection.
220///
221/// rusqlite is blocking start to finish. Calling it inline in an `async fn`
222/// parks a tokio worker for the duration, and enough of those at once starve
223/// the runtime — including the `ReaderStream`s feeding in-flight audio.
224async 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
237/// Run any other blocking work (HTTP fetches, file decoding, tag reads) off the
238/// async workers.
239async 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
249/// Log the detail, return a generic message.
250///
251/// SQLite errors quote the offending statement and filesystem errors quote
252/// absolute paths — a map of the host handed to whoever asked.
253pub(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
258// ---------------------------------------------------------------------------
259// Schema builder
260// ---------------------------------------------------------------------------
261
262pub 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    // Batching only, no caching: a schema-wide loader outlives the request, and
280    // favourites and library contents change under it.
281    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    // A single nested query can otherwise fan out across the whole library and
294    // pin the process for minutes.
295    builder.limit_depth(12).limit_complexity(2000).finish()
296}
297
298// ---------------------------------------------------------------------------
299// Shared helpers used by queries + mutations
300// ---------------------------------------------------------------------------
301
302fn 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
308/// How long to wait for room on the player command channel.
309///
310/// The channel is bounded(16) and the player can sit inside `start_playback`
311/// for about a second during a device sample-rate change. A blocking `send`
312/// there parks a tokio worker; this gives the player a moment to drain and
313/// then reports back rather than holding the thread.
314const 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
326/// Extract the authenticated user from GraphQL context.
327/// Returns anonymous admin if no user is present (auth disabled or in-process).
328fn get_auth_user(ctx: &Context<'_>) -> AuthUser {
329    ctx.data::<AuthUser>()
330        .cloned()
331        .unwrap_or_else(|_| AuthUser::anonymous_admin())
332}
333
334/// Check that the current user has at least the required role.
335/// Returns an error suitable for GraphQL if the check fails.
336fn 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// ---------------------------------------------------------------------------
349// Tests
350// ---------------------------------------------------------------------------
351
352#[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    /// One disposable configuration directory for the whole test binary.
361    ///
362    /// Deliberately not the per-test `TempDir`: enqueueing spawns downloads on
363    /// a thread that outlives the test which started it, and that thread reads
364    /// the configuration when it runs. Without this it reads the developer's
365    /// own — their library, their server, and a prompt for their keychain.
366    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    /// Same schema, but keeping the `DbHandle` so tests can read its counters.
392    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    /// Seed a library on one connection — the per-track helper opens its own.
411    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        // Only 1 artist ("Artist"), so hasNextPage should be false
614        // since all 5 tracks are by the same artist.
615        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        // Insert a track into the DB.
635        let track_id = insert_test_track(&db_path, "Windowlicker", "Aphex Twin", "Windowlicker EP");
636
637        // Execute the addToQueue mutation.
638        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        // Verify the PlayerCommand was sent through the channel.
653        // The mutation sends AddToPlaylist and then Play (auto-play when stopped).
654        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        // Auto-play command should follow.
666        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        // One command, not clear-then-add-then-play: three commands down a
693        // bounded channel means the first track starts before the cursor lands
694        // on the one that was asked for.
695        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    // ---- Phase 1 tests: queue snapshot, viz, config, playlist version, subscriptions ----
728
729    #[tokio::test]
730    async fn queue_snapshot_has_version_and_status() {
731        let (schema, _rx, _tmp) = test_schema();
732
733        let resp = schema
734            .execute("{ queue { version entries { queueItemId status } hasPlaying queueCount } }")
735            .await;
736        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
737        let data = resp.data.into_json().unwrap();
738        // Empty queue.
739        assert_eq!(data["queue"]["version"], 0);
740        assert_eq!(data["queue"]["entries"].as_array().unwrap().len(), 0);
741        assert_eq!(data["queue"]["hasPlaying"], false);
742        assert_eq!(data["queue"]["queueCount"], 0);
743    }
744
745    #[tokio::test]
746    async fn queue_entries_have_status_and_download_progress() {
747        use koan_core::player::state::{LoadState, PlaylistItem};
748
749        // Build schema with a shared state we can manipulate directly.
750        let tmp = TempDir::new().unwrap();
751        let db_path = tmp.path().join("test.db");
752        let db = Database::open(&db_path).unwrap();
753        koan_core::db::schema::create_tables(&db.conn).unwrap();
754
755        let state = SharedPlayerState::new();
756        let ch = CommandChannel::new();
757        let schema = build_schema(state.clone(), ch.tx.clone(), db_path, None);
758
759        // Directly add items to the playlist (simulating what the player thread does).
760        let item = PlaylistItem {
761            id: QueueItemId::new(),
762            db_id: None,
763            path: std::path::PathBuf::from("/tmp/test/windowlicker.flac"),
764            title: "Windowlicker".to_string(),
765            artist: "Aphex Twin".to_string(),
766            album_artist: "Aphex Twin".to_string(),
767            album: "Windowlicker EP".to_string(),
768            year: None,
769            codec: Some("FLAC".to_string()),
770            track_number: Some(1),
771            disc: Some(1),
772            duration_ms: Some(240000),
773            load_state: LoadState::Ready,
774        };
775        state.add_items(vec![item]);
776
777        // Query the queue — should have one entry with QUEUED status.
778        let resp = schema
779            .execute(
780                "{ queue { version entries { queueItemId title status downloadProgress { downloaded total } isCurrent } finishedCount } }",
781            )
782            .await;
783        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
784        let data = resp.data.into_json().unwrap();
785        let entries = data["queue"]["entries"].as_array().unwrap();
786        assert_eq!(entries.len(), 1);
787        assert_eq!(entries[0]["title"], "Windowlicker");
788        // Without a cursor set, all entries are QUEUED.
789        assert_eq!(entries[0]["status"], "QUEUED");
790        assert_eq!(entries[0]["isCurrent"], false);
791        // Local track — no download progress.
792        assert!(entries[0]["downloadProgress"].is_null());
793    }
794
795    #[tokio::test]
796    async fn viz_frame_returns_none_without_viz() {
797        let (schema, _rx, _tmp) = test_schema();
798
799        let resp = schema
800            .execute("{ vizFrame { spectrum peaks vuLevels beatEnergy } }")
801            .await;
802        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
803        let data = resp.data.into_json().unwrap();
804        assert!(data["vizFrame"].is_null());
805    }
806
807    #[tokio::test]
808    async fn viz_frame_returns_data_with_viz() {
809        // Build schema with a VizSnapshot.
810        let tmp = TempDir::new().unwrap();
811        let db_path = tmp.path().join("test.db");
812        let db = Database::open(&db_path).unwrap();
813        koan_core::db::schema::create_tables(&db.conn).unwrap();
814
815        let state = SharedPlayerState::new();
816        let ch = CommandChannel::new();
817        let viz = koan_core::audio::viz::VizSnapshot::new();
818
819        // Write some test data.
820        let mut spectrum = [0.0f32; 48];
821        spectrum[0] = 0.75;
822        viz.write(koan_core::audio::viz::VizFrame {
823            spectrum,
824            peaks: [0.0; 48],
825            vu_levels: [0.42, 0.38],
826            beat_energy: 0.6,
827            timestamp: std::time::Instant::now(),
828            waveform: Vec::new(),
829        });
830
831        let schema = build_schema(state, ch.tx.clone(), db_path, Some(viz));
832
833        let resp = schema
834            .execute("{ vizFrame { spectrum peaks vuLevels beatEnergy waveform } }")
835            .await;
836        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
837        let data = resp.data.into_json().unwrap();
838        let frame = &data["vizFrame"];
839        assert!(!frame.is_null());
840        let spectrum = frame["spectrum"].as_array().unwrap();
841        assert_eq!(spectrum.len(), 48);
842        assert!((spectrum[0].as_f64().unwrap() - 0.75).abs() < 0.01);
843        let vu = frame["vuLevels"].as_array().unwrap();
844        assert_eq!(vu.len(), 2);
845        assert!((vu[0].as_f64().unwrap() - 0.42).abs() < 0.01);
846        assert!((frame["beatEnergy"].as_f64().unwrap() - 0.6).abs() < 0.01);
847        // Waveform empty — we didn't request includeWaveform.
848        let waveform = frame["waveform"].as_array().unwrap();
849        assert!(waveform.is_empty());
850    }
851
852    #[tokio::test]
853    async fn config_query() {
854        let (schema, _rx, _tmp) = test_schema();
855
856        let resp = schema
857            .execute(
858                "{ config { libraryFolders replaygainMode targetFps artSize remoteEnabled graphqlPort } }",
859            )
860            .await;
861        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
862        let data = resp.data.into_json().unwrap();
863        let cfg = &data["config"];
864        // Defaults from Config::default().
865        assert!(cfg["libraryFolders"].is_array());
866        assert!(cfg["targetFps"].as_i64().unwrap() > 0);
867        assert!(cfg["artSize"].as_i64().unwrap() > 0);
868    }
869
870    #[tokio::test]
871    async fn playlist_version_query() {
872        let (schema, _rx, _tmp) = test_schema();
873
874        let resp = schema.execute("{ playlistVersion }").await;
875        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
876        let data = resp.data.into_json().unwrap();
877        assert_eq!(data["playlistVersion"], 0);
878    }
879
880    #[tokio::test]
881    async fn subscription_types_in_schema() {
882        // Verify that subscriptions are registered by introspecting the schema.
883        let (schema, _rx, _tmp) = test_schema();
884
885        let resp = schema
886            .execute("{ __schema { subscriptionType { fields { name } } } }")
887            .await;
888        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
889        let data = resp.data.into_json().unwrap();
890        let fields = data["__schema"]["subscriptionType"]["fields"]
891            .as_array()
892            .unwrap();
893        let names: Vec<&str> = fields.iter().filter_map(|f| f["name"].as_str()).collect();
894        assert!(
895            names.contains(&"nowPlaying"),
896            "missing nowPlaying subscription"
897        );
898        assert!(
899            names.contains(&"queueUpdated"),
900            "missing queueUpdated subscription"
901        );
902        assert!(names.contains(&"vizFrame"), "missing vizFrame subscription");
903    }
904    // -- Fan-out and pagination --
905
906    #[tokio::test]
907    async fn nested_fan_out_opens_a_bounded_number_of_connections() {
908        let (schema, handle, _rx, tmp) = instrumented_schema();
909        seed_library(&tmp.path().join("test.db"), 10, 3, 4);
910
911        let before = handle.open_count();
912        let resp = schema
913            .execute(
914                "{ artists(first: 10) { edges { node { name albumCount trackCount \
915                   albums(first: 10) { edges { node { title trackCount totalDurationMs \
916                   tracks(first: 10) { edges { node { title isFavourite } } } } } } } } } }",
917            )
918            .await;
919        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
920
921        let data = resp.data.into_json().unwrap();
922        let artists = data["artists"]["edges"].as_array().unwrap();
923        assert_eq!(artists.len(), 10, "query did not actually fan out");
924        assert_eq!(artists[0]["node"]["albumCount"], 3);
925        assert_eq!(artists[0]["node"]["trackCount"], 12);
926
927        // Before pooling this was one full `Database::open` — DDL batch,
928        // migrations and a WAL checkpoint — per resolver field.
929        let opened = handle.open_count() - before;
930        assert!(opened <= 16, "opened {} connections", opened);
931    }
932
933    #[tokio::test]
934    async fn is_favourite_batches_into_one_query() {
935        let (schema, handle, _rx, tmp) = instrumented_schema();
936        seed_library(&tmp.path().join("test.db"), 1, 1, 100);
937
938        let before = handle.batch_count();
939        let resp = schema
940            .execute("{ tracks(first: 100) { edges { node { title isFavourite } } } }")
941            .await;
942        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
943        let data = resp.data.into_json().unwrap();
944        assert_eq!(data["tracks"]["edges"].as_array().unwrap().len(), 100);
945
946        // The invariant is that the query count does not scale with the row
947        // count: without the dataloader this was one full `favourites` scan per
948        // track. The dataloader's gather window can close more than once under
949        // load, so assert the property rather than an exact batch count.
950        let batches = handle.batch_count() - before;
951        assert!(
952            batches < 10,
953            "{batches} batches for 100 tracks — expected a handful, not one per row"
954        );
955    }
956
957    #[tokio::test]
958    async fn tracks_without_first_returns_the_default_page() {
959        let (schema, _handle, _rx, tmp) = instrumented_schema();
960        seed_library(
961            &tmp.path().join("test.db"),
962            1,
963            1,
964            helpers::DEFAULT_PAGE + 20,
965        );
966
967        let resp = schema
968            .execute("{ tracks { edges { node { title } } pageInfo { hasNextPage } } }")
969            .await;
970        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
971        let data = resp.data.into_json().unwrap();
972        assert_eq!(
973            data["tracks"]["edges"].as_array().unwrap().len(),
974            helpers::DEFAULT_PAGE
975        );
976        assert_eq!(data["tracks"]["pageInfo"]["hasNextPage"], true);
977    }
978
979    #[tokio::test]
980    async fn first_is_clamped_to_the_maximum_page() {
981        let (schema, _handle, _rx, tmp) = instrumented_schema();
982        seed_library(&tmp.path().join("test.db"), 1, 1, 10);
983
984        let resp = schema
985            .execute("{ tracks(first: 100000) { edges { node { title } } } }")
986            .await;
987        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
988        let data = resp.data.into_json().unwrap();
989        assert_eq!(data["tracks"]["edges"].as_array().unwrap().len(), 10);
990    }
991
992    #[tokio::test]
993    async fn negative_first_is_empty_not_a_panic() {
994        let (schema, _handle, _rx, tmp) = instrumented_schema();
995        seed_library(&tmp.path().join("test.db"), 2, 1, 3);
996
997        for query in [
998            "{ tracks(first: -1) { edges { node { title } } } }",
999            "{ artists(first: -1) { edges { node { name } } } }",
1000            "{ albums(first: -1) { edges { node { title } } } }",
1001        ] {
1002            let resp = schema.execute(query).await;
1003            assert!(resp.errors.is_empty(), "{}: {:?}", query, resp.errors);
1004        }
1005    }
1006
1007    #[tokio::test]
1008    async fn sort_arguments_are_honoured() {
1009        let (schema, _handle, _rx, tmp) = instrumented_schema();
1010        seed_library(&tmp.path().join("test.db"), 3, 1, 2);
1011
1012        let resp = schema
1013            .execute(
1014                "{ tracks(first: 100, sortBy: TITLE, sortDir: DESC) { edges { node { title } } } }",
1015            )
1016            .await;
1017        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1018        let data = resp.data.into_json().unwrap();
1019        let titles: Vec<String> = data["tracks"]["edges"]
1020            .as_array()
1021            .unwrap()
1022            .iter()
1023            .map(|e| e["node"]["title"].as_str().unwrap().to_string())
1024            .collect();
1025        let mut sorted = titles.clone();
1026        sorted.sort();
1027        sorted.reverse();
1028        assert_eq!(titles, sorted, "sortBy/sortDir were ignored");
1029    }
1030
1031    /// A blocking resolver must not hold the runtime. On a single-threaded
1032    /// runtime, anything running inline would freeze every other task for the
1033    /// whole query — which is what stalled in-flight audio streams.
1034    #[test]
1035    fn a_slow_query_does_not_stall_other_tasks() {
1036        use std::sync::atomic::{AtomicUsize, Ordering};
1037
1038        let rt = tokio::runtime::Builder::new_current_thread()
1039            .enable_time()
1040            .build()
1041            .unwrap();
1042        let (schema, _handle, _rx, tmp) = instrumented_schema();
1043        seed_library(&tmp.path().join("test.db"), 20, 5, 8);
1044
1045        rt.block_on(async move {
1046            let ticks = Arc::new(AtomicUsize::new(0));
1047            let counter = ticks.clone();
1048            let ticker = tokio::spawn(async move {
1049                loop {
1050                    tokio::time::sleep(Duration::from_millis(1)).await;
1051                    counter.fetch_add(1, Ordering::Relaxed);
1052                }
1053            });
1054
1055            // fuzzySearch loads the library and builds a whole Nucleo matcher.
1056            let mut running = Vec::new();
1057            for _ in 0..4 {
1058                let schema = schema.clone();
1059                running.push(tokio::spawn(async move {
1060                    schema
1061                        .execute("{ fuzzySearch(query: \"track\") { id } }")
1062                        .await
1063                }));
1064            }
1065            for handle in running {
1066                let resp = handle.await.unwrap();
1067                assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1068            }
1069
1070            ticker.abort();
1071            assert!(
1072                ticks.load(Ordering::Relaxed) >= 2,
1073                "no other task ran while the queries were in flight"
1074            );
1075        });
1076    }
1077}