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
This commit is contained in:
Joakim Hulthe
2026-04-14 19:35:06 +00:00
parent 3284a18dcb
commit c55d2b9080
4 changed files with 577 additions and 1 deletions

View File

@@ -2,11 +2,13 @@
pub mod albums; pub mod albums;
pub mod assets; pub mod assets;
pub mod search;
pub mod server; pub mod server;
pub mod timeline; pub mod timeline;
// Re-export main API modules // Re-export main API modules
pub use albums::AlbumsApi; pub use albums::AlbumsApi;
pub use assets::AssetsApi; pub use assets::AssetsApi;
pub use search::SearchApi;
pub use server::ServerApi; pub use server::ServerApi;
pub use timeline::TimelineApi; pub use timeline::TimelineApi;

395
src/apis/search.rs Normal file
View File

@@ -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<AssetId>,
checksum: Option<String>,
city: Option<String>,
country: Option<String>,
created_after: Option<DateTime<Utc>>,
created_before: Option<DateTime<Utc>>,
description: Option<String>,
device_asset_id: Option<String>,
device_id: Option<String>,
is_favorite: Option<bool>,
is_motion: Option<bool>,
is_not_in_album: Option<bool>,
is_offline: Option<bool>,
lens_model: Option<String>,
library_id: Option<AssetId>,
make: Option<String>,
model: Option<String>,
ocr: Option<String>,
order: Option<AssetOrder>,
original_file_name: Option<String>,
page: Option<u32>,
person_ids: Vec<AssetId>,
rating: Option<i32>,
size: Option<u32>,
state: Option<String>,
tag_ids: Vec<AssetId>,
taken_after: Option<DateTime<Utc>>,
taken_before: Option<DateTime<Utc>>,
asset_type: Option<AssetType>,
updated_after: Option<DateTime<Utc>>,
updated_before: Option<DateTime<Utc>>,
visibility: Option<AssetVisibility>,
with_deleted: Option<bool>,
with_exif: Option<bool>,
with_people: Option<bool>,
with_stacked: Option<bool>,
}
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<String>) -> Self {
self.checksum = Some(checksum.into());
self
}
/// Filter by city name
pub fn city(mut self, city: impl Into<String>) -> Self {
self.city = Some(city.into());
self
}
/// Filter by country name
pub fn country(mut self, country: impl Into<String>) -> Self {
self.country = Some(country.into());
self
}
/// Filter by creation date after
pub fn created_after(mut self, after: DateTime<Utc>) -> Self {
self.created_after = Some(after);
self
}
/// Filter by creation date before
pub fn created_before(mut self, before: DateTime<Utc>) -> Self {
self.created_before = Some(before);
self
}
/// Filter by description text
pub fn description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
/// Filter by device asset ID
pub fn device_asset_id(mut self, id: impl Into<String>) -> Self {
self.device_asset_id = Some(id.into());
self
}
/// Filter by device ID
pub fn device_id(mut self, id: impl Into<String>) -> 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<String>) -> 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<String>) -> Self {
self.make = Some(make.into());
self
}
/// Filter by camera model
pub fn model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
/// Filter by OCR text content
pub fn ocr(mut self, text: impl Into<String>) -> 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<String>) -> 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<String>) -> 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<Utc>) -> Self {
self.taken_after = Some(after);
self
}
/// Filter by taken date before
pub fn taken_before(mut self, before: DateTime<Utc>) -> 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<Utc>) -> Self {
self.updated_after = Some(after);
self
}
/// Filter by update date before
pub fn updated_before(mut self, before: DateTime<Utc>) -> 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<SearchResponse> {
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)
}
}

View File

@@ -1,6 +1,6 @@
//! Client for interacting with the Immich API //! 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 crate::error::{ImmichError, Result};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -201,6 +201,11 @@ impl Client {
AssetsApi::new(self.clone()) AssetsApi::new(self.clone())
} }
/// Access the search API
pub fn search(&self) -> SearchApi {
SearchApi::new(self.clone())
}
/// Access the server API /// Access the server API
pub fn server(&self) -> ServerApi { pub fn server(&self) -> ServerApi {
ServerApi::new(self.clone()) ServerApi::new(self.clone())

View File

@@ -344,6 +344,180 @@ pub struct TimeBucketAssetResponse {
pub visibility: Vec<AssetVisibility>, pub visibility: Vec<AssetVisibility>,
} }
/// 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<Vec<AssetId>>,
/// Filter by file checksum
#[serde(skip_serializing_if = "Option::is_none")]
pub checksum: Option<String>,
/// Filter by city name
#[serde(skip_serializing_if = "Option::is_none")]
pub city: Option<String>,
/// Filter by country name
#[serde(skip_serializing_if = "Option::is_none")]
pub country: Option<String>,
/// Filter by creation date (after)
#[serde(skip_serializing_if = "Option::is_none")]
pub created_after: Option<DateTime<Utc>>,
/// Filter by creation date (before)
#[serde(skip_serializing_if = "Option::is_none")]
pub created_before: Option<DateTime<Utc>>,
/// Filter by description text
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Filter by device asset ID
#[serde(skip_serializing_if = "Option::is_none")]
pub device_asset_id: Option<String>,
/// Device ID to filter by
#[serde(skip_serializing_if = "Option::is_none")]
pub device_id: Option<String>,
/// Filter by favorite status
#[serde(skip_serializing_if = "Option::is_none")]
pub is_favorite: Option<bool>,
/// Filter by motion photo status
#[serde(skip_serializing_if = "Option::is_none")]
pub is_motion: Option<bool>,
/// Filter assets not in any album
#[serde(skip_serializing_if = "Option::is_none")]
pub is_not_in_album: Option<bool>,
/// Filter by offline status
#[serde(skip_serializing_if = "Option::is_none")]
pub is_offline: Option<bool>,
/// Filter by lens model
#[serde(skip_serializing_if = "Option::is_none")]
pub lens_model: Option<String>,
/// Library ID to filter by
#[serde(skip_serializing_if = "Option::is_none")]
pub library_id: Option<AssetId>,
/// Filter by camera make
#[serde(skip_serializing_if = "Option::is_none")]
pub make: Option<String>,
/// Filter by camera model
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Filter by OCR text content
#[serde(skip_serializing_if = "Option::is_none")]
pub ocr: Option<String>,
/// Sort order
#[serde(skip_serializing_if = "Option::is_none")]
pub order: Option<AssetOrder>,
/// Filter by original file name
#[serde(skip_serializing_if = "Option::is_none")]
pub original_file_name: Option<String>,
/// Page number (default: 1)
#[serde(skip_serializing_if = "Option::is_none")]
pub page: Option<u32>,
/// Filter by person IDs
#[serde(skip_serializing_if = "Option::is_none")]
pub person_ids: Option<Vec<AssetId>>,
/// Filter by rating [1-5], or null for unrated (-1 deprecated)
#[serde(skip_serializing_if = "Option::is_none")]
pub rating: Option<i32>,
/// Number of results to return (default: 100)
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<u32>,
/// Filter by state/province name
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
/// Filter by tag IDs
#[serde(skip_serializing_if = "Option::is_none")]
pub tag_ids: Option<Vec<AssetId>>,
/// Filter by taken date (after)
#[serde(skip_serializing_if = "Option::is_none")]
pub taken_after: Option<DateTime<Utc>>,
/// Filter by taken date (before)
#[serde(skip_serializing_if = "Option::is_none")]
pub taken_before: Option<DateTime<Utc>>,
/// Asset type filter
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub asset_type: Option<AssetType>,
/// Filter by update date (after)
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_after: Option<DateTime<Utc>>,
/// Filter by update date (before)
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_before: Option<DateTime<Utc>>,
/// Filter by visibility
#[serde(skip_serializing_if = "Option::is_none")]
pub visibility: Option<AssetVisibility>,
/// Include deleted assets
#[serde(skip_serializing_if = "Option::is_none")]
pub with_deleted: Option<bool>,
/// Include EXIF data in response
#[serde(skip_serializing_if = "Option::is_none")]
pub with_exif: Option<bool>,
/// Include people data in response
#[serde(skip_serializing_if = "Option::is_none")]
pub with_people: Option<bool>,
/// Include stacked assets
#[serde(skip_serializing_if = "Option::is_none")]
pub with_stacked: Option<bool>,
}
/// 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<SearchFacetCount>,
/// 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<SearchFacet>,
/// The actual assets
pub items: Vec<AssetResponse>,
/// Next page token
pub next_page: Option<String>,
/// 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<SearchFacet>,
/// The actual albums
pub items: Vec<AlbumResponse>,
/// 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;