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::sync::Arc;
12use std::sync::atomic::{AtomicUsize, Ordering};
13use std::time::Duration;
14
15use async_graphql::dataloader::DataLoader;
16use async_graphql::{Context, Schema};
17use crossbeam_channel::Sender;
18use koan_core::audio::viz::VizSnapshot;
19use koan_core::db::connection::Database;
20use koan_core::db::pool::Pool;
21use koan_core::db::queries::{UidKind, is_uid};
22use koan_core::player::commands::PlayerCommand;
23use koan_core::player::state::{QueueItemId, SharedPlayerState};
24use uuid::Uuid;
25
26use koan_core::auth::Role;
27use loaders::DbLoader;
28use mutations::MutationRoot;
29use queries::QueryRoot;
30pub use server::{
31    ApiServerOpts, cmd_serve, cmd_serve_daemon, execute_in_process, start_api_background,
32};
33use subscriptions::SubscriptionRoot;
34
35use crate::auth::AuthUser;
36
37// ---------------------------------------------------------------------------
38// Connection pool
39// ---------------------------------------------------------------------------
40
41/// How long the dataloader gathers keys before running a batch.
42///
43/// The default 1ms closes the window while a wide selection set is still
44/// registering keys, splitting one query into several.
45const BATCH_WINDOW: Duration = Duration::from_millis(10);
46
47/// How long a resolver waits for a free connection before giving up. Long
48/// enough to ride out a scan chunk, short enough that a wedged writer surfaces
49/// as an error rather than a hung request.
50const ACQUIRE_TIMEOUT: Duration = Duration::from_secs(10);
51
52/// The process's connections, handed out per query rather than per field, and
53/// at most `max` at once.
54///
55/// The connections are the server's own pool, shared with Subsonic, the web UI
56/// and MCP: one set of page caches for the one database, rather than one per
57/// front end. What this adds is the bound. A resolver past it waits up to
58/// `ACQUIRE_TIMEOUT` for another to finish, so a query fanning out wide cannot
59/// take a connection per field, and a wedged writer surfaces as an error rather
60/// than a hung request.
61struct DbPool {
62    pool: Arc<Pool>,
63    /// One token per connection that may be out at once. Taken to acquire,
64    /// returned on drop.
65    permits_tx: Sender<()>,
66    permits_rx: crossbeam_channel::Receiver<()>,
67    /// Dataloader batches run. Instrumentation only — the N+1 tests assert one
68    /// batch serves a whole selection set.
69    batches: AtomicUsize,
70}
71
72impl DbPool {
73    fn new(pool: Arc<Pool>) -> Arc<Self> {
74        // SQLite readers scale with cores; past that they only queue on the OS.
75        let max = std::thread::available_parallelism()
76            .map(|n| n.get())
77            .unwrap_or(4)
78            .clamp(4, 16);
79        let (permits_tx, permits_rx) = crossbeam_channel::bounded(max);
80        for _ in 0..max {
81            let _ = permits_tx.try_send(());
82        }
83        Arc::new(Self {
84            pool,
85            permits_tx,
86            permits_rx,
87            batches: AtomicUsize::new(0),
88        })
89    }
90
91    fn acquire(self: &Arc<Self>) -> async_graphql::Result<PooledDb> {
92        self.permits_rx
93            .recv_timeout(ACQUIRE_TIMEOUT)
94            .map_err(|_| async_graphql::Error::new("database busy"))?;
95        match self.pool.take() {
96            Ok(db) => Ok(PooledDb {
97                db: Some(db),
98                pool: self.clone(),
99            }),
100            Err(e) => {
101                let _ = self.permits_tx.try_send(());
102                Err(internal_error("db open", e))
103            }
104        }
105    }
106}
107
108/// A connection checked out of the pool, returned when the guard drops.
109struct PooledDb {
110    db: Option<Database>,
111    pool: Arc<DbPool>,
112}
113
114impl Deref for PooledDb {
115    type Target = Database;
116
117    fn deref(&self) -> &Database {
118        self.db.as_ref().expect("connection taken before drop")
119    }
120}
121
122impl Drop for PooledDb {
123    fn drop(&mut self) {
124        if let Some(db) = self.db.take() {
125            self.pool.pool.put_back(db);
126        }
127        let _ = self.pool.permits_tx.try_send(());
128    }
129}
130
131// ---------------------------------------------------------------------------
132// DB handle wrapper (so we can put it in Context)
133// ---------------------------------------------------------------------------
134
135#[derive(Clone)]
136struct DbHandle {
137    pool: Arc<DbPool>,
138}
139
140impl DbHandle {
141    fn new(pool: Arc<Pool>) -> Self {
142        Self {
143            pool: DbPool::new(pool),
144        }
145    }
146
147    fn acquire(&self) -> async_graphql::Result<PooledDb> {
148        self.pool.acquire()
149    }
150
151    /// The database this schema serves, which need not be the default one.
152    fn path(&self) -> std::path::PathBuf {
153        self.pool.pool.path().to_owned()
154    }
155
156    /// A connection outside the bound, for work that runs for minutes and must
157    /// not deny a connection to request-path resolvers.
158    fn open_detached(&self) -> Result<Database, koan_core::db::connection::DbError> {
159        self.pool.pool.take()
160    }
161
162    fn note_batch(&self) {
163        self.pool.batches.fetch_add(1, Ordering::Relaxed);
164    }
165
166    /// Connections the underlying pool has opened.
167    #[cfg_attr(not(test), allow(dead_code))]
168    fn open_count(&self) -> usize {
169        self.pool.pool.opened()
170    }
171
172    /// Dataloader batches run since the schema was built.
173    #[cfg_attr(not(test), allow(dead_code))]
174    fn batch_count(&self) -> usize {
175        self.pool.batches.load(Ordering::Relaxed)
176    }
177}
178
179/// Run a rusqlite closure on the blocking pool with a pooled connection.
180///
181/// rusqlite is blocking start to finish. Calling it inline in an `async fn`
182/// parks a tokio worker for the duration, and enough of those at once starve
183/// the runtime — including the `ReaderStream`s feeding in-flight audio.
184async fn with_db<T, F>(ctx: &Context<'_>, f: F) -> async_graphql::Result<T>
185where
186    F: FnOnce(&Database) -> async_graphql::Result<T> + Send + 'static,
187    T: Send + 'static,
188{
189    let handle = ctx.data::<DbHandle>()?.clone();
190    blocking(move || {
191        let db = handle.acquire()?;
192        f(&db)
193    })
194    .await
195}
196
197/// A row as its published id: its uid, the same on the server and every device
198/// synced from it. See `koan_core::db::queries::uids`.
199async fn uid(
200    ctx: &Context<'_>,
201    kind: UidKind,
202    id: i64,
203) -> async_graphql::Result<async_graphql::ID> {
204    Ok(async_graphql::ID(
205        id_loader(ctx)?
206            .load_one(loaders::UidOf(kind, id))
207            .await?
208            .unwrap_or_else(|| id.to_string()),
209    ))
210}
211
212async fn uids(
213    ctx: &Context<'_>,
214    kind: UidKind,
215    ids: &[i64],
216) -> async_graphql::Result<Vec<async_graphql::ID>> {
217    let found = id_loader(ctx)?
218        .load_many(ids.iter().map(|&id| loaders::UidOf(kind, id)))
219        .await?;
220    Ok(ids
221        .iter()
222        .map(|&id| {
223            async_graphql::ID(
224                found
225                    .get(&loaders::UidOf(kind, id))
226                    .cloned()
227                    .unwrap_or_else(|| id.to_string()),
228            )
229        })
230        .collect())
231}
232
233async fn opt_uid(
234    ctx: &Context<'_>,
235    kind: UidKind,
236    id: Option<i64>,
237) -> async_graphql::Result<Option<async_graphql::ID>> {
238    match id {
239        Some(id) => uid(ctx, kind, id).await.map(Some),
240        None => Ok(None),
241    }
242}
243
244/// The row an id argument names: its uid, or the bare row id that callers
245/// from before uids still send.
246async fn row_id(ctx: &Context<'_>, kind: UidKind, id: &str) -> async_graphql::Result<i64> {
247    Ok(row_ids(ctx, kind, &[id]).await?[0])
248}
249
250/// `row_id` for each, in one lookup.
251async fn row_ids<S: AsRef<str> + Sync>(
252    ctx: &Context<'_>,
253    kind: UidKind,
254    ids: &[S],
255) -> async_graphql::Result<Vec<i64>> {
256    let loader = id_loader(ctx)?;
257    let uids = ids
258        .iter()
259        .map(AsRef::as_ref)
260        .filter(|id| id.parse::<i64>().is_err())
261        .map(|id| {
262            if is_uid(id) {
263                Ok(loaders::RowOf(kind, id.to_string()))
264            } else {
265                Err(async_graphql::Error::new(format!("not an id: {id}")))
266            }
267        })
268        .collect::<async_graphql::Result<Vec<_>>>()?;
269    let found = loader.load_many(uids).await?;
270    ids.iter()
271        .map(AsRef::as_ref)
272        .map(|id| match id.parse::<i64>() {
273            Ok(row) => Ok(row),
274            Err(_) => found
275                .get(&loaders::RowOf(kind, id.to_string()))
276                .copied()
277                .ok_or_else(|| {
278                    async_graphql::Error::new(format!("no {kind:?} with id {id}").to_lowercase())
279                }),
280        })
281        .collect()
282}
283
284async fn opt_row_id(
285    ctx: &Context<'_>,
286    kind: UidKind,
287    id: Option<&async_graphql::ID>,
288) -> async_graphql::Result<Option<i64>> {
289    match id {
290        Some(id) => row_id(ctx, kind, id).await.map(Some),
291        None => Ok(None),
292    }
293}
294
295async fn opt_row_ids(
296    ctx: &Context<'_>,
297    kind: UidKind,
298    ids: Option<&[async_graphql::ID]>,
299) -> async_graphql::Result<Option<Vec<i64>>> {
300    match ids {
301        Some(ids) => row_ids(ctx, kind, ids).await.map(Some),
302        None => Ok(None),
303    }
304}
305
306fn id_loader<'a>(ctx: &'a Context<'_>) -> async_graphql::Result<&'a DataLoader<DbLoader>> {
307    ctx.data::<DataLoader<DbLoader>>()
308}
309
310/// Run any other blocking work (HTTP fetches, file decoding, tag reads) off the
311/// async workers.
312async fn blocking<T, F>(f: F) -> async_graphql::Result<T>
313where
314    F: FnOnce() -> async_graphql::Result<T> + Send + 'static,
315    T: Send + 'static,
316{
317    tokio::task::spawn_blocking(f)
318        .await
319        .map_err(|e| internal_error("blocking task", e))?
320}
321
322/// Log the detail, return a generic message.
323///
324/// SQLite errors quote the offending statement and filesystem errors quote
325/// absolute paths — a map of the host handed to whoever asked.
326pub(super) fn internal_error(context: &str, e: impl std::fmt::Display) -> async_graphql::Error {
327    log::error!("graphql {}: {}", context, e);
328    async_graphql::Error::new("internal error")
329}
330
331// ---------------------------------------------------------------------------
332// Schema builder
333// ---------------------------------------------------------------------------
334
335pub type KoanSchema = Schema<QueryRoot, MutationRoot, SubscriptionRoot>;
336
337pub fn build_schema(
338    state: Arc<SharedPlayerState>,
339    cmd_tx: Sender<PlayerCommand>,
340    pool: Arc<Pool>,
341    viz: Option<Arc<VizSnapshot>>,
342) -> KoanSchema {
343    build_schema_with(DbHandle::new(pool), state, cmd_tx, viz)
344}
345
346/// `build_schema` with `extension` running on every request.
347pub(crate) fn build_schema_extended(
348    state: Arc<SharedPlayerState>,
349    cmd_tx: Sender<PlayerCommand>,
350    pool: Arc<Pool>,
351    extension: impl async_graphql::extensions::ExtensionFactory,
352) -> KoanSchema {
353    builder_with(DbHandle::new(pool), state, cmd_tx, None)
354        .extension(extension)
355        .finish()
356}
357
358fn build_schema_with(
359    handle: DbHandle,
360    state: Arc<SharedPlayerState>,
361    cmd_tx: Sender<PlayerCommand>,
362    viz: Option<Arc<VizSnapshot>>,
363) -> KoanSchema {
364    builder_with(handle, state, cmd_tx, viz).finish()
365}
366
367fn builder_with(
368    handle: DbHandle,
369    state: Arc<SharedPlayerState>,
370    cmd_tx: Sender<PlayerCommand>,
371    viz: Option<Arc<VizSnapshot>>,
372) -> async_graphql::SchemaBuilder<QueryRoot, MutationRoot, SubscriptionRoot> {
373    // Batching only, no caching: a schema-wide loader outlives the request, and
374    // favourites and library contents change under it.
375    let loader = DataLoader::new(DbLoader::new(handle.clone()), tokio::spawn).delay(BATCH_WINDOW);
376    loader.enable_all_cache(false);
377
378    let mut builder = Schema::build(QueryRoot, MutationRoot, SubscriptionRoot)
379        .data(handle)
380        .data(loader)
381        .data(jobs::JobRegistry::default())
382        .data(Arc::new(queries::Fuzzy::default()))
383        .data(state)
384        .data(cmd_tx);
385    if let Some(viz) = viz {
386        builder = builder.data(viz);
387    }
388    // A single nested query can otherwise fan out across the whole library and
389    // pin the process for minutes.
390    builder.limit_depth(12).limit_complexity(2000)
391}
392
393// ---------------------------------------------------------------------------
394// Shared helpers used by queries + mutations
395// ---------------------------------------------------------------------------
396
397fn parse_queue_item_id(s: &str) -> async_graphql::Result<QueueItemId> {
398    Uuid::parse_str(s)
399        .map(QueueItemId)
400        .map_err(|e| async_graphql::Error::new(format!("invalid queue item ID '{}': {}", s, e)))
401}
402
403/// How long to wait for room on the player command channel.
404///
405/// The channel is bounded(16) and the player can sit inside `start_playback`
406/// for about a second during a device sample-rate change. A blocking `send`
407/// there parks a tokio worker; this gives the player a moment to drain and
408/// then reports back rather than holding the thread.
409const CMD_SEND_TIMEOUT: Duration = Duration::from_millis(250);
410
411fn send_cmd(ctx: &Context<'_>, cmd: PlayerCommand) -> async_graphql::Result<()> {
412    let tx = ctx.data::<Sender<PlayerCommand>>()?;
413    send_cmd_via(tx, cmd)
414}
415
416fn send_cmd_via(tx: &Sender<PlayerCommand>, cmd: PlayerCommand) -> async_graphql::Result<()> {
417    tx.send_timeout(cmd, CMD_SEND_TIMEOUT)
418        .map_err(|_| async_graphql::Error::new("player busy — command not accepted"))
419}
420
421/// The address the request came in on, for links that point back at it.
422#[derive(Clone)]
423pub(crate) struct RequestOrigin(pub String);
424
425/// The authenticated user, or an anonymous admin when there is none (auth
426/// disabled, or an in-process caller).
427fn get_auth_user(ctx: &Context<'_>) -> AuthUser {
428    ctx.data::<AuthUser>()
429        .cloned()
430        .unwrap_or_else(|_| AuthUser::anonymous_admin())
431}
432
433/// Whose favourites, playlists and history the current request reads and writes.
434fn user_id(ctx: &Context<'_>) -> i64 {
435    get_auth_user(ctx).user_id
436}
437
438/// Whose shares the current user may list and change: their own, or
439/// everyone's for an admin.
440fn share_owner(ctx: &Context<'_>) -> Option<i64> {
441    let user = get_auth_user(ctx);
442    (user.role != Role::Admin).then_some(user.user_id)
443}
444
445/// A playlist `user` may see: their own, or anyone's public one. Anyone
446/// else's is not there, as far as they can tell.
447fn readable_playlist(
448    db: &Database,
449    user: i64,
450    id: i64,
451) -> async_graphql::Result<koan_core::db::queries::PlaylistRow> {
452    use koan_core::db::queries;
453    let me = queries::auth::resolve_user(&db.conn, user).map_err(|e| internal_error("db", e))?;
454    queries::get_playlist(&db.conn, id)
455        .map_err(|e| internal_error("db", e))?
456        .filter(|p| p.readable_by(me))
457        .ok_or_else(|| async_graphql::Error::new(format!("playlist {id} not found")))
458}
459
460/// A playlist `user` may change: their own only.
461fn editable_playlist(
462    db: &Database,
463    user: i64,
464    id: i64,
465) -> async_graphql::Result<koan_core::db::queries::PlaylistRow> {
466    let list = readable_playlist(db, user, id)?;
467    let me = koan_core::db::queries::auth::resolve_user(&db.conn, user)
468        .map_err(|e| internal_error("db", e))?;
469    if list.editable_by(me) {
470        Ok(list)
471    } else {
472        Err(async_graphql::Error::new(format!(
473            "playlist {id} belongs to someone else"
474        )))
475    }
476}
477
478/// A playlist whose contents `user` may change: their own, and not a smart
479/// one.
480fn fillable_playlist(
481    db: &Database,
482    user: i64,
483    id: i64,
484) -> async_graphql::Result<koan_core::db::queries::PlaylistRow> {
485    let list = editable_playlist(db, user, id)?;
486    if list.readonly {
487        return Err(async_graphql::Error::new(format!(
488            "playlist {id} is read-only: its rules (setPlaylistRules changes \
489             them), its file in the library or its server decide what it holds"
490        )));
491    }
492    Ok(list)
493}
494
495/// A playlist `user` may rename or give rules: their own, and not one read
496/// from a file in the library, which decides both.
497fn renamable_playlist(
498    db: &Database,
499    user: i64,
500    id: i64,
501) -> async_graphql::Result<koan_core::db::queries::PlaylistRow> {
502    let list = editable_playlist(db, user, id)?;
503    if let Some(path) = &list.source_path {
504        return Err(async_graphql::Error::new(format!(
505            "playlist {id} is read from {path}: edit the file instead"
506        )));
507    }
508    Ok(list)
509}
510
511/// Evaluate the smart playlists `user` can see that are due, and have every
512/// device pull any whose contents moved.
513fn refresh_smart(db: &Database, user: i64) {
514    match koan_core::db::queries::smart::refresh_due(&db.conn, user) {
515        Ok(changed) if !changed.is_empty() => crate::clients::changed(),
516        Ok(_) => {}
517        Err(e) => log::warn!("smart playlists not refreshed: {e}"),
518    }
519}
520
521/// Whose linked clients the current user may see and command: their own,
522/// whatever their role.
523fn client_scope(ctx: &Context<'_>) -> Option<String> {
524    Some(get_auth_user(ctx).username)
525}
526
527/// Check that the current user has at least the required role.
528/// Returns an error suitable for GraphQL if the check fails.
529fn require_role(ctx: &Context<'_>, required: Role) -> async_graphql::Result<()> {
530    let user = get_auth_user(ctx);
531    if user.role.has_permission(required) {
532        Ok(())
533    } else {
534        Err(async_graphql::Error::new(format!(
535            "forbidden: requires {} role, you have {}",
536            required, user.role
537        )))
538    }
539}
540
541// ---------------------------------------------------------------------------
542// Tests
543// ---------------------------------------------------------------------------
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548    use koan_core::db::connection::Database;
549    use koan_core::db::pool::Pool;
550    use koan_core::db::queries;
551    use koan_core::player::commands::CommandChannel;
552    use tempfile::TempDir;
553
554    /// One disposable configuration directory for the whole test binary.
555    ///
556    /// Deliberately not the per-test `TempDir`: enqueueing spawns downloads on
557    /// a thread that outlives the test which started it, and that thread reads
558    /// the configuration when it runs. Without this it reads the developer's
559    /// own — their library, their server, and their credentials.
560    fn isolate_config() {
561        static DIR: std::sync::OnceLock<TempDir> = std::sync::OnceLock::new();
562        koan_core::config::set_config_dir(DIR.get_or_init(|| TempDir::new().unwrap()).path());
563    }
564
565    fn test_schema() -> (
566        KoanSchema,
567        crossbeam_channel::Receiver<PlayerCommand>,
568        TempDir,
569    ) {
570        isolate_config();
571        let tmp = TempDir::new().unwrap();
572        let db_path = tmp.path().join("test.db");
573        let db = Database::open(&db_path).unwrap();
574        koan_core::db::schema::create_tables(&db.conn).unwrap();
575
576        let state = SharedPlayerState::new();
577        let ch = CommandChannel::new();
578        let tx = ch.tx.clone();
579        let rx = ch.rx.clone();
580
581        let schema = build_schema(state, tx, Arc::new(Pool::new(db_path.clone())), None);
582        (schema, rx, tmp)
583    }
584
585    /// Same schema, but keeping the `DbHandle` so tests can read its counters.
586    fn instrumented_schema() -> (
587        KoanSchema,
588        DbHandle,
589        crossbeam_channel::Receiver<PlayerCommand>,
590        TempDir,
591    ) {
592        let tmp = TempDir::new().unwrap();
593        let db_path = tmp.path().join("test.db");
594        let db = Database::open(&db_path).unwrap();
595        koan_core::db::schema::create_tables(&db.conn).unwrap();
596
597        let state = SharedPlayerState::new();
598        let ch = CommandChannel::new();
599        let handle = DbHandle::new(Arc::new(Pool::new(db_path)));
600        let schema = build_schema_with(handle.clone(), state, ch.tx.clone(), None);
601        (schema, handle, ch.rx.clone(), tmp)
602    }
603
604    /// Seed a library on one connection — the per-track helper opens its own.
605    fn seed_library(db_path: &std::path::Path, artists: usize, albums: usize, tracks: usize) {
606        let db = Database::open(db_path).unwrap();
607        for a in 0..artists {
608            for al in 0..albums {
609                for t in 0..tracks {
610                    let meta = queries::TrackMeta {
611                        title: format!("Track {:03}-{}-{:03}", a, al, t),
612                        artist: format!("Artist {:03}", a),
613                        album_artist: Some(format!("Artist {:03}", a)),
614                        album: format!("Album {:03}-{}", a, al),
615                        track_number: Some(t as i32),
616                        disc: Some(1),
617                        date: Some("2024".into()),
618                        genre: Some("Electronic".into()),
619                        duration_ms: Some(240_000),
620                        path: Some(format!("/tmp/koan-test/{}/{}/{}.flac", a, al, t)),
621                        codec: Some("FLAC".into()),
622                        sample_rate: Some(44100),
623                        bit_depth: Some(16),
624                        channels: Some(2),
625                        bitrate: Some(1411),
626                        size_bytes: Some(42_000_000),
627                        mtime: Some(1700000000),
628                        source: "local".into(),
629                        remote_id: None,
630                        remote_url: None,
631                        album_remote_id: None,
632                        artist_remote_id: None,
633                        mbid: None,
634                        album_mbid: None,
635                        album_added_at: None,
636                        label: None,
637                    };
638                    queries::upsert_track(&db.conn, &meta).unwrap();
639                }
640            }
641        }
642    }
643
644    fn insert_test_track(db_path: &std::path::Path, title: &str, artist: &str, album: &str) -> i64 {
645        let db = Database::open(db_path).unwrap();
646        let meta = queries::TrackMeta {
647            title: title.to_string(),
648            artist: artist.to_string(),
649            album_artist: Some(artist.to_string()),
650            album: album.to_string(),
651            track_number: Some(1),
652            disc: Some(1),
653            date: Some("2024".into()),
654            genre: Some("Electronic".into()),
655            duration_ms: Some(240000),
656            path: Some(format!(
657                "/tmp/test/{}.flac",
658                title.to_lowercase().replace(' ', "_")
659            )),
660            codec: Some("FLAC".into()),
661            sample_rate: Some(44100),
662            bit_depth: Some(16),
663            channels: Some(2),
664            bitrate: Some(1411),
665            size_bytes: Some(42_000_000),
666            mtime: Some(1700000000),
667            source: "local".into(),
668            remote_id: None,
669            remote_url: None,
670            album_remote_id: None,
671            artist_remote_id: None,
672            mbid: None,
673            album_mbid: None,
674            album_added_at: None,
675            label: None,
676        };
677        queries::upsert_track(&db.conn, &meta).unwrap()
678    }
679
680    #[test]
681    fn schema_builds() {
682        let (_schema, _rx, _tmp) = test_schema();
683    }
684
685    #[tokio::test]
686    async fn library_stats_query() {
687        let (schema, _rx, tmp) = test_schema();
688        let db_path = tmp.path().join("test.db");
689        insert_test_track(&db_path, "Track1", "Artist1", "Album1");
690
691        let resp = schema
692            .execute("{ libraryStats { totalTracks totalAlbums totalArtists } }")
693            .await;
694        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
695        let data = resp.data.into_json().unwrap();
696        assert_eq!(data["libraryStats"]["totalTracks"], 1);
697        assert_eq!(data["libraryStats"]["totalAlbums"], 1);
698        assert_eq!(data["libraryStats"]["totalArtists"], 1);
699    }
700
701    #[tokio::test]
702    async fn artists_query() {
703        let (schema, _rx, tmp) = test_schema();
704        let db_path = tmp.path().join("test.db");
705        insert_test_track(&db_path, "T1", "Aphex Twin", "Drukqs");
706        insert_test_track(&db_path, "T2", "Boards of Canada", "MHTRTC");
707
708        let resp = schema
709            .execute("{ artists { edges { node { id name } } } }")
710            .await;
711        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
712        let data = resp.data.into_json().unwrap();
713        let edges = data["artists"]["edges"].as_array().unwrap();
714        assert_eq!(edges.len(), 2);
715    }
716
717    #[tokio::test]
718    async fn tracks_search() {
719        let (schema, _rx, tmp) = test_schema();
720        let db_path = tmp.path().join("test.db");
721        insert_test_track(&db_path, "Windowlicker", "Aphex Twin", "Windowlicker EP");
722        insert_test_track(&db_path, "Roygbiv", "Boards of Canada", "MHTRTC");
723
724        let resp = schema
725            .execute(r#"{ tracks(search: "Aphex") { edges { node { id title artist } } } }"#)
726            .await;
727        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
728        let data = resp.data.into_json().unwrap();
729        let edges = data["tracks"]["edges"].as_array().unwrap();
730        assert_eq!(edges.len(), 1);
731        assert_eq!(edges[0]["node"]["title"], "Windowlicker");
732    }
733
734    #[tokio::test]
735    async fn now_playing_stopped() {
736        let (schema, _rx, _tmp) = test_schema();
737        let resp = schema
738            .execute("{ nowPlaying { state positionMs track { title } } }")
739            .await;
740        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
741        let data = resp.data.into_json().unwrap();
742        assert_eq!(data["nowPlaying"]["state"], "STOPPED");
743    }
744
745    #[tokio::test]
746    async fn pause_mutation() {
747        let (schema, rx, _tmp) = test_schema();
748        let resp = schema.execute("mutation { pause { ok message } }").await;
749        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
750        let data = resp.data.into_json().unwrap();
751        assert_eq!(data["pause"]["ok"], true);
752        let cmd = rx.try_recv().unwrap();
753        assert!(matches!(cmd, PlayerCommand::Pause));
754    }
755
756    #[tokio::test]
757    async fn nested_artist_albums_tracks() {
758        let (schema, _rx, tmp) = test_schema();
759        let db_path = tmp.path().join("test.db");
760        insert_test_track(&db_path, "Vordhosbn", "Aphex Twin", "Drukqs");
761        insert_test_track(&db_path, "Avril 14th", "Aphex Twin", "Drukqs");
762
763        let resp = schema
764            .execute(
765                r#"{ artists(search: "Aphex") {
766                    edges { node {
767                        name
768                        albums { edges { node {
769                            title
770                            tracks { edges { node { title } } }
771                        } } }
772                    } }
773                } }"#,
774            )
775            .await;
776        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
777        let data = resp.data.into_json().unwrap();
778        let artist = &data["artists"]["edges"][0]["node"];
779        assert_eq!(artist["name"], "Aphex Twin");
780        let album = &artist["albums"]["edges"][0]["node"];
781        assert_eq!(album["title"], "Drukqs");
782        let tracks = album["tracks"]["edges"].as_array().unwrap();
783        assert_eq!(tracks.len(), 2);
784    }
785
786    #[tokio::test]
787    async fn pagination_has_next() {
788        let (schema, _rx, tmp) = test_schema();
789        let db_path = tmp.path().join("test.db");
790        for i in 0..5 {
791            insert_test_track(
792                &db_path,
793                &format!("Track{}", i),
794                "Artist",
795                &format!("Album{}", i),
796            );
797        }
798
799        let resp = schema
800            .execute(
801                r#"{ artists(first: 1) {
802                    edges { node { name } cursor }
803                    pageInfo { hasNextPage endCursor }
804                } }"#,
805            )
806            .await;
807        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
808        let data = resp.data.into_json().unwrap();
809        // Only 1 artist ("Artist"), so hasNextPage should be false
810        // since all 5 tracks are by the same artist.
811        assert_eq!(data["artists"]["edges"].as_array().unwrap().len(), 1);
812    }
813
814    #[tokio::test]
815    async fn clear_queue_mutation() {
816        let (schema, rx, _tmp) = test_schema();
817        let resp = schema
818            .execute("mutation { clearQueue { ok message } }")
819            .await;
820        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
821        let cmd = rx.try_recv().unwrap();
822        assert!(matches!(cmd, PlayerCommand::ClearPlaylist));
823    }
824
825    #[tokio::test]
826    async fn enqueue_mutation_adds_to_queue() {
827        let (schema, rx, tmp) = test_schema();
828        let db_path = tmp.path().join("test.db");
829
830        // Insert a track into the DB.
831        let track_id = insert_test_track(&db_path, "Windowlicker", "Aphex Twin", "Windowlicker EP");
832
833        // Execute the addToQueue mutation.
834        let query = format!(
835            "mutation {{ addToQueue(trackIds: [{}]) {{ ok message addedCount queueItemIds }} }}",
836            track_id
837        );
838        let resp = schema.execute(&query).await;
839        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
840
841        let data = resp.data.into_json().unwrap();
842        assert_eq!(data["addToQueue"]["ok"], true);
843        assert_eq!(data["addToQueue"]["addedCount"], 1);
844
845        let queue_ids = data["addToQueue"]["queueItemIds"].as_array().unwrap();
846        assert_eq!(queue_ids.len(), 1, "should return one queue item ID");
847
848        // Verify the PlayerCommand was sent through the channel.
849        // The mutation sends AddToPlaylist and then Play (auto-play when stopped).
850        let cmd = rx.try_recv().unwrap();
851        match cmd {
852            PlayerCommand::AddToPlaylist(items) => {
853                assert_eq!(items.len(), 1);
854                assert_eq!(items[0].title, "Windowlicker");
855                assert_eq!(items[0].artist, "Aphex Twin");
856                assert_eq!(items[0].album, "Windowlicker EP");
857            }
858            other => panic!("expected AddToPlaylist, got {:?}", other),
859        }
860
861        // Auto-play command should follow.
862        let play_cmd = rx.try_recv().unwrap();
863        assert!(
864            matches!(play_cmd, PlayerCommand::Play(_)),
865            "expected Play command for auto-play, got {:?}",
866            play_cmd
867        );
868    }
869
870    #[tokio::test]
871    async fn replace_queue_mutation_clears_and_enqueues() {
872        let (schema, rx, tmp) = test_schema();
873        let db_path = tmp.path().join("test.db");
874
875        let id1 = insert_test_track(&db_path, "Track A", "Artist", "Album");
876        let id2 = insert_test_track(&db_path, "Track B", "Artist", "Album");
877
878        let query = format!(
879            "mutation {{ replaceQueue(trackIds: [{}, {}]) {{ ok addedCount queueItemIds }} }}",
880            id1, id2
881        );
882        let resp = schema.execute(&query).await;
883        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
884
885        let data = resp.data.into_json().unwrap();
886        assert_eq!(data["replaceQueue"]["addedCount"], 2);
887
888        // One command, not clear-then-add-then-play: three commands down a
889        // bounded channel means the first track starts before the cursor lands
890        // on the one that was asked for.
891        match rx.try_recv().unwrap() {
892            PlayerCommand::ReplacePlaylist { items, start, .. } => {
893                assert_eq!(items.len(), 2);
894                assert_eq!(start, 0, "defaults to the first track");
895            }
896            other => panic!("expected ReplacePlaylist, got {:?}", other),
897        }
898        assert!(rx.try_recv().is_err(), "no follow-up commands");
899    }
900
901    #[tokio::test]
902    async fn replace_queue_starts_where_it_was_asked_to() {
903        let (schema, rx, tmp) = test_schema();
904        let db_path = tmp.path().join("test.db");
905
906        let id1 = insert_test_track(&db_path, "Track A", "Artist", "Album");
907        let id2 = insert_test_track(&db_path, "Track B", "Artist", "Album");
908
909        let resp = schema
910            .execute(&format!(
911                "mutation {{ replaceQueue(trackIds: [{}, {}], startAt: 1) {{ ok }} }}",
912                id1, id2
913            ))
914            .await;
915        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
916
917        match rx.try_recv().unwrap() {
918            PlayerCommand::ReplacePlaylist { start, .. } => assert_eq!(start, 1),
919            other => panic!("expected ReplacePlaylist, got {:?}", other),
920        }
921    }
922
923    // ---- Playlists ----
924
925    /// Ids are published as uids; an argument takes a uid, or the bare row id
926    /// older callers send, and a uid only names a row of the kind asked for.
927    #[tokio::test]
928    async fn ids_are_uids_and_arguments_take_either() {
929        let (schema, _rx, tmp) = test_schema();
930        let row = insert_test_track(&tmp.path().join("test.db"), "Archangel", "Burial", "Untrue");
931
932        let resp = schema
933            .execute("{ tracks { edges { node { id albumId artistId } } } }")
934            .await;
935        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
936        let data = resp.data.into_json().unwrap();
937        let node = &data["tracks"]["edges"][0]["node"];
938        let [id, album, artist] = ["id", "albumId", "artistId"].map(|k| {
939            let v = node[k].as_str().unwrap().to_string();
940            assert!(is_uid(&v), "{k}: {v}");
941            v
942        });
943        assert!(id != album && album != artist);
944
945        for arg in [format!("\"{id}\""), row.to_string()] {
946            let resp = schema
947                .execute(format!("{{ track(id: {arg}) {{ id }} }}"))
948                .await;
949            assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
950            assert_eq!(resp.data.into_json().unwrap()["track"]["id"], id);
951        }
952
953        let resp = schema
954            .execute(format!("{{ track(id: \"{album}\") {{ id }} }}"))
955            .await;
956        assert!(!resp.errors.is_empty(), "an album's uid named a track");
957    }
958
959    /// A smart playlist made over the API selects by its rules, reports them
960    /// back, refuses edits to its contents, and becomes ordinary on request.
961    #[tokio::test]
962    async fn smart_playlists_over_the_api() {
963        let (schema, _rx, tmp) = test_schema();
964        let db_path = tmp.path().join("test.db");
965        let a = insert_test_track(&db_path, "Archangel", "Burial", "Untrue");
966        insert_test_track(&db_path, "Teardrop", "Massive Attack", "Mezzanine");
967
968        let resp = schema
969            .execute(
970                r#"mutation { createSmartPlaylist(name: "Burial", rules: {
971                    rules: [{ field: "artist", op: "is", value: "burial" }]
972                }) { id trackCount readonly rules } }"#,
973            )
974            .await;
975        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
976        let data = resp.data.into_json().unwrap();
977        let made = &data["createSmartPlaylist"];
978        assert_eq!(made["trackCount"], 1);
979        assert_eq!(made["readonly"], true);
980        assert_eq!(made["rules"]["rules"][0]["field"], "artist");
981        let id = made["id"].to_string();
982
983        let resp = schema
984            .execute(format!(
985                "mutation {{ addToPlaylist(id: {id}, trackIds: [{a}]) {{ ok }} }}"
986            ))
987            .await;
988        assert!(
989            !resp.errors.is_empty(),
990            "an edit to a smart playlist's contents"
991        );
992
993        let resp = schema
994            .execute(
995                r#"mutation { createSmartPlaylist(name: "Bad", rules: {
996                    rules: [{ field: "plays", op: "gt", value: 1 }]
997                }) { id } }"#,
998            )
999            .await;
1000        let message = &resp.errors[0].message;
1001        assert!(message.contains("unknown field 'plays'"), "{message}");
1002
1003        let resp = schema
1004            .execute(format!(
1005                "mutation {{ setPlaylistRules(id: {id}, rules: null) {{ readonly rules trackCount }} }}"
1006            ))
1007            .await;
1008        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1009        let data = resp.data.into_json().unwrap();
1010        assert_eq!(data["setPlaylistRules"]["readonly"], false);
1011        assert_eq!(
1012            data["setPlaylistRules"]["trackCount"], 1,
1013            "keeps what it held"
1014        );
1015    }
1016
1017    /// The whole life of a playlist over the API: made, added to, reordered,
1018    /// renamed, played, deleted.
1019    #[tokio::test]
1020    async fn playlists_round_trip_over_the_api() {
1021        let (schema, rx, tmp) = test_schema();
1022        let db_path = tmp.path().join("test.db");
1023
1024        let a = insert_test_track(&db_path, "Track A", "Artist", "Album");
1025        let b = insert_test_track(&db_path, "Track B", "Artist", "Album");
1026        let c = insert_test_track(&db_path, "Track C", "Artist", "Album");
1027
1028        let resp = schema
1029            .execute(&format!(
1030                "mutation {{ createPlaylist(name: \"Evening\", trackIds: [{a}, {b}]) \
1031                 {{ id name trackCount }} }}"
1032            ))
1033            .await;
1034        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1035        let data = resp.data.into_json().unwrap();
1036        assert_eq!(data["createPlaylist"]["name"], "Evening");
1037        assert_eq!(data["createPlaylist"]["trackCount"], 2);
1038        assert!(is_uid(data["createPlaylist"]["id"].as_str().unwrap()));
1039        // As a quoted GraphQL literal; the track ids stay bare row ids, which
1040        // arguments still accept.
1041        let id = data["createPlaylist"]["id"].to_string();
1042
1043        let resp = schema
1044            .execute(&format!(
1045                "mutation {{ addToPlaylist(id: {id}, trackIds: [{c}]) {{ ok }} }}"
1046            ))
1047            .await;
1048        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1049
1050        let resp = schema
1051            .execute(&format!("{{ playlistTracks(id: {id}) {{ title }} }}"))
1052            .await;
1053        let data = resp.data.into_json().unwrap();
1054        let titles: Vec<&str> = data["playlistTracks"]
1055            .as_array()
1056            .unwrap()
1057            .iter()
1058            .map(|t| t["title"].as_str().unwrap())
1059            .collect();
1060        assert_eq!(
1061            titles,
1062            ["Track A", "Track B", "Track C"],
1063            "playlist order is kept"
1064        );
1065
1066        // A reorder is a wholesale replacement — the only shape Subsonic can
1067        // express, so it is the only shape koan stores.
1068        let resp = schema
1069            .execute(&format!(
1070                "mutation {{ setPlaylistTracks(id: {id}, trackIds: [{c}, {a}]) {{ ok }} }}"
1071            ))
1072            .await;
1073        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1074
1075        let resp = schema
1076            .execute(&format!(
1077                "mutation {{ renamePlaylist(id: {id}, name: \"Late\") {{ ok }} }}"
1078            ))
1079            .await;
1080        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1081
1082        let resp = schema.execute("{ playlists { id name trackCount } }").await;
1083        let data = resp.data.into_json().unwrap();
1084        assert_eq!(data["playlists"][0]["name"], "Late");
1085        assert_eq!(data["playlists"][0]["trackCount"], 2);
1086
1087        // Playing it replaces the queue rather than adding to it.
1088        let resp = schema
1089            .execute(&format!("mutation {{ playPlaylist(id: {id}) {{ ok }} }}"))
1090            .await;
1091        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1092        match rx.try_recv().unwrap() {
1093            PlayerCommand::ReplacePlaylist { items, start, .. } => {
1094                assert_eq!(items.len(), 2);
1095                assert_eq!(start, 0);
1096                assert!(items.iter().all(|i| i.playlist_entry_id.is_some()));
1097            }
1098            other => panic!("expected ReplacePlaylist, got {:?}", other),
1099        }
1100        assert!(rx.try_recv().is_err());
1101
1102        let resp = schema
1103            .execute(&format!("mutation {{ deletePlaylist(id: {id}) {{ ok }} }}"))
1104            .await;
1105        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1106        let resp = schema.execute("{ playlists { id } }").await;
1107        let data = resp.data.into_json().unwrap();
1108        assert!(data["playlists"].as_array().unwrap().is_empty());
1109    }
1110
1111    /// A queue item with no library row behind it cannot go in a playlist: a
1112    /// playlist points at rows, not at paths.
1113    #[tokio::test]
1114    async fn saving_the_queue_keeps_only_what_the_library_knows() {
1115        use koan_core::player::state::{ItemState, PlaylistItem};
1116
1117        let tmp = TempDir::new().unwrap();
1118        let db_path = tmp.path().join("test.db");
1119        let db = Database::open(&db_path).unwrap();
1120        koan_core::db::schema::create_tables(&db.conn).unwrap();
1121        drop(db);
1122        let known = insert_test_track(&db_path, "Known", "Artist", "Album");
1123
1124        let state = SharedPlayerState::new();
1125        let ch = CommandChannel::new();
1126        let schema = build_schema(
1127            state.clone(),
1128            ch.tx.clone(),
1129            Arc::new(Pool::new(db_path.clone())),
1130            None,
1131        );
1132
1133        let item = |title: &str, path: &str, db_id: Option<i64>| PlaylistItem {
1134            playlist_entry_id: None,
1135            id: QueueItemId::new(),
1136            db_id,
1137            path: std::path::PathBuf::from(path),
1138            title: title.to_string(),
1139            artist: "Artist".to_string(),
1140            album_artist: "Artist".to_string(),
1141            album: "Album".to_string(),
1142            year: None,
1143            codec: None,
1144            track_number: None,
1145            disc: None,
1146            duration_ms: None,
1147            state: ItemState::Ready,
1148            played: false,
1149        };
1150        state.add_items(vec![
1151            item("Known", "/music/known.flac", Some(known)),
1152            // Played from a file that was never indexed — a queue can hold one,
1153            // a playlist cannot.
1154            item("Stranger", "/elsewhere/stranger.flac", None),
1155        ]);
1156
1157        let resp = schema
1158            .execute("mutation { saveQueueAsPlaylist(name: \"Tonight\") { id trackCount } }")
1159            .await;
1160        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1161        let data = resp.data.into_json().unwrap();
1162        assert_eq!(data["saveQueueAsPlaylist"]["trackCount"], 1);
1163    }
1164
1165    // ---- Queue snapshot, viz, config, playlist version, subscriptions ----
1166
1167    #[tokio::test]
1168    async fn queue_snapshot_has_version_and_status() {
1169        let (schema, _rx, _tmp) = test_schema();
1170
1171        let resp = schema
1172            .execute("{ queue { version entries { queueItemId status } hasPlaying queueCount } }")
1173            .await;
1174        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1175        let data = resp.data.into_json().unwrap();
1176        // Empty queue.
1177        assert_eq!(data["queue"]["version"], 0);
1178        assert_eq!(data["queue"]["entries"].as_array().unwrap().len(), 0);
1179        assert_eq!(data["queue"]["hasPlaying"], false);
1180        assert_eq!(data["queue"]["queueCount"], 0);
1181    }
1182
1183    #[tokio::test]
1184    async fn queue_entries_have_status_and_download_progress() {
1185        use koan_core::player::state::{ItemState, PlaylistItem};
1186
1187        // Build schema with a shared state we can manipulate directly.
1188        let tmp = TempDir::new().unwrap();
1189        let db_path = tmp.path().join("test.db");
1190        let db = Database::open(&db_path).unwrap();
1191        koan_core::db::schema::create_tables(&db.conn).unwrap();
1192
1193        let state = SharedPlayerState::new();
1194        let ch = CommandChannel::new();
1195        let schema = build_schema(
1196            state.clone(),
1197            ch.tx.clone(),
1198            Arc::new(Pool::new(db_path.clone())),
1199            None,
1200        );
1201
1202        // Directly add items to the playlist (simulating what the player thread does).
1203        let item = PlaylistItem {
1204            playlist_entry_id: None,
1205            id: QueueItemId::new(),
1206            db_id: None,
1207            path: std::path::PathBuf::from("/tmp/test/windowlicker.flac"),
1208            title: "Windowlicker".to_string(),
1209            artist: "Aphex Twin".to_string(),
1210            album_artist: "Aphex Twin".to_string(),
1211            album: "Windowlicker EP".to_string(),
1212            year: None,
1213            codec: Some("FLAC".to_string()),
1214            track_number: Some(1),
1215            disc: Some(1),
1216            duration_ms: Some(240000),
1217            state: ItemState::Ready,
1218            played: false,
1219        };
1220        state.add_items(vec![item]);
1221
1222        // Query the queue — should have one entry with QUEUED status.
1223        let resp = schema
1224            .execute(
1225                "{ queue { version entries { queueItemId title status downloadProgress { downloaded total } isCurrent } finishedCount } }",
1226            )
1227            .await;
1228        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1229        let data = resp.data.into_json().unwrap();
1230        let entries = data["queue"]["entries"].as_array().unwrap();
1231        assert_eq!(entries.len(), 1);
1232        assert_eq!(entries[0]["title"], "Windowlicker");
1233        // Without a cursor set, all entries are QUEUED.
1234        assert_eq!(entries[0]["status"], "QUEUED");
1235        assert_eq!(entries[0]["isCurrent"], false);
1236        // Local track — no download progress.
1237        assert!(entries[0]["downloadProgress"].is_null());
1238    }
1239
1240    #[tokio::test]
1241    async fn viz_frame_returns_none_without_viz() {
1242        let (schema, _rx, _tmp) = test_schema();
1243
1244        let resp = schema
1245            .execute("{ vizFrame { spectrum peaks vuLevels beatEnergy } }")
1246            .await;
1247        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1248        let data = resp.data.into_json().unwrap();
1249        assert!(data["vizFrame"].is_null());
1250    }
1251
1252    #[tokio::test]
1253    async fn viz_frame_returns_data_with_viz() {
1254        // Build schema with a VizSnapshot.
1255        let tmp = TempDir::new().unwrap();
1256        let db_path = tmp.path().join("test.db");
1257        let db = Database::open(&db_path).unwrap();
1258        koan_core::db::schema::create_tables(&db.conn).unwrap();
1259
1260        let state = SharedPlayerState::new();
1261        let ch = CommandChannel::new();
1262        let viz = koan_core::audio::viz::VizSnapshot::new();
1263
1264        // Write some test data.
1265        let mut spectrum = [0.0f32; 48];
1266        spectrum[0] = 0.75;
1267        viz.write(koan_core::audio::viz::VizFrame {
1268            spectrum,
1269            peaks: [0.0; 48],
1270            vu_levels: [0.42, 0.38],
1271            beat_energy: 0.6,
1272            timestamp: std::time::Instant::now(),
1273            waveform: Vec::new(),
1274        });
1275
1276        let schema = build_schema(
1277            state,
1278            ch.tx.clone(),
1279            Arc::new(Pool::new(db_path.clone())),
1280            Some(viz),
1281        );
1282
1283        let resp = schema
1284            .execute("{ vizFrame { spectrum peaks vuLevels beatEnergy waveform } }")
1285            .await;
1286        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1287        let data = resp.data.into_json().unwrap();
1288        let frame = &data["vizFrame"];
1289        assert!(!frame.is_null());
1290        let spectrum = frame["spectrum"].as_array().unwrap();
1291        assert_eq!(spectrum.len(), 48);
1292        assert!((spectrum[0].as_f64().unwrap() - 0.75).abs() < 0.01);
1293        let vu = frame["vuLevels"].as_array().unwrap();
1294        assert_eq!(vu.len(), 2);
1295        assert!((vu[0].as_f64().unwrap() - 0.42).abs() < 0.01);
1296        assert!((frame["beatEnergy"].as_f64().unwrap() - 0.6).abs() < 0.01);
1297        // Waveform empty — we didn't request includeWaveform.
1298        let waveform = frame["waveform"].as_array().unwrap();
1299        assert!(waveform.is_empty());
1300    }
1301
1302    #[tokio::test]
1303    async fn config_query() {
1304        let (schema, _rx, _tmp) = test_schema();
1305
1306        let resp = schema
1307            .execute(
1308                "{ config { libraryFolders replaygainMode targetFps artSize remoteEnabled graphqlPort } }",
1309            )
1310            .await;
1311        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1312        let data = resp.data.into_json().unwrap();
1313        let cfg = &data["config"];
1314        // Defaults from Config::default().
1315        assert!(cfg["libraryFolders"].is_array());
1316        assert!(cfg["targetFps"].as_i64().unwrap() > 0);
1317        assert!(cfg["artSize"].as_i64().unwrap() > 0);
1318    }
1319
1320    #[tokio::test]
1321    async fn playlist_version_query() {
1322        let (schema, _rx, _tmp) = test_schema();
1323
1324        let resp = schema.execute("{ playlistVersion }").await;
1325        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1326        let data = resp.data.into_json().unwrap();
1327        assert_eq!(data["playlistVersion"], 0);
1328    }
1329
1330    #[tokio::test]
1331    async fn subscription_types_in_schema() {
1332        // Verify that subscriptions are registered by introspecting the schema.
1333        let (schema, _rx, _tmp) = test_schema();
1334
1335        let resp = schema
1336            .execute("{ __schema { subscriptionType { fields { name } } } }")
1337            .await;
1338        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1339        let data = resp.data.into_json().unwrap();
1340        let fields = data["__schema"]["subscriptionType"]["fields"]
1341            .as_array()
1342            .unwrap();
1343        let names: Vec<&str> = fields.iter().filter_map(|f| f["name"].as_str()).collect();
1344        assert!(
1345            names.contains(&"nowPlaying"),
1346            "missing nowPlaying subscription"
1347        );
1348        assert!(
1349            names.contains(&"queueUpdated"),
1350            "missing queueUpdated subscription"
1351        );
1352        assert!(names.contains(&"vizFrame"), "missing vizFrame subscription");
1353    }
1354    // -- Fan-out and pagination --
1355
1356    #[tokio::test]
1357    async fn nested_fan_out_opens_a_bounded_number_of_connections() {
1358        let (schema, handle, _rx, tmp) = instrumented_schema();
1359        seed_library(&tmp.path().join("test.db"), 10, 3, 4);
1360
1361        let before = handle.open_count();
1362        let resp = schema
1363            .execute(
1364                "{ artists(first: 10) { edges { node { name albumCount trackCount \
1365                   albums(first: 10) { edges { node { title trackCount totalDurationMs \
1366                   tracks(first: 10) { edges { node { title isFavourite } } } } } } } } } }",
1367            )
1368            .await;
1369        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1370
1371        let data = resp.data.into_json().unwrap();
1372        let artists = data["artists"]["edges"].as_array().unwrap();
1373        assert_eq!(artists.len(), 10, "query did not actually fan out");
1374        assert_eq!(artists[0]["node"]["albumCount"], 3);
1375        assert_eq!(artists[0]["node"]["trackCount"], 12);
1376
1377        // Before pooling this was one full `Database::open` — DDL batch,
1378        // migrations and a WAL checkpoint — per resolver field.
1379        let opened = handle.open_count() - before;
1380        assert!(opened <= 16, "opened {} connections", opened);
1381    }
1382
1383    #[tokio::test]
1384    async fn is_favourite_batches_into_one_query() {
1385        let (schema, handle, _rx, tmp) = instrumented_schema();
1386        seed_library(&tmp.path().join("test.db"), 1, 1, 100);
1387
1388        let before = handle.batch_count();
1389        let resp = schema
1390            .execute("{ tracks(first: 100) { edges { node { title isFavourite } } } }")
1391            .await;
1392        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1393        let data = resp.data.into_json().unwrap();
1394        assert_eq!(data["tracks"]["edges"].as_array().unwrap().len(), 100);
1395
1396        // The invariant is that the query count does not scale with the row
1397        // count: without the dataloader this was one full `favourites` scan per
1398        // track. The dataloader's gather window can close more than once under
1399        // load, so assert the property rather than an exact batch count.
1400        let batches = handle.batch_count() - before;
1401        assert!(
1402            batches < 10,
1403            "{batches} batches for 100 tracks — expected a handful, not one per row"
1404        );
1405    }
1406
1407    #[tokio::test]
1408    async fn tracks_without_first_returns_the_default_page() {
1409        let (schema, _handle, _rx, tmp) = instrumented_schema();
1410        seed_library(
1411            &tmp.path().join("test.db"),
1412            1,
1413            1,
1414            helpers::DEFAULT_PAGE + 20,
1415        );
1416
1417        let resp = schema
1418            .execute("{ tracks { edges { node { title } } pageInfo { hasNextPage } } }")
1419            .await;
1420        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1421        let data = resp.data.into_json().unwrap();
1422        assert_eq!(
1423            data["tracks"]["edges"].as_array().unwrap().len(),
1424            helpers::DEFAULT_PAGE
1425        );
1426        assert_eq!(data["tracks"]["pageInfo"]["hasNextPage"], true);
1427    }
1428
1429    #[tokio::test]
1430    async fn first_is_clamped_to_the_maximum_page() {
1431        let (schema, _handle, _rx, tmp) = instrumented_schema();
1432        seed_library(&tmp.path().join("test.db"), 1, 1, 10);
1433
1434        let resp = schema
1435            .execute("{ tracks(first: 100000) { edges { node { title } } } }")
1436            .await;
1437        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1438        let data = resp.data.into_json().unwrap();
1439        assert_eq!(data["tracks"]["edges"].as_array().unwrap().len(), 10);
1440    }
1441
1442    #[tokio::test]
1443    async fn negative_first_is_empty_not_a_panic() {
1444        let (schema, _handle, _rx, tmp) = instrumented_schema();
1445        seed_library(&tmp.path().join("test.db"), 2, 1, 3);
1446
1447        for query in [
1448            "{ tracks(first: -1) { edges { node { title } } } }",
1449            "{ artists(first: -1) { edges { node { name } } } }",
1450            "{ albums(first: -1) { edges { node { title } } } }",
1451        ] {
1452            let resp = schema.execute(query).await;
1453            assert!(resp.errors.is_empty(), "{}: {:?}", query, resp.errors);
1454        }
1455    }
1456
1457    #[tokio::test]
1458    async fn sort_arguments_are_honoured() {
1459        let (schema, _handle, _rx, tmp) = instrumented_schema();
1460        seed_library(&tmp.path().join("test.db"), 3, 1, 2);
1461
1462        let resp = schema
1463            .execute(
1464                "{ tracks(first: 100, sortBy: TITLE, sortDir: DESC) { edges { node { title } } } }",
1465            )
1466            .await;
1467        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1468        let data = resp.data.into_json().unwrap();
1469        let titles: Vec<String> = data["tracks"]["edges"]
1470            .as_array()
1471            .unwrap()
1472            .iter()
1473            .map(|e| e["node"]["title"].as_str().unwrap().to_string())
1474            .collect();
1475        let mut sorted = titles.clone();
1476        sorted.sort();
1477        sorted.reverse();
1478        assert_eq!(titles, sorted, "sortBy/sortDir were ignored");
1479    }
1480
1481    /// A blocking resolver must not hold the runtime. On a single-threaded
1482    /// runtime, anything running inline would freeze every other task for the
1483    /// whole query — which is what stalled in-flight audio streams.
1484    #[test]
1485    fn a_slow_query_does_not_stall_other_tasks() {
1486        use std::sync::atomic::{AtomicUsize, Ordering};
1487
1488        let rt = tokio::runtime::Builder::new_current_thread()
1489            .enable_time()
1490            .build()
1491            .unwrap();
1492        let (schema, _handle, _rx, tmp) = instrumented_schema();
1493        seed_library(&tmp.path().join("test.db"), 20, 5, 8);
1494
1495        rt.block_on(async move {
1496            let ticks = Arc::new(AtomicUsize::new(0));
1497            let counter = ticks.clone();
1498            let ticker = tokio::spawn(async move {
1499                loop {
1500                    tokio::time::sleep(Duration::from_millis(1)).await;
1501                    counter.fetch_add(1, Ordering::Relaxed);
1502                }
1503            });
1504
1505            // fuzzySearch loads the library and builds a whole Nucleo matcher.
1506            let mut running = Vec::new();
1507            for _ in 0..4 {
1508                let schema = schema.clone();
1509                running.push(tokio::spawn(async move {
1510                    schema
1511                        .execute("{ fuzzySearch(query: \"track\") { id } }")
1512                        .await
1513                }));
1514            }
1515            for handle in running {
1516                let resp = handle.await.unwrap();
1517                assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1518            }
1519
1520            ticker.abort();
1521            assert!(
1522                ticks.load(Ordering::Relaxed) >= 2,
1523                "no other task ran while the queries were in flight"
1524            );
1525        });
1526    }
1527
1528    #[tokio::test]
1529    async fn accounts_are_made_and_invited_through_graphql() {
1530        let (schema, _rx, _tmp) = test_schema();
1531        let run = |q: &str| {
1532            schema.execute(
1533                async_graphql::Request::new(q)
1534                    .data(RequestOrigin("https://music.example.com".into())),
1535            )
1536        };
1537        let r = run(r#"mutation { createUser(username: "sarita", role: READONLY) { username link password emailText } }"#).await;
1538        assert!(r.errors.is_empty(), "{:?}", r.errors);
1539        let v = r.data.into_json().unwrap();
1540        let link = v["createUser"]["link"].as_str().unwrap();
1541        let invite = koan_core::invite::Invite::parse(link).unwrap();
1542        assert_eq!(invite.server, "https://music.example.com");
1543        assert!(invite.token.is_some() && invite.password.is_none());
1544        let password = v["createUser"]["password"].as_str().unwrap();
1545        assert!(
1546            v["createUser"]["emailText"]
1547                .as_str()
1548                .unwrap()
1549                .contains(password)
1550        );
1551
1552        let r = run(r#"mutation { inviteUser(username: "sarita", server: "https://other.example.com") { server password } }"#).await;
1553        let v = r.data.into_json().unwrap();
1554        assert_eq!(v["inviteUser"]["server"], "https://other.example.com");
1555        assert!(v["inviteUser"]["password"].is_null());
1556
1557        let r = run(
1558            r#"mutation { setUserPassword(username: "sarita", password: "correct horse") { ok } }"#,
1559        )
1560        .await;
1561        assert!(r.errors.is_empty(), "{:?}", r.errors);
1562
1563        let r = run(r#"mutation { setUserRole(username: "sarita", role: USER) { ok } }"#).await;
1564        assert!(r.errors.is_empty(), "{:?}", r.errors);
1565        let r = run("{ users { username role } }").await;
1566        let v = r.data.into_json().unwrap();
1567        assert_eq!(v["users"][0]["role"], "USER");
1568
1569        let r = run(r#"mutation { deleteUser(username: "sarita") { ok } }"#).await;
1570        assert!(r.errors.is_empty(), "{:?}", r.errors);
1571        let r = run("{ users { username } }").await;
1572        assert_eq!(
1573            r.data.into_json().unwrap()["users"]
1574                .as_array()
1575                .unwrap()
1576                .len(),
1577            0
1578        );
1579    }
1580}