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 their credentials.
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    // ---- Playlists ----
728
729    /// The whole life of a playlist over the API: made, added to, reordered,
730    /// renamed, played, deleted.
731    #[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        // A reorder is a wholesale replacement — the only shape Subsonic can
776        // express, so it is the only shape koan stores.
777        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        // Playing it replaces the queue rather than adding to it.
797        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    /// A queue item with no library row behind it cannot go in a playlist: a
821    /// playlist points at rows, not at paths.
822    #[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            // Played from a file that was never indexed — a queue can hold one,
856            // a playlist cannot.
857            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    // ---- Phase 1 tests: queue snapshot, viz, config, playlist version, subscriptions ----
869
870    #[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        // Empty queue.
880        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        // Build schema with a shared state we can manipulate directly.
891        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        // Directly add items to the playlist (simulating what the player thread does).
901        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        // Query the queue — should have one entry with QUEUED status.
920        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        // Without a cursor set, all entries are QUEUED.
931        assert_eq!(entries[0]["status"], "QUEUED");
932        assert_eq!(entries[0]["isCurrent"], false);
933        // Local track — no download progress.
934        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        // Build schema with a VizSnapshot.
952        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        // Write some test data.
962        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        // Waveform empty — we didn't request includeWaveform.
990        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        // Defaults from Config::default().
1007        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        // Verify that subscriptions are registered by introspecting the schema.
1025        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    // -- Fan-out and pagination --
1047
1048    #[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        // Before pooling this was one full `Database::open` — DDL batch,
1070        // migrations and a WAL checkpoint — per resolver field.
1071        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        // The invariant is that the query count does not scale with the row
1089        // count: without the dataloader this was one full `favourites` scan per
1090        // track. The dataloader's gather window can close more than once under
1091        // load, so assert the property rather than an exact batch count.
1092        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    /// A blocking resolver must not hold the runtime. On a single-threaded
1174    /// runtime, anything running inline would freeze every other task for the
1175    /// whole query — which is what stalled in-flight audio streams.
1176    #[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            // fuzzySearch loads the library and builds a whole Nucleo matcher.
1198            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}