pinakes/crates/pinakes-tui/src/client.rs
NotAShelf 8129c5a6e7
pinakes-tui: cover more API routes in the TUI crate
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Id14b6f82d3b9f3c27bee9c214a1bdedc6a6a6964
2026-03-22 17:58:42 +03:00

991 lines
22 KiB
Rust

use anyhow::Result;
use reqwest::Client;
use rustc_hash::FxHashMap;
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: FxHashMap<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,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PlaylistResponse {
pub id: String,
pub name: String,
pub description: Option<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CommentResponse {
pub text: String,
pub created_at: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TranscodeSessionResponse {
pub id: String,
pub profile: String,
pub status: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SubtitleEntry {
pub language: Option<String>,
pub format: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SubtitleListResponse {
pub subtitles: Vec<SubtitleEntry>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DeviceResponse {
pub id: String,
pub name: String,
pub device_type: Option<String>,
pub last_seen: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct WebhookInfo {
#[serde(default)]
pub id: String,
pub url: String,
pub events: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct UserResponse {
pub id: String,
pub username: String,
pub role: String,
pub created_at: String,
}
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_else(|e| {
tracing::warn!(
"failed to build authenticated HTTP client: {e}; falling back to \
unauthenticated client"
);
Client::new()
})
});
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(())
}
pub async fn list_playlists(&self) -> Result<Vec<PlaylistResponse>> {
let resp = self
.client
.get(self.url("/playlists"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn create_playlist(
&self,
name: &str,
description: Option<&str>,
) -> Result<PlaylistResponse> {
let mut body = serde_json::json!({"name": name});
if let Some(desc) = description {
body["description"] = serde_json::Value::String(desc.to_string());
}
let resp = self
.client
.post(self.url("/playlists"))
.json(&body)
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn delete_playlist(&self, id: &str) -> Result<()> {
self
.client
.delete(self.url(&format!("/playlists/{id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn get_playlist_items(
&self,
id: &str,
) -> Result<Vec<MediaResponse>> {
let resp = self
.client
.get(self.url(&format!("/playlists/{id}/items")))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn remove_from_playlist(
&self,
playlist_id: &str,
media_id: &str,
) -> Result<()> {
self
.client
.delete(self.url(&format!("/playlists/{playlist_id}/items/{media_id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn shuffle_playlist(&self, id: &str) -> Result<()> {
self
.client
.post(self.url(&format!("/playlists/{id}/shuffle")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn rate_media(&self, media_id: &str, stars: u8) -> Result<()> {
self
.client
.post(self.url(&format!("/media/{media_id}/ratings")))
.json(&serde_json::json!({"stars": stars}))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn add_comment(
&self,
media_id: &str,
text: &str,
) -> Result<CommentResponse> {
let resp = self
.client
.post(self.url(&format!("/media/{media_id}/comments")))
.json(&serde_json::json!({"text": text}))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn list_comments(
&self,
media_id: &str,
) -> Result<Vec<CommentResponse>> {
let resp = self
.client
.get(self.url(&format!("/media/{media_id}/comments")))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn toggle_favorite(&self, media_id: &str) -> Result<()> {
// Try POST to add; if it fails with conflict, DELETE to remove
let post_resp = self
.client
.post(self.url("/favorites"))
.json(&serde_json::json!({"media_id": media_id}))
.send()
.await?;
if post_resp.status() == reqwest::StatusCode::CONFLICT
|| post_resp.status() == reqwest::StatusCode::UNPROCESSABLE_ENTITY
{
// Already a favorite: remove it
self
.client
.delete(self.url(&format!("/favorites/{media_id}")))
.send()
.await?
.error_for_status()?;
} else {
post_resp.error_for_status()?;
}
Ok(())
}
pub async fn enrich_media(&self, media_id: &str) -> Result<()> {
self
.client
.post(self.url(&format!("/media/{media_id}/enrich")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn start_transcode(
&self,
media_id: &str,
profile: &str,
) -> Result<TranscodeSessionResponse> {
let resp = self
.client
.post(self.url(&format!("/media/{media_id}/transcode")))
.json(&serde_json::json!({"profile": profile}))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn list_transcodes(&self) -> Result<Vec<TranscodeSessionResponse>> {
let resp = self
.client
.get(self.url("/transcode"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn cancel_transcode(&self, id: &str) -> Result<()> {
self
.client
.delete(self.url(&format!("/transcode/{id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn list_subtitles(
&self,
media_id: &str,
) -> Result<SubtitleListResponse> {
let resp = self
.client
.get(self.url(&format!("/media/{media_id}/subtitles")))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn list_sync_devices(&self) -> Result<Vec<DeviceResponse>> {
let resp = self
.client
.get(self.url("/sync/devices"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn delete_sync_device(&self, id: &str) -> Result<()> {
self
.client
.delete(self.url(&format!("/sync/devices/{id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn list_webhooks(&self) -> Result<Vec<WebhookInfo>> {
let resp = self
.client
.get(self.url("/webhooks"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn test_webhook(&self, id: &str) -> Result<()> {
self
.client
.post(self.url("/webhooks/test"))
.json(&serde_json::json!({"id": id}))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn list_users(&self) -> Result<Vec<UserResponse>> {
let resp = self
.client
.get(self.url("/users"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn delete_user(&self, id: &str) -> Result<()> {
self
.client
.delete(self.url(&format!("/users/{id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn add_to_playlist(
&self,
playlist_id: &str,
media_id: &str,
) -> Result<()> {
let body = serde_json::json!({"media_id": media_id});
let resp = self
.client
.post(self.url(&format!("/playlists/{playlist_id}/items")))
.json(&body)
.send()
.await?;
if resp.status().is_success() {
Ok(())
} else {
anyhow::bail!("add to playlist failed: {}", resp.status())
}
}
}