Skip to main content

mimick/api_client/
mod.rs

1//! Integrates with the Immich API, handles connectivity failover, and provides album/cache helpers.
2//!
3//! The client probes both an internal (LAN) and external (WAN) URL and
4//! locks onto whichever responds first. Submodules split the API surface
5//! by domain: `albums`, `library`, `search`, `upload`, and `errors`.
6//! All HTTP requests share a single `reqwest::Client` connection pool.
7
8use parking_lot::RwLock;
9use reqwest::Client;
10use std::collections::HashMap;
11use std::sync::Arc;
12use std::time::{Duration, Instant};
13use tokio::sync::{Mutex, Semaphore};
14
15mod albums;
16mod errors;
17mod library;
18mod search;
19mod suggestions;
20mod upload;
21mod upload_helpers;
22
23#[cfg(test)]
24use errors::{RequestContext, classify_http_issue};
25#[cfg(test)]
26use upload_helpers::{
27    looks_like_iana_timezone, mime_for_path, normalize_file_timestamps, unix_to_utc_iso8601,
28};
29
30pub type TransferProgressCallback = Arc<dyn Fn(u64, Option<u64>) + Send + Sync>;
31
32/// Represents an actionable API or connection issue encountered during operations.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ApiIssue {
35    /// Concise summary of the encountered issue.
36    pub summary: String,
37    /// Guidance text showing the user how to resolve it.
38    pub guidance: String,
39}
40
41/// In-memory API client connection URLs and configuration settings.
42#[derive(Debug, Clone)]
43pub(super) struct ApiClientSettings {
44    /// Server URL for local network connections.
45    pub(super) internal_url: String,
46    /// Server URL for external network connections.
47    pub(super) external_url: String,
48    /// Immich authorization API key.
49    pub(super) api_key: String,
50}
51
52/// Simplified album summary response from Immich.
53#[derive(Debug, serde::Deserialize)]
54pub(super) struct AlbumSummary {
55    /// Album identifier.
56    pub(super) id: String,
57    /// Name of the album.
58    #[serde(rename = "albumName")]
59    pub(super) album_name: String,
60}
61
62/// Detailed Immich library album representation.
63#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
64pub struct LibraryAlbum {
65    /// Unique album identifier.
66    pub id: String,
67    /// Name of the album.
68    #[serde(rename = "albumName")]
69    pub album_name: String,
70    /// Count of assets contained in the album.
71    #[serde(rename = "assetCount")]
72    pub asset_count: u32,
73    /// Asset ID used as the album's cover thumbnail.
74    #[serde(rename = "albumThumbnailAssetId")]
75    pub thumbnail_asset_id: Option<String>,
76    /// ISO 8601 creation timestamp.
77    #[serde(rename = "createdAt")]
78    pub created_at: String,
79    /// ISO 8601 modification timestamp.
80    #[serde(rename = "updatedAt")]
81    pub updated_at: String,
82    /// User description of the album.
83    #[serde(default)]
84    pub description: String,
85    /// Users associated with this album (owner + editors + viewers).
86    /// In Immich v3 the top-level `ownerId` was removed; ownership is now
87    /// represented as an entry in this list with `role == "owner"`.
88    #[serde(rename = "albumUsers", default)]
89    pub album_users: Vec<AlbumUser>,
90}
91
92impl LibraryAlbum {
93    /// Extract the owner's user ID from the `albumUsers` list.
94    /// Returns an empty string when no owner entry is present.
95    pub fn owner_id(&self) -> &str {
96        self.album_users
97            .iter()
98            .find(|u| u.role == "owner")
99            .map(|u| u.user.id.as_str())
100            .unwrap_or("")
101    }
102
103    /// Whether the album is shared (has more than one user).
104    pub fn is_shared(&self) -> bool {
105        self.album_users.len() > 1
106    }
107}
108
109/// A user entry within an album's user list.
110#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
111pub struct AlbumUser {
112    /// User details.
113    pub user: AlbumUserInfo,
114    /// Role in the album ("owner", "editor", or "viewer").
115    pub role: String,
116}
117
118/// Minimal user identity nested inside an `AlbumUser`.
119#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
120pub struct AlbumUserInfo {
121    /// Unique user identifier.
122    pub id: String,
123}
124
125/// Detailed Immich library asset representation.
126#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
127pub struct LibraryAsset {
128    /// Unique asset identifier on the server.
129    pub id: String,
130    /// Original file name.
131    #[serde(rename = "originalFileName")]
132    pub filename: String,
133    /// Original MIME type.
134    #[serde(rename = "originalMimeType")]
135    pub mime_type: String,
136    /// Asset file creation timestamp.
137    #[serde(rename = "fileCreatedAt")]
138    pub created_at: String,
139    /// Asset kind (e.g. `"IMAGE"` or `"VIDEO"`).
140    #[serde(rename = "type")]
141    pub asset_type: String,
142    /// Thumbhash representation for preview blur effects.
143    pub thumbhash: Option<String>,
144    /// Display width in pixels.
145    pub width: Option<u32>,
146    /// Display height in pixels.
147    pub height: Option<u32>,
148    /// Canonical lowercase SHA-1 checksum.
149    #[serde(default, deserialize_with = "deserialize_checksum_to_hex")]
150    pub checksum: Option<String>,
151    /// EXIF block; Immich keeps pixel dimensions here, not at the top level.
152    #[serde(rename = "exifInfo", default)]
153    pub exif_info: Option<ExifInfo>,
154}
155
156/// Immich returns asset checksums as base64-encoded SHA1, while Mimick computes
157/// and stores them as lowercase hex. Normalize on deserialization so every
158/// comparison site (album diff, deletion lookup, sync index) sees the same
159/// canonical form.
160fn deserialize_checksum_to_hex<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
161where
162    D: serde::Deserializer<'de>,
163{
164    use serde::Deserialize;
165    let raw: Option<String> = Option::deserialize(deserializer)?;
166    Ok(raw.as_deref().and_then(normalize_checksum_to_hex))
167}
168
169pub fn normalize_checksum_to_hex(input: &str) -> Option<String> {
170    let trimmed = input.trim();
171    if trimmed.is_empty() {
172        return None;
173    }
174    if trimmed.len() == 40 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
175        return Some(trimmed.to_ascii_lowercase());
176    }
177    use base64::Engine;
178    let bytes = base64::engine::general_purpose::STANDARD
179        .decode(trimmed.as_bytes())
180        .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(trimmed.as_bytes()))
181        .or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(trimmed.as_bytes()))
182        .ok()?;
183    if bytes.len() != 20 {
184        return None;
185    }
186    Some(bytes.iter().map(|b| format!("{b:02x}")).collect())
187}
188
189/// Structured filters passed down search operations.
190#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
191#[serde(rename_all = "camelCase")]
192pub struct MetadataSearchFilters {
193    /// Inferred or original file name.
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub original_file_name: Option<String>,
196    /// User description or caption search query.
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub description: Option<String>,
199    /// Match against OCR-extracted text inside images. Distinct from
200    /// `description` (user-set caption) — Immich indexes recognised text
201    /// during ML processing and exposes it as its own filter dimension on
202    /// both `MetadataSearchDto` and `SmartSearchDto`.
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub ocr: Option<String>,
205    /// `"IMAGE"` or `"VIDEO"`; `None` returns both.
206    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
207    pub asset_type: Option<String>,
208    /// ISO 8601 inclusive lower bound on `fileCreatedAt`.
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub taken_after: Option<String>,
211    /// ISO 8601 inclusive upper bound on `fileCreatedAt`.
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub taken_before: Option<String>,
214    /// Camera make.
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub make: Option<String>,
217    /// Camera model.
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub model: Option<String>,
220    /// Camera lens model.
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub lens_model: Option<String>,
223    /// City location.
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub country: Option<String>,
226    /// State location.
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub state: Option<String>,
229    /// City location.
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub city: Option<String>,
232    /// True to only return favorited assets.
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub is_favorite: Option<bool>,
235    /// True to only return archived assets.
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub is_archived: Option<bool>,
238    /// True to only return motion photos.
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub is_motion: Option<bool>,
241    /// True to only return assets not associated with any albums.
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub is_not_in_album: Option<bool>,
244    /// True to only return assets having valid EXIF.
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub with_exif: Option<bool>,
247    /// True to also return deleted assets.
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub with_deleted: Option<bool>,
250    /// List of person identifiers to match.
251    #[serde(skip_serializing_if = "Option::is_none")]
252    pub person_ids: Option<Vec<String>>,
253    /// List of tag identifiers to match.
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub tag_ids: Option<Vec<String>>,
256    /// Desired sorting order.
257    #[serde(skip_serializing_if = "Option::is_none")]
258    pub order: Option<SortOrder>,
259    /// Star rating (1-5).
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub rating: Option<u32>,
262    /// Restrict results to specific albums.
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub album_ids: Option<Vec<String>>,
265    /// True to only return encoded (transcoded) assets.
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub is_encoded: Option<bool>,
268    /// True to only return offline assets.
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub is_offline: Option<bool>,
271    /// Restrict to a specific external library.
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub library_id: Option<String>,
274    /// ISO 8601 lower bound on asset creation timestamp.
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub created_after: Option<String>,
277    /// ISO 8601 upper bound on asset creation timestamp.
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub created_before: Option<String>,
280    /// ISO 8601 lower bound on asset update timestamp.
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub updated_after: Option<String>,
283    /// ISO 8601 upper bound on asset update timestamp.
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub updated_before: Option<String>,
286    /// ISO 8601 lower bound on trashed timestamp.
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub trashed_after: Option<String>,
289    /// ISO 8601 upper bound on trashed timestamp.
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub trashed_before: Option<String>,
292    /// Visibility filter: "archive", "timeline", "hidden", "locked".
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub visibility: Option<String>,
295    /// True to include person face data in results.
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub with_people: Option<bool>,
298    /// True to include stacked asset data in results.
299    #[serde(skip_serializing_if = "Option::is_none")]
300    pub with_stacked: Option<bool>,
301}
302
303/// Direction of sort results.
304#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
305#[serde(rename_all = "lowercase")]
306pub enum SortOrder {
307    /// Ascending order.
308    Asc,
309    /// Descending order.
310    Desc,
311}
312
313/// Immich server asset count statistics.
314#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
315pub struct ServerStats {
316    /// Number of image assets.
317    pub images: u64,
318    /// Number of video assets.
319    pub videos: u64,
320    /// Total assets.
321    pub total: u64,
322}
323
324/// Immich server configuration and version details.
325#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
326pub struct ServerAbout {
327    /// Semver string of the Immich instance.
328    pub version: String,
329}
330
331/// Per-user usage row from `/api/server/statistics`.
332#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
333#[serde(rename_all = "camelCase")]
334pub struct UsageByUser {
335    pub user_id: String,
336    pub user_name: String,
337    #[serde(default)]
338    pub photos: u64,
339    #[serde(default)]
340    pub videos: u64,
341    #[serde(default)]
342    pub usage: u64,
343    #[serde(default)]
344    pub quota_size_in_bytes: Option<u64>,
345}
346
347/// Server-wide statistics (admin-only). Returned by `/api/server/statistics`.
348#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
349#[serde(rename_all = "camelCase")]
350pub struct ServerStatistics {
351    #[serde(default)]
352    pub photos: u64,
353    #[serde(default)]
354    pub videos: u64,
355    #[serde(default)]
356    pub usage: u64,
357    #[serde(default)]
358    pub usage_by_user: Vec<UsageByUser>,
359}
360
361/// A recognized person returned by Immich's facial recognition.
362#[derive(Debug, Clone, serde::Deserialize)]
363pub struct Person {
364    /// Unique identifier of the person.
365    pub id: String,
366    /// Name assigned to the person.
367    #[serde(default)]
368    pub name: String,
369    #[serde(default, rename = "isHidden")]
370    pub is_hidden: bool,
371}
372
373/// List wrapper of recognized people returned by Immich.
374#[derive(Debug, Clone, serde::Deserialize)]
375pub(super) struct PeopleResponse {
376    /// Internal people list array.
377    pub(super) people: Vec<Person>,
378}
379
380/// Discovered item in an Immich explore page section.
381#[derive(Debug, Clone, serde::Deserialize)]
382pub struct ExploreItem {
383    /// Text value representing the item category (e.g. city or tag name).
384    pub value: String,
385    /// Associated representative asset for preview.
386    pub data: LibraryAsset,
387}
388
389/// Discovered category section on the Immich explore page (e.g. places or tags).
390#[derive(Debug, Clone, serde::Deserialize)]
391pub struct ExploreSection {
392    /// Categorisation field name.
393    #[serde(rename = "fieldName")]
394    pub field_name: String,
395    /// Discovered list of explore items.
396    pub items: Vec<ExploreItem>,
397}
398
399/// Container matching assets returned alongside their EXIF metadata.
400#[derive(Debug, Clone, serde::Deserialize)]
401pub(super) struct AssetWithExif {
402    /// Unique asset ID.
403    pub(super) id: String,
404    /// EXIF info details payload if populated.
405    #[serde(rename = "exifInfo", default)]
406    pub(super) exif_info: Option<ExifInfo>,
407}
408
409/// Search results response wrapper for EXIF queries.
410#[derive(Debug, serde::Deserialize)]
411pub(super) struct ExifSearchResponse {
412    /// Contained assets list wrapper.
413    pub(super) assets: ExifSearchAssets,
414}
415
416/// Asset items list returned inside an EXIF search response.
417#[derive(Debug, serde::Deserialize)]
418pub(super) struct ExifSearchAssets {
419    /// Individual items within search page.
420    pub(super) items: Vec<AssetWithExif>,
421    /// Pagination pointer to the next search page, if any.
422    #[serde(rename = "nextPage", default)]
423    pub(super) next_page: Option<String>,
424}
425
426/// One city with a representative asset ID for thumbnail display.
427/// Places display category representation holding the representative thumbnail asset.
428pub struct PlaceItem {
429    /// Name of the city location.
430    pub city: String,
431    /// Asset ID mapped as the representative cover image.
432    pub asset_id: String,
433}
434
435/// Full EXIF metadata schema properties returned by Immich.
436#[derive(Debug, Clone, Default, PartialEq, serde::Deserialize)]
437#[serde(rename_all = "camelCase")]
438pub struct ExifInfo {
439    /// Camera manufacturer name.
440    #[serde(default)]
441    pub make: Option<String>,
442    /// Camera model.
443    #[serde(default)]
444    pub model: Option<String>,
445    /// Camera lens model name.
446    #[serde(default)]
447    pub lens_model: Option<String>,
448    /// F-number aperture value.
449    #[serde(default)]
450    pub f_number: Option<f64>,
451    /// Focal length in millimeters.
452    #[serde(default)]
453    pub focal_length: Option<f64>,
454    /// ISO speed rating.
455    #[serde(default)]
456    pub iso: Option<u32>,
457    /// Shutter speed string representation.
458    #[serde(default)]
459    pub exposure_time: Option<String>,
460    /// Uncompressed file size in bytes.
461    #[serde(default)]
462    pub file_size_in_byte: Option<u64>,
463    /// Original capture datetime string.
464    #[serde(default)]
465    pub date_time_original: Option<String>,
466    /// Discovered city name.
467    #[serde(default)]
468    pub city: Option<String>,
469    /// Discovered state or region name.
470    #[serde(default)]
471    pub state: Option<String>,
472    /// Discovered country name.
473    #[serde(default)]
474    pub country: Option<String>,
475    /// GPS Latitude coordinate in decimal degrees.
476    #[serde(default)]
477    pub latitude: Option<f64>,
478    /// GPS Longitude coordinate in decimal degrees.
479    #[serde(default)]
480    pub longitude: Option<f64>,
481    /// Metadata description text.
482    #[serde(default)]
483    pub description: Option<String>,
484    /// Image width in pixels.
485    #[serde(default)]
486    pub exif_image_width: Option<u32>,
487    /// Image height in pixels.
488    #[serde(default)]
489    pub exif_image_height: Option<u32>,
490}
491
492/// Asset details metadata payload.
493#[derive(Debug, Clone, serde::Deserialize)]
494#[serde(rename_all = "camelCase")]
495pub struct AssetDetails {
496    /// Extracted EXIF metadata block.
497    #[serde(default)]
498    pub exif_info: Option<ExifInfo>,
499}
500
501/// Type of asset thumbnails requested.
502#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
503pub enum ThumbnailSize {
504    Thumbnail,
505    Preview,
506    /// Server-generated full-resolution copy; only present when the server
507    /// has the "save full-size image" job enabled. 404s otherwise.
508    Fullsize,
509}
510
511impl ThumbnailSize {
512    pub(super) fn as_str(self) -> &'static str {
513        match self {
514            ThumbnailSize::Thumbnail => "thumbnail",
515            ThumbnailSize::Preview => "preview",
516            ThumbnailSize::Fullsize => "fullsize",
517        }
518    }
519}
520
521/// Search response wrapper.
522#[derive(Debug, serde::Deserialize)]
523pub(super) struct SearchResponse {
524    /// Inner assets block.
525    pub(super) assets: SearchAssetSection,
526}
527
528/// Paginated asset list section within a search response.
529#[derive(Debug, serde::Deserialize)]
530pub(super) struct SearchAssetSection {
531    /// Assets returned in the search.
532    pub(super) items: Vec<LibraryAsset>,
533    /// Authoritative pagination signal from Immich. Some(page) when more
534    /// results exist, None when the search is exhausted. We only need its
535    /// presence — has_more is computed as `next_page.is_some()`.
536    #[serde(rename = "nextPage", default)]
537    pub(super) next_page: Option<String>,
538}
539
540/// Asynchronous Immich API client with failover and request serialization.
541pub struct ImmichApiClient {
542    /// Internal HTTP client instance.
543    pub client: Client,
544    /// Configuration settings wrapper.
545    settings: RwLock<ApiClientSettings>,
546    /// The currently active base URL, selected by the last successful connectivity check.
547    pub active_url: Mutex<Option<String>>,
548    /// Most recent actionable API/client problem, used for the dashboard and diagnostics.
549    last_issue: Mutex<Option<ApiIssue>>,
550    /// Caches album names to album IDs to avoid repeated list/create API calls.
551    album_cache: Mutex<HashMap<String, String>>,
552    /// Per-album-name async locks that serialize concurrent get-or-create calls
553    /// for the *same* name, preventing duplicate-album creation under load.
554    album_create_locks: Mutex<HashMap<String, Arc<Mutex<()>>>>,
555    /// Serializes `fetch_all_albums` so concurrent callers don't all hit the
556    /// network and race writes into the cache.
557    album_fetch_lock: Mutex<()>,
558    /// Serializes `check_connection` so concurrent callers collapse into one
559    /// connectivity probe instead of each issuing their own LAN+WAN pings.
560    connection_check_lock: Mutex<()>,
561    /// Timestamp of the most recent successful connectivity probe. Used to
562    /// coalesce concurrent burst callers without suppressing periodic re-checks.
563    last_successful_check: Mutex<Option<Instant>>,
564    /// Flag indicating whether the album list has been successfully fetched in this session.
565    albums_fetched: Mutex<bool>,
566    /// Semaphore guarding maximum concurrent thumbnail downloads.
567    thumbnail_semaphore: Arc<Semaphore>,
568}
569
570impl ImmichApiClient {
571    /// Initialize a new ImmichApiClient.
572    pub fn new(internal_url: String, external_url: String, api_key: String) -> Self {
573        let client = Client::builder()
574            .timeout(Duration::from_secs(300))
575            .pool_max_idle_per_host(1) // keep at most 1 idle connection per host
576            .pool_idle_timeout(Duration::from_secs(30)) // drop idle connections after 30s
577            .build()
578            .unwrap_or_default();
579
580        let int = internal_url.trim_end_matches('/').to_string();
581        let ext = external_url.trim_end_matches('/').to_string();
582
583        log::debug!(
584            "ImmichApiClient created: internal={}, external={}",
585            int,
586            ext
587        );
588
589        Self {
590            client,
591            settings: RwLock::new(ApiClientSettings {
592                internal_url: int,
593                external_url: ext,
594                api_key,
595            }),
596            active_url: Mutex::new(None),
597            last_issue: Mutex::new(None),
598            album_cache: Mutex::new(HashMap::new()),
599            album_create_locks: Mutex::new(HashMap::new()),
600            album_fetch_lock: Mutex::new(()),
601            connection_check_lock: Mutex::new(()),
602            last_successful_check: Mutex::new(None),
603            albums_fetched: Mutex::new(false),
604            thumbnail_semaphore: Arc::new(Semaphore::new(8)),
605        }
606    }
607
608    /// Retrieve the route label (LAN/WAN) for the currently active connection URL.
609    pub async fn active_route_label(&self) -> Option<String> {
610        let active = self.active_url.lock().await.clone()?;
611        Some(self.route_label_for_url(&active))
612    }
613
614    /// Retrieve the most recently recorded API issue.
615    pub async fn latest_issue(&self) -> Option<ApiIssue> {
616        self.last_issue.lock().await.clone()
617    }
618
619    /// Update in-memory configuration settings and reset connection state.
620    pub async fn update_settings(
621        &self,
622        internal_url: String,
623        external_url: String,
624        api_key: String,
625    ) {
626        {
627            let mut settings = self.settings.write();
628            settings.internal_url = internal_url.trim_end_matches('/').to_string();
629            settings.external_url = external_url.trim_end_matches('/').to_string();
630            settings.api_key = api_key;
631        }
632
633        *self.active_url.lock().await = None;
634        self.refresh_album_cache().await;
635        self.clear_issue().await;
636    }
637
638    /// Set the last encountered API issue.
639    pub(super) async fn set_issue(&self, issue: ApiIssue) {
640        *self.last_issue.lock().await = Some(issue);
641    }
642
643    /// Clear the active API issue.
644    pub(super) async fn clear_issue(&self) {
645        *self.last_issue.lock().await = None;
646    }
647
648    /// Retrieve a snapshot copy of the current configuration settings.
649    pub(super) fn settings_snapshot(&self) -> ApiClientSettings {
650        self.settings.read().clone()
651    }
652
653    /// Retrieve the LAN/WAN/Custom route label matching a specific URL.
654    pub(super) fn route_label_for_url(&self, url: &str) -> String {
655        let settings = self.settings.read().clone();
656        let trimmed = url.trim_end_matches('/');
657        if !settings.internal_url.is_empty() && trimmed == settings.internal_url {
658            "LAN".to_string()
659        } else if !settings.external_url.is_empty() && trimmed == settings.external_url {
660            "WAN".to_string()
661        } else {
662            "Custom".to_string()
663        }
664    }
665
666    /// Determine which base URL to use, preferring the internal address when reachable.
667    pub async fn check_connection(&self) -> bool {
668        let _check_guard = self.connection_check_lock.lock().await;
669
670        if self.active_url.lock().await.is_some()
671            && let Some(when) = *self.last_successful_check.lock().await
672            && when.elapsed() < Duration::from_secs(1)
673        {
674            return true;
675        }
676
677        log::debug!("Checking connectivity...");
678        let settings = self.settings.read().clone();
679        let was_active = self.active_url.lock().await.clone();
680
681        for url in [&settings.internal_url, &settings.external_url] {
682            if self.try_activate_url(url, was_active.is_none()).await {
683                return true;
684            }
685        }
686
687        *self.active_url.lock().await = None;
688        if was_active.is_some() {
689            log::error!("Could not connect to Immich server.");
690        } else {
691            log::debug!("Server still unreachable.");
692        }
693        self.set_issue(ApiIssue {
694            summary: "Could not reach the Immich server".to_string(),
695            guidance: "Check the LAN/WAN URLs, confirm the server is running, and verify your network connection."
696                .to_string(),
697        })
698        .await;
699        false
700    }
701
702    async fn try_activate_url(&self, url: &str, was_offline: bool) -> bool {
703        if !self.ping_url(url).await {
704            return false;
705        }
706        *self.active_url.lock().await = Some(url.to_string());
707        *self.last_successful_check.lock().await = Some(Instant::now());
708        self.clear_issue().await;
709        let label = self.route_label_for_url(url);
710        if was_offline {
711            log::info!("Connected via {}: {}", label, url);
712        } else {
713            log::debug!("Connected via {}: {}", label, url);
714        }
715        true
716    }
717
718    /// Ping a specific Immich base URL and validate that it returns a real `pong` response.
719    pub async fn ping_url(&self, url: &str) -> bool {
720        if url.is_empty() {
721            return false;
722        }
723        let endpoint = format!("{}/api/server/ping", url.trim_end_matches('/'));
724        log::debug!("Pinging: {}", endpoint);
725
726        match self
727            .client
728            .get(&endpoint)
729            .timeout(Duration::from_secs(2))
730            .send()
731            .await
732        {
733            Ok(resp) if resp.status().as_u16() == 200 => {
734                match resp.json::<serde_json::Value>().await {
735                    Ok(json)
736                        if json["res"].as_str().map(|s| s.to_lowercase())
737                            == Some("pong".into()) =>
738                    {
739                        log::debug!("Ping success: {}", endpoint);
740                        true
741                    }
742                    _ => {
743                        log::warn!("Ping failed (not a valid Immich response): {}", endpoint);
744                        false
745                    }
746                }
747            }
748            Ok(resp) => {
749                log::warn!("Ping failed ({}): {}", resp.status(), endpoint);
750                false
751            }
752            Err(e) => {
753                log::warn!("Ping error ({}): {}", e, endpoint);
754                false
755            }
756        }
757    }
758
759    /// Return the cached active base URL, resolving connectivity first if needed.
760    pub(super) async fn get_active_url(&self) -> Option<String> {
761        {
762            let active = self.active_url.lock().await;
763            if active.is_some() {
764                return active.clone();
765            }
766        }
767        if self.check_connection().await {
768            let active = self.active_url.lock().await;
769            return active.clone();
770        }
771        None
772    }
773}
774
775#[cfg(test)]
776mod tests {
777    use super::*;
778    use std::path::Path;
779
780    #[test]
781    fn normalize_checksum_passes_through_lowercase_hex() {
782        let hex = "823cb0e790d643a90f61f07e5c2bdd588cf8f230";
783        assert_eq!(normalize_checksum_to_hex(hex), Some(hex.to_string()));
784    }
785
786    #[test]
787    fn normalize_checksum_lowercases_uppercase_hex() {
788        let hex_up = "823CB0E790D643A90F61F07E5C2BDD588CF8F230";
789        assert_eq!(
790            normalize_checksum_to_hex(hex_up),
791            Some(hex_up.to_ascii_lowercase())
792        );
793    }
794
795    #[test]
796    fn normalize_checksum_decodes_base64_to_hex() {
797        // Base64 of the 20-byte SHA1 0x82 0x3c ... 0x30
798        let b64 = "gjyw55DWQ6kPYfB+XCvdWIz48jA=";
799        let expected = "823cb0e790d643a90f61f07e5c2bdd588cf8f230";
800        assert_eq!(normalize_checksum_to_hex(b64), Some(expected.to_string()));
801    }
802
803    #[test]
804    fn normalize_checksum_rejects_empty_and_unknown() {
805        assert_eq!(normalize_checksum_to_hex(""), None);
806        assert_eq!(normalize_checksum_to_hex("not-a-checksum"), None);
807    }
808
809    #[test]
810    fn library_asset_deserializes_base64_checksum_as_hex() {
811        let json = serde_json::json!({
812            "id": "asset-1",
813            "originalFileName": "a.png",
814            "originalMimeType": "image/png",
815            "fileCreatedAt": "2024-01-01T00:00:00.000Z",
816            "type": "IMAGE",
817            "checksum": "gjyw55DWQ6kPYfB+XCvdWIz48jA="
818        });
819        let asset: LibraryAsset = serde_json::from_value(json).unwrap();
820        assert_eq!(
821            asset.checksum.as_deref(),
822            Some("823cb0e790d643a90f61f07e5c2bdd588cf8f230")
823        );
824    }
825
826    #[test]
827    fn test_unix_to_utc_iso8601() {
828        assert_eq!(unix_to_utc_iso8601(0), "1970-01-01T00:00:00.000Z");
829        assert_eq!(unix_to_utc_iso8601(1704067200), "2024-01-01T00:00:00.000Z");
830    }
831
832    #[test]
833    fn test_mime_for_path() {
834        assert_eq!(mime_for_path(Path::new("test.avif")), "image/avif");
835        assert_eq!(mime_for_path(Path::new("test.jpg")), "image/jpeg");
836        assert_eq!(mime_for_path(Path::new("test.jpe")), "image/jpeg");
837        assert_eq!(mime_for_path(Path::new("test.heif")), "image/heif");
838        assert_eq!(mime_for_path(Path::new("test.jp2")), "image/jp2");
839        assert_eq!(mime_for_path(Path::new("test.jxl")), "image/jxl");
840        assert_eq!(mime_for_path(Path::new("test.PNG")), "image/png");
841        assert_eq!(
842            mime_for_path(Path::new("test.psd")),
843            "image/vnd.adobe.photoshop"
844        );
845        assert_eq!(mime_for_path(Path::new("test.svg")), "image/svg+xml");
846        assert_eq!(mime_for_path(Path::new("test.mp4")), "video/mp4");
847        assert_eq!(mime_for_path(Path::new("test.insv")), "video/mp4");
848        assert_eq!(mime_for_path(Path::new("test.mkv")), "video/x-matroska");
849        assert_eq!(mime_for_path(Path::new("test.mxf")), "application/mxf");
850        assert_eq!(
851            mime_for_path(Path::new("test.unknown")),
852            "application/octet-stream"
853        );
854    }
855
856    #[test]
857    fn test_mime_for_path_covers_immich_spec() {
858        // Every extension from pv_docs/Library_view_feature.md must map to a
859        // non-fallback MIME so uploads/downloads pick the right pipeline.
860        const SPEC_EXTENSIONS: &[&str] = &[
861            // RAW
862            "3fr", "ari", "arw", "cap", "cin", "cr2", "cr3", "crw", "dcr", "dng", "erf", "fff",
863            "iiq", "k25", "kdc", "mrw", "nef", "nrw", "orf", "ori", "pef", "psd", "raf", "raw",
864            "rw2", "rwl", "sr2", "srf", "srw", "x3f", // Web image
865            "avif", "bmp", "gif", "jpeg", "jpg", "png", "webp", // Other image
866            "heic", "heif", "hif", "insp", "jp2", "jpe", "jxl", "svg", "tif", "tiff",
867            // Video
868            "3gp", "3gpp", "avi", "flv", "insv", "m2t", "m2ts", "m4v", "mkv", "mov", "mp4", "mpe",
869            "mpeg", "mpg", "mts", "mxf", "ts", "vob", "webm", "wmv",
870        ];
871        for ext in SPEC_EXTENSIONS {
872            let p = std::path::PathBuf::from(format!("a.{}", ext));
873            let mime = mime_for_path(&p);
874            assert_ne!(
875                mime, "application/octet-stream",
876                "extension `.{}` falls through to octet-stream",
877                ext
878            );
879        }
880    }
881
882    #[test]
883    fn test_classify_http_issue_for_invalid_api_key() {
884        let issue = classify_http_issue(RequestContext::Upload, 401, Some("photo.jpg"));
885        assert_eq!(issue.summary, "Immich rejected the API key");
886        assert!(issue.guidance.contains("API key"));
887    }
888
889    #[test]
890    fn test_classify_http_issue_for_album_assign_404() {
891        let issue = classify_http_issue(RequestContext::AlbumAssign, 404, Some("album-1"));
892        assert_eq!(issue.summary, "An album reference is no longer valid");
893    }
894
895    #[test]
896    fn test_library_album_deserializes_from_immich_shape() {
897        let album: LibraryAlbum = serde_json::from_value(serde_json::json!({
898            "id": "album-1",
899            "albumName": "Trips",
900            "assetCount": 42,
901            "albumThumbnailAssetId": "asset-9",
902            "createdAt": "2024-01-01T00:00:00.000Z",
903            "updatedAt": "2024-01-02T00:00:00.000Z",
904            "description": "Vacation"
905        }))
906        .unwrap();
907
908        assert_eq!(album.id, "album-1");
909        assert_eq!(album.album_name, "Trips");
910        assert_eq!(album.asset_count, 42);
911        assert_eq!(album.thumbnail_asset_id.as_deref(), Some("asset-9"));
912        assert_eq!(album.description, "Vacation");
913    }
914
915    #[test]
916    fn test_library_asset_deserializes_from_search_result_shape() {
917        let asset: LibraryAsset = serde_json::from_value(serde_json::json!({
918            "id": "asset-1",
919            "originalFileName": "IMG_0001.JPG",
920            "originalMimeType": "image/jpeg",
921            "fileCreatedAt": "2024-01-01T12:00:00.000Z",
922            "type": "IMAGE",
923            "thumbhash": "abcd",
924            "width": 4032,
925            "height": 3024
926        }))
927        .unwrap();
928
929        assert_eq!(asset.id, "asset-1");
930        assert_eq!(asset.filename, "IMG_0001.JPG");
931        assert_eq!(asset.mime_type, "image/jpeg");
932        assert_eq!(asset.asset_type, "IMAGE");
933        assert_eq!(asset.thumbhash.as_deref(), Some("abcd"));
934        assert_eq!(asset.width, Some(4032));
935        assert_eq!(asset.height, Some(3024));
936        assert!(asset.checksum.is_none());
937    }
938
939    #[test]
940    fn test_search_response_deserializes_items() {
941        let response: SearchResponse = serde_json::from_value(serde_json::json!({
942            "assets": {
943                "items": [
944                    {
945                        "id": "asset-1",
946                        "originalFileName": "IMG_0001.JPG",
947                        "originalMimeType": "image/jpeg",
948                        "fileCreatedAt": "2024-01-01T12:00:00.000Z",
949                        "type": "IMAGE",
950                        "thumbhash": null,
951                        "width": 12,
952                        "height": 10
953                    }
954                ]
955            }
956        }))
957        .unwrap();
958
959        assert_eq!(response.assets.items.len(), 1);
960        assert_eq!(response.assets.items[0].filename, "IMG_0001.JPG");
961        assert!(response.assets.next_page.is_none());
962    }
963
964    #[test]
965    fn test_search_response_parses_next_page() {
966        let response: SearchResponse = serde_json::from_value(serde_json::json!({
967            "assets": {
968                "items": [],
969                "nextPage": "2"
970            }
971        }))
972        .unwrap();
973        assert_eq!(response.assets.next_page.as_deref(), Some("2"));
974    }
975
976    #[test]
977    fn test_server_structs_deserialize() {
978        let stats: ServerStats = serde_json::from_value(serde_json::json!({
979            "images": 100,
980            "videos": 25,
981            "total": 125
982        }))
983        .unwrap();
984        let about: ServerAbout = serde_json::from_value(serde_json::json!({
985            "version": "1.132.0"
986        }))
987        .unwrap();
988
989        assert_eq!(stats.total, 125);
990        assert_eq!(about.version, "1.132.0");
991    }
992
993    #[test]
994    fn test_thumbnail_size_serialization_values() {
995        assert_eq!(ThumbnailSize::Thumbnail.as_str(), "thumbnail");
996        assert_eq!(ThumbnailSize::Preview.as_str(), "preview");
997    }
998
999    #[test]
1000    fn test_normalize_file_timestamps_prefers_earliest_available_time_for_created_at() {
1001        let (created, modified) =
1002            normalize_file_timestamps(Some(1_704_153_600), Some(1_704_067_200), 99);
1003
1004        assert_eq!(created, 1_704_067_200);
1005        assert_eq!(modified, 1_704_067_200);
1006    }
1007
1008    #[test]
1009    fn test_normalize_file_timestamps_falls_back_to_created_time_when_modified_is_missing() {
1010        let (created, modified) = normalize_file_timestamps(Some(1_704_067_200), None, 99);
1011
1012        assert_eq!(created, 1_704_067_200);
1013        assert_eq!(modified, 1_704_067_200);
1014    }
1015
1016    #[test]
1017    fn test_looks_like_iana_timezone() {
1018        assert!(looks_like_iana_timezone("Asia/Kolkata"));
1019        assert!(looks_like_iana_timezone("America/New_York"));
1020        assert!(!looks_like_iana_timezone("UTC"));
1021        assert!(!looks_like_iana_timezone("Africa Abidjan"));
1022    }
1023
1024    #[tokio::test]
1025    async fn test_active_route_label_tracks_selected_url() {
1026        let client = ImmichApiClient::new(
1027            "http://lan.example".into(),
1028            "https://wan.example".into(),
1029            "token".into(),
1030        );
1031        *client.active_url.lock().await = Some("https://wan.example".into());
1032
1033        assert_eq!(client.active_route_label().await.as_deref(), Some("WAN"));
1034    }
1035
1036    #[test]
1037    fn person_deserializes_with_is_hidden_field() {
1038        let person: Person = serde_json::from_value(serde_json::json!({
1039            "id": "p1",
1040            "name": "Alice",
1041            "isHidden": true
1042        }))
1043        .expect("person json");
1044        assert_eq!(person.id, "p1");
1045        assert_eq!(person.name, "Alice");
1046        assert!(person.is_hidden);
1047    }
1048
1049    #[test]
1050    fn person_defaults_is_hidden_when_missing() {
1051        let person: Person = serde_json::from_value(serde_json::json!({
1052            "id": "p2",
1053            "name": ""
1054        }))
1055        .expect("person json");
1056        assert!(!person.is_hidden, "missing isHidden must default to false");
1057    }
1058
1059    #[test]
1060    fn server_statistics_parses_usage_by_user() {
1061        let stats: ServerStatistics = serde_json::from_value(serde_json::json!({
1062            "photos": 42,
1063            "videos": 7,
1064            "usage": 1024,
1065            "usageByUser": [
1066                {
1067                    "userId": "u1",
1068                    "userName": "alice",
1069                    "photos": 10,
1070                    "videos": 2,
1071                    "usage": 512,
1072                    "quotaSizeInBytes": 4096
1073                }
1074            ]
1075        }))
1076        .expect("server statistics json");
1077        assert_eq!(stats.photos, 42);
1078        assert_eq!(stats.videos, 7);
1079        assert_eq!(stats.usage, 1024);
1080        assert_eq!(stats.usage_by_user.len(), 1);
1081        let user = &stats.usage_by_user[0];
1082        assert_eq!(user.user_name, "alice");
1083        assert_eq!(user.photos, 10);
1084        assert_eq!(user.quota_size_in_bytes, Some(4096));
1085    }
1086
1087    #[test]
1088    fn server_statistics_tolerates_missing_quota() {
1089        let stats: ServerStatistics = serde_json::from_value(serde_json::json!({
1090            "photos": 0,
1091            "videos": 0,
1092            "usage": 0,
1093            "usageByUser": [
1094                { "userId": "u1", "userName": "anon", "photos": 0, "videos": 0, "usage": 0 }
1095            ]
1096        }))
1097        .expect("server statistics json");
1098        assert_eq!(stats.usage_by_user[0].quota_size_in_bytes, None);
1099    }
1100}