use anyhow::Result; use reqwest::Client; use reqwest::header; use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// 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, } #[derive(Clone)] pub struct ApiClient { client: Client, base_url: String, } 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, } #[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 { 48 } 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, } #[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, } 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(()) } // ── 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(()) } 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()); } } #[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"); } }