Skip to main content

mimick/api_client/
search.rs

1//! Search and explore: smart search, OCR, metadata filters, people, places, server stats.
2//!
3//! Wraps the Immich `/search` and `/people` endpoints used by the library
4//! explore tab and search bar. Supports CLIP-based smart search, OCR text
5//! matching, and structured metadata filters with pagination.
6
7use std::time::Duration;
8
9use super::errors::{RequestContext, classify_http_issue, classify_network_issue};
10use super::{
11    ExifSearchResponse, ExploreSection, ImmichApiClient, LibraryAsset, MetadataSearchFilters,
12    PeopleResponse, Person, PlaceItem, SearchResponse, ServerAbout, ServerStatistics, ServerStats,
13    SortOrder,
14};
15
16impl ImmichApiClient {
17    /// Fetch the list of recognized people faces from the server.
18    pub async fn fetch_people(&self, include_hidden: bool) -> Result<Vec<Person>, String> {
19        let base_url = self
20            .get_active_url()
21            .await
22            .ok_or_else(|| "No active connection".to_string())?;
23        let settings = self.settings_snapshot();
24        let url = format!(
25            "{}/api/people?withHidden={}",
26            base_url,
27            if include_hidden { "true" } else { "false" }
28        );
29        match self
30            .client
31            .get(&url)
32            .header("x-api-key", &settings.api_key)
33            .header("Accept", "application/json")
34            .timeout(Duration::from_secs(10))
35            .send()
36            .await
37        {
38            Ok(resp) if resp.status().is_success() => {
39                let body = resp
40                    .json::<PeopleResponse>()
41                    .await
42                    .map_err(|err| err.to_string())?;
43                self.clear_issue().await;
44                Ok(body.people)
45            }
46            Ok(resp) => Err(format!("HTTP {}", resp.status())),
47            Err(err) => Err(err.to_string()),
48        }
49    }
50
51    /// Sectioned tile data (places + things) for the Explore landing.
52    pub async fn fetch_explore(&self) -> Result<Vec<ExploreSection>, String> {
53        let base_url = self
54            .get_active_url()
55            .await
56            .ok_or_else(|| "No active connection".to_string())?;
57        let settings = self.settings_snapshot();
58        let url = format!("{}/api/search/explore", base_url);
59        match self
60            .client
61            .get(&url)
62            .header("x-api-key", &settings.api_key)
63            .header("Accept", "application/json")
64            .timeout(Duration::from_secs(10))
65            .send()
66            .await
67        {
68            Ok(resp) if resp.status().is_success() => {
69                let sections = resp
70                    .json::<Vec<ExploreSection>>()
71                    .await
72                    .map_err(|err| err.to_string())?;
73                self.clear_issue().await;
74                Ok(sections)
75            }
76            Ok(resp) => Err(format!("HTTP {}", resp.status())),
77            Err(err) => Err(err.to_string()),
78        }
79    }
80
81    /// Fetch all unique cities that have at least one asset with EXIF city data.
82    /// Pages through `/api/search/metadata` collecting one representative asset
83    /// per city. Caps at 500 pages to bound runtime on very large libraries.
84    pub async fn fetch_all_places(&self) -> Result<Vec<PlaceItem>, String> {
85        let base_url = self
86            .get_active_url()
87            .await
88            .ok_or_else(|| "No active connection".to_string())?;
89        let settings = self.settings_snapshot();
90        let url = format!("{}/api/search/metadata", base_url);
91
92        let mut seen: std::collections::HashMap<String, String> = std::collections::HashMap::new();
93        let mut page: u32 = 1;
94        const PAGE_SIZE: u32 = 250;
95        const MAX_PAGES: u32 = 500;
96        let start = std::time::Instant::now();
97
98        loop {
99            let body = serde_json::json!({
100                "withExif": true,
101                "page": page,
102                "size": PAGE_SIZE,
103            });
104            let resp = match self
105                .client
106                .post(&url)
107                .header("x-api-key", &settings.api_key)
108                .header("Content-Type", "application/json")
109                .header("Accept", "application/json")
110                .json(&body)
111                .timeout(Duration::from_secs(30))
112                .send()
113                .await
114            {
115                Ok(r) if r.status().is_success() => r,
116                Ok(r) => return Err(format!("HTTP {}", r.status())),
117                Err(e) => return Err(e.to_string()),
118            };
119            let parsed: ExifSearchResponse = match resp.json().await {
120                Ok(p) => p,
121                Err(e) => return Err(e.to_string()),
122            };
123            let has_more = parsed.assets.next_page.is_some();
124            for asset in parsed.assets.items {
125                if let Some(city) = asset.exif_info.as_ref().and_then(|e| e.city.clone()) {
126                    seen.entry(city).or_insert(asset.id);
127                }
128            }
129            if !has_more || page >= MAX_PAGES {
130                break;
131            }
132            page += 1;
133        }
134
135        let mut places: Vec<PlaceItem> = seen
136            .into_iter()
137            .map(|(city, asset_id)| PlaceItem { city, asset_id })
138            .collect();
139        places.sort_by(|a, b| a.city.cmp(&b.city));
140        log::debug!(
141            "fetch_all_places: {} cities from {} pages in {:.1}s",
142            places.len(),
143            page,
144            start.elapsed().as_secs_f64()
145        );
146        Ok(places)
147    }
148
149    /// Per-person face thumbnail. Distinct from `fetch_thumbnail` (asset).
150    pub async fn fetch_person_thumbnail(&self, person_id: &str) -> Result<Vec<u8>, String> {
151        let _permit = self
152            .thumbnail_semaphore
153            .clone()
154            .acquire_owned()
155            .await
156            .map_err(|err| err.to_string())?;
157        let base_url = self
158            .get_active_url()
159            .await
160            .ok_or_else(|| "No active connection".to_string())?;
161        let settings = self.settings_snapshot();
162        let url = format!("{}/api/people/{}/thumbnail", base_url, person_id);
163        match self
164            .client
165            .get(&url)
166            .header("x-api-key", &settings.api_key)
167            .header("Accept", "application/octet-stream")
168            .timeout(Duration::from_secs(10))
169            .send()
170            .await
171        {
172            Ok(resp) if resp.status().is_success() => {
173                let bytes = resp.bytes().await.map_err(|err| err.to_string())?;
174                Ok(bytes.to_vec())
175            }
176            Ok(resp) => Err(format!("HTTP {}", resp.status())),
177            Err(err) => Err(err.to_string()),
178        }
179    }
180
181    /// Perform a CLIP embedding-based smart search for matching assets.
182    pub async fn search_smart(
183        &self,
184        query: &str,
185        page: u32,
186        size: u32,
187    ) -> Result<(Vec<LibraryAsset>, bool), String> {
188        let body = serde_json::json!({
189            "query": query,
190            "page": page,
191            "size": size.max(1),
192        });
193        self.fetch_search_assets(
194            "/api/search/smart",
195            body,
196            RequestContext::SmartSearch,
197            Some(query),
198        )
199        .await
200    }
201
202    /// CLIP-based smart search combined with metadata filters.
203    ///
204    /// The Immich `SmartSearchDto` supports the same filter dimensions as
205    /// `MetadataSearchDto` (location, date, type, etc.) alongside the CLIP
206    /// `query` field. This method serialises the filters and injects the
207    /// CLIP query on top.
208    pub async fn search_smart_filtered(
209        &self,
210        query: &str,
211        filters: &MetadataSearchFilters,
212        page: u32,
213        size: u32,
214    ) -> Result<(Vec<LibraryAsset>, bool), String> {
215        let mut body = serde_json::to_value(filters).map_err(|e| e.to_string())?;
216        if let Some(obj) = body.as_object_mut() {
217            obj.insert("query".into(), serde_json::json!(query));
218            obj.insert("page".into(), serde_json::json!(page));
219            obj.insert("size".into(), serde_json::json!(size.max(1)));
220            // SmartSearchDto does not accept these metadata-only fields.
221            obj.remove("originalFileName");
222            obj.remove("description");
223        }
224        self.fetch_search_assets(
225            "/api/search/smart",
226            body,
227            RequestContext::SmartSearch,
228            Some(query),
229        )
230        .await
231    }
232
233    /// Perform an OCR-based search to match recognized text inside library images.
234    pub async fn search_ocr(
235        &self,
236        query: &str,
237        page: u32,
238        size: u32,
239        order: Option<SortOrder>,
240    ) -> Result<(Vec<LibraryAsset>, bool), String> {
241        let mut body = serde_json::json!({
242            "ocr": query,
243            "page": page,
244            "size": size.max(1),
245        });
246        if let Some(order) = order
247            && let Some(obj) = body.as_object_mut()
248        {
249            obj.insert(
250                "order".into(),
251                serde_json::to_value(order).unwrap_or(serde_json::Value::Null),
252            );
253        }
254        self.fetch_search_assets(
255            "/api/search/metadata",
256            body,
257            RequestContext::MetadataSearch,
258            Some(query),
259        )
260        .await
261    }
262
263    /// Search library assets matching specific text within their original filenames.
264    pub async fn search_metadata(
265        &self,
266        query: &str,
267        page: u32,
268        size: u32,
269        order: Option<SortOrder>,
270    ) -> Result<(Vec<LibraryAsset>, bool), String> {
271        let filters = MetadataSearchFilters {
272            original_file_name: Some(query.to_string()),
273            order,
274            ..Default::default()
275        };
276        self.search_metadata_with_filters(&filters, page, size)
277            .await
278    }
279
280    /// Perform a advanced search matching specific metadata filters.
281    pub async fn search_metadata_with_filters(
282        &self,
283        filters: &MetadataSearchFilters,
284        page: u32,
285        size: u32,
286    ) -> Result<(Vec<LibraryAsset>, bool), String> {
287        let mut body = serde_json::to_value(filters).map_err(|err| err.to_string())?;
288        if let Some(obj) = body.as_object_mut() {
289            obj.insert("page".into(), serde_json::json!(page));
290            obj.insert("size".into(), serde_json::json!(size.max(1)));
291        }
292        let label = filters.original_file_name.as_deref().unwrap_or("");
293        self.fetch_search_assets(
294            "/api/search/metadata",
295            body,
296            RequestContext::MetadataSearch,
297            Some(label),
298        )
299        .await
300    }
301
302    /// Retrieve total images, videos, and overall asset count statistics from the server.
303    pub async fn fetch_server_stats(&self) -> Result<ServerStats, String> {
304        let base_url = self
305            .get_active_url()
306            .await
307            .ok_or_else(|| "No active connection".to_string())?;
308        let settings = self.settings_snapshot();
309        let url = format!("{}/api/assets/statistics", base_url);
310
311        match self
312            .client
313            .get(&url)
314            .header("x-api-key", &settings.api_key)
315            .header("Accept", "application/json")
316            .timeout(Duration::from_secs(10))
317            .send()
318            .await
319        {
320            Ok(resp) if resp.status().is_success() => {
321                let stats = resp
322                    .json::<ServerStats>()
323                    .await
324                    .map_err(|err| err.to_string())?;
325                self.clear_issue().await;
326                Ok(stats)
327            }
328            Ok(resp) => {
329                self.set_issue(classify_http_issue(
330                    RequestContext::ServerStats,
331                    resp.status().as_u16(),
332                    None,
333                ))
334                .await;
335                Err(format!("HTTP {}", resp.status()))
336            }
337            Err(err) => {
338                *self.active_url.lock().await = None;
339                self.set_issue(classify_network_issue(RequestContext::ServerStats, &err))
340                    .await;
341                Err(err.to_string())
342            }
343        }
344    }
345
346    /// Fetch server-wide statistics including per-user usage breakdown.
347    ///
348    /// Admin-only endpoint; non-admin sessions will receive an HTTP 403 and an
349    /// `Err` is returned. Caller should fall back to per-user asset counts.
350    pub async fn fetch_server_statistics(&self) -> Result<ServerStatistics, String> {
351        let base_url = self
352            .get_active_url()
353            .await
354            .ok_or_else(|| "No active connection".to_string())?;
355        let settings = self.settings_snapshot();
356        let url = format!("{}/api/server/statistics", base_url);
357
358        match self
359            .client
360            .get(&url)
361            .header("x-api-key", &settings.api_key)
362            .header("Accept", "application/json")
363            .timeout(Duration::from_secs(10))
364            .send()
365            .await
366        {
367            Ok(resp) if resp.status().is_success() => resp
368                .json::<ServerStatistics>()
369                .await
370                .map_err(|err| err.to_string()),
371            Ok(resp) => Err(format!("HTTP {}", resp.status())),
372            Err(err) => Err(err.to_string()),
373        }
374    }
375
376    /// Retrieve detailed Immich server system information (e.g. version).
377    pub async fn fetch_server_about(&self) -> Result<ServerAbout, String> {
378        let base_url = self
379            .get_active_url()
380            .await
381            .ok_or_else(|| "No active connection".to_string())?;
382        let settings = self.settings_snapshot();
383        let url = format!("{}/api/server/about", base_url);
384
385        match self
386            .client
387            .get(&url)
388            .header("x-api-key", &settings.api_key)
389            .header("Accept", "application/json")
390            .timeout(Duration::from_secs(10))
391            .send()
392            .await
393        {
394            Ok(resp) if resp.status().is_success() => {
395                let about = resp
396                    .json::<ServerAbout>()
397                    .await
398                    .map_err(|err| err.to_string())?;
399                self.clear_issue().await;
400                Ok(about)
401            }
402            Ok(resp) => {
403                self.set_issue(classify_http_issue(
404                    RequestContext::ServerAbout,
405                    resp.status().as_u16(),
406                    None,
407                ))
408                .await;
409                Err(format!("HTTP {}", resp.status()))
410            }
411            Err(err) => {
412                *self.active_url.lock().await = None;
413                self.set_issue(classify_network_issue(RequestContext::ServerAbout, &err))
414                    .await;
415                Err(err.to_string())
416            }
417        }
418    }
419
420    /// Shared internal helper executing paginated POST search queries against asset endpoints.
421    pub(super) async fn fetch_search_assets(
422        &self,
423        endpoint: &str,
424        mut body: serde_json::Value,
425        context: RequestContext,
426        subject: Option<&str>,
427    ) -> Result<(Vec<LibraryAsset>, bool), String> {
428        if let Some(obj) = body.as_object_mut() {
429            obj.entry("withExif")
430                .or_insert(serde_json::Value::Bool(true));
431        }
432        let base_url = self
433            .get_active_url()
434            .await
435            .ok_or_else(|| "No active connection".to_string())?;
436        let settings = self.settings_snapshot();
437        let url = format!("{}{}", base_url, endpoint);
438
439        match self
440            .client
441            .post(&url)
442            .header("x-api-key", &settings.api_key)
443            .header("Content-Type", "application/json")
444            .header("Accept", "application/json")
445            .json(&body)
446            .timeout(Duration::from_secs(10))
447            .send()
448            .await
449        {
450            Ok(resp) if resp.status().is_success() => {
451                let response = resp
452                    .json::<SearchResponse>()
453                    .await
454                    .map_err(|err| err.to_string())?;
455                self.clear_issue().await;
456                let has_more = response.assets.next_page.is_some();
457                Ok((response.assets.items, has_more))
458            }
459            Ok(resp) => {
460                self.set_issue(classify_http_issue(
461                    context,
462                    resp.status().as_u16(),
463                    subject,
464                ))
465                .await;
466                Err(format!("HTTP {}", resp.status()))
467            }
468            Err(err) => {
469                *self.active_url.lock().await = None;
470                self.set_issue(classify_network_issue(context, &err)).await;
471                Err(err.to_string())
472            }
473        }
474    }
475}