From c55d2b90804a5d25555f0e859eb5ec7a53c20482 Mon Sep 17 00:00:00 2001 From: Joakim Hulthe Date: Tue, 14 Apr 2026 19:35:06 +0000 Subject: [PATCH] Add search/metadata API endpoint - Add MetadataSearchRequest, SearchResponse, SearchAssetResult, SearchAlbumResult, SearchFacet, SearchFacetCount models - Create SearchApi with SearchMetadataBuilder supporting 35+ filters - Support filtering by location, dates, camera info, favorites, tags, people, albums, text search, and more - Integrate into Client with client.search().metadata() API --- src/apis/mod.rs | 2 + src/apis/search.rs | 395 +++++++++++++++++++++++++++++++++++++++++++++ src/client.rs | 7 +- src/models/mod.rs | 174 ++++++++++++++++++++ 4 files changed, 577 insertions(+), 1 deletion(-) create mode 100644 src/apis/search.rs diff --git a/src/apis/mod.rs b/src/apis/mod.rs index fc3c118..ff5425a 100644 --- a/src/apis/mod.rs +++ b/src/apis/mod.rs @@ -2,11 +2,13 @@ pub mod albums; pub mod assets; +pub mod search; pub mod server; pub mod timeline; // Re-export main API modules pub use albums::AlbumsApi; pub use assets::AssetsApi; +pub use search::SearchApi; pub use server::ServerApi; pub use timeline::TimelineApi; diff --git a/src/apis/search.rs b/src/apis/search.rs new file mode 100644 index 0000000..dd78935 --- /dev/null +++ b/src/apis/search.rs @@ -0,0 +1,395 @@ +//! Search API - Search for assets and albums by metadata + +use crate::{ + Client, + error::Result, + models::{ + AssetId, AssetOrder, AssetType, AssetVisibility, MetadataSearchRequest, SearchResponse, + }, +}; +use chrono::{DateTime, Utc}; + +/// API for searching assets and albums +#[derive(Debug, Clone)] +pub struct SearchApi { + client: Client, +} + +impl SearchApi { + /// Create a new search API instance + pub const fn new(client: Client) -> Self { + Self { client } + } + + /// Start a metadata search with the builder pattern + pub fn metadata(&self) -> SearchMetadataBuilder { + SearchMetadataBuilder::new(self.client.clone()) + } +} + +/// Builder for the metadata search endpoint +#[derive(Debug)] +pub struct SearchMetadataBuilder { + client: Client, + album_ids: Vec, + checksum: Option, + city: Option, + country: Option, + created_after: Option>, + created_before: Option>, + description: Option, + device_asset_id: Option, + device_id: Option, + is_favorite: Option, + is_motion: Option, + is_not_in_album: Option, + is_offline: Option, + lens_model: Option, + library_id: Option, + make: Option, + model: Option, + ocr: Option, + order: Option, + original_file_name: Option, + page: Option, + person_ids: Vec, + rating: Option, + size: Option, + state: Option, + tag_ids: Vec, + taken_after: Option>, + taken_before: Option>, + asset_type: Option, + updated_after: Option>, + updated_before: Option>, + visibility: Option, + with_deleted: Option, + with_exif: Option, + with_people: Option, + with_stacked: Option, +} + +impl SearchMetadataBuilder { + /// Create a new metadata search builder + const fn new(client: Client) -> Self { + Self { + client, + album_ids: Vec::new(), + checksum: None, + city: None, + country: None, + created_after: None, + created_before: None, + description: None, + device_asset_id: None, + device_id: None, + is_favorite: None, + is_motion: None, + is_not_in_album: None, + is_offline: None, + lens_model: None, + library_id: None, + make: None, + model: None, + ocr: None, + order: None, + original_file_name: None, + page: None, + person_ids: Vec::new(), + rating: None, + size: None, + state: None, + tag_ids: Vec::new(), + taken_after: None, + taken_before: None, + asset_type: None, + updated_after: None, + updated_before: None, + visibility: None, + with_deleted: None, + with_exif: None, + with_people: None, + with_stacked: None, + } + } + + /// Filter by album ID (can be called multiple times to filter by multiple albums) + pub fn album_id(mut self, id: AssetId) -> Self { + self.album_ids.push(id); + self + } + + /// Filter by file checksum + pub fn checksum(mut self, checksum: impl Into) -> Self { + self.checksum = Some(checksum.into()); + self + } + + /// Filter by city name + pub fn city(mut self, city: impl Into) -> Self { + self.city = Some(city.into()); + self + } + + /// Filter by country name + pub fn country(mut self, country: impl Into) -> Self { + self.country = Some(country.into()); + self + } + + /// Filter by creation date after + pub fn created_after(mut self, after: DateTime) -> Self { + self.created_after = Some(after); + self + } + + /// Filter by creation date before + pub fn created_before(mut self, before: DateTime) -> Self { + self.created_before = Some(before); + self + } + + /// Filter by description text + pub fn description(mut self, desc: impl Into) -> Self { + self.description = Some(desc.into()); + self + } + + /// Filter by device asset ID + pub fn device_asset_id(mut self, id: impl Into) -> Self { + self.device_asset_id = Some(id.into()); + self + } + + /// Filter by device ID + pub fn device_id(mut self, id: impl Into) -> Self { + self.device_id = Some(id.into()); + self + } + + /// Filter by favorite status + pub fn favorite(mut self, is_favorite: bool) -> Self { + self.is_favorite = Some(is_favorite); + self + } + + /// Filter by motion photo status + pub fn motion(mut self, is_motion: bool) -> Self { + self.is_motion = Some(is_motion); + self + } + + /// Filter assets not in any album + pub fn not_in_album(mut self) -> Self { + self.is_not_in_album = Some(true); + self + } + + /// Filter by offline status + pub fn offline(mut self, is_offline: bool) -> Self { + self.is_offline = Some(is_offline); + self + } + + /// Filter by lens model + pub fn lens_model(mut self, model: impl Into) -> Self { + self.lens_model = Some(model.into()); + self + } + + /// Filter by library ID + pub fn library_id(mut self, id: AssetId) -> Self { + self.library_id = Some(id); + self + } + + /// Filter by camera make + pub fn make(mut self, make: impl Into) -> Self { + self.make = Some(make.into()); + self + } + + /// Filter by camera model + pub fn model(mut self, model: impl Into) -> Self { + self.model = Some(model.into()); + self + } + + /// Filter by OCR text content + pub fn ocr(mut self, text: impl Into) -> Self { + self.ocr = Some(text.into()); + self + } + + /// Set sort order + pub fn order(mut self, order: AssetOrder) -> Self { + self.order = Some(order); + self + } + + /// Filter by original file name + pub fn file_name(mut self, name: impl Into) -> Self { + self.original_file_name = Some(name.into()); + self + } + + /// Set page number + pub fn page(mut self, page: u32) -> Self { + self.page = Some(page); + self + } + + /// Filter by person ID (can be called multiple times to filter by multiple people) + pub fn person_id(mut self, id: AssetId) -> Self { + self.person_ids.push(id); + self + } + + /// Filter by rating (1-5, or -1 deprecated) + pub fn rating(mut self, rating: i32) -> Self { + self.rating = Some(rating); + self + } + + /// Set number of results (1-1000, default 100) + pub fn size(mut self, size: u32) -> Self { + self.size = Some(size); + self + } + + /// Filter by state/province name + pub fn state(mut self, state: impl Into) -> Self { + self.state = Some(state.into()); + self + } + + /// Filter by tag ID (can be called multiple times to filter by multiple tags) + pub fn tag_id(mut self, id: AssetId) -> Self { + self.tag_ids.push(id); + self + } + + /// Filter by taken date after + pub fn taken_after(mut self, after: DateTime) -> Self { + self.taken_after = Some(after); + self + } + + /// Filter by taken date before + pub fn taken_before(mut self, before: DateTime) -> Self { + self.taken_before = Some(before); + self + } + + /// Filter by asset type + pub fn asset_type(mut self, asset_type: AssetType) -> Self { + self.asset_type = Some(asset_type); + self + } + + /// Filter by update date after + pub fn updated_after(mut self, after: DateTime) -> Self { + self.updated_after = Some(after); + self + } + + /// Filter by update date before + pub fn updated_before(mut self, before: DateTime) -> Self { + self.updated_before = Some(before); + self + } + + /// Filter by visibility + pub fn visibility(mut self, visibility: AssetVisibility) -> Self { + self.visibility = Some(visibility); + self + } + + /// Include deleted assets in results + pub fn with_deleted(mut self) -> Self { + self.with_deleted = Some(true); + self + } + + /// Include EXIF data in response + pub fn with_exif(mut self) -> Self { + self.with_exif = Some(true); + self + } + + /// Include people data in response + pub fn with_people(mut self) -> Self { + self.with_people = Some(true); + self + } + + /// Include stacked assets + pub fn with_stacked(mut self) -> Self { + self.with_stacked = Some(true); + self + } + + /// Build the request and execute it + /// + /// # Errors + /// Returns an error if the HTTP request fails, if the response cannot be deserialized, + /// or if authentication is missing/invalid + pub async fn execute(self) -> Result { + let request = MetadataSearchRequest { + album_ids: if self.album_ids.is_empty() { + None + } else { + Some(self.album_ids) + }, + checksum: self.checksum, + city: self.city, + country: self.country, + created_after: self.created_after, + created_before: self.created_before, + description: self.description, + device_asset_id: self.device_asset_id, + device_id: self.device_id, + is_favorite: self.is_favorite, + is_motion: self.is_motion, + is_not_in_album: self.is_not_in_album, + is_offline: self.is_offline, + lens_model: self.lens_model, + library_id: self.library_id, + make: self.make, + model: self.model, + ocr: self.ocr, + order: self.order, + original_file_name: self.original_file_name, + page: self.page, + person_ids: if self.person_ids.is_empty() { + None + } else { + Some(self.person_ids) + }, + rating: self.rating, + size: self.size, + state: self.state, + tag_ids: if self.tag_ids.is_empty() { + None + } else { + Some(self.tag_ids) + }, + taken_after: self.taken_after, + taken_before: self.taken_before, + asset_type: self.asset_type, + updated_after: self.updated_after, + updated_before: self.updated_before, + visibility: self.visibility, + with_deleted: self.with_deleted, + with_exif: self.with_exif, + with_people: self.with_people, + with_stacked: self.with_stacked, + }; + + let req = self.client.post("/search/metadata").json(&request); + let response = self.client.execute(req.build()?).await?; + let result: SearchResponse = response.json().await?; + + Ok(result) + } +} diff --git a/src/client.rs b/src/client.rs index 973b1df..c526931 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,6 +1,6 @@ //! Client for interacting with the Immich API -use crate::apis::{AlbumsApi, AssetsApi, ServerApi, TimelineApi}; +use crate::apis::{AlbumsApi, AssetsApi, SearchApi, ServerApi, TimelineApi}; use crate::error::{ImmichError, Result}; use std::sync::Arc; use std::time::Duration; @@ -201,6 +201,11 @@ impl Client { AssetsApi::new(self.clone()) } + /// Access the search API + pub fn search(&self) -> SearchApi { + SearchApi::new(self.clone()) + } + /// Access the server API pub fn server(&self) -> ServerApi { ServerApi::new(self.clone()) diff --git a/src/models/mod.rs b/src/models/mod.rs index aee744a..6cbd565 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -344,6 +344,180 @@ pub struct TimeBucketAssetResponse { pub visibility: Vec, } +/// Metadata search request for filtering assets +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct MetadataSearchRequest { + /// Filter by album IDs + #[serde(skip_serializing_if = "Option::is_none")] + pub album_ids: Option>, + /// Filter by file checksum + #[serde(skip_serializing_if = "Option::is_none")] + pub checksum: Option, + /// Filter by city name + #[serde(skip_serializing_if = "Option::is_none")] + pub city: Option, + /// Filter by country name + #[serde(skip_serializing_if = "Option::is_none")] + pub country: Option, + /// Filter by creation date (after) + #[serde(skip_serializing_if = "Option::is_none")] + pub created_after: Option>, + /// Filter by creation date (before) + #[serde(skip_serializing_if = "Option::is_none")] + pub created_before: Option>, + /// Filter by description text + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Filter by device asset ID + #[serde(skip_serializing_if = "Option::is_none")] + pub device_asset_id: Option, + /// Device ID to filter by + #[serde(skip_serializing_if = "Option::is_none")] + pub device_id: Option, + /// Filter by favorite status + #[serde(skip_serializing_if = "Option::is_none")] + pub is_favorite: Option, + /// Filter by motion photo status + #[serde(skip_serializing_if = "Option::is_none")] + pub is_motion: Option, + /// Filter assets not in any album + #[serde(skip_serializing_if = "Option::is_none")] + pub is_not_in_album: Option, + /// Filter by offline status + #[serde(skip_serializing_if = "Option::is_none")] + pub is_offline: Option, + /// Filter by lens model + #[serde(skip_serializing_if = "Option::is_none")] + pub lens_model: Option, + /// Library ID to filter by + #[serde(skip_serializing_if = "Option::is_none")] + pub library_id: Option, + /// Filter by camera make + #[serde(skip_serializing_if = "Option::is_none")] + pub make: Option, + /// Filter by camera model + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + /// Filter by OCR text content + #[serde(skip_serializing_if = "Option::is_none")] + pub ocr: Option, + /// Sort order + #[serde(skip_serializing_if = "Option::is_none")] + pub order: Option, + /// Filter by original file name + #[serde(skip_serializing_if = "Option::is_none")] + pub original_file_name: Option, + /// Page number (default: 1) + #[serde(skip_serializing_if = "Option::is_none")] + pub page: Option, + /// Filter by person IDs + #[serde(skip_serializing_if = "Option::is_none")] + pub person_ids: Option>, + /// Filter by rating [1-5], or null for unrated (-1 deprecated) + #[serde(skip_serializing_if = "Option::is_none")] + pub rating: Option, + /// Number of results to return (default: 100) + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, + /// Filter by state/province name + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, + /// Filter by tag IDs + #[serde(skip_serializing_if = "Option::is_none")] + pub tag_ids: Option>, + /// Filter by taken date (after) + #[serde(skip_serializing_if = "Option::is_none")] + pub taken_after: Option>, + /// Filter by taken date (before) + #[serde(skip_serializing_if = "Option::is_none")] + pub taken_before: Option>, + /// Asset type filter + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub asset_type: Option, + /// Filter by update date (after) + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_after: Option>, + /// Filter by update date (before) + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_before: Option>, + /// Filter by visibility + #[serde(skip_serializing_if = "Option::is_none")] + pub visibility: Option, + /// Include deleted assets + #[serde(skip_serializing_if = "Option::is_none")] + pub with_deleted: Option, + /// Include EXIF data in response + #[serde(skip_serializing_if = "Option::is_none")] + pub with_exif: Option, + /// Include people data in response + #[serde(skip_serializing_if = "Option::is_none")] + pub with_people: Option, + /// Include stacked assets + #[serde(skip_serializing_if = "Option::is_none")] + pub with_stacked: Option, +} + +/// Represents a facet value count +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchFacetCount { + /// Number of assets with this facet value + pub count: i64, + /// Facet value + pub value: String, +} + +/// Represents a search facet +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchFacet { + /// Facet counts + pub counts: Vec, + /// Facet field name + pub field_name: String, +} + +/// Paginated asset search results +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchAssetResult { + /// Number of assets in this page + pub count: i64, + /// Facet information + pub facets: Vec, + /// The actual assets + pub items: Vec, + /// Next page token + pub next_page: Option, + /// Total number of matching assets + pub total: i64, +} + +/// Paginated album search results +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchAlbumResult { + /// Number of albums in this page + pub count: i64, + /// Facet information + pub facets: Vec, + /// The actual albums + pub items: Vec, + /// Total number of matching albums + pub total: i64, +} + +/// Combined search response +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchResponse { + /// Album results + pub albums: SearchAlbumResult, + /// Asset results + pub assets: SearchAssetResult, +} + #[cfg(test)] mod tests { use super::*;