use anyhow::Result; use reqwest::Client; use serde::{Deserialize, Serialize}; use std::collections::HashMap; #[derive(Clone)] pub struct ApiClient { client: Client, base_url: String, } // Response types (mirror server DTOs) #[derive(Debug, Clone, 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, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct CustomFieldResponse { pub field_type: String, pub value: String, } #[derive(Debug, Clone, Deserialize)] pub struct ImportResponse { pub media_id: String, pub was_duplicate: bool, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct TagResponse { pub id: String, pub name: String, pub parent_id: Option, pub created_at: String, } #[derive(Debug, Clone, 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, Deserialize)] pub struct SearchResponse { pub items: Vec, pub total_count: u64, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct AuditEntryResponse { pub id: String, pub media_id: Option, pub action: String, pub details: Option, pub timestamp: String, } #[derive(Debug, Clone, Deserialize)] pub struct ScanResponse { pub files_found: usize, pub files_processed: usize, pub errors: Vec, } #[derive(Debug, Clone, 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, } #[derive(Debug, Clone, Deserialize)] pub struct DuplicateGroupResponse { pub content_hash: String, pub items: Vec, } #[derive(Debug, Clone, Deserialize)] pub struct JobResponse { pub id: String, pub kind: serde_json::Value, pub status: serde_json::Value, pub created_at: String, pub updated_at: String, } #[derive(Debug, Clone, 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, 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, Deserialize)] pub struct TypeCount { pub name: String, pub count: u64, } impl ApiClient { pub fn new(base_url: &str) -> Self { Self { client: Client::new(), base_url: base_url.trim_end_matches('/').to_string(), } } fn url(&self, path: &str) -> String { format!("{}/api/v1{}", self.base_url, path) } pub async fn list_media(&self, offset: u64, limit: u64) -> Result> { let resp = self .client .get(self.url("/media")) .query(&[("offset", offset.to_string()), ("limit", limit.to_string())]) .send() .await? .error_for_status()? .json() .await?; Ok(resp) } pub async fn get_media(&self, id: &str) -> Result { let resp = self .client .get(self.url(&format!("/media/{id}"))) .send() .await? .error_for_status()? .json() .await?; Ok(resp) } pub async fn import_file(&self, path: &str) -> Result { let resp = self .client .post(self.url("/media/import")) .json(&serde_json::json!({"path": path})) .send() .await? .error_for_status()? .json() .await?; Ok(resp) } 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 async fn search(&self, query: &str, offset: u64, limit: u64) -> Result { let resp = self .client .get(self.url("/search")) .query(&[ ("q", query.to_string()), ("offset", offset.to_string()), ("limit", limit.to_string()), ]) .send() .await? .error_for_status()? .json() .await?; Ok(resp) } pub async fn list_tags(&self) -> Result> { let resp = self .client .get(self.url("/tags")) .send() .await? .error_for_status()? .json() .await?; Ok(resp) } 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()); } let resp = self .client .post(self.url("/tags")) .json(&body) .send() .await? .error_for_status()? .json() .await?; Ok(resp) } 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> { let resp = self .client .get(self.url(&format!("/media/{media_id}/tags"))) .send() .await? .error_for_status()? .json() .await?; Ok(resp) } pub async fn list_collections(&self) -> Result> { let resp = self .client .get(self.url("/collections")) .send() .await? .error_for_status()? .json() .await?; Ok(resp) } 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 trigger_scan(&self, path: Option<&str>) -> Result> { let body = match path { Some(p) => serde_json::json!({"path": p}), None => serde_json::json!({"path": null}), }; let resp = self .client .post(self.url("/scan")) .json(&body) .send() .await? .error_for_status()? .json() .await?; Ok(resp) } pub async fn list_audit(&self, offset: u64, limit: u64) -> Result> { let resp = self .client .get(self.url("/audit")) .query(&[("offset", offset.to_string()), ("limit", limit.to_string())]) .send() .await? .error_for_status()? .json() .await?; Ok(resp) } pub async fn find_duplicates(&self) -> Result> { let resp = self .client .get(self.url("/duplicates")) .send() .await? .error_for_status()? .json() .await?; Ok(resp) } pub async fn database_stats(&self) -> Result { let resp = self .client .get(self.url("/database/stats")) .send() .await? .error_for_status()? .json() .await?; Ok(resp) } pub async fn list_jobs(&self) -> Result> { let resp = self .client .get(self.url("/jobs")) .send() .await? .error_for_status()? .json() .await?; Ok(resp) } 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 update_media( &self, id: &str, updates: serde_json::Value, ) -> Result { let resp = self .client .patch(self.url(&format!("/media/{id}"))) .json(&updates) .send() .await? .error_for_status()? .json() .await?; Ok(resp) } pub async fn library_statistics(&self) -> Result { let resp = self .client .get(self.url("/statistics")) .send() .await? .error_for_status()? .json() .await?; Ok(resp) } pub async fn list_scheduled_tasks(&self) -> Result> { let resp = self .client .get(self.url("/tasks/scheduled")) .send() .await? .error_for_status()? .json() .await?; Ok(resp) } pub async fn toggle_scheduled_task(&self, id: &str) -> Result<()> { self.client .post(self.url(&format!("/tasks/scheduled/{id}/toggle"))) .send() .await? .error_for_status()?; Ok(()) } pub async fn run_task_now(&self, id: &str) -> Result<()> { self.client .post(self.url(&format!("/tasks/scheduled/{id}/run-now"))) .send() .await? .error_for_status()?; Ok(()) } }