1mod helpers;
2mod mutations;
3mod queries;
4mod server;
5mod subscriptions;
6mod types;
7
8use std::path::PathBuf;
9use std::sync::Arc;
10
11use async_graphql::{Context, Schema};
12use crossbeam_channel::Sender;
13use koan_core::audio::viz::VizSnapshot;
14use koan_core::db::connection::Database;
15use koan_core::player::commands::PlayerCommand;
16use koan_core::player::state::{QueueItemId, SharedPlayerState};
17use uuid::Uuid;
18
19use koan_core::auth::Role;
20use mutations::MutationRoot;
21use queries::QueryRoot;
22pub use server::{
23 ApiServerOpts, cmd_serve, cmd_serve_daemon, execute_in_process, start_api_background,
24};
25use subscriptions::SubscriptionRoot;
26
27use crate::auth::AuthUser;
28
29#[derive(Clone)]
34struct DbHandle {
35 path: PathBuf,
36}
37
38impl DbHandle {
39 fn open(&self) -> async_graphql::Result<Database> {
40 Database::open(&self.path)
41 .map_err(|e| async_graphql::Error::new(format!("db error: {}", e)))
42 }
43}
44
45pub type KoanSchema = Schema<QueryRoot, MutationRoot, SubscriptionRoot>;
50
51pub fn build_schema(
52 state: Arc<SharedPlayerState>,
53 cmd_tx: Sender<PlayerCommand>,
54 db_path: PathBuf,
55 viz: Option<Arc<VizSnapshot>>,
56) -> KoanSchema {
57 let mut builder = Schema::build(QueryRoot, MutationRoot, SubscriptionRoot)
58 .data(DbHandle { path: db_path })
59 .data(state)
60 .data(cmd_tx);
61 if let Some(viz) = viz {
62 builder = builder.data(viz);
63 }
64 builder.finish()
65}
66
67fn parse_queue_item_id(s: &str) -> async_graphql::Result<QueueItemId> {
72 Uuid::parse_str(s)
73 .map(QueueItemId)
74 .map_err(|e| async_graphql::Error::new(format!("invalid queue item ID '{}': {}", s, e)))
75}
76
77fn send_cmd(ctx: &Context<'_>, cmd: PlayerCommand) -> async_graphql::Result<()> {
78 let tx = ctx.data::<Sender<PlayerCommand>>()?;
79 tx.send(cmd)
80 .map_err(|e| async_graphql::Error::new(format!("send error: {}", e)))
81}
82
83fn get_auth_user(ctx: &Context<'_>) -> AuthUser {
86 ctx.data::<AuthUser>()
87 .cloned()
88 .unwrap_or_else(|_| AuthUser::anonymous_admin())
89}
90
91fn require_role(ctx: &Context<'_>, required: Role) -> async_graphql::Result<()> {
94 let user = get_auth_user(ctx);
95 if user.role.has_permission(required) {
96 Ok(())
97 } else {
98 Err(async_graphql::Error::new(format!(
99 "forbidden: requires {} role, you have {}",
100 required, user.role
101 )))
102 }
103}
104
105#[cfg(test)]
110mod tests {
111 use super::*;
112 use koan_core::db::connection::Database;
113 use koan_core::db::queries;
114 use koan_core::player::commands::CommandChannel;
115 use tempfile::TempDir;
116
117 fn test_schema() -> (
118 KoanSchema,
119 crossbeam_channel::Receiver<PlayerCommand>,
120 TempDir,
121 ) {
122 let tmp = TempDir::new().unwrap();
123 let db_path = tmp.path().join("test.db");
124 let db = Database::open(&db_path).unwrap();
125 koan_core::db::schema::create_tables(&db.conn).unwrap();
126
127 let state = SharedPlayerState::new();
128 let ch = CommandChannel::new();
129 let tx = ch.tx.clone();
130 let rx = ch.rx.clone();
131
132 let schema = build_schema(state, tx, db_path, None);
133 (schema, rx, tmp)
134 }
135
136 fn insert_test_track(db_path: &std::path::Path, title: &str, artist: &str, album: &str) -> i64 {
137 let db = Database::open(db_path).unwrap();
138 let meta = queries::TrackMeta {
139 title: title.to_string(),
140 artist: artist.to_string(),
141 album_artist: Some(artist.to_string()),
142 album: album.to_string(),
143 track_number: Some(1),
144 disc: Some(1),
145 date: Some("2024".into()),
146 genre: Some("Electronic".into()),
147 duration_ms: Some(240000),
148 path: Some(format!(
149 "/tmp/test/{}.flac",
150 title.to_lowercase().replace(' ', "_")
151 )),
152 codec: Some("FLAC".into()),
153 sample_rate: Some(44100),
154 bit_depth: Some(16),
155 channels: Some(2),
156 bitrate: Some(1411),
157 size_bytes: Some(42_000_000),
158 mtime: Some(1700000000),
159 source: "local".into(),
160 remote_id: None,
161 remote_url: None,
162 label: None,
163 };
164 queries::upsert_track(&db.conn, &meta).unwrap()
165 }
166
167 #[test]
168 fn schema_builds() {
169 let (_schema, _rx, _tmp) = test_schema();
170 }
171
172 #[tokio::test]
173 async fn library_stats_query() {
174 let (schema, _rx, tmp) = test_schema();
175 let db_path = tmp.path().join("test.db");
176 insert_test_track(&db_path, "Track1", "Artist1", "Album1");
177
178 let resp = schema
179 .execute("{ libraryStats { totalTracks totalAlbums totalArtists } }")
180 .await;
181 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
182 let data = resp.data.into_json().unwrap();
183 assert_eq!(data["libraryStats"]["totalTracks"], 1);
184 assert_eq!(data["libraryStats"]["totalAlbums"], 1);
185 assert_eq!(data["libraryStats"]["totalArtists"], 1);
186 }
187
188 #[tokio::test]
189 async fn artists_query() {
190 let (schema, _rx, tmp) = test_schema();
191 let db_path = tmp.path().join("test.db");
192 insert_test_track(&db_path, "T1", "Aphex Twin", "Drukqs");
193 insert_test_track(&db_path, "T2", "Boards of Canada", "MHTRTC");
194
195 let resp = schema
196 .execute("{ artists { edges { node { id name } } } }")
197 .await;
198 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
199 let data = resp.data.into_json().unwrap();
200 let edges = data["artists"]["edges"].as_array().unwrap();
201 assert_eq!(edges.len(), 2);
202 }
203
204 #[tokio::test]
205 async fn tracks_search() {
206 let (schema, _rx, tmp) = test_schema();
207 let db_path = tmp.path().join("test.db");
208 insert_test_track(&db_path, "Windowlicker", "Aphex Twin", "Windowlicker EP");
209 insert_test_track(&db_path, "Roygbiv", "Boards of Canada", "MHTRTC");
210
211 let resp = schema
212 .execute(r#"{ tracks(search: "Aphex") { edges { node { id title artist } } } }"#)
213 .await;
214 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
215 let data = resp.data.into_json().unwrap();
216 let edges = data["tracks"]["edges"].as_array().unwrap();
217 assert_eq!(edges.len(), 1);
218 assert_eq!(edges[0]["node"]["title"], "Windowlicker");
219 }
220
221 #[tokio::test]
222 async fn now_playing_stopped() {
223 let (schema, _rx, _tmp) = test_schema();
224 let resp = schema
225 .execute("{ nowPlaying { state positionMs track { title } } }")
226 .await;
227 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
228 let data = resp.data.into_json().unwrap();
229 assert_eq!(data["nowPlaying"]["state"], "STOPPED");
230 }
231
232 #[tokio::test]
233 async fn pause_mutation() {
234 let (schema, rx, _tmp) = test_schema();
235 let resp = schema.execute("mutation { pause { ok message } }").await;
236 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
237 let data = resp.data.into_json().unwrap();
238 assert_eq!(data["pause"]["ok"], true);
239 let cmd = rx.try_recv().unwrap();
240 assert!(matches!(cmd, PlayerCommand::Pause));
241 }
242
243 #[tokio::test]
244 async fn nested_artist_albums_tracks() {
245 let (schema, _rx, tmp) = test_schema();
246 let db_path = tmp.path().join("test.db");
247 insert_test_track(&db_path, "Vordhosbn", "Aphex Twin", "Drukqs");
248 insert_test_track(&db_path, "Avril 14th", "Aphex Twin", "Drukqs");
249
250 let resp = schema
251 .execute(
252 r#"{ artists(search: "Aphex") {
253 edges { node {
254 name
255 albums { edges { node {
256 title
257 tracks { edges { node { title } } }
258 } } }
259 } }
260 } }"#,
261 )
262 .await;
263 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
264 let data = resp.data.into_json().unwrap();
265 let artist = &data["artists"]["edges"][0]["node"];
266 assert_eq!(artist["name"], "Aphex Twin");
267 let album = &artist["albums"]["edges"][0]["node"];
268 assert_eq!(album["title"], "Drukqs");
269 let tracks = album["tracks"]["edges"].as_array().unwrap();
270 assert_eq!(tracks.len(), 2);
271 }
272
273 #[tokio::test]
274 async fn pagination_has_next() {
275 let (schema, _rx, tmp) = test_schema();
276 let db_path = tmp.path().join("test.db");
277 for i in 0..5 {
278 insert_test_track(
279 &db_path,
280 &format!("Track{}", i),
281 "Artist",
282 &format!("Album{}", i),
283 );
284 }
285
286 let resp = schema
287 .execute(
288 r#"{ artists(first: 1) {
289 edges { node { name } cursor }
290 pageInfo { hasNextPage endCursor }
291 } }"#,
292 )
293 .await;
294 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
295 let data = resp.data.into_json().unwrap();
296 assert_eq!(data["artists"]["edges"].as_array().unwrap().len(), 1);
299 }
300
301 #[tokio::test]
302 async fn clear_queue_mutation() {
303 let (schema, rx, _tmp) = test_schema();
304 let resp = schema
305 .execute("mutation { clearQueue { ok message } }")
306 .await;
307 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
308 let cmd = rx.try_recv().unwrap();
309 assert!(matches!(cmd, PlayerCommand::ClearPlaylist));
310 }
311
312 #[tokio::test]
313 async fn enqueue_mutation_adds_to_queue() {
314 let (schema, rx, tmp) = test_schema();
315 let db_path = tmp.path().join("test.db");
316
317 let track_id = insert_test_track(&db_path, "Windowlicker", "Aphex Twin", "Windowlicker EP");
319
320 let query = format!(
322 "mutation {{ addToQueue(trackIds: [{}]) {{ ok message addedCount queueItemIds }} }}",
323 track_id
324 );
325 let resp = schema.execute(&query).await;
326 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
327
328 let data = resp.data.into_json().unwrap();
329 assert_eq!(data["addToQueue"]["ok"], true);
330 assert_eq!(data["addToQueue"]["addedCount"], 1);
331
332 let queue_ids = data["addToQueue"]["queueItemIds"].as_array().unwrap();
333 assert_eq!(queue_ids.len(), 1, "should return one queue item ID");
334
335 let cmd = rx.try_recv().unwrap();
338 match cmd {
339 PlayerCommand::AddToPlaylist(items) => {
340 assert_eq!(items.len(), 1);
341 assert_eq!(items[0].title, "Windowlicker");
342 assert_eq!(items[0].artist, "Aphex Twin");
343 assert_eq!(items[0].album, "Windowlicker EP");
344 }
345 other => panic!("expected AddToPlaylist, got {:?}", other),
346 }
347
348 let play_cmd = rx.try_recv().unwrap();
350 assert!(
351 matches!(play_cmd, PlayerCommand::Play(_)),
352 "expected Play command for auto-play, got {:?}",
353 play_cmd
354 );
355 }
356
357 #[tokio::test]
358 async fn replace_queue_mutation_clears_and_enqueues() {
359 let (schema, rx, tmp) = test_schema();
360 let db_path = tmp.path().join("test.db");
361
362 let id1 = insert_test_track(&db_path, "Track A", "Artist", "Album");
363 let id2 = insert_test_track(&db_path, "Track B", "Artist", "Album");
364
365 let query = format!(
366 "mutation {{ replaceQueue(trackIds: [{}, {}]) {{ ok addedCount queueItemIds }} }}",
367 id1, id2
368 );
369 let resp = schema.execute(&query).await;
370 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
371
372 let data = resp.data.into_json().unwrap();
373 assert_eq!(data["replaceQueue"]["addedCount"], 2);
374
375 let cmd1 = rx.try_recv().unwrap();
377 assert!(
378 matches!(cmd1, PlayerCommand::ClearPlaylist),
379 "first command should be ClearPlaylist"
380 );
381
382 let cmd2 = rx.try_recv().unwrap();
383 match cmd2 {
384 PlayerCommand::AddToPlaylist(items) => {
385 assert_eq!(items.len(), 2);
386 }
387 other => panic!("expected AddToPlaylist, got {:?}", other),
388 }
389
390 let cmd3 = rx.try_recv().unwrap();
391 assert!(
392 matches!(cmd3, PlayerCommand::Play(_)),
393 "third command should be Play"
394 );
395 }
396
397 #[tokio::test]
400 async fn queue_snapshot_has_version_and_status() {
401 let (schema, _rx, _tmp) = test_schema();
402
403 let resp = schema
404 .execute("{ queue { version entries { queueItemId status } hasPlaying queueCount } }")
405 .await;
406 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
407 let data = resp.data.into_json().unwrap();
408 assert_eq!(data["queue"]["version"], 0);
410 assert_eq!(data["queue"]["entries"].as_array().unwrap().len(), 0);
411 assert_eq!(data["queue"]["hasPlaying"], false);
412 assert_eq!(data["queue"]["queueCount"], 0);
413 }
414
415 #[tokio::test]
416 async fn queue_entries_have_status_and_download_progress() {
417 use koan_core::player::state::{LoadState, PlaylistItem};
418
419 let tmp = TempDir::new().unwrap();
421 let db_path = tmp.path().join("test.db");
422 let db = Database::open(&db_path).unwrap();
423 koan_core::db::schema::create_tables(&db.conn).unwrap();
424
425 let state = SharedPlayerState::new();
426 let ch = CommandChannel::new();
427 let schema = build_schema(state.clone(), ch.tx.clone(), db_path, None);
428
429 let item = PlaylistItem {
431 id: QueueItemId::new(),
432 db_id: None,
433 path: std::path::PathBuf::from("/tmp/test/windowlicker.flac"),
434 title: "Windowlicker".to_string(),
435 artist: "Aphex Twin".to_string(),
436 album_artist: "Aphex Twin".to_string(),
437 album: "Windowlicker EP".to_string(),
438 year: None,
439 codec: Some("FLAC".to_string()),
440 track_number: Some(1),
441 disc: Some(1),
442 duration_ms: Some(240000),
443 load_state: LoadState::Ready,
444 };
445 state.add_items(vec![item]);
446
447 let resp = schema
449 .execute(
450 "{ queue { version entries { queueItemId title status downloadProgress { downloaded total } isCurrent } finishedCount } }",
451 )
452 .await;
453 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
454 let data = resp.data.into_json().unwrap();
455 let entries = data["queue"]["entries"].as_array().unwrap();
456 assert_eq!(entries.len(), 1);
457 assert_eq!(entries[0]["title"], "Windowlicker");
458 assert_eq!(entries[0]["status"], "QUEUED");
460 assert_eq!(entries[0]["isCurrent"], false);
461 assert!(entries[0]["downloadProgress"].is_null());
463 }
464
465 #[tokio::test]
466 async fn viz_frame_returns_none_without_viz() {
467 let (schema, _rx, _tmp) = test_schema();
468
469 let resp = schema
470 .execute("{ vizFrame { spectrum peaks vuLevels beatEnergy } }")
471 .await;
472 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
473 let data = resp.data.into_json().unwrap();
474 assert!(data["vizFrame"].is_null());
475 }
476
477 #[tokio::test]
478 async fn viz_frame_returns_data_with_viz() {
479 let tmp = TempDir::new().unwrap();
481 let db_path = tmp.path().join("test.db");
482 let db = Database::open(&db_path).unwrap();
483 koan_core::db::schema::create_tables(&db.conn).unwrap();
484
485 let state = SharedPlayerState::new();
486 let ch = CommandChannel::new();
487 let viz = koan_core::audio::viz::VizSnapshot::new();
488
489 let mut spectrum = [0.0f32; 48];
491 spectrum[0] = 0.75;
492 viz.write(koan_core::audio::viz::VizFrame {
493 spectrum,
494 peaks: [0.0; 48],
495 vu_levels: [0.42, 0.38],
496 beat_energy: 0.6,
497 timestamp: std::time::Instant::now(),
498 waveform: Vec::new(),
499 });
500
501 let schema = build_schema(state, ch.tx.clone(), db_path, Some(viz));
502
503 let resp = schema
504 .execute("{ vizFrame { spectrum peaks vuLevels beatEnergy waveform } }")
505 .await;
506 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
507 let data = resp.data.into_json().unwrap();
508 let frame = &data["vizFrame"];
509 assert!(!frame.is_null());
510 let spectrum = frame["spectrum"].as_array().unwrap();
511 assert_eq!(spectrum.len(), 48);
512 assert!((spectrum[0].as_f64().unwrap() - 0.75).abs() < 0.01);
513 let vu = frame["vuLevels"].as_array().unwrap();
514 assert_eq!(vu.len(), 2);
515 assert!((vu[0].as_f64().unwrap() - 0.42).abs() < 0.01);
516 assert!((frame["beatEnergy"].as_f64().unwrap() - 0.6).abs() < 0.01);
517 let waveform = frame["waveform"].as_array().unwrap();
519 assert!(waveform.is_empty());
520 }
521
522 #[tokio::test]
523 async fn config_query() {
524 let (schema, _rx, _tmp) = test_schema();
525
526 let resp = schema
527 .execute(
528 "{ config { libraryFolders replaygainMode targetFps artSize remoteEnabled graphqlPort } }",
529 )
530 .await;
531 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
532 let data = resp.data.into_json().unwrap();
533 let cfg = &data["config"];
534 assert!(cfg["libraryFolders"].is_array());
536 assert!(cfg["targetFps"].as_i64().unwrap() > 0);
537 assert!(cfg["artSize"].as_i64().unwrap() > 0);
538 }
539
540 #[tokio::test]
541 async fn playlist_version_query() {
542 let (schema, _rx, _tmp) = test_schema();
543
544 let resp = schema.execute("{ playlistVersion }").await;
545 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
546 let data = resp.data.into_json().unwrap();
547 assert_eq!(data["playlistVersion"], 0);
548 }
549
550 #[tokio::test]
551 async fn subscription_types_in_schema() {
552 let (schema, _rx, _tmp) = test_schema();
554
555 let resp = schema
556 .execute("{ __schema { subscriptionType { fields { name } } } }")
557 .await;
558 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
559 let data = resp.data.into_json().unwrap();
560 let fields = data["__schema"]["subscriptionType"]["fields"]
561 .as_array()
562 .unwrap();
563 let names: Vec<&str> = fields.iter().filter_map(|f| f["name"].as_str()).collect();
564 assert!(
565 names.contains(&"nowPlaying"),
566 "missing nowPlaying subscription"
567 );
568 assert!(
569 names.contains(&"queueUpdated"),
570 "missing queueUpdated subscription"
571 );
572 assert!(names.contains(&"vizFrame"), "missing vizFrame subscription");
573 }
574}