Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I12432bc956453cc4b0a2db82dce1b4976a6a6964
1126 lines
30 KiB
Rust
1126 lines
30 KiB
Rust
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<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>,
|
|
}
|
|
|
|
#[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<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, 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 {
|
|
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<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,
|
|
}
|
|
|
|
#[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>,
|
|
}
|
|
|
|
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(())
|
|
}
|
|
|
|
// ── 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(())
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|