Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I751e5c7ec66f045ee1f0bad6c72759416a6a6964
1775 lines
40 KiB
Rust
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(¶ms)
|
|
.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(¶ms)
|
|
.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");
|
|
}
|
|
}
|