1use 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#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ApiIssue {
35 pub summary: String,
37 pub guidance: String,
39}
40
41#[derive(Debug, Clone)]
43pub(super) struct ApiClientSettings {
44 pub(super) internal_url: String,
46 pub(super) external_url: String,
48 pub(super) api_key: String,
50}
51
52#[derive(Debug, serde::Deserialize)]
54pub(super) struct AlbumSummary {
55 pub(super) id: String,
57 #[serde(rename = "albumName")]
59 pub(super) album_name: String,
60}
61
62#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
64pub struct LibraryAlbum {
65 pub id: String,
67 #[serde(rename = "albumName")]
69 pub album_name: String,
70 #[serde(rename = "assetCount")]
72 pub asset_count: u32,
73 #[serde(rename = "albumThumbnailAssetId")]
75 pub thumbnail_asset_id: Option<String>,
76 #[serde(rename = "createdAt")]
78 pub created_at: String,
79 #[serde(rename = "updatedAt")]
81 pub updated_at: String,
82 #[serde(default)]
84 pub description: String,
85 #[serde(rename = "albumUsers", default)]
89 pub album_users: Vec<AlbumUser>,
90}
91
92impl LibraryAlbum {
93 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 pub fn is_shared(&self) -> bool {
105 self.album_users.len() > 1
106 }
107}
108
109#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
111pub struct AlbumUser {
112 pub user: AlbumUserInfo,
114 pub role: String,
116}
117
118#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
120pub struct AlbumUserInfo {
121 pub id: String,
123}
124
125#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
127pub struct LibraryAsset {
128 pub id: String,
130 #[serde(rename = "originalFileName")]
132 pub filename: String,
133 #[serde(rename = "originalMimeType")]
135 pub mime_type: String,
136 #[serde(rename = "fileCreatedAt")]
138 pub created_at: String,
139 #[serde(rename = "type")]
141 pub asset_type: String,
142 pub thumbhash: Option<String>,
144 pub width: Option<u32>,
146 pub height: Option<u32>,
148 #[serde(default, deserialize_with = "deserialize_checksum_to_hex")]
150 pub checksum: Option<String>,
151 #[serde(rename = "exifInfo", default)]
153 pub exif_info: Option<ExifInfo>,
154}
155
156fn 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#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
191#[serde(rename_all = "camelCase")]
192pub struct MetadataSearchFilters {
193 #[serde(skip_serializing_if = "Option::is_none")]
195 pub original_file_name: Option<String>,
196 #[serde(skip_serializing_if = "Option::is_none")]
198 pub description: Option<String>,
199 #[serde(skip_serializing_if = "Option::is_none")]
204 pub ocr: Option<String>,
205 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
207 pub asset_type: Option<String>,
208 #[serde(skip_serializing_if = "Option::is_none")]
210 pub taken_after: Option<String>,
211 #[serde(skip_serializing_if = "Option::is_none")]
213 pub taken_before: Option<String>,
214 #[serde(skip_serializing_if = "Option::is_none")]
216 pub make: Option<String>,
217 #[serde(skip_serializing_if = "Option::is_none")]
219 pub model: Option<String>,
220 #[serde(skip_serializing_if = "Option::is_none")]
222 pub lens_model: Option<String>,
223 #[serde(skip_serializing_if = "Option::is_none")]
225 pub country: Option<String>,
226 #[serde(skip_serializing_if = "Option::is_none")]
228 pub state: Option<String>,
229 #[serde(skip_serializing_if = "Option::is_none")]
231 pub city: Option<String>,
232 #[serde(skip_serializing_if = "Option::is_none")]
234 pub is_favorite: Option<bool>,
235 #[serde(skip_serializing_if = "Option::is_none")]
237 pub is_archived: Option<bool>,
238 #[serde(skip_serializing_if = "Option::is_none")]
240 pub is_motion: Option<bool>,
241 #[serde(skip_serializing_if = "Option::is_none")]
243 pub is_not_in_album: Option<bool>,
244 #[serde(skip_serializing_if = "Option::is_none")]
246 pub with_exif: Option<bool>,
247 #[serde(skip_serializing_if = "Option::is_none")]
249 pub with_deleted: Option<bool>,
250 #[serde(skip_serializing_if = "Option::is_none")]
252 pub person_ids: Option<Vec<String>>,
253 #[serde(skip_serializing_if = "Option::is_none")]
255 pub tag_ids: Option<Vec<String>>,
256 #[serde(skip_serializing_if = "Option::is_none")]
258 pub order: Option<SortOrder>,
259 #[serde(skip_serializing_if = "Option::is_none")]
261 pub rating: Option<u32>,
262 #[serde(skip_serializing_if = "Option::is_none")]
264 pub album_ids: Option<Vec<String>>,
265 #[serde(skip_serializing_if = "Option::is_none")]
267 pub is_encoded: Option<bool>,
268 #[serde(skip_serializing_if = "Option::is_none")]
270 pub is_offline: Option<bool>,
271 #[serde(skip_serializing_if = "Option::is_none")]
273 pub library_id: Option<String>,
274 #[serde(skip_serializing_if = "Option::is_none")]
276 pub created_after: Option<String>,
277 #[serde(skip_serializing_if = "Option::is_none")]
279 pub created_before: Option<String>,
280 #[serde(skip_serializing_if = "Option::is_none")]
282 pub updated_after: Option<String>,
283 #[serde(skip_serializing_if = "Option::is_none")]
285 pub updated_before: Option<String>,
286 #[serde(skip_serializing_if = "Option::is_none")]
288 pub trashed_after: Option<String>,
289 #[serde(skip_serializing_if = "Option::is_none")]
291 pub trashed_before: Option<String>,
292 #[serde(skip_serializing_if = "Option::is_none")]
294 pub visibility: Option<String>,
295 #[serde(skip_serializing_if = "Option::is_none")]
297 pub with_people: Option<bool>,
298 #[serde(skip_serializing_if = "Option::is_none")]
300 pub with_stacked: Option<bool>,
301}
302
303#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
305#[serde(rename_all = "lowercase")]
306pub enum SortOrder {
307 Asc,
309 Desc,
311}
312
313#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
315pub struct ServerStats {
316 pub images: u64,
318 pub videos: u64,
320 pub total: u64,
322}
323
324#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
326pub struct ServerAbout {
327 pub version: String,
329}
330
331#[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#[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#[derive(Debug, Clone, serde::Deserialize)]
363pub struct Person {
364 pub id: String,
366 #[serde(default)]
368 pub name: String,
369 #[serde(default, rename = "isHidden")]
370 pub is_hidden: bool,
371}
372
373#[derive(Debug, Clone, serde::Deserialize)]
375pub(super) struct PeopleResponse {
376 pub(super) people: Vec<Person>,
378}
379
380#[derive(Debug, Clone, serde::Deserialize)]
382pub struct ExploreItem {
383 pub value: String,
385 pub data: LibraryAsset,
387}
388
389#[derive(Debug, Clone, serde::Deserialize)]
391pub struct ExploreSection {
392 #[serde(rename = "fieldName")]
394 pub field_name: String,
395 pub items: Vec<ExploreItem>,
397}
398
399#[derive(Debug, Clone, serde::Deserialize)]
401pub(super) struct AssetWithExif {
402 pub(super) id: String,
404 #[serde(rename = "exifInfo", default)]
406 pub(super) exif_info: Option<ExifInfo>,
407}
408
409#[derive(Debug, serde::Deserialize)]
411pub(super) struct ExifSearchResponse {
412 pub(super) assets: ExifSearchAssets,
414}
415
416#[derive(Debug, serde::Deserialize)]
418pub(super) struct ExifSearchAssets {
419 pub(super) items: Vec<AssetWithExif>,
421 #[serde(rename = "nextPage", default)]
423 pub(super) next_page: Option<String>,
424}
425
426pub struct PlaceItem {
429 pub city: String,
431 pub asset_id: String,
433}
434
435#[derive(Debug, Clone, Default, PartialEq, serde::Deserialize)]
437#[serde(rename_all = "camelCase")]
438pub struct ExifInfo {
439 #[serde(default)]
441 pub make: Option<String>,
442 #[serde(default)]
444 pub model: Option<String>,
445 #[serde(default)]
447 pub lens_model: Option<String>,
448 #[serde(default)]
450 pub f_number: Option<f64>,
451 #[serde(default)]
453 pub focal_length: Option<f64>,
454 #[serde(default)]
456 pub iso: Option<u32>,
457 #[serde(default)]
459 pub exposure_time: Option<String>,
460 #[serde(default)]
462 pub file_size_in_byte: Option<u64>,
463 #[serde(default)]
465 pub date_time_original: Option<String>,
466 #[serde(default)]
468 pub city: Option<String>,
469 #[serde(default)]
471 pub state: Option<String>,
472 #[serde(default)]
474 pub country: Option<String>,
475 #[serde(default)]
477 pub latitude: Option<f64>,
478 #[serde(default)]
480 pub longitude: Option<f64>,
481 #[serde(default)]
483 pub description: Option<String>,
484 #[serde(default)]
486 pub exif_image_width: Option<u32>,
487 #[serde(default)]
489 pub exif_image_height: Option<u32>,
490}
491
492#[derive(Debug, Clone, serde::Deserialize)]
494#[serde(rename_all = "camelCase")]
495pub struct AssetDetails {
496 #[serde(default)]
498 pub exif_info: Option<ExifInfo>,
499}
500
501#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
503pub enum ThumbnailSize {
504 Thumbnail,
505 Preview,
506 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#[derive(Debug, serde::Deserialize)]
523pub(super) struct SearchResponse {
524 pub(super) assets: SearchAssetSection,
526}
527
528#[derive(Debug, serde::Deserialize)]
530pub(super) struct SearchAssetSection {
531 pub(super) items: Vec<LibraryAsset>,
533 #[serde(rename = "nextPage", default)]
537 pub(super) next_page: Option<String>,
538}
539
540pub struct ImmichApiClient {
542 pub client: Client,
544 settings: RwLock<ApiClientSettings>,
546 pub active_url: Mutex<Option<String>>,
548 last_issue: Mutex<Option<ApiIssue>>,
550 album_cache: Mutex<HashMap<String, String>>,
552 album_create_locks: Mutex<HashMap<String, Arc<Mutex<()>>>>,
555 album_fetch_lock: Mutex<()>,
558 connection_check_lock: Mutex<()>,
561 last_successful_check: Mutex<Option<Instant>>,
564 albums_fetched: Mutex<bool>,
566 thumbnail_semaphore: Arc<Semaphore>,
568}
569
570impl ImmichApiClient {
571 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) .pool_idle_timeout(Duration::from_secs(30)) .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 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 pub async fn latest_issue(&self) -> Option<ApiIssue> {
616 self.last_issue.lock().await.clone()
617 }
618
619 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 pub(super) async fn set_issue(&self, issue: ApiIssue) {
640 *self.last_issue.lock().await = Some(issue);
641 }
642
643 pub(super) async fn clear_issue(&self) {
645 *self.last_issue.lock().await = None;
646 }
647
648 pub(super) fn settings_snapshot(&self) -> ApiClientSettings {
650 self.settings.read().clone()
651 }
652
653 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 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 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 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 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 const SPEC_EXTENSIONS: &[&str] = &[
861 "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", "avif", "bmp", "gif", "jpeg", "jpg", "png", "webp", "heic", "heif", "hif", "insp", "jp2", "jpe", "jxl", "svg", "tif", "tiff",
867 "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}