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    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    /// Same schema, but keeping the `DbHandle` so tests can read its counters.
380    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    /// Seed a library on one connection — the per-track helper opens its own.
399    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        // Only 1 artist ("Artist"), so hasNextPage should be false
600        // since all 5 tracks are by the same artist.
601        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        // Insert a track into the DB.
621        let track_id = insert_test_track(&db_path, "Windowlicker", "Aphex Twin", "Windowlicker EP");
622
623        // Execute the addToQueue mutation.
624        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        // Verify the PlayerCommand was sent through the channel.
639        // The mutation sends AddToPlaylist and then Play (auto-play when stopped).
640        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        // Auto-play command should follow.
652        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        // One command, not clear-then-add-then-play: three commands down a
679        // bounded channel means the first track starts before the cursor lands
680        // on the one that was asked for.
681        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    // ---- Phase 1 tests: queue snapshot, viz, config, playlist version, subscriptions ----
714
715    #[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        // Empty queue.
725        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        // Build schema with a shared state we can manipulate directly.
736        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        // Directly add items to the playlist (simulating what the player thread does).
746        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        // Query the queue — should have one entry with QUEUED status.
764        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        // Without a cursor set, all entries are QUEUED.
775        assert_eq!(entries[0]["status"], "QUEUED");
776        assert_eq!(entries[0]["isCurrent"], false);
777        // Local track — no download progress.
778        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        // Build schema with a VizSnapshot.
796        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        // Write some test data.
806        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        // Waveform empty — we didn't request includeWaveform.
834        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        // Defaults from Config::default().
851        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        // Verify that subscriptions are registered by introspecting the schema.
869        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    // -- Fan-out and pagination --
891
892    #[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        // Before pooling this was one full `Database::open` — DDL batch,
914        // migrations and a WAL checkpoint — per resolver field.
915        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        // The invariant is that the query count does not scale with the row
933        // count: without the dataloader this was one full `favourites` scan per
934        // track. The dataloader's gather window can close more than once under
935        // load, so assert the property rather than an exact batch count.
936        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    /// A blocking resolver must not hold the runtime. On a single-threaded
1018    /// runtime, anything running inline would freeze every other task for the
1019    /// whole query — which is what stalled in-flight audio streams.
1020    #[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            // fuzzySearch loads the library and builds a whole Nucleo matcher.
1042            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}