feat(qbzd): library management + integrations API (67 endpoints)

Library endpoints for standalone operation:
- GET /api/library/albums, artists, album tracks, search, stats
- POST/DELETE /api/library/folders (add/remove scan directories)

Integrations endpoints:
- GET/POST/DELETE /api/integrations/listenbrainz (status, connect, disconnect)
- GET /api/integrations/lastfm (status, setup deferred to TUI wizard)

ListenBrainz connects by writing token directly to the V2 cache
database (same format as desktop app). Last.fm requires OAuth
flow which will be implemented in the TUI wizard.

Total: 67 HTTP endpoints. The daemon is now fully operable
without the desktop app for all core functionality.

cc
This commit is contained in:
vicrodh committed 2026-04-09 04:46:12 -06:00
1 parent b524d62318
commit 42a8ff5665
4 files changed
+244 -1

No files matched your search

+92
View File
@@ -0,0 +1,92 @@
use std::sync::Arc;
use axum::Json;
use serde::Deserialize;
use crate::daemon::DaemonCore;
// For now, integrations are read/write via the shared SQLite databases
// in the user's data directory. The V2 integration states
// (ListenBrainzV2State, etc.) live in src-tauri/integrations_v2.rs
// and are not yet in a standalone crate. As a workaround, we read/write
// the cache databases directly.
pub async fn get_listenbrainz_status(
daemon: Arc<DaemonCore>,
) -> Result<Json<serde_json::Value>, String> {
let user = daemon.user.read().await;
let session = user.as_ref().ok_or("No active session")?;
let cache_path = session.data_dir.join("cache").join("listenbrainz_v2.db");
if !cache_path.exists() {
return Ok(Json(serde_json::json!({"connected": false})));
}
let conn = rusqlite::Connection::open(&cache_path)
.map_err(|e| format!("DB error: {}", e))?;
let token: Option<String> = conn
.query_row("SELECT value FROM config WHERE key = 'token'", [], |row| row.get(0))
.ok();
Ok(Json(serde_json::json!({
"connected": token.is_some(),
"token_preview": token.as_ref().map(|t| {
if t.len() > 8 { format!("{}...{}", &t[..4], &t[t.len()-4..]) } else { "****".to_string() }
}),
})))
}
#[derive(Deserialize)]
pub struct ListenBrainzConnectRequest {
pub token: String,
}
pub async fn connect_listenbrainz(
daemon: Arc<DaemonCore>,
Json(req): Json<ListenBrainzConnectRequest>,
) -> Result<Json<serde_json::Value>, String> {
let user = daemon.user.read().await;
let session = user.as_ref().ok_or("No active session")?;
let cache_dir = session.data_dir.join("cache");
std::fs::create_dir_all(&cache_dir).ok();
let cache_path = cache_dir.join("listenbrainz_v2.db");
let conn = rusqlite::Connection::open(&cache_path)
.map_err(|e| format!("DB error: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
.map_err(|e| format!("Schema error: {}", e))?;
conn.execute(
"INSERT OR REPLACE INTO config (key, value) VALUES ('token', ?1)",
rusqlite::params![req.token],
).map_err(|e| format!("Save error: {}", e))?;
log::info!("[qbzd] ListenBrainz token saved");
Ok(Json(serde_json::json!({"connected": true})))
}
pub async fn disconnect_listenbrainz(
daemon: Arc<DaemonCore>,
) -> Result<&'static str, String> {
let user = daemon.user.read().await;
let session = user.as_ref().ok_or("No active session")?;
let cache_path = session.data_dir.join("cache").join("listenbrainz_v2.db");
if cache_path.exists() {
let conn = rusqlite::Connection::open(&cache_path)
.map_err(|e| format!("DB error: {}", e))?;
let _ = conn.execute("DELETE FROM config WHERE key = 'token'", []);
}
log::info!("[qbzd] ListenBrainz disconnected");
Ok("ok")
}
pub async fn get_lastfm_status(
daemon: Arc<DaemonCore>,
) -> Result<Json<serde_json::Value>, String> {
// Last.fm V2 state doesn't have persistent cache yet in the crate
// Just report not connected for now
Ok(Json(serde_json::json!({
"connected": false,
"note": "Last.fm setup requires desktop app or TUI wizard"
})))
}
+135
View File
@@ -0,0 +1,135 @@
use std::sync::Arc;
use axum::extract::{Path, Query};
use axum::Json;
use serde::Deserialize;
use crate::daemon::DaemonCore;
#[derive(Deserialize)]
pub struct SearchQuery {
pub q: String,
#[serde(default = "default_limit")]
pub limit: u32,
#[serde(default)]
pub offset: u32,
}
fn default_limit() -> u32 { 50 }
pub async fn get_albums(daemon: Arc<DaemonCore>) -> Result<Json<serde_json::Value>, String> {
let user = daemon.user.read().await;
let session = user.as_ref().ok_or("No active session")?;
let db_path = session.data_dir.join("library.db");
let db = qbz_library::LibraryDatabase::open(&db_path)
.map_err(|e| format!("Library DB error: {}", e))?;
let albums = db.get_albums(false)
.map_err(|e| format!("Failed to get albums: {}", e))?;
Ok(Json(serde_json::to_value(albums).unwrap_or_default()))
}
pub async fn get_artists(daemon: Arc<DaemonCore>) -> Result<Json<serde_json::Value>, String> {
let user = daemon.user.read().await;
let session = user.as_ref().ok_or("No active session")?;
let db_path = session.data_dir.join("library.db");
let db = qbz_library::LibraryDatabase::open(&db_path)
.map_err(|e| format!("Library DB error: {}", e))?;
let artists = db.get_artists()
.map_err(|e| format!("Failed to get artists: {}", e))?;
Ok(Json(serde_json::to_value(artists).unwrap_or_default()))
}
pub async fn get_album_tracks(
daemon: Arc<DaemonCore>,
Path(key): Path<String>,
) -> Result<Json<serde_json::Value>, String> {
let user = daemon.user.read().await;
let session = user.as_ref().ok_or("No active session")?;
let db_path = session.data_dir.join("library.db");
let db = qbz_library::LibraryDatabase::open(&db_path)
.map_err(|e| format!("Library DB error: {}", e))?;
let tracks = db.get_album_tracks(&key)
.map_err(|e| format!("Failed to get tracks: {}", e))?;
Ok(Json(serde_json::to_value(tracks).unwrap_or_default()))
}
pub async fn search_library(
daemon: Arc<DaemonCore>,
Query(q): Query<SearchQuery>,
) -> Result<Json<serde_json::Value>, String> {
let user = daemon.user.read().await;
let session = user.as_ref().ok_or("No active session")?;
let db_path = session.data_dir.join("library.db");
let db = qbz_library::LibraryDatabase::open(&db_path)
.map_err(|e| format!("Library DB error: {}", e))?;
let tracks = db.search(&q.q, q.limit)
.map_err(|e| format!("Search failed: {}", e))?;
Ok(Json(serde_json::to_value(tracks).unwrap_or_default()))
}
pub async fn get_stats(daemon: Arc<DaemonCore>) -> Result<Json<serde_json::Value>, String> {
let user = daemon.user.read().await;
let session = user.as_ref().ok_or("No active session")?;
let db_path = session.data_dir.join("library.db");
let db = qbz_library::LibraryDatabase::open(&db_path)
.map_err(|e| format!("Library DB error: {}", e))?;
let stats = db.get_stats(true)
.map_err(|e| format!("Stats failed: {}", e))?;
Ok(Json(serde_json::to_value(stats).unwrap_or_default()))
}
#[derive(Deserialize)]
pub struct AddFolderRequest {
pub path: String,
}
pub async fn get_folders(daemon: Arc<DaemonCore>) -> Result<Json<serde_json::Value>, String> {
let user = daemon.user.read().await;
let session = user.as_ref().ok_or("No active session")?;
let db_path = session.data_dir.join("library.db");
let db = qbz_library::LibraryDatabase::open(&db_path)
.map_err(|e| format!("Library DB error: {}", e))?;
let folders = db.get_folders()
.map_err(|e| format!("Failed to get folders: {}", e))?;
Ok(Json(serde_json::to_value(folders).unwrap_or_default()))
}
pub async fn add_folder(
daemon: Arc<DaemonCore>,
Json(req): Json<AddFolderRequest>,
) -> Result<Json<serde_json::Value>, String> {
let user = daemon.user.read().await;
let session = user.as_ref().ok_or("No active session")?;
let db_path = session.data_dir.join("library.db");
let db = qbz_library::LibraryDatabase::open(&db_path)
.map_err(|e| format!("Library DB error: {}", e))?;
db.add_folder(&req.path)
.map_err(|e| format!("Failed to add folder: {}", e))?;
Ok(Json(serde_json::json!({"path": req.path, "status": "added"})))
}
#[derive(Deserialize)]
pub struct RemoveFolderRequest {
pub path: String,
}
pub async fn remove_folder(
daemon: Arc<DaemonCore>,
Json(req): Json<RemoveFolderRequest>,
) -> Result<&'static str, String> {
let user = daemon.user.read().await;
let session = user.as_ref().ok_or("No active session")?;
let db_path = session.data_dir.join("library.db");
let db = qbz_library::LibraryDatabase::open(&db_path)
.map_err(|e| format!("Library DB error: {}", e))?;
db.remove_folder(&req.path)
.map_err(|e| format!("Failed to remove folder: {}", e))?;
Ok("ok")
}
+2
View File
@@ -4,6 +4,8 @@ pub mod catalog_ext;
pub mod discover;
pub mod events;
pub mod favorites;
pub mod integrations;
pub mod library;
pub mod playback;
pub mod playlists;
pub mod queue;
+15 -1
View File
@@ -338,7 +338,7 @@ macro_rules! with_daemon {
/// Build the Axum HTTP router.
fn build_router(daemon: Arc<DaemonCore>) -> axum::Router {
use axum::routing::{get, post, patch, put, delete};
use crate::api::{audio, catalog, catalog_ext, discover, favorites, playback, playlists, queue, search, system};
use crate::api::{audio, catalog, catalog_ext, discover, favorites, integrations, library, playback, playlists, queue, search, system};
axum::Router::new()
// System
@@ -404,6 +404,20 @@ fn build_router(daemon: Arc<DaemonCore>) -> axum::Router {
.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)))
// Library (local files)
.route("/api/library/albums", get(with_daemon!(daemon, library::get_albums)))
.route("/api/library/artists", get(with_daemon!(daemon, library::get_artists)))
.route("/api/library/albums/{key}/tracks", get(with_daemon!(daemon, library::get_album_tracks, path)))
.route("/api/library/search", get(with_daemon!(daemon, library::search_library, query)))
.route("/api/library/stats", get(with_daemon!(daemon, library::get_stats)))
.route("/api/library/folders", get(with_daemon!(daemon, library::get_folders)))
.route("/api/library/folders", post(with_daemon!(daemon, library::add_folder, json)))
.route("/api/library/folders", delete(with_daemon!(daemon, library::remove_folder, json)))
// Integrations
.route("/api/integrations/listenbrainz", get(with_daemon!(daemon, integrations::get_listenbrainz_status)))
.route("/api/integrations/listenbrainz/connect", post(with_daemon!(daemon, integrations::connect_listenbrainz, json)))
.route("/api/integrations/listenbrainz", delete(with_daemon!(daemon, integrations::disconnect_listenbrainz)))
.route("/api/integrations/lastfm", get(with_daemon!(daemon, integrations::get_lastfm_status)))
// System / Resources
.route("/api/system/resources", get(with_daemon!(daemon, system::get_resources)))
.route("/api/cache", delete(with_daemon!(daemon, system::clear_cache)))