Skip to main content

immich_lib/
client.rs

1//! HTTP client wrapper for the Immich API.
2
3use chrono::{DateTime, Utc};
4use futures::StreamExt;
5use reqwest::header::{HeaderMap, HeaderValue, InvalidHeaderValue};
6use reqwest::multipart::{Form, Part};
7use serde::de::DeserializeOwned;
8use serde::{Deserialize, Serialize};
9use std::path::Path;
10use std::time::Duration;
11use tokio::io::AsyncWriteExt;
12use url::Url;
13
14use crate::error::{ImmichError, Result};
15use crate::models::{AssetResponse, DuplicateGroup};
16
17/// Response from the Immich upload endpoint.
18#[derive(Debug, Clone, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub struct UploadResponse {
21    /// The ID of the newly created asset
22    pub id: String,
23    /// Whether this was a duplicate of an existing asset
24    #[serde(default)]
25    pub duplicate: bool,
26}
27
28/// Client for interacting with the Immich REST API.
29///
30/// Handles authentication via API key and provides typed methods for API endpoints.
31///
32/// # Example
33///
34/// ```no_run
35/// use immich_lib::ImmichClient;
36///
37/// # async fn example() -> immich_lib::Result<()> {
38/// let client = ImmichClient::new("https://immich.example.com", "your-api-key")?;
39/// let duplicates = client.get_duplicates().await?;
40/// println!("Found {} duplicate groups", duplicates.len());
41/// # Ok(())
42/// # }
43/// ```
44#[derive(Debug, Clone)]
45pub struct ImmichClient {
46    /// HTTP client with default headers (API key) configured
47    client: reqwest::Client,
48    /// Base URL of the Immich server
49    base_url: Url,
50}
51
52impl ImmichClient {
53    /// Creates a new ImmichClient with the given base URL and API key.
54    ///
55    /// # Arguments
56    ///
57    /// * `base_url` - The base URL of the Immich server (e.g., `https://immich.example.com`)
58    /// * `api_key` - The API key for authentication (created in Immich web UI)
59    ///
60    /// # Errors
61    ///
62    /// Returns an error if:
63    /// - The base_url is not a valid URL
64    /// - The api_key is empty or contains invalid characters
65    /// - The HTTP client cannot be built
66    pub fn new(base_url: &str, api_key: &str) -> Result<Self> {
67        // Validate API key
68        if api_key.is_empty() {
69            return Err(ImmichError::InvalidApiKey);
70        }
71
72        // Parse base URL
73        let base_url = Url::parse(base_url)?;
74
75        // Build default headers with API key
76        let mut headers = HeaderMap::new();
77        let header_value = HeaderValue::from_str(api_key).map_err(|_: InvalidHeaderValue| {
78            ImmichError::InvalidApiKey
79        })?;
80        headers.insert("x-api-key", header_value);
81
82        // Build HTTP client with defaults
83        let client = reqwest::Client::builder()
84            .default_headers(headers)
85            .timeout(Duration::from_secs(30))
86            .build()?;
87
88        Ok(Self { client, base_url })
89    }
90
91    /// Fetches all duplicate groups from the Immich server.
92    ///
93    /// # Returns
94    ///
95    /// A vector of duplicate groups, each containing assets that Immich has
96    /// identified as duplicates. Returns an empty vector if no duplicates exist.
97    ///
98    /// # Errors
99    ///
100    /// Returns an error if:
101    /// - The HTTP request fails (network error, timeout)
102    /// - The server returns an error response (401 unauthorized, etc.)
103    /// - The response cannot be parsed as JSON
104    pub async fn get_duplicates(&self) -> Result<Vec<DuplicateGroup>> {
105        let url = self.base_url.join("/api/duplicates")?;
106        let response = self.client.get(url).send().await?;
107        self.handle_response(response).await
108    }
109
110    /// Fetches all assets from the Immich server.
111    ///
112    /// Uses pagination to handle large libraries. Automatically filters out
113    /// trashed assets.
114    ///
115    /// # Returns
116    ///
117    /// A vector of all non-trashed assets in the library.
118    ///
119    /// # Errors
120    ///
121    /// Returns an error if:
122    /// - The HTTP request fails (network error, timeout)
123    /// - The server returns an error response (401 unauthorized, etc.)
124    /// - The response cannot be parsed as JSON
125    pub async fn get_all_assets(&self) -> Result<Vec<AssetResponse>> {
126        const PAGE_SIZE: usize = 1000;
127        let mut all_assets = Vec::new();
128        let mut page: usize = 1;
129
130        // Response structure from POST /search/metadata
131        #[derive(Deserialize)]
132        #[serde(rename_all = "camelCase")]
133        struct AssetSearchResult {
134            items: Vec<AssetResponse>,
135            next_page: Option<String>,
136        }
137
138        #[derive(Deserialize)]
139        struct SearchResponse {
140            assets: AssetSearchResult,
141        }
142
143        let url = self.base_url.join("/api/search/metadata")?;
144
145        loop {
146            let body = serde_json::json!({
147                "page": page,
148                "size": PAGE_SIZE,
149                "withExif": true
150            });
151
152            let response = self.client.post(url.clone()).json(&body).send().await?;
153            let search_result: SearchResponse = self.handle_response(response).await?;
154
155            if search_result.assets.items.is_empty() {
156                break;
157            }
158
159            // Filter out trashed assets
160            let non_trashed: Vec<AssetResponse> = search_result
161                .assets
162                .items
163                .into_iter()
164                .filter(|a| !a.is_trashed)
165                .collect();
166            all_assets.extend(non_trashed);
167
168            // Check if there are more pages
169            if search_result.assets.next_page.is_none() {
170                break;
171            }
172
173            page += 1;
174        }
175
176        Ok(all_assets)
177    }
178
179    /// Fetches a single asset by ID.
180    ///
181    /// # Arguments
182    ///
183    /// * `asset_id` - The ID of the asset to fetch
184    ///
185    /// # Returns
186    ///
187    /// The asset with its EXIF metadata.
188    ///
189    /// # Errors
190    ///
191    /// Returns an error if:
192    /// - The HTTP request fails (network error, timeout)
193    /// - The server returns an error response (401 unauthorized, 404 not found)
194    /// - The response cannot be parsed as JSON
195    pub async fn get_asset(&self, asset_id: &str) -> Result<AssetResponse> {
196        let url = self.base_url.join(&format!("/api/assets/{}", asset_id))?;
197        let response = self.client.get(url).send().await?;
198        self.handle_response(response).await
199    }
200
201    /// Downloads an asset's original file to the specified path.
202    ///
203    /// Uses streaming to avoid buffering the entire file in memory,
204    /// making it suitable for large files.
205    ///
206    /// # Arguments
207    ///
208    /// * `asset_id` - The ID of the asset to download
209    /// * `path` - The destination path to save the file
210    ///
211    /// # Returns
212    ///
213    /// The total number of bytes written to the file.
214    ///
215    /// # Errors
216    ///
217    /// Returns an error if:
218    /// - The HTTP request fails
219    /// - The server returns an error response
220    /// - The file cannot be created or written to
221    pub async fn download_asset(&self, asset_id: &str, path: &Path) -> Result<u64> {
222        let url = self
223            .base_url
224            .join(&format!("/api/assets/{}/original", asset_id))?;
225        let response = self.client.get(url).send().await?;
226
227        let status = response.status();
228        if !status.is_success() {
229            let body = response.text().await.unwrap_or_default();
230            return Err(ImmichError::Api {
231                status: status.as_u16(),
232                message: body,
233            });
234        }
235
236        let mut file = tokio::fs::File::create(path).await?;
237        let mut stream = response.bytes_stream();
238        let mut bytes_written: u64 = 0;
239
240        while let Some(chunk) = stream.next().await {
241            let chunk = chunk?;
242            file.write_all(&chunk).await?;
243            bytes_written += chunk.len() as u64;
244        }
245
246        file.flush().await?;
247        Ok(bytes_written)
248    }
249
250    /// Deletes multiple assets in a single API call.
251    ///
252    /// # Arguments
253    ///
254    /// * `asset_ids` - The IDs of the assets to delete
255    /// * `force` - If true, permanently delete; if false, move to trash
256    ///
257    /// # Errors
258    ///
259    /// Returns an error if:
260    /// - The HTTP request fails
261    /// - The server returns an error response
262    pub async fn delete_assets(&self, asset_ids: &[String], force: bool) -> Result<()> {
263        #[derive(Serialize)]
264        struct DeleteRequest<'a> {
265            ids: &'a [String],
266            force: bool,
267        }
268
269        let url = self.base_url.join("/api/assets")?;
270        let body = DeleteRequest {
271            ids: asset_ids,
272            force,
273        };
274
275        let response = self.client.delete(url).json(&body).send().await?;
276
277        let status = response.status();
278        if !status.is_success() {
279            let body = response.text().await.unwrap_or_default();
280            return Err(ImmichError::Api {
281                status: status.as_u16(),
282                message: body,
283            });
284        }
285
286        Ok(())
287    }
288
289    /// Updates an asset's metadata fields.
290    ///
291    /// This method allows updating GPS coordinates, date/time, and description
292    /// for an asset. Only non-None fields will be sent in the update request.
293    ///
294    /// # Arguments
295    ///
296    /// * `asset_id` - The ID of the asset to update
297    /// * `latitude` - New GPS latitude (optional)
298    /// * `longitude` - New GPS longitude (optional)
299    /// * `date_time_original` - New original date/time as ISO 8601 string (optional)
300    /// * `description` - New description (optional)
301    ///
302    /// # Errors
303    ///
304    /// Returns an error if:
305    /// - The HTTP request fails
306    /// - The server returns an error response
307    pub async fn update_asset_metadata(
308        &self,
309        asset_id: &str,
310        latitude: Option<f64>,
311        longitude: Option<f64>,
312        date_time_original: Option<&str>,
313        description: Option<&str>,
314    ) -> Result<()> {
315        #[derive(Serialize)]
316        #[serde(rename_all = "camelCase")]
317        struct UpdateRequest<'a> {
318            #[serde(skip_serializing_if = "Option::is_none")]
319            latitude: Option<f64>,
320            #[serde(skip_serializing_if = "Option::is_none")]
321            longitude: Option<f64>,
322            #[serde(skip_serializing_if = "Option::is_none")]
323            date_time_original: Option<&'a str>,
324            #[serde(skip_serializing_if = "Option::is_none")]
325            description: Option<&'a str>,
326        }
327
328        let url = self.base_url.join(&format!("/api/assets/{}", asset_id))?;
329        let body = UpdateRequest {
330            latitude,
331            longitude,
332            date_time_original,
333            description,
334        };
335
336        let response = self.client.put(url).json(&body).send().await?;
337
338        let status = response.status();
339        if !status.is_success() {
340            let body = response.text().await.unwrap_or_default();
341            return Err(ImmichError::Api {
342                status: status.as_u16(),
343                message: body,
344            });
345        }
346
347        Ok(())
348    }
349
350    /// Uploads a file to Immich as a new asset.
351    ///
352    /// # Arguments
353    ///
354    /// * `file_path` - Path to the file to upload
355    ///
356    /// # Returns
357    ///
358    /// Information about the uploaded asset including its new ID.
359    ///
360    /// # Errors
361    ///
362    /// Returns an error if:
363    /// - The file cannot be read
364    /// - The HTTP request fails
365    /// - The server returns an error response
366    pub async fn upload_asset(&self, file_path: &Path) -> Result<UploadResponse> {
367        // Read file content
368        let file_content = tokio::fs::read(file_path).await?;
369
370        // Extract filename - strip asset ID prefix if present (format: {uuid}_{original})
371        let original_filename = file_path
372            .file_name()
373            .and_then(|n| n.to_str())
374            .map(|name| {
375                // Check if filename starts with UUID pattern (8-4-4-4-12 chars + underscore)
376                if name.len() > 37 && name.chars().nth(36) == Some('_') {
377                    // Check if first 36 chars look like a UUID
378                    let prefix = &name[..36];
379                    if prefix.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
380                        return name[37..].to_string();
381                    }
382                }
383                name.to_string()
384            })
385            .unwrap_or_else(|| "unknown".to_string());
386
387        // Get file modification time for timestamps
388        let file_time = tokio::fs::metadata(file_path)
389            .await
390            .ok()
391            .and_then(|m| m.modified().ok())
392            .map(DateTime::<Utc>::from)
393            .unwrap_or_else(Utc::now);
394
395        let file_time_str = file_time.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
396
397        // Determine MIME type from extension
398        let mime_type = match file_path.extension().and_then(|e| e.to_str()) {
399            Some("jpg") | Some("jpeg") => "image/jpeg",
400            Some("png") => "image/png",
401            Some("gif") => "image/gif",
402            Some("webp") => "image/webp",
403            Some("heic") | Some("heif") => "image/heic",
404            Some("mp4") => "video/mp4",
405            Some("mov") => "video/quicktime",
406            Some("avi") => "video/x-msvideo",
407            Some("webm") => "video/webm",
408            _ => "application/octet-stream",
409        };
410
411        // Build multipart form
412        let file_part = Part::bytes(file_content)
413            .file_name(original_filename.clone())
414            .mime_str(mime_type)?;
415
416        let form = Form::new()
417            .part("assetData", file_part)
418            .text("deviceAssetId", format!("restore-{}", uuid::Uuid::new_v4()))
419            .text("deviceId", "immich-dupes-restore")
420            .text("fileCreatedAt", file_time_str.clone())
421            .text("fileModifiedAt", file_time_str);
422
423        let url = self.base_url.join("/api/assets")?;
424        let response = self.client.post(url).multipart(form).send().await?;
425
426        let status = response.status();
427        if status.is_success() {
428            Ok(response.json().await?)
429        } else {
430            let body = response.text().await.unwrap_or_default();
431            Err(ImmichError::Api {
432                status: status.as_u16(),
433                message: body,
434            })
435        }
436    }
437
438    /// Handles an HTTP response, parsing success responses or extracting error details.
439    async fn handle_response<T: DeserializeOwned>(
440        &self,
441        response: reqwest::Response,
442    ) -> Result<T> {
443        let status = response.status();
444
445        if status.is_success() {
446            Ok(response.json().await?)
447        } else {
448            let body = response.text().await.unwrap_or_default();
449            Err(ImmichError::Api {
450                status: status.as_u16(),
451                message: body,
452            })
453        }
454    }
455}