1use 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}