1use 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 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 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 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 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}