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:
7 files changed
+424
-2
No files matched your search
@@ -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()))
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user