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, Vec, Option); /// Payload for media update events #[derive(Debug, Clone, PartialEq)] pub struct MediaUpdateEvent { pub id: String, pub title: Option, pub artist: Option, pub album: Option, pub genre: Option, pub year: Option, pub description: Option, } 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, pub artist: Option, pub album: Option, pub genre: Option, pub year: Option, pub duration_secs: Option, pub description: Option, #[serde(default)] pub has_thumbnail: bool, pub custom_fields: HashMap, pub created_at: String, pub updated_at: String, #[serde(default)] pub links_extracted_at: Option, } #[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, 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, pub was_duplicate: bool, pub error: Option, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct DirectoryPreviewResponse { pub files: Vec, 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, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct TagResponse { pub id: String, pub name: String, pub parent_id: Option, pub created_at: String, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct CollectionResponse { pub id: String, pub name: String, pub description: Option, pub kind: String, pub filter_query: Option, pub created_at: String, pub updated_at: String, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct SearchResponse { pub items: Vec, pub total_count: u64, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct AuditEntryResponse { pub id: String, pub media_id: Option, pub action: String, pub details: Option, pub timestamp: String, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct ConfigResponse { pub backend: String, pub database_path: Option, pub roots: Vec, pub scanning: ScanningConfigResponse, pub server: ServerConfigResponse, #[serde(default)] pub ui: UiConfigResponse, pub config_path: Option, 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, } #[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, } #[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, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct BatchOperationResponse { pub processed: usize, pub errors: Vec, } #[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, pub storage_by_type: Vec, pub newest_item: Option, pub oldest_item: Option, pub top_tags: Vec, pub top_collections: Vec, 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, pub next_run: Option, pub last_status: Option, } #[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, pub count: usize, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct BacklinkItem { pub link_id: String, pub source_id: String, pub source_title: Option, pub source_path: String, pub link_text: Option, pub line_number: Option, pub context: Option, pub link_type: String, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct OutgoingLinksResponse { pub links: Vec, pub count: usize, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct OutgoingLinkItem { pub id: String, pub target_path: String, pub target_id: Option, pub link_text: Option, pub line_number: Option, pub link_type: String, pub is_resolved: bool, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct GraphResponse { pub nodes: Vec, pub edges: Vec, 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, 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, pub created_at: chrono::DateTime, } #[derive(Debug, Clone, Serialize)] pub struct CreateSavedSearchRequest { pub name: String, pub query: String, pub sort_order: Option, } // Book management response types #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct BookMetadataResponse { pub media_id: String, pub isbn: Option, pub isbn13: Option, pub publisher: Option, pub language: Option, pub page_count: Option, pub publication_date: Option, pub series_name: Option, pub series_index: Option, pub format: Option, pub authors: Vec, #[serde(default)] pub identifiers: HashMap>, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct BookAuthorResponse { pub name: String, pub role: String, pub file_as: Option, 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, 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> { 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(¶ms) .send() .await? .error_for_status()? .json() .await?, ) } pub async fn get_media(&self, id: &str) -> Result { 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 { 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 { #[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 { 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 { 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 { 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 { 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 { 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(¶ms) .send() .await? .error_for_status()? .json() .await?, ) } // Tags pub async fn list_tags(&self) -> Result> { 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 { 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> { 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> { 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 { 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> { 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 { 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 { 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 { 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 { 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> { 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> { 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 { Ok( self .client .get(self.url("/scan/status")) .send() .await? .error_for_status()? .json() .await?, ) } // Config pub async fn get_config(&self) -> Result { Ok( self .client .get(self.url("/config")) .send() .await? .error_for_status()? .json() .await?, ) } pub async fn update_scanning( &self, watch: Option, poll_interval: Option, ignore_patterns: Option>, ) -> Result { 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 { 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 { 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 { 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 { 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> { 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> { 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> { 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> { 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> { 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 { 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> { 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> { 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 { 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 { 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 { Ok( self .client .get(self.url("/auth/me")) .send() .await? .error_for_status()? .json() .await?, ) } pub async fn library_statistics(&self) -> Result { Ok( self .client .get(self.url("/statistics")) .send() .await? .error_for_status()? .json() .await?, ) } pub async fn list_scheduled_tasks( &self, ) -> Result> { 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 { 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 { 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> { 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 { 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 { 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 { 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, ) -> Result { 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 { 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 { #[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)>> { #[derive(Deserialize)] struct PageEntry { plugin_id: String, page: pinakes_plugin_api::UiPage, #[serde(default)] allowed_endpoints: Vec, } let entries: Vec = 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> { #[derive(Deserialize)] struct WidgetEntry { plugin_id: String, widget: pinakes_plugin_api::UiWidget, } let entries: Vec = 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> { 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"); } }