feat(qbzd): HTTP API tier 1 - playback, queue, search, catalog (Phase 4)

27 endpoints covering core remote control functionality:

System (4): ping, info, status, SSE events
Playback (8): get state, play, pause, stop, next, prev, seek, volume
Queue (10): get, set, add, add-next, play-index, remove, move, clear,
  shuffle, repeat
Search (1): unified search (all/albums/tracks/artists via type param)
Catalog (4): album detail, artist detail, track detail, batch tracks

All handlers call QbzCore methods directly (no Tauri dependency).
with_daemon! macro reduces route handler boilerplate.

cc
This commit is contained in:
vicrodh committed 2026-04-09 03:43:54 -06:00
1 parent ac17787264
commit 3b8555992e
6 files changed
+363 -22

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
use std::sync::Arc;
use axum::extract::{Path, Query};
use axum::Json;
use serde::Deserialize;
use crate::daemon::DaemonCore;
#[derive(Deserialize)]
pub struct BatchParams {
pub ids: String,
}
pub async fn get_album(
daemon: Arc<DaemonCore>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, String> {
let album = daemon.core.get_album(&id).await.map_err(|e| e.to_string())?;
Ok(Json(serde_json::to_value(album).unwrap_or_default()))
}
pub async fn get_artist(
daemon: Arc<DaemonCore>,
Path(id): Path<u64>,
) -> Result<Json<serde_json::Value>, String> {
let artist = daemon.core.get_artist(id).await.map_err(|e| e.to_string())?;
Ok(Json(serde_json::to_value(artist).unwrap_or_default()))
}
pub async fn get_track(
daemon: Arc<DaemonCore>,
Path(id): Path<u64>,
) -> Result<Json<serde_json::Value>, String> {
let track = daemon.core.get_track(id).await.map_err(|e| e.to_string())?;
Ok(Json(serde_json::to_value(track).unwrap_or_default()))
}
pub async fn get_tracks_batch(
daemon: Arc<DaemonCore>,
Query(params): Query<BatchParams>,
) -> Result<Json<serde_json::Value>, String> {
let ids: Vec<u64> = params.ids.split(',')
.filter_map(|s| s.trim().parse().ok())
.collect();
if ids.is_empty() {
return Ok(Json(serde_json::json!({"tracks": []})));
}
let tracks = daemon.core.get_tracks_batch(&ids).await.map_err(|e| e.to_string())?;
Ok(Json(serde_json::to_value(tracks).unwrap_or_default()))
}
+4
View File
@@ -1 +1,5 @@
pub mod catalog;
pub mod events;
pub mod playback;
pub mod queue;
pub mod search;
+74
View File
@@ -0,0 +1,74 @@
use std::sync::Arc;
use axum::Json;
use serde::Deserialize;
use crate::daemon::DaemonCore;
#[derive(Deserialize)]
pub struct SeekRequest {
pub position_secs: u64,
}
#[derive(Deserialize)]
pub struct VolumeRequest {
pub volume: f32,
}
pub async fn get_playback(daemon: Arc<DaemonCore>) -> Json<serde_json::Value> {
let player = daemon.core.player();
let state = &player.state;
Json(serde_json::json!({
"state": if state.is_playing() { "Playing" } else if state.current_track_id() != 0 { "Paused" } else { "Stopped" },
"track_id": state.current_track_id(),
"position_secs": state.current_position(),
"duration_secs": state.duration(),
"volume": state.volume(),
"sample_rate": state.get_sample_rate(),
"bit_depth": state.get_bit_depth(),
}))
}
pub async fn play(daemon: Arc<DaemonCore>) -> Result<&'static str, String> {
daemon.core.resume().map_err(|e| e.to_string())?;
Ok("ok")
}
pub async fn pause(daemon: Arc<DaemonCore>) -> Result<&'static str, String> {
daemon.core.pause().map_err(|e| e.to_string())?;
Ok("ok")
}
pub async fn stop(daemon: Arc<DaemonCore>) -> Result<&'static str, String> {
daemon.core.stop().map_err(|e| e.to_string())?;
Ok("ok")
}
pub async fn next(daemon: Arc<DaemonCore>) -> Json<serde_json::Value> {
let track = daemon.core.next_track().await;
Json(serde_json::json!({
"track": track,
}))
}
pub async fn previous(daemon: Arc<DaemonCore>) -> Json<serde_json::Value> {
let track = daemon.core.previous_track().await;
Json(serde_json::json!({
"track": track,
}))
}
pub async fn seek(
daemon: Arc<DaemonCore>,
Json(req): Json<SeekRequest>,
) -> Result<&'static str, String> {
daemon.core.seek(req.position_secs).map_err(|e| e.to_string())?;
Ok("ok")
}
pub async fn volume(
daemon: Arc<DaemonCore>,
Json(req): Json<VolumeRequest>,
) -> Result<&'static str, String> {
daemon.core.set_volume(req.volume).map_err(|e| e.to_string())?;
Ok("ok")
}
+124
View File
@@ -0,0 +1,124 @@
use std::sync::Arc;
use axum::Json;
use qbz_models::{QueueTrack, RepeatMode};
use serde::Deserialize;
use crate::daemon::DaemonCore;
#[derive(Deserialize)]
pub struct SetQueueRequest {
pub tracks: Vec<QueueTrack>,
pub start_index: Option<usize>,
}
#[derive(Deserialize)]
pub struct AddTracksRequest {
pub tracks: Vec<QueueTrack>,
}
#[derive(Deserialize)]
pub struct PlayIndexRequest {
pub index: usize,
}
#[derive(Deserialize)]
pub struct RemoveRequest {
pub index: usize,
}
#[derive(Deserialize)]
pub struct MoveRequest {
pub from: usize,
pub to: usize,
}
#[derive(Deserialize)]
pub struct ShuffleRequest {
pub enabled: bool,
}
#[derive(Deserialize)]
pub struct RepeatRequest {
pub mode: String,
}
pub async fn get_queue(daemon: Arc<DaemonCore>) -> Json<serde_json::Value> {
let state = daemon.core.get_queue_state().await;
Json(serde_json::to_value(state).unwrap_or_default())
}
pub async fn set_queue(
daemon: Arc<DaemonCore>,
Json(req): Json<SetQueueRequest>,
) -> &'static str {
daemon.core.set_queue(req.tracks, req.start_index).await;
"ok"
}
pub async fn add(
daemon: Arc<DaemonCore>,
Json(req): Json<AddTracksRequest>,
) -> &'static str {
daemon.core.add_tracks(req.tracks).await;
"ok"
}
pub async fn add_next(
daemon: Arc<DaemonCore>,
Json(req): Json<AddTracksRequest>,
) -> &'static str {
for track in req.tracks {
daemon.core.add_track_next(track).await;
}
"ok"
}
pub async fn play_index(
daemon: Arc<DaemonCore>,
Json(req): Json<PlayIndexRequest>,
) -> Json<serde_json::Value> {
let track = daemon.core.play_index(req.index).await;
Json(serde_json::json!({ "track": track }))
}
pub async fn remove(
daemon: Arc<DaemonCore>,
Json(req): Json<RemoveRequest>,
) -> Json<serde_json::Value> {
let removed = daemon.core.remove_track(req.index).await;
Json(serde_json::json!({ "removed": removed }))
}
pub async fn move_track(
daemon: Arc<DaemonCore>,
Json(req): Json<MoveRequest>,
) -> Json<serde_json::Value> {
let ok = daemon.core.move_track(req.from, req.to).await;
Json(serde_json::json!({ "success": ok }))
}
pub async fn clear(daemon: Arc<DaemonCore>) -> &'static str {
daemon.core.clear_queue().await;
"ok"
}
pub async fn shuffle(
daemon: Arc<DaemonCore>,
Json(req): Json<ShuffleRequest>,
) -> Json<serde_json::Value> {
daemon.core.set_shuffle(req.enabled).await;
Json(serde_json::json!({ "shuffle": req.enabled }))
}
pub async fn repeat(
daemon: Arc<DaemonCore>,
Json(req): Json<RepeatRequest>,
) -> Json<serde_json::Value> {
let mode = match req.mode.as_str() {
"one" => RepeatMode::One,
"all" => RepeatMode::All,
_ => RepeatMode::Off,
};
daemon.core.set_repeat_mode(mode.clone()).await;
Json(serde_json::json!({ "repeat": format!("{:?}", mode) }))
}
+53
View File
@@ -0,0 +1,53 @@
use std::sync::Arc;
use axum::extract::Query;
use axum::Json;
use serde::Deserialize;
use crate::daemon::DaemonCore;
#[derive(Deserialize)]
pub struct SearchParams {
pub q: String,
#[serde(default = "default_type")]
pub r#type: String,
#[serde(default = "default_limit")]
pub limit: u32,
#[serde(default)]
pub offset: u32,
}
fn default_type() -> String { "all".to_string() }
fn default_limit() -> u32 { 30 }
pub async fn search(
daemon: Arc<DaemonCore>,
Query(params): Query<SearchParams>,
) -> Result<Json<serde_json::Value>, String> {
match params.r#type.as_str() {
"all" => {
let result = daemon.core.catalog_search(&params.q, params.limit, params.offset)
.await
.map_err(|e| e.to_string())?;
Ok(Json(result))
}
"albums" => {
let result = daemon.core.search_albums(&params.q, params.limit, params.offset, None)
.await
.map_err(|e| e.to_string())?;
Ok(Json(serde_json::to_value(result).unwrap_or_default()))
}
"tracks" => {
let result = daemon.core.search_tracks(&params.q, params.limit, params.offset, None)
.await
.map_err(|e| e.to_string())?;
Ok(Json(serde_json::to_value(result).unwrap_or_default()))
}
"artists" => {
let result = daemon.core.search_artists(&params.q, params.limit, params.offset, None)
.await
.map_err(|e| e.to_string())?;
Ok(Json(serde_json::to_value(result).unwrap_or_default()))
}
_ => Err(format!("Unknown search type: {}", params.r#type)),
}
}
+57 -22
View File
@@ -261,33 +261,68 @@ fn spawn_playback_loop(
});
}
/// Macro to reduce boilerplate for route handlers that take Arc<DaemonCore>
macro_rules! with_daemon {
($daemon:expr, $handler:path) => {{
let d = $daemon.clone();
move || $handler(d.clone())
}};
($daemon:expr, $handler:path, json) => {{
let d = $daemon.clone();
move |body| $handler(d.clone(), body)
}};
($daemon:expr, $handler:path, query) => {{
let d = $daemon.clone();
move |q| $handler(d.clone(), q)
}};
($daemon:expr, $handler:path, path) => {{
let d = $daemon.clone();
move |p| $handler(d.clone(), p)
}};
($daemon:expr, $handler:path, path_query) => {{
let d = $daemon.clone();
move |p, q| $handler(d.clone(), p, q)
}};
}
/// Build the Axum HTTP router.
fn build_router(daemon: Arc<DaemonCore>) -> axum::Router {
use axum::routing::get;
use axum::routing::{get, post};
use crate::api::{playback, queue, search, catalog};
axum::Router::new()
// System
.route("/api/ping", get(ping_handler))
.route(
"/api/info",
get({
let d = daemon.clone();
move || info_handler(d.clone())
}),
)
.route(
"/api/status",
get({
let d = daemon.clone();
move || status_handler(d.clone())
}),
)
.route(
"/api/events",
get({
let d = daemon.clone();
move || crate::api::events::sse_handler(d.clone())
}),
)
.route("/api/info", get(with_daemon!(daemon, info_handler)))
.route("/api/status", get(with_daemon!(daemon, status_handler)))
.route("/api/events", get(with_daemon!(daemon, crate::api::events::sse_handler)))
// Playback
.route("/api/playback", get(with_daemon!(daemon, playback::get_playback)))
.route("/api/playback/play", post(with_daemon!(daemon, playback::play)))
.route("/api/playback/pause", post(with_daemon!(daemon, playback::pause)))
.route("/api/playback/stop", post(with_daemon!(daemon, playback::stop)))
.route("/api/playback/next", post(with_daemon!(daemon, playback::next)))
.route("/api/playback/previous", post(with_daemon!(daemon, playback::previous)))
.route("/api/playback/seek", post(with_daemon!(daemon, playback::seek, json)))
.route("/api/playback/volume", post(with_daemon!(daemon, playback::volume, json)))
// Queue
.route("/api/queue", get(with_daemon!(daemon, queue::get_queue)))
.route("/api/queue/set", post(with_daemon!(daemon, queue::set_queue, json)))
.route("/api/queue/add", post(with_daemon!(daemon, queue::add, json)))
.route("/api/queue/add-next", post(with_daemon!(daemon, queue::add_next, json)))
.route("/api/queue/play-index", post(with_daemon!(daemon, queue::play_index, json)))
.route("/api/queue/remove", post(with_daemon!(daemon, queue::remove, json)))
.route("/api/queue/move", post(with_daemon!(daemon, queue::move_track, json)))
.route("/api/queue/clear", post(with_daemon!(daemon, queue::clear)))
.route("/api/queue/shuffle", post(with_daemon!(daemon, queue::shuffle, json)))
.route("/api/queue/repeat", post(with_daemon!(daemon, queue::repeat, json)))
// Search
.route("/api/search", get(with_daemon!(daemon, search::search, query)))
// Catalog
.route("/api/albums/{id}", get(with_daemon!(daemon, catalog::get_album, path)))
.route("/api/artists/{id}", get(with_daemon!(daemon, catalog::get_artist, path)))
.route("/api/tracks/{id}", get(with_daemon!(daemon, catalog::get_track, path)))
.route("/api/tracks/batch", get(with_daemon!(daemon, catalog::get_tracks_batch, query)))
}
async fn ping_handler() -> &'static str {