feat(qbzd): API tiers 2-3 — discover, playlists, favorites, labels, system (55 endpoints)

Tier 2 - Browsing & Management:
- Discover: index, playlists, featured, genres (4 endpoints)
- Favorites: get, add, remove (3)
- Playlists: CRUD + tracks + search (8)
- Extended catalog: artist page, similar, labels, tags (6)

Tier 3 - Server Management:
- System resources: RAM, cache stats (1)
- Cache clear (1)

Total: 55 HTTP endpoints covering playback, queue, search, catalog,
discover, favorites, playlists, audio settings, labels, and system.

cc
This commit is contained in:
vicrodh committed 2026-04-09 04:41:49 -06:00
1 parent 8c947c02b4
commit b524d62318
7 files changed
+424 -2

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
use std::sync::Arc;
use axum::extract::{Path, Query};
use axum::Json;
use serde::Deserialize;
use crate::daemon::DaemonCore;
#[derive(Deserialize)]
pub struct PaginationQuery {
#[serde(default = "default_limit")]
pub limit: u32,
#[serde(default)]
pub offset: u32,
}
fn default_limit() -> u32 { 20 }
pub async fn get_artist_page(
daemon: Arc<DaemonCore>,
Path(id): Path<u64>,
) -> Result<Json<serde_json::Value>, String> {
let result = daemon.core.get_artist_page(id, None)
.await
.map_err(|e| e.to_string())?;
Ok(Json(serde_json::to_value(result).unwrap_or_default()))
}
pub async fn get_similar_artists(
daemon: Arc<DaemonCore>,
Path(id): Path<u64>,
Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, String> {
let result = daemon.core.get_similar_artists(id, q.limit, q.offset)
.await
.map_err(|e| e.to_string())?;
Ok(Json(serde_json::to_value(result).unwrap_or_default()))
}
pub async fn get_label(
daemon: Arc<DaemonCore>,
Path(id): Path<u64>,
Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, String> {
let result = daemon.core.get_label(id, q.limit, q.offset)
.await
.map_err(|e| e.to_string())?;
Ok(Json(serde_json::to_value(result).unwrap_or_default()))
}
pub async fn get_label_page(
daemon: Arc<DaemonCore>,
Path(id): Path<u64>,
) -> Result<Json<serde_json::Value>, String> {
let result = daemon.core.get_label_page(id)
.await
.map_err(|e| e.to_string())?;
Ok(Json(serde_json::to_value(result).unwrap_or_default()))
}
pub async fn get_label_explore(
daemon: Arc<DaemonCore>,
Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, String> {
let result = daemon.core.get_label_explore(q.limit, q.offset)
.await
.map_err(|e| e.to_string())?;
Ok(Json(serde_json::to_value(result).unwrap_or_default()))
}
pub async fn get_playlist_tags(
daemon: Arc<DaemonCore>,
) -> Result<Json<serde_json::Value>, String> {
let result = daemon.core.get_playlist_tags()
.await
.map_err(|e| e.to_string())?;
Ok(Json(serde_json::to_value(result).unwrap_or_default()))
}
+93
View File
@@ -0,0 +1,93 @@
use std::sync::Arc;
use axum::extract::Query;
use axum::Json;
use serde::Deserialize;
use crate::daemon::DaemonCore;
#[derive(Deserialize)]
pub struct DiscoverQuery {
pub genre: Option<String>,
}
#[derive(Deserialize)]
pub struct DiscoverAlbumsQuery {
pub r#type: Option<String>,
pub genre: Option<String>,
#[serde(default = "default_limit")]
pub limit: u32,
#[serde(default)]
pub offset: u32,
}
#[derive(Deserialize)]
pub struct DiscoverPlaylistsQuery {
pub tag: Option<String>,
pub genre: Option<String>,
#[serde(default = "default_limit")]
pub limit: u32,
#[serde(default)]
pub offset: u32,
}
#[derive(Deserialize)]
pub struct FeaturedQuery {
pub r#type: Option<String>,
#[serde(default = "default_limit")]
pub limit: u32,
#[serde(default)]
pub offset: u32,
pub genre: Option<String>,
}
fn default_limit() -> u32 { 20 }
fn parse_genre_ids(genre: &Option<String>) -> Option<Vec<u64>> {
genre.as_ref().map(|g| {
g.split(',').filter_map(|s| s.trim().parse().ok()).collect()
})
}
pub async fn get_discover_index(
daemon: Arc<DaemonCore>,
Query(q): Query<DiscoverQuery>,
) -> Result<Json<serde_json::Value>, String> {
let genres = parse_genre_ids(&q.genre);
let result = daemon.core.get_discover_index(genres)
.await
.map_err(|e| e.to_string())?;
Ok(Json(serde_json::to_value(result).unwrap_or_default()))
}
pub async fn get_discover_playlists(
daemon: Arc<DaemonCore>,
Query(q): Query<DiscoverPlaylistsQuery>,
) -> Result<Json<serde_json::Value>, String> {
let genres = parse_genre_ids(&q.genre);
let result = daemon.core.get_discover_playlists(
q.tag,
genres,
Some(q.limit),
Some(q.offset),
).await.map_err(|e| e.to_string())?;
Ok(Json(serde_json::to_value(result).unwrap_or_default()))
}
pub async fn get_featured(
daemon: Arc<DaemonCore>,
Query(q): Query<FeaturedQuery>,
) -> Result<Json<serde_json::Value>, String> {
let genre_id = q.genre.as_ref().and_then(|g| g.parse().ok());
let featured_type = q.r#type.as_deref().unwrap_or("new-releases");
let result = daemon.core.get_featured_albums(featured_type, q.limit, q.offset, genre_id)
.await
.map_err(|e| e.to_string())?;
Ok(Json(serde_json::to_value(result).unwrap_or_default()))
}
pub async fn get_genres(daemon: Arc<DaemonCore>) -> Result<Json<serde_json::Value>, String> {
let result = daemon.core.get_genres(None)
.await
.map_err(|e| e.to_string())?;
Ok(Json(serde_json::to_value(result).unwrap_or_default()))
}
+62
View File
@@ -0,0 +1,62 @@
use std::sync::Arc;
use axum::extract::Query;
use axum::Json;
use serde::Deserialize;
use crate::daemon::DaemonCore;
#[derive(Deserialize)]
pub struct FavoritesQuery {
pub r#type: String,
#[serde(default = "default_limit")]
pub limit: u32,
#[serde(default)]
pub offset: u32,
}
fn default_limit() -> u32 { 50 }
#[derive(Deserialize)]
pub struct FavoriteAction {
pub r#type: String,
pub item_id: String,
}
pub async fn get_favorites(
daemon: Arc<DaemonCore>,
Query(q): Query<FavoritesQuery>,
) -> Result<Json<serde_json::Value>, String> {
let client = daemon.core.client();
let guard = client.read().await;
let client = guard.as_ref().ok_or("Not initialized")?;
let result = client.get_favorites(&q.r#type, q.limit, q.offset)
.await
.map_err(|e| e.to_string())?;
Ok(Json(result))
}
pub async fn add_favorite(
daemon: Arc<DaemonCore>,
Json(req): Json<FavoriteAction>,
) -> Result<&'static str, String> {
let client = daemon.core.client();
let guard = client.read().await;
let client = guard.as_ref().ok_or("Not initialized")?;
client.add_favorite(&req.r#type, &req.item_id)
.await
.map_err(|e| e.to_string())?;
Ok("ok")
}
pub async fn remove_favorite(
daemon: Arc<DaemonCore>,
Json(req): Json<FavoriteAction>,
) -> Result<&'static str, String> {
let client = daemon.core.client();
let guard = client.read().await;
let client = guard.as_ref().ok_or("Not initialized")?;
client.remove_favorite(&req.r#type, &req.item_id)
.await
.map_err(|e| e.to_string())?;
Ok("ok")
}
+5
View File
@@ -1,6 +1,11 @@
pub mod audio;
pub mod catalog;
pub mod catalog_ext;
pub mod discover;
pub mod events;
pub mod favorites;
pub mod playback;
pub mod playlists;
pub mod queue;
pub mod search;
pub mod system;
+118
View File
@@ -0,0 +1,118 @@
use std::sync::Arc;
use axum::extract::Path;
use axum::Json;
use serde::Deserialize;
use crate::daemon::DaemonCore;
#[derive(Deserialize)]
pub struct CreatePlaylistRequest {
pub name: String,
pub description: Option<String>,
#[serde(default)]
pub is_public: bool,
}
#[derive(Deserialize)]
pub struct UpdatePlaylistRequest {
pub name: Option<String>,
pub description: Option<String>,
pub is_public: Option<bool>,
}
#[derive(Deserialize)]
pub struct TrackIdsRequest {
pub track_ids: Vec<u64>,
}
pub async fn get_playlists(daemon: Arc<DaemonCore>) -> Result<Json<serde_json::Value>, String> {
let client = daemon.core.client();
let guard = client.read().await;
let client = guard.as_ref().ok_or("Not initialized")?;
let playlists = client.get_user_playlists()
.await
.map_err(|e| e.to_string())?;
Ok(Json(serde_json::to_value(playlists).unwrap_or_default()))
}
pub async fn get_playlist(
daemon: Arc<DaemonCore>,
Path(id): Path<u64>,
) -> Result<Json<serde_json::Value>, String> {
let client = daemon.core.client();
let guard = client.read().await;
let client = guard.as_ref().ok_or("Not initialized")?;
let playlist = client.get_playlist(id)
.await
.map_err(|e| e.to_string())?;
Ok(Json(serde_json::to_value(playlist).unwrap_or_default()))
}
pub async fn create_playlist(
daemon: Arc<DaemonCore>,
Json(req): Json<CreatePlaylistRequest>,
) -> Result<Json<serde_json::Value>, String> {
let result = daemon.core.create_playlist(&req.name, req.description.as_deref(), req.is_public)
.await
.map_err(|e| e.to_string())?;
Ok(Json(serde_json::to_value(result).unwrap_or_default()))
}
pub async fn update_playlist(
daemon: Arc<DaemonCore>,
Path(id): Path<u64>,
Json(req): Json<UpdatePlaylistRequest>,
) -> Result<Json<serde_json::Value>, String> {
let result = daemon.core.update_playlist(id, req.name.as_deref(), req.description.as_deref(), req.is_public)
.await
.map_err(|e| e.to_string())?;
Ok(Json(serde_json::to_value(result).unwrap_or_default()))
}
pub async fn delete_playlist(
daemon: Arc<DaemonCore>,
Path(id): Path<u64>,
) -> Result<&'static str, String> {
daemon.core.delete_playlist(id)
.await
.map_err(|e| e.to_string())?;
Ok("ok")
}
pub async fn add_tracks(
daemon: Arc<DaemonCore>,
Path(id): Path<u64>,
Json(req): Json<TrackIdsRequest>,
) -> Result<&'static str, String> {
let client = daemon.core.client();
let guard = client.read().await;
let client = guard.as_ref().ok_or("Not initialized")?;
client.add_tracks_to_playlist(id, &req.track_ids)
.await
.map_err(|e| e.to_string())?;
Ok("ok")
}
pub async fn remove_tracks(
daemon: Arc<DaemonCore>,
Path(id): Path<u64>,
Json(req): Json<TrackIdsRequest>,
) -> Result<&'static str, String> {
let client = daemon.core.client();
let guard = client.read().await;
let client = guard.as_ref().ok_or("Not initialized")?;
client.remove_tracks_from_playlist(id, &req.track_ids)
.await
.map_err(|e| e.to_string())?;
Ok("ok")
}
pub async fn search_playlists(
daemon: Arc<DaemonCore>,
axum::extract::Query(q): axum::extract::Query<super::search::SearchParams>,
) -> Result<Json<serde_json::Value>, String> {
let result = daemon.core.search_playlists(&q.q, q.limit, q.offset)
.await
.map_err(|e| e.to_string())?;
Ok(Json(serde_json::to_value(result).unwrap_or_default()))
}
+35
View File
@@ -0,0 +1,35 @@
use std::sync::Arc;
use axum::Json;
use crate::daemon::DaemonCore;
pub async fn get_resources(daemon: Arc<DaemonCore>) -> Json<serde_json::Value> {
let sys = sysinfo::System::new_with_specifics(
sysinfo::RefreshKind::nothing()
.with_memory(sysinfo::MemoryRefreshKind::everything())
.with_cpu(sysinfo::CpuRefreshKind::nothing().with_cpu_usage()),
);
let cache_stats = daemon.audio_cache.stats();
Json(serde_json::json!({
"ram": {
"total_mb": sys.total_memory() / (1024 * 1024),
"used_mb": sys.used_memory() / (1024 * 1024),
"available_mb": sys.available_memory() / (1024 * 1024),
},
"cache": {
"memory_used_bytes": cache_stats.current_size_bytes,
"memory_max_bytes": cache_stats.max_size_bytes,
"memory_tracks": cache_stats.cached_tracks,
"config_memory_mb": daemon.config.cache.memory_mb,
"config_disk_mb": daemon.config.cache.disk_mb,
},
}))
}
pub async fn clear_cache(daemon: Arc<DaemonCore>) -> &'static str {
daemon.audio_cache.clear();
log::info!("[qbzd] Audio cache cleared via API");
"ok"
}
+34 -2
View File
@@ -329,12 +329,16 @@ macro_rules! with_daemon {
let d = $daemon.clone();
move |p, q| $handler(d.clone(), p, q)
}};
($daemon:expr, $handler:path, path_json) => {{
let d = $daemon.clone();
move |p, j| $handler(d.clone(), p, j)
}};
}
/// Build the Axum HTTP router.
fn build_router(daemon: Arc<DaemonCore>) -> axum::Router {
use axum::routing::{get, post, patch};
use crate::api::{audio, playback, queue, search, catalog};
use axum::routing::{get, post, patch, put, delete};
use crate::api::{audio, catalog, catalog_ext, discover, favorites, playback, playlists, queue, search, system};
axum::Router::new()
// System
@@ -375,6 +379,34 @@ fn build_router(daemon: Arc<DaemonCore>) -> axum::Router {
.route("/api/audio/backends", get(with_daemon!(daemon, audio::get_backends)))
.route("/api/audio/devices", get(with_daemon!(daemon, audio::get_devices, query)))
.route("/api/audio/hardware-status", get(with_daemon!(daemon, audio::get_hardware_status)))
// Discover / Home
.route("/api/discover", get(with_daemon!(daemon, discover::get_discover_index, query)))
.route("/api/discover/playlists", get(with_daemon!(daemon, discover::get_discover_playlists, query)))
.route("/api/discover/featured", get(with_daemon!(daemon, discover::get_featured, query)))
.route("/api/genres", get(with_daemon!(daemon, discover::get_genres)))
// Favorites
.route("/api/favorites", get(with_daemon!(daemon, favorites::get_favorites, query)))
.route("/api/favorites", post(with_daemon!(daemon, favorites::add_favorite, json)))
.route("/api/favorites", delete(with_daemon!(daemon, favorites::remove_favorite, json)))
// Playlists
.route("/api/playlists", get(with_daemon!(daemon, playlists::get_playlists)))
.route("/api/playlists", post(with_daemon!(daemon, playlists::create_playlist, json)))
.route("/api/playlists/search", get(with_daemon!(daemon, playlists::search_playlists, query)))
.route("/api/playlists/{id}", get(with_daemon!(daemon, playlists::get_playlist, path)))
.route("/api/playlists/{id}", put(with_daemon!(daemon, playlists::update_playlist, path_json)))
.route("/api/playlists/{id}", delete(with_daemon!(daemon, playlists::delete_playlist, path)))
.route("/api/playlists/{id}/tracks", post(with_daemon!(daemon, playlists::add_tracks, path_json)))
.route("/api/playlists/{id}/tracks", delete(with_daemon!(daemon, playlists::remove_tracks, path_json)))
// Extended catalog
.route("/api/artists/{id}/page", get(with_daemon!(daemon, catalog_ext::get_artist_page, path)))
.route("/api/artists/{id}/similar", get(with_daemon!(daemon, catalog_ext::get_similar_artists, path_query)))
.route("/api/labels/{id}", get(with_daemon!(daemon, catalog_ext::get_label, path_query)))
.route("/api/labels/{id}/page", get(with_daemon!(daemon, catalog_ext::get_label_page, path)))
.route("/api/labels/explore", get(with_daemon!(daemon, catalog_ext::get_label_explore, query)))
.route("/api/playlist-tags", get(with_daemon!(daemon, catalog_ext::get_playlist_tags)))
// System / Resources
.route("/api/system/resources", get(with_daemon!(daemon, system::get_resources)))
.route("/api/cache", delete(with_daemon!(daemon, system::clear_cache)))
}
async fn ping_handler() -> &'static str {