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}