Skip to main content

mimick/api_client/
upload.rs

1//! Asset upload flow including duplicate detection, checksum computation, and retry logic.
2//!
3//! Builds a multipart request with the file payload and EXIF-derived timestamps.
4//! After a successful upload, the asset is assigned to the target album and a
5//! timezone fixup is scheduled.
6
7use std::path::Path;
8use std::time::Duration;
9
10use futures_util::TryStreamExt;
11
12use super::errors::{RequestContext, classify_http_issue, classify_network_issue};
13use super::upload_helpers::{
14    apply_asset_timezone_fixup, file_timestamps, local_timezone_name, mime_for_path,
15    unix_to_utc_iso8601,
16};
17use super::{ApiIssue, ImmichApiClient, TransferProgressCallback};
18
19impl ImmichApiClient {
20    /// Upload a single local asset to the Immich server with progressive status tracking.
21    ///
22    /// When `sidecar_path` points to an existing XMP file it is attached as the
23    /// `sidecarData` multipart field so Immich ingests it alongside the asset.
24    pub async fn upload_asset(
25        &self,
26        file_path: &str,
27        checksum: &str,
28        sidecar_path: Option<&str>,
29        progress: Option<TransferProgressCallback>,
30    ) -> Option<String> {
31        let base_url = match self.check_active_url(file_path).await {
32            Some(u) => u,
33            None => return None,
34        };
35
36        let meta = match self.read_asset_metadata(file_path).await {
37            Some(m) => m,
38            None => return None,
39        };
40        let path = Path::new(file_path);
41
42        let (created_ts, modified_ts) = file_timestamps(&meta);
43        let created_at = unix_to_utc_iso8601(created_ts);
44        let modified_at = unix_to_utc_iso8601(modified_ts);
45        let desired_time_zone = local_timezone_name();
46        let filename = path
47            .file_name()
48            .map(|n| n.to_string_lossy().to_string())
49            .unwrap_or_else(|| "upload".to_string());
50        let mime = mime_for_path(path);
51
52        log::info!("Uploading: {} ({} bytes)", file_path, meta.len());
53        log::debug!("  checksum={}, created={}", checksum, created_at);
54        let file_len = meta.len();
55
56        // Stream the file body so large videos do not get buffered into memory.
57        let file = self.open_asset_file(path, file_path).await?;
58
59        let progress_for_stream = progress.clone();
60        let mut uploaded_bytes = 0_u64;
61        let stream = tokio_util::codec::FramedRead::new(file, tokio_util::codec::BytesCodec::new())
62            .inspect_ok(move |chunk| {
63                uploaded_bytes = uploaded_bytes.saturating_add(chunk.len() as u64);
64                if let Some(callback) = &progress_for_stream {
65                    callback(uploaded_bytes, Some(file_len));
66                }
67            });
68        let file_body = reqwest::Body::wrap_stream(stream);
69
70        let file_part = reqwest::multipart::Part::stream_with_length(file_body, file_len)
71            .file_name(filename.clone())
72            .mime_str(mime)
73            .ok()?;
74
75        let form = reqwest::multipart::Form::new()
76            .part("assetData", file_part)
77            .text("fileCreatedAt", created_at)
78            .text("fileModifiedAt", modified_at)
79            .text("isFavorite", "false");
80
81        let form = Self::attach_sidecar(form, sidecar_path).await;
82
83        self.execute_upload_request(
84            form,
85            base_url,
86            filename,
87            file_len,
88            desired_time_zone,
89            progress,
90        )
91        .await
92    }
93
94    async fn handle_upload_response(
95        &self,
96        resp: reqwest::Response,
97        filename: &str,
98        file_len: u64,
99        desired_time_zone: &Option<String>,
100        base_url: String,
101        progress: Option<&TransferProgressCallback>,
102    ) -> Option<String> {
103        let status = resp.status().as_u16();
104        match status {
105            200 | 201 => {
106                self.handle_upload_success(
107                    resp,
108                    filename,
109                    file_len,
110                    desired_time_zone,
111                    base_url,
112                    progress,
113                )
114                .await
115            }
116            409 => {
117                self.handle_upload_duplicate(resp, filename, file_len, progress)
118                    .await
119            }
120            413 => self.handle_upload_too_large(filename).await,
121            401 | 403 => self.handle_upload_auth_error().await,
122            502..=504 => self.handle_upload_server_error(status, filename).await,
123            _ => {
124                self.handle_upload_unknown_error(resp, status, filename)
125                    .await
126            }
127        }
128    }
129
130    async fn handle_upload_too_large(&self, filename: &str) -> Option<String> {
131        log::error!("Upload failed (file too large): {}", filename);
132        self.set_upload_issue(file_too_large_issue()).await;
133        None
134    }
135
136    async fn handle_upload_auth_error(&self) -> Option<String> {
137        self.set_upload_issue(upload_auth_issue()).await;
138        None
139    }
140
141    async fn handle_upload_server_error(&self, status: u16, filename: &str) -> Option<String> {
142        log::warn!("Server error {}: retrying later for {}", status, filename);
143        *self.active_url.lock().await = None;
144        self.set_upload_issue(temporary_server_issue()).await;
145        None
146    }
147
148    async fn handle_upload_unknown_error(
149        &self,
150        resp: reqwest::Response,
151        status: u16,
152        filename: &str,
153    ) -> Option<String> {
154        let body = resp.text().await.unwrap_or_default();
155        log::error!("Upload failed [{}] for {}: {}", status, filename, body);
156        self.set_issue(classify_http_issue(
157            RequestContext::Upload,
158            status,
159            Some(filename),
160        ))
161        .await;
162        None
163    }
164
165    async fn set_upload_issue(&self, issue: ApiIssue) {
166        self.set_issue(issue).await;
167    }
168
169    // --------------- Album Management ---------------
170
171    /// Schedule a background task to fix asset timezones after upload.
172    fn schedule_asset_timezone_fixup(
173        &self,
174        base_url: String,
175        asset_id: String,
176        time_zone: Option<String>,
177    ) {
178        let client = self.client.clone();
179        let api_key = self.settings.read().api_key.clone();
180
181        tokio::spawn(async move {
182            let Some(time_zone) = time_zone else {
183                log::warn!(
184                    "Could not determine local timezone for uploaded asset {}; leaving Immich timezone unchanged",
185                    asset_id
186                );
187                return;
188            };
189
190            // Immich can rewrite the timeline placement after the initial upload once
191            // the metadata extraction job finishes. Re-apply the intended timezone
192            // after a few short delays so the final stored value matches the source file.
193            for delay_secs in [2_u64, 8, 20] {
194                tokio::time::sleep(Duration::from_secs(delay_secs)).await;
195                apply_asset_timezone_fixup(&client, &api_key, &base_url, &asset_id, &time_zone)
196                    .await;
197            }
198        });
199    }
200
201    async fn handle_upload_success(
202        &self,
203        resp: reqwest::Response,
204        filename: &str,
205        file_len: u64,
206        desired_time_zone: &Option<String>,
207        base_url: String,
208        progress: Option<&TransferProgressCallback>,
209    ) -> Option<String> {
210        let json = resp.json::<serde_json::Value>().await.ok()?;
211        let asset_id = json["id"].as_str().map(String::from);
212        if let Some(callback) = progress {
213            callback(file_len, Some(file_len));
214        }
215        if let Some(asset_id) = asset_id.as_deref() {
216            self.schedule_asset_timezone_fixup(
217                base_url,
218                asset_id.to_string(),
219                desired_time_zone.clone(),
220            );
221        }
222        self.clear_issue().await;
223        log::info!("Upload OK: {} => {:?}", filename, asset_id);
224        asset_id
225    }
226
227    async fn handle_upload_duplicate(
228        &self,
229        resp: reqwest::Response,
230        filename: &str,
231        file_len: u64,
232        progress: Option<&TransferProgressCallback>,
233    ) -> Option<String> {
234        log::info!("Duplicate (already in Immich): {}", filename);
235        self.clear_issue().await;
236        if let Some(callback) = progress {
237            callback(file_len, Some(file_len));
238        }
239        if let Ok(json) = resp.json::<serde_json::Value>().await
240            && let Some(id) = json["id"].as_str()
241        {
242            return Some(id.to_string());
243        }
244        Some("DUPLICATE".to_string())
245    }
246
247    async fn attach_sidecar(
248        mut form: reqwest::multipart::Form,
249        sidecar_path: Option<&str>,
250    ) -> reqwest::multipart::Form {
251        if let Some(sidecar) = sidecar_path {
252            let sidecar_p = Path::new(sidecar);
253            if sidecar_p.exists() {
254                match tokio::fs::read(sidecar_p).await {
255                    Ok(sidecar_bytes) => {
256                        let sidecar_filename = sidecar_p
257                            .file_name()
258                            .map(|n| n.to_string_lossy().to_string())
259                            .unwrap_or_else(|| "sidecar.xmp".to_string());
260                        if let Ok(sidecar_part) = reqwest::multipart::Part::bytes(sidecar_bytes)
261                            .file_name(sidecar_filename.clone())
262                            .mime_str("application/xml")
263                        {
264                            form = form.part("sidecarData", sidecar_part);
265                            log::info!("Attaching sidecar: {}", sidecar_filename);
266                        }
267                    }
268                    Err(err) => {
269                        log::warn!("Could not read sidecar '{}': {}", sidecar, err);
270                    }
271                }
272            }
273        }
274        form
275    }
276
277    async fn read_asset_metadata(&self, file_path: &str) -> Option<std::fs::Metadata> {
278        let path = Path::new(file_path);
279        if !path.exists() {
280            log::warn!("File not found, skipping: {}", file_path);
281            self.set_issue(ApiIssue {
282                summary: "A queued file is no longer available".to_string(),
283                guidance: "Check that the watched folder still exists and that the file was not moved or deleted before upload."
284                    .to_string(),
285            })
286            .await;
287            return None;
288        }
289
290        match std::fs::metadata(path) {
291            Ok(m) => Some(m),
292            Err(e) => {
293                log::error!("Could not read metadata for {}: {}", file_path, e);
294                self.set_issue(ApiIssue {
295                    summary: "Mimick could not read a queued file".to_string(),
296                    guidance: "Verify folder permissions and make sure the file is still accessible to the app."
297                        .to_string(),
298                })
299                .await;
300                None
301            }
302        }
303    }
304    async fn open_asset_file(&self, path: &Path, file_path: &str) -> Option<tokio::fs::File> {
305        match tokio::fs::File::open(path).await {
306            Ok(f) => Some(f),
307            Err(e) => {
308                log::error!("Failed to open {}: {}", file_path, e);
309                self.set_issue(ApiIssue {
310                    summary: "Mimick could not open a queued file".to_string(),
311                    guidance: "The file may be locked, deleted, or outside the app's allowed folder access."
312                        .to_string(),
313                })
314                .await;
315                None
316            }
317        }
318    }
319    async fn check_active_url(&self, file_path: &str) -> Option<String> {
320        match self.get_active_url().await {
321            Some(u) => Some(u),
322            None => {
323                log::error!("No active connection. Skipping upload: {}", file_path);
324                self.set_issue(ApiIssue {
325                    summary: "No active server connection".to_string(),
326                    guidance: "Test the server connection in Settings and confirm at least one Immich URL is reachable."
327                        .to_string(),
328                })
329                .await;
330                None
331            }
332        }
333    }
334
335    async fn execute_upload_request(
336        &self,
337        form: reqwest::multipart::Form,
338        base_url: String,
339        filename: String,
340        file_len: u64,
341        desired_time_zone: Option<String>,
342        progress: Option<TransferProgressCallback>,
343    ) -> Option<String> {
344        let url = format!("{}/api/assets", base_url);
345        let api_key = self.settings.read().api_key.clone();
346
347        match self
348            .client
349            .post(&url)
350            .header("x-api-key", &api_key)
351            .header("Accept", "application/json")
352            .multipart(form)
353            .send()
354            .await
355        {
356            Ok(resp) => {
357                self.handle_upload_response(
358                    resp,
359                    &filename,
360                    file_len,
361                    &desired_time_zone,
362                    base_url,
363                    progress.as_ref(),
364                )
365                .await
366            }
367            Err(e) => {
368                log::error!("Network error uploading {}: {}", filename, e);
369                let mut active = self.active_url.lock().await;
370                *active = None;
371                self.set_issue(classify_network_issue(RequestContext::Upload, &e))
372                    .await;
373                None
374            }
375        }
376    }
377}
378
379fn file_too_large_issue() -> ApiIssue {
380    ApiIssue {
381        summary: "Immich rejected a file as too large".to_string(),
382        guidance: "Reduce the file size, raise the server's upload limits, or use a folder rule to skip oversized files."
383            .to_string(),
384    }
385}
386
387fn upload_auth_issue() -> ApiIssue {
388    ApiIssue {
389        summary: "Immich rejected the API key".to_string(),
390        guidance: "Update the API key in Settings and ensure it has the Asset upload + update and Album read/create/albumAsset.create permissions."
391            .to_string(),
392    }
393}
394
395fn temporary_server_issue() -> ApiIssue {
396    ApiIssue {
397        summary: "Immich is temporarily unavailable".to_string(),
398        guidance: "Wait a moment and retry. If it keeps happening, check the server logs and reverse proxy."
399            .to_string(),
400    }
401}