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:
6 files changed
+363
-22
No files matched your search
@@ -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()))
|
||||
}
|
||||
@@ -1 +1,5 @@
|
||||
pub mod catalog;
|
||||
pub mod events;
|
||||
pub mod playback;
|
||||
pub mod queue;
|
||||
pub mod search;
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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) }))
|
||||
}
|
||||
@@ -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(¶ms.q, params.limit, params.offset)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
"albums" => {
|
||||
let result = daemon.core.search_albums(¶ms.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(¶ms.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(¶ms.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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user