pinakes/crates/pinakes-ui/src/client.rs
NotAShelf 9389af9fda
pinakes-ui: enforce plugin endpoint allowlist; replace inline styles with CSS custom properties
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I751e5c7ec66f045ee1f0bad6c72759416a6a6964
2026-03-11 21:30:44 +03:00

1775 lines
40 KiB
Rust

use std::collections::HashMap;
use anyhow::Result;
use reqwest::{Client, header};
use serde::{Deserialize, Serialize};
/// Payload for import events: (path, tag_ids, new_tags, collection_id)
pub type ImportEvent = (String, Vec<String>, Vec<String>, Option<String>);
/// Payload for media update events
#[derive(Debug, Clone, PartialEq)]
pub struct MediaUpdateEvent {
pub id: String,
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub genre: Option<String>,
pub year: Option<i32>,
pub description: Option<String>,
}
pub struct ApiClient {
client: Client,
base_url: String,
}
impl Clone for ApiClient {
fn clone(&self) -> Self {
Self {
client: self.client.clone(),
base_url: self.base_url.clone(),
}
}
}
impl std::fmt::Debug for ApiClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ApiClient")
.field("base_url", &self.base_url)
.finish()
}
}
impl PartialEq for ApiClient {
fn eq(&self, other: &Self) -> bool {
self.base_url == other.base_url
}
}
// Response types
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct MediaResponse {
pub id: String,
pub path: String,
pub file_name: String,
pub media_type: String,
pub content_hash: String,
pub file_size: u64,
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub genre: Option<String>,
pub year: Option<i32>,
pub duration_secs: Option<f64>,
pub description: Option<String>,
#[serde(default)]
pub has_thumbnail: bool,
pub custom_fields: HashMap<String, CustomFieldResponse>,
pub created_at: String,
pub updated_at: String,
#[serde(default)]
pub links_extracted_at: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct CustomFieldResponse {
pub field_type: String,
pub value: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct ImportResponse {
pub media_id: String,
pub was_duplicate: bool,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct BatchImportResponse {
pub results: Vec<BatchImportItemResult>,
pub total: usize,
pub imported: usize,
pub duplicates: usize,
pub errors: usize,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct BatchImportItemResult {
pub path: String,
pub media_id: Option<String>,
pub was_duplicate: bool,
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct DirectoryPreviewResponse {
pub files: Vec<DirectoryPreviewFile>,
pub total_count: usize,
pub total_size: u64,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct DirectoryPreviewFile {
pub path: String,
pub file_name: String,
pub media_type: String,
pub file_size: u64,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct DuplicateGroupResponse {
pub content_hash: String,
pub items: Vec<MediaResponse>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct TagResponse {
pub id: String,
pub name: String,
pub parent_id: Option<String>,
pub created_at: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct CollectionResponse {
pub id: String,
pub name: String,
pub description: Option<String>,
pub kind: String,
pub filter_query: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct SearchResponse {
pub items: Vec<MediaResponse>,
pub total_count: u64,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct AuditEntryResponse {
pub id: String,
pub media_id: Option<String>,
pub action: String,
pub details: Option<String>,
pub timestamp: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct ConfigResponse {
pub backend: String,
pub database_path: Option<String>,
pub roots: Vec<String>,
pub scanning: ScanningConfigResponse,
pub server: ServerConfigResponse,
#[serde(default)]
pub ui: UiConfigResponse,
pub config_path: Option<String>,
pub config_writable: bool,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Default)]
pub struct UiConfigResponse {
#[serde(default = "default_theme")]
pub theme: String,
#[serde(default = "default_view")]
pub default_view: String,
#[serde(default = "default_page_size")]
pub default_page_size: usize,
#[serde(default = "default_view_mode")]
pub default_view_mode: String,
#[serde(default)]
pub auto_play_media: bool,
#[serde(default = "default_true")]
pub show_thumbnails: bool,
#[serde(default)]
pub sidebar_collapsed: bool,
}
fn default_theme() -> String {
"dark".to_string()
}
fn default_view() -> String {
"library".to_string()
}
fn default_page_size() -> usize {
50
}
fn default_view_mode() -> String {
"grid".to_string()
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct LoginResponse {
pub token: String,
pub username: String,
pub role: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct UserInfoResponse {
pub username: String,
pub role: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct ScanningConfigResponse {
pub watch: bool,
pub poll_interval_secs: u64,
pub ignore_patterns: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct ServerConfigResponse {
pub host: String,
pub port: u16,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct ScanResponse {
pub files_found: usize,
pub files_processed: usize,
pub errors: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct ScanStatusResponse {
pub scanning: bool,
pub files_found: usize,
pub files_processed: usize,
pub error_count: usize,
pub errors: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct BatchOperationResponse {
pub processed: usize,
pub errors: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct LibraryStatisticsResponse {
pub total_media: u64,
pub total_size_bytes: u64,
pub avg_file_size_bytes: u64,
pub media_by_type: Vec<TypeCountResponse>,
pub storage_by_type: Vec<TypeCountResponse>,
pub newest_item: Option<String>,
pub oldest_item: Option<String>,
pub top_tags: Vec<TypeCountResponse>,
pub top_collections: Vec<TypeCountResponse>,
pub total_tags: u64,
pub total_collections: u64,
pub total_duplicates: u64,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct TypeCountResponse {
pub name: String,
pub count: u64,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct ScheduledTaskResponse {
pub id: String,
pub name: String,
pub schedule: String,
pub enabled: bool,
pub last_run: Option<String>,
pub next_run: Option<String>,
pub last_status: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct DatabaseStatsResponse {
pub media_count: u64,
pub tag_count: u64,
pub collection_count: u64,
pub audit_count: u64,
pub database_size_bytes: u64,
pub backend_name: String,
}
// Markdown notes/links response types
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct BacklinksResponse {
pub backlinks: Vec<BacklinkItem>,
pub count: usize,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct BacklinkItem {
pub link_id: String,
pub source_id: String,
pub source_title: Option<String>,
pub source_path: String,
pub link_text: Option<String>,
pub line_number: Option<i32>,
pub context: Option<String>,
pub link_type: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct OutgoingLinksResponse {
pub links: Vec<OutgoingLinkItem>,
pub count: usize,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct OutgoingLinkItem {
pub id: String,
pub target_path: String,
pub target_id: Option<String>,
pub link_text: Option<String>,
pub line_number: Option<i32>,
pub link_type: String,
pub is_resolved: bool,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct GraphResponse {
pub nodes: Vec<GraphNodeResponse>,
pub edges: Vec<GraphEdgeResponse>,
pub node_count: usize,
pub edge_count: usize,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct GraphNodeResponse {
pub id: String,
pub label: String,
pub title: Option<String>,
pub media_type: String,
pub link_count: u32,
pub backlink_count: u32,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct GraphEdgeResponse {
pub source: String,
pub target: String,
pub link_type: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct ReindexLinksResponse {
pub message: String,
pub links_extracted: usize,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct SavedSearchResponse {
pub id: String,
pub name: String,
pub query: String,
pub sort_order: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CreateSavedSearchRequest {
pub name: String,
pub query: String,
pub sort_order: Option<String>,
}
// Book management response types
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct BookMetadataResponse {
pub media_id: String,
pub isbn: Option<String>,
pub isbn13: Option<String>,
pub publisher: Option<String>,
pub language: Option<String>,
pub page_count: Option<i32>,
pub publication_date: Option<String>,
pub series_name: Option<String>,
pub series_index: Option<f64>,
pub format: Option<String>,
pub authors: Vec<BookAuthorResponse>,
#[serde(default)]
pub identifiers: HashMap<String, Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct BookAuthorResponse {
pub name: String,
pub role: String,
pub file_as: Option<String>,
pub position: i32,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct ReadingProgressResponse {
pub media_id: String,
pub user_id: String,
pub current_page: i32,
pub total_pages: Option<i32>,
pub progress_percent: f64,
pub last_read_at: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct SeriesSummary {
pub name: String,
pub book_count: u64,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct AuthorSummary {
pub name: String,
pub book_count: u64,
}
impl ApiClient {
pub fn new(base_url: &str, api_key: Option<&str>) -> Self {
let mut headers = header::HeaderMap::new();
if let Some(key) = api_key
&& let Ok(val) = header::HeaderValue::from_str(&format!("Bearer {key}"))
{
headers.insert(header::AUTHORIZATION, val);
}
let client = Client::builder()
.default_headers(headers)
.build()
.unwrap_or_else(|_| Client::new());
Self {
client,
base_url: base_url.trim_end_matches('/').to_string(),
}
}
pub fn base_url(&self) -> &str {
&self.base_url
}
fn url(&self, path: &str) -> String {
format!("{}/api/v1{}", self.base_url, path)
}
pub async fn health_check(&self) -> bool {
match self
.client
.get(self.url("/health"))
.timeout(std::time::Duration::from_secs(3))
.send()
.await
{
Ok(resp) => resp.status().is_success(),
Err(_) => false,
}
}
// Media
pub async fn list_media(
&self,
offset: u64,
limit: u64,
sort: Option<&str>,
) -> Result<Vec<MediaResponse>> {
let mut params =
vec![("offset", offset.to_string()), ("limit", limit.to_string())];
if let Some(s) = sort {
params.push(("sort", s.to_string()));
}
Ok(
self
.client
.get(self.url("/media"))
.query(&params)
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn get_media(&self, id: &str) -> Result<MediaResponse> {
Ok(
self
.client
.get(self.url(&format!("/media/{id}")))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn update_media(
&self,
event: &MediaUpdateEvent,
) -> Result<MediaResponse> {
let mut body = serde_json::Map::new();
if let Some(v) = &event.title {
body.insert("title".into(), serde_json::json!(v));
}
if let Some(v) = &event.artist {
body.insert("artist".into(), serde_json::json!(v));
}
if let Some(v) = &event.album {
body.insert("album".into(), serde_json::json!(v));
}
if let Some(v) = &event.genre {
body.insert("genre".into(), serde_json::json!(v));
}
if let Some(v) = event.year {
body.insert("year".into(), serde_json::json!(v));
}
if let Some(v) = &event.description {
body.insert("description".into(), serde_json::json!(v));
}
let id = &event.id;
Ok(
self
.client
.patch(self.url(&format!("/media/{id}")))
.json(&serde_json::Value::Object(body))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn delete_media(&self, id: &str) -> Result<()> {
self
.client
.delete(self.url(&format!("/media/{id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn open_media(&self, id: &str) -> Result<()> {
self
.client
.post(self.url(&format!("/media/{id}/open")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub fn stream_url(&self, id: &str) -> String {
self.url(&format!("/media/{id}/stream"))
}
pub fn thumbnail_url(&self, id: &str) -> String {
self.url(&format!("/media/{id}/thumbnail"))
}
pub async fn get_media_count(&self) -> Result<u64> {
#[derive(Deserialize)]
struct CountResp {
count: u64,
}
let resp: CountResp = self
.client
.get(self.url("/media/count"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp.count)
}
// Import
pub async fn import_file(&self, path: &str) -> Result<ImportResponse> {
Ok(
self
.client
.post(self.url("/media/import"))
.json(&serde_json::json!({"path": path}))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn import_with_options(
&self,
path: &str,
tag_ids: &[String],
new_tags: &[String],
collection_id: Option<&str>,
) -> Result<ImportResponse> {
let mut body = serde_json::json!({"path": path});
if !tag_ids.is_empty() {
body["tag_ids"] = serde_json::json!(tag_ids);
}
if !new_tags.is_empty() {
body["new_tags"] = serde_json::json!(new_tags);
}
if let Some(cid) = collection_id {
body["collection_id"] = serde_json::json!(cid);
}
Ok(
self
.client
.post(self.url("/media/import/options"))
.json(&body)
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn import_directory(
&self,
path: &str,
tag_ids: &[String],
new_tags: &[String],
collection_id: Option<&str>,
) -> Result<BatchImportResponse> {
let mut body = serde_json::json!({"path": path});
if !tag_ids.is_empty() {
body["tag_ids"] = serde_json::json!(tag_ids);
}
if !new_tags.is_empty() {
body["new_tags"] = serde_json::json!(new_tags);
}
if let Some(cid) = collection_id {
body["collection_id"] = serde_json::json!(cid);
}
Ok(
self
.client
.post(self.url("/media/import/directory"))
.json(&body)
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn preview_directory(
&self,
path: &str,
recursive: bool,
) -> Result<DirectoryPreviewResponse> {
Ok(
self
.client
.post(self.url("/media/import/preview"))
.json(&serde_json::json!({"path": path, "recursive": recursive}))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
// Search
pub async fn search(
&self,
query: &str,
sort: Option<&str>,
offset: u64,
limit: u64,
) -> Result<SearchResponse> {
let mut params = vec![
("q", query.to_string()),
("offset", offset.to_string()),
("limit", limit.to_string()),
];
if let Some(s) = sort {
params.push(("sort", s.to_string()));
}
Ok(
self
.client
.get(self.url("/search"))
.query(&params)
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
// Tags
pub async fn list_tags(&self) -> Result<Vec<TagResponse>> {
Ok(
self
.client
.get(self.url("/tags"))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn create_tag(
&self,
name: &str,
parent_id: Option<&str>,
) -> Result<TagResponse> {
let mut body = serde_json::json!({"name": name});
if let Some(pid) = parent_id {
body["parent_id"] = serde_json::Value::String(pid.to_string());
}
Ok(
self
.client
.post(self.url("/tags"))
.json(&body)
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn delete_tag(&self, id: &str) -> Result<()> {
self
.client
.delete(self.url(&format!("/tags/{id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn tag_media(&self, media_id: &str, tag_id: &str) -> Result<()> {
self
.client
.post(self.url(&format!("/media/{media_id}/tags")))
.json(&serde_json::json!({"tag_id": tag_id}))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn untag_media(&self, media_id: &str, tag_id: &str) -> Result<()> {
self
.client
.delete(self.url(&format!("/media/{media_id}/tags/{tag_id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn get_media_tags(
&self,
media_id: &str,
) -> Result<Vec<TagResponse>> {
Ok(
self
.client
.get(self.url(&format!("/media/{media_id}/tags")))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
// Custom fields
pub async fn set_custom_field(
&self,
media_id: &str,
name: &str,
field_type: &str,
value: &str,
) -> Result<()> {
self.client
.post(self.url(&format!("/media/{media_id}/custom-fields")))
.json(&serde_json::json!({"name": name, "field_type": field_type, "value": value}))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn delete_custom_field(
&self,
media_id: &str,
name: &str,
) -> Result<()> {
self
.client
.delete(self.url(&format!("/media/{media_id}/custom-fields/{name}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
// Collections
pub async fn list_collections(&self) -> Result<Vec<CollectionResponse>> {
Ok(
self
.client
.get(self.url("/collections"))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn create_collection(
&self,
name: &str,
kind: &str,
description: Option<&str>,
filter_query: Option<&str>,
) -> Result<CollectionResponse> {
let mut body = serde_json::json!({"name": name, "kind": kind});
if let Some(desc) = description {
body["description"] = serde_json::Value::String(desc.to_string());
}
if let Some(fq) = filter_query {
body["filter_query"] = serde_json::Value::String(fq.to_string());
}
Ok(
self
.client
.post(self.url("/collections"))
.json(&body)
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn delete_collection(&self, id: &str) -> Result<()> {
self
.client
.delete(self.url(&format!("/collections/{id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn get_collection_members(
&self,
id: &str,
) -> Result<Vec<MediaResponse>> {
Ok(
self
.client
.get(self.url(&format!("/collections/{id}/members")))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn add_to_collection(
&self,
collection_id: &str,
media_id: &str,
position: i32,
) -> Result<()> {
self
.client
.post(self.url(&format!("/collections/{collection_id}/members")))
.json(&serde_json::json!({"media_id": media_id, "position": position}))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn remove_from_collection(
&self,
collection_id: &str,
media_id: &str,
) -> Result<()> {
self
.client
.delete(
self.url(&format!("/collections/{collection_id}/members/{media_id}")),
)
.send()
.await?
.error_for_status()?;
Ok(())
}
// Batch operations
pub async fn batch_tag(
&self,
media_ids: &[String],
tag_ids: &[String],
) -> Result<BatchOperationResponse> {
Ok(
self
.client
.post(self.url("/media/batch/tag"))
.json(&serde_json::json!({"media_ids": media_ids, "tag_ids": tag_ids}))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn batch_delete(
&self,
media_ids: &[String],
) -> Result<BatchOperationResponse> {
Ok(
self
.client
.post(self.url("/media/batch/delete"))
.json(&serde_json::json!({"media_ids": media_ids}))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn delete_all_media(&self) -> Result<BatchOperationResponse> {
Ok(
self
.client
.delete(self.url("/media/all"))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn batch_add_to_collection(
&self,
media_ids: &[String],
collection_id: &str,
) -> Result<BatchOperationResponse> {
Ok(self
.client
.post(self.url("/media/batch/collection"))
.json(&serde_json::json!({"media_ids": media_ids, "collection_id": collection_id}))
.send()
.await?
.error_for_status()?
.json()
.await?)
}
// Audit
pub async fn list_audit(
&self,
offset: u64,
limit: u64,
) -> Result<Vec<AuditEntryResponse>> {
Ok(
self
.client
.get(self.url("/audit"))
.query(&[("offset", offset.to_string()), ("limit", limit.to_string())])
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
// Scan
pub async fn trigger_scan(&self) -> Result<Vec<ScanResponse>> {
Ok(
self
.client
.post(self.url("/scan"))
.json(&serde_json::json!({"path": serde_json::Value::Null}))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn scan_status(&self) -> Result<ScanStatusResponse> {
Ok(
self
.client
.get(self.url("/scan/status"))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
// Config
pub async fn get_config(&self) -> Result<ConfigResponse> {
Ok(
self
.client
.get(self.url("/config"))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn update_scanning(
&self,
watch: Option<bool>,
poll_interval: Option<u64>,
ignore_patterns: Option<Vec<String>>,
) -> Result<ConfigResponse> {
let mut body = serde_json::Map::new();
if let Some(w) = watch {
body.insert("watch".into(), serde_json::Value::Bool(w));
}
if let Some(p) = poll_interval {
body.insert("poll_interval_secs".into(), serde_json::json!(p));
}
if let Some(pat) = ignore_patterns {
body.insert("ignore_patterns".into(), serde_json::json!(pat));
}
Ok(
self
.client
.put(self.url("/config/scanning"))
.json(&body)
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn add_root(&self, path: &str) -> Result<ConfigResponse> {
Ok(
self
.client
.post(self.url("/config/roots"))
.json(&serde_json::json!({"path": path}))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn remove_root(&self, path: &str) -> Result<ConfigResponse> {
Ok(
self
.client
.delete(self.url("/config/roots"))
.json(&serde_json::json!({"path": path}))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
// Database management
pub async fn database_stats(&self) -> Result<DatabaseStatsResponse> {
Ok(
self
.client
.get(self.url("/database/stats"))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn vacuum_database(&self) -> Result<()> {
self
.client
.post(self.url("/database/vacuum"))
.json(&serde_json::json!({}))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn clear_database(&self) -> Result<()> {
self
.client
.post(self.url("/database/clear"))
.json(&serde_json::json!({}))
.send()
.await?
.error_for_status()?;
Ok(())
}
/// Download a database backup and save it to the given path.
pub async fn backup_database(&self, save_path: &str) -> Result<()> {
let bytes = self
.client
.post(self.url("/database/backup"))
.send()
.await?
.error_for_status()?
.bytes()
.await?;
tokio::fs::write(save_path, &bytes).await?;
Ok(())
}
// Books
pub async fn get_book_metadata(
&self,
media_id: &str,
) -> Result<BookMetadataResponse> {
Ok(
self
.client
.get(self.url(&format!("/books/{media_id}/metadata")))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn list_books(
&self,
offset: u64,
limit: u64,
author: Option<&str>,
series: Option<&str>,
) -> Result<Vec<MediaResponse>> {
let mut url = format!("/books?offset={offset}&limit={limit}");
if let Some(a) = author {
url.push_str(&format!("&author={}", urlencoding::encode(a)));
}
if let Some(s) = series {
url.push_str(&format!("&series={}", urlencoding::encode(s)));
}
Ok(
self
.client
.get(self.url(&url))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn list_series(&self) -> Result<Vec<SeriesSummary>> {
Ok(
self
.client
.get(self.url("/books/series"))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn get_series_books(
&self,
series_name: &str,
) -> Result<Vec<MediaResponse>> {
Ok(
self
.client
.get(self.url(&format!(
"/books/series/{}",
urlencoding::encode(series_name)
)))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn list_authors(&self) -> Result<Vec<AuthorSummary>> {
Ok(
self
.client
.get(self.url("/books/authors"))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn get_author_books(
&self,
author_name: &str,
) -> Result<Vec<MediaResponse>> {
Ok(
self
.client
.get(self.url(&format!(
"/books/authors/{}/books",
urlencoding::encode(author_name)
)))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn get_reading_progress(
&self,
media_id: &str,
) -> Result<ReadingProgressResponse> {
Ok(
self
.client
.get(self.url(&format!("/books/{media_id}/progress")))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn update_reading_progress(
&self,
media_id: &str,
current_page: i32,
) -> Result<()> {
self
.client
.put(self.url(&format!("/books/{media_id}/progress")))
.json(&serde_json::json!({"current_page": current_page}))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn get_reading_list(
&self,
status: Option<&str>,
) -> Result<Vec<MediaResponse>> {
let mut url = "/books/reading-list".to_string();
if let Some(s) = status {
url.push_str(&format!("?status={s}"));
}
Ok(
self
.client
.get(self.url(&url))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
// Duplicates
pub async fn list_duplicates(&self) -> Result<Vec<DuplicateGroupResponse>> {
Ok(
self
.client
.get(self.url("/duplicates"))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
// UI config
pub async fn update_ui_config(
&self,
updates: serde_json::Value,
) -> Result<UiConfigResponse> {
Ok(
self
.client
.put(self.url("/config/ui"))
.json(&updates)
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
// Auth
pub async fn login(
&self,
username: &str,
password: &str,
) -> Result<LoginResponse> {
Ok(
self
.client
.post(self.url("/auth/login"))
.json(&serde_json::json!({"username": username, "password": password}))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn logout(&self) -> Result<()> {
self
.client
.post(self.url("/auth/logout"))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn get_current_user(&self) -> Result<UserInfoResponse> {
Ok(
self
.client
.get(self.url("/auth/me"))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn library_statistics(&self) -> Result<LibraryStatisticsResponse> {
Ok(
self
.client
.get(self.url("/statistics"))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn list_scheduled_tasks(
&self,
) -> Result<Vec<ScheduledTaskResponse>> {
Ok(
self
.client
.get(self.url("/tasks/scheduled"))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn toggle_scheduled_task(
&self,
id: &str,
) -> Result<serde_json::Value> {
Ok(
self
.client
.post(self.url(&format!("/tasks/scheduled/{}/toggle", id)))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn run_scheduled_task_now(
&self,
id: &str,
) -> Result<serde_json::Value> {
Ok(
self
.client
.post(self.url(&format!("/tasks/scheduled/{}/run-now", id)))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
// Saved searches
pub async fn list_saved_searches(&self) -> Result<Vec<SavedSearchResponse>> {
Ok(
self
.client
.get(self.url("/saved-searches"))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn create_saved_search(
&self,
name: &str,
query: &str,
sort_order: Option<&str>,
) -> Result<SavedSearchResponse> {
let req = CreateSavedSearchRequest {
name: name.to_string(),
query: query.to_string(),
sort_order: sort_order.map(|s| s.to_string()),
};
Ok(
self
.client
.post(self.url("/saved-searches"))
.json(&req)
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
pub async fn delete_saved_search(&self, id: &str) -> Result<()> {
self
.client
.delete(self.url(&format!("/saved-searches/{id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
// Markdown notes/links
/// Get backlinks (incoming links) to a media item.
pub async fn get_backlinks(&self, id: &str) -> Result<BacklinksResponse> {
Ok(
self
.client
.get(self.url(&format!("/media/{id}/backlinks")))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
/// Get outgoing links from a media item.
pub async fn get_outgoing_links(
&self,
id: &str,
) -> Result<OutgoingLinksResponse> {
Ok(
self
.client
.get(self.url(&format!("/media/{id}/outgoing-links")))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
/// Get graph data for visualization.
pub async fn get_graph(
&self,
center_id: Option<&str>,
depth: Option<u32>,
) -> Result<GraphResponse> {
let mut url = self.url("/notes/graph");
let mut query_parts = Vec::new();
if let Some(center) = center_id {
query_parts.push(format!("center={}", center));
}
if let Some(d) = depth {
query_parts.push(format!("depth={}", d));
}
if !query_parts.is_empty() {
url = format!("{}?{}", url, query_parts.join("&"));
}
Ok(
self
.client
.get(&url)
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
/// Re-extract links from a media item.
pub async fn reindex_links(&self, id: &str) -> Result<ReindexLinksResponse> {
Ok(
self
.client
.post(self.url(&format!("/media/{id}/reindex-links")))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
/// Get count of unresolved links.
pub async fn get_unresolved_links_count(&self) -> Result<u64> {
#[derive(Deserialize)]
struct CountResp {
count: u64,
}
let resp: CountResp = self
.client
.get(self.url("/notes/unresolved-count"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp.count)
}
pub fn set_token(&mut self, token: &str) {
let mut headers = header::HeaderMap::new();
if let Ok(val) = header::HeaderValue::from_str(&format!("Bearer {token}")) {
headers.insert(header::AUTHORIZATION, val);
}
self.client = Client::builder()
.default_headers(headers)
.build()
.unwrap_or_else(|_| Client::new());
}
/// List all UI pages provided by loaded plugins.
///
/// Returns a vector of `(plugin_id, page, allowed_endpoints)` tuples.
pub async fn get_plugin_ui_pages(
&self,
) -> Result<Vec<(String, pinakes_plugin_api::UiPage, Vec<String>)>> {
#[derive(Deserialize)]
struct PageEntry {
plugin_id: String,
page: pinakes_plugin_api::UiPage,
#[serde(default)]
allowed_endpoints: Vec<String>,
}
let entries: Vec<PageEntry> = self
.client
.get(self.url("/plugins/ui-pages"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(
entries
.into_iter()
.map(|e| (e.plugin_id, e.page, e.allowed_endpoints))
.collect(),
)
}
/// List all UI widgets provided by loaded plugins.
///
/// Returns a vector of `(plugin_id, widget)` tuples.
pub async fn get_plugin_ui_widgets(
&self,
) -> Result<Vec<(String, pinakes_plugin_api::UiWidget)>> {
#[derive(Deserialize)]
struct WidgetEntry {
plugin_id: String,
widget: pinakes_plugin_api::UiWidget,
}
let entries: Vec<WidgetEntry> = self
.client
.get(self.url("/plugins/ui-widgets"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(
entries
.into_iter()
.map(|e| (e.plugin_id, e.widget))
.collect(),
)
}
/// Fetch merged CSS custom property overrides from all enabled plugins.
///
/// Returns a map of CSS property names to values.
pub async fn get_plugin_ui_theme_extensions(
&self,
) -> Result<HashMap<String, String>> {
Ok(
self
.client
.get(self.url("/plugins/ui-theme-extensions"))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
/// Emit a plugin event to the server-side event bus.
///
/// # Errors
///
/// Returns an error if the request fails or the server returns an error
/// status.
pub async fn post_plugin_event(
&self,
event: &str,
payload: &serde_json::Value,
) -> Result<()> {
self
.client
.post(self.url("/plugins/events"))
.json(&serde_json::json!({ "event": event, "payload": payload }))
.send()
.await?
.error_for_status()?;
Ok(())
}
/// Make a raw HTTP request to an API path.
///
/// The `path` is appended to the base URL without any prefix.
/// Use this for plugin action endpoints that specify full API paths.
pub fn raw_request(
&self,
method: reqwest::Method,
path: &str,
) -> reqwest::RequestBuilder {
let url = format!("{}{}", self.base_url, path);
self.client.request(method, url)
}
}
impl Default for ApiClient {
fn default() -> Self {
Self::new("", None)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_base_url() {
let client = ApiClient::new("http://localhost:3000", None);
assert_eq!(client.base_url(), "http://localhost:3000");
}
#[test]
fn test_stream_url() {
let client = ApiClient::new("http://localhost:3000", None);
let url = client.stream_url("test-id-123");
assert_eq!(url, "http://localhost:3000/api/v1/media/test-id-123/stream");
}
#[test]
fn test_thumbnail_url() {
let client = ApiClient::new("http://localhost:3000", None);
let url = client.thumbnail_url("test-id-456");
assert_eq!(
url,
"http://localhost:3000/api/v1/media/test-id-456/thumbnail"
);
}
#[test]
fn test_client_creation_with_api_key() {
let client = ApiClient::new("http://localhost:3000", Some("test-key"));
assert_eq!(client.base_url(), "http://localhost:3000");
}
#[test]
fn test_base_url_trailing_slash() {
let client = ApiClient::new("http://localhost:3000/", None);
assert_eq!(client.base_url(), "http://localhost:3000");
}
}