Skip to main content

koan_server/graphql/
mod.rs

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