Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I20f205d9e06a93a89e8f4433ed6f80576a6a6964
631 lines
14 KiB
Rust
631 lines
14 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use anyhow::Result;
|
|
use reqwest::Client;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
#[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<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,
|
|
}
|
|
|
|
#[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<String>,
|
|
pub created_at: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, 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, Deserialize)]
|
|
pub struct SearchResponse {
|
|
pub items: Vec<MediaResponse>,
|
|
pub total_count: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, 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, Deserialize)]
|
|
pub struct ScanResponse {
|
|
pub files_found: usize,
|
|
pub files_processed: usize,
|
|
pub errors: Vec<String>,
|
|
}
|
|
|
|
#[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<MediaResponse>,
|
|
}
|
|
|
|
/// Background job response from the API.
|
|
#[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<String>,
|
|
pub next_run: Option<String>,
|
|
pub last_status: Option<String>,
|
|
}
|
|
|
|
#[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<TypeCount>,
|
|
pub storage_by_type: Vec<TypeCount>,
|
|
pub newest_item: Option<String>,
|
|
pub oldest_item: Option<String>,
|
|
pub top_tags: Vec<TypeCount>,
|
|
pub top_collections: Vec<TypeCount>,
|
|
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,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct BookMetadataResponse {
|
|
pub media_id: String,
|
|
pub title: Option<String>,
|
|
pub subtitle: Option<String>,
|
|
pub publisher: Option<String>,
|
|
pub language: Option<String>,
|
|
pub isbn: Option<String>,
|
|
pub isbn13: Option<String>,
|
|
pub page_count: Option<i32>,
|
|
pub series: Option<String>,
|
|
pub series_index: Option<f64>,
|
|
pub authors: Vec<BookAuthorResponse>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct BookAuthorResponse {
|
|
pub name: String,
|
|
pub role: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct ReadingProgressResponse {
|
|
pub media_id: String,
|
|
pub current_page: i32,
|
|
pub total_pages: Option<i32>,
|
|
pub status: String,
|
|
pub updated_at: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct SeriesSummary {
|
|
pub name: String,
|
|
pub count: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct AuthorSummary {
|
|
pub name: String,
|
|
pub count: u64,
|
|
}
|
|
|
|
impl ApiClient {
|
|
pub fn new(base_url: &str, api_key: Option<&str>) -> Self {
|
|
let client = api_key.map_or_else(Client::new, |key| {
|
|
let mut headers = reqwest::header::HeaderMap::new();
|
|
if let Ok(val) =
|
|
reqwest::header::HeaderValue::from_str(&format!("Bearer {key}"))
|
|
{
|
|
headers.insert(reqwest::header::AUTHORIZATION, val);
|
|
}
|
|
Client::builder()
|
|
.default_headers(headers)
|
|
.build()
|
|
.unwrap_or_default()
|
|
});
|
|
Self {
|
|
client,
|
|
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<Vec<MediaResponse>> {
|
|
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<MediaResponse> {
|
|
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<ImportResponse> {
|
|
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<SearchResponse> {
|
|
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<Vec<TagResponse>> {
|
|
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<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());
|
|
}
|
|
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<Vec<TagResponse>> {
|
|
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<Vec<CollectionResponse>> {
|
|
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<Vec<ScanResponse>> {
|
|
let body = path.map_or_else(
|
|
|| serde_json::json!({"path": null}),
|
|
|p| serde_json::json!({"path": p}),
|
|
);
|
|
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<Vec<AuditEntryResponse>> {
|
|
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<Vec<DuplicateGroupResponse>> {
|
|
let resp = self
|
|
.client
|
|
.get(self.url("/duplicates"))
|
|
.send()
|
|
.await?
|
|
.error_for_status()?
|
|
.json()
|
|
.await?;
|
|
Ok(resp)
|
|
}
|
|
|
|
pub async fn database_stats(&self) -> Result<DatabaseStatsResponse> {
|
|
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<Vec<JobResponse>> {
|
|
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<MediaResponse> {
|
|
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<LibraryStatisticsResponse> {
|
|
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<Vec<ScheduledTaskResponse>> {
|
|
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(())
|
|
}
|
|
|
|
pub async fn get_book_metadata(
|
|
&self,
|
|
media_id: &str,
|
|
) -> Result<BookMetadataResponse> {
|
|
let resp = self
|
|
.client
|
|
.get(self.url(&format!("/books/{media_id}/metadata")))
|
|
.send()
|
|
.await?
|
|
.error_for_status()?
|
|
.json()
|
|
.await?;
|
|
Ok(resp)
|
|
}
|
|
|
|
pub async fn list_books(
|
|
&self,
|
|
offset: u64,
|
|
limit: u64,
|
|
) -> Result<Vec<MediaResponse>> {
|
|
let resp = self
|
|
.client
|
|
.get(self.url("/books"))
|
|
.query(&[("offset", offset.to_string()), ("limit", limit.to_string())])
|
|
.send()
|
|
.await?
|
|
.error_for_status()?
|
|
.json()
|
|
.await?;
|
|
Ok(resp)
|
|
}
|
|
|
|
pub async fn list_series(&self) -> Result<Vec<SeriesSummary>> {
|
|
let resp = self
|
|
.client
|
|
.get(self.url("/books/series"))
|
|
.send()
|
|
.await?
|
|
.error_for_status()?
|
|
.json()
|
|
.await?;
|
|
Ok(resp)
|
|
}
|
|
|
|
pub async fn list_book_authors(&self) -> Result<Vec<AuthorSummary>> {
|
|
let resp = self
|
|
.client
|
|
.get(self.url("/books/authors"))
|
|
.send()
|
|
.await?
|
|
.error_for_status()?
|
|
.json()
|
|
.await?;
|
|
Ok(resp)
|
|
}
|
|
|
|
pub async fn get_reading_progress(
|
|
&self,
|
|
media_id: &str,
|
|
) -> Result<ReadingProgressResponse> {
|
|
let resp = self
|
|
.client
|
|
.get(self.url(&format!("/books/{media_id}/progress")))
|
|
.send()
|
|
.await?
|
|
.error_for_status()?
|
|
.json()
|
|
.await?;
|
|
Ok(resp)
|
|
}
|
|
|
|
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(())
|
|
}
|
|
}
|