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:
@@ -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;
|
||||
|
||||
395
src/apis/search.rs
Normal file
395
src/apis/search.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -344,6 +344,180 @@ pub struct TimeBucketAssetResponse {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user