1use crate::api_client::{ImmichApiClient, TransferProgressCallback};
9use crate::notifications;
10use crate::runtime_env;
11use crate::state_manager::{AppState, TransferDirection};
12use crate::sync_index::{ShardedSyncIndex, SyncTarget};
13use chrono::Timelike;
14use std::collections::HashSet;
15use std::fs;
16use std::path::PathBuf;
17use std::sync::Arc;
18use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
19use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
20use tokio::sync::{Mutex, mpsc};
21use tokio_util::sync::CancellationToken;
22
23fn upload_progress_callback(
25 shared_state: &Arc<parking_lot::Mutex<AppState>>,
26 item_id: String,
27) -> TransferProgressCallback {
28 let state_ref = shared_state.clone();
29 Arc::new(move |bytes_done, total_bytes| {
30 let mut state = state_ref.lock();
31 let route = state.active_server_route.clone();
32 if let Some(total_bytes) = total_bytes {
33 let current = state
34 .transfer
35 .active_item_totals
36 .get(&item_id)
37 .copied()
38 .unwrap_or(0);
39 if current == 0 {
40 state.transfer.update_item_total(&item_id, total_bytes);
41 }
42 }
43 state
44 .transfer
45 .update_item_bytes(TransferDirection::Upload, &item_id, bytes_done, route);
46 })
47}
48
49#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
51pub struct FileTask {
52 pub path: String,
54 #[serde(default)]
56 pub watch_path: String,
57 pub checksum: String,
59 #[serde(default)]
61 pub album_id: Option<String>,
62 #[serde(default)]
64 pub album_name: Option<String>,
65 #[serde(default)]
67 pub reassociate_only: bool,
68 #[serde(default)]
72 pub skip_album: bool,
73 #[serde(default)]
76 pub sidecar_path: Option<String>,
77}
78
79pub struct QueueManager {
80 sender: mpsc::Sender<FileTask>,
82 shared_state: Arc<parking_lot::Mutex<AppState>>,
84 retry_list: Arc<parking_lot::Mutex<Vec<FileTask>>>,
86 pending_paths: Arc<parking_lot::Mutex<HashSet<String>>>,
88 retry_path: PathBuf,
90 policy: Arc<parking_lot::Mutex<EnvironmentPolicy>>,
92 worker_limit: Arc<AtomicUsize>,
94 batch_notify_state: Arc<parking_lot::Mutex<BatchNotifyState>>,
96 accepting_new: Arc<AtomicBool>,
98 shutdown_token: CancellationToken,
100}
101
102#[derive(Debug, Default)]
103struct BatchNotifyState {
104 active: bool,
106 notify_scheduled: bool,
108 current_batch_id: u64,
110 last_notified_batch_id: u64,
112 start_processed: usize,
114 start_failed: usize,
116}
117
118#[derive(Debug, Clone, Copy)]
119pub struct EnvironmentPolicy {
120 pub pause_on_metered_network: bool,
122 pub pause_on_battery_power: bool,
124 pub quiet_hours_start: Option<u8>,
126 pub quiet_hours_end: Option<u8>,
128}
129
130impl QueueManager {
131 pub fn new(
133 api_client: Arc<ImmichApiClient>,
134 workers: usize,
135 shared_state: Arc<parking_lot::Mutex<AppState>>,
136 sync_index: Arc<ShardedSyncIndex>,
137 policy: EnvironmentPolicy,
138 ) -> Self {
139 const MAX_WORKERS: usize = 10;
140 let (tx, rx) = mpsc::channel::<FileTask>(64);
141 let rx = Arc::new(Mutex::new(rx));
142
143 let retry_path = {
144 let mut p = crate::profile::cache_dir()
145 .unwrap_or_else(|| PathBuf::from("~/.cache").join(crate::profile::dir_segment()));
146 p.push("retries.json");
147 p
148 };
149
150 let loaded_retries = load_retries(&retry_path);
152 if !loaded_retries.is_empty() {
153 log::info!(
154 "Loaded {} item(s) from retry queue. Clearing file.",
155 loaded_retries.len()
156 );
157 let _ = fs::write(&retry_path, "[]");
158 shared_state.lock().failed_count = loaded_retries.len();
159 }
160
161 let retry_list = Arc::new(parking_lot::Mutex::new(Vec::<FileTask>::new()));
163 let pending_paths = Arc::new(parking_lot::Mutex::new(
164 loaded_retries
165 .iter()
166 .map(|task| task.path.clone())
167 .collect(),
168 ));
169 let policy_ref = Arc::new(parking_lot::Mutex::new(policy));
170 let worker_limit = Arc::new(AtomicUsize::new(workers.clamp(1, MAX_WORKERS)));
171 let batch_notify_state = Arc::new(parking_lot::Mutex::new(BatchNotifyState::default()));
172 let connectivity_lost_notified = Arc::new(parking_lot::Mutex::new(false));
173 let consecutive_failures = Arc::new(parking_lot::Mutex::new(0usize));
174 let accepting_new = Arc::new(AtomicBool::new(true));
175 let shutdown_token = CancellationToken::new();
176
177 let qm = Self {
178 sender: tx,
179 shared_state: shared_state.clone(),
180 retry_list: retry_list.clone(),
181 pending_paths: pending_paths.clone(),
182 retry_path: retry_path.clone(),
183 policy: policy_ref.clone(),
184 worker_limit: worker_limit.clone(),
185 batch_notify_state: batch_notify_state.clone(),
186 accepting_new: accepting_new.clone(),
187 shutdown_token: shutdown_token.clone(),
188 };
189
190 for i in 0..MAX_WORKERS {
191 let rx_clone = rx.clone();
192 let tx_clone = qm.sender.clone();
193 let api = api_client.clone();
194 let state_ref = shared_state.clone();
195 let retry_ref = retry_list.clone();
196 let pending_ref = pending_paths.clone();
197 let sync_index_ref = sync_index.clone();
198 let batch_notify_ref = batch_notify_state.clone();
199 let connectivity_notified_ref = connectivity_lost_notified.clone();
200 let consec_fail_ref = consecutive_failures.clone();
201 let policy_ref = policy_ref.clone();
202 let worker_limit_ref = worker_limit.clone();
203 let cancel = shutdown_token.clone();
204
205 tokio::spawn(async move {
206 log::debug!("Worker {} started", i);
207 loop {
208 while i >= worker_limit_ref.load(Ordering::Relaxed) {
209 if cancel.is_cancelled() {
210 log::debug!("Worker {} cancelled while throttled, exiting.", i);
211 return;
212 }
213 tokio::time::sleep(tokio::time::Duration::from_millis(250)).await;
214 }
215
216 let task = tokio::select! {
217 biased;
218 _ = cancel.cancelled() => None,
219 msg = async {
220 let mut receiver = rx_clone.lock().await;
221 receiver.recv().await
222 } => msg,
223 };
224
225 match task {
226 Some(file_task) => {
227 wait_until_allowed(&state_ref, &policy_ref).await;
228
229 let (pc, tq) = {
231 let mut s = state_ref.lock();
232 s.active_workers += 1;
233 s.status = "uploading".to_string();
234 s.pause_reason = None;
235 s.current_file = Some(file_task.path.clone());
236 s.queue_size = s.total_queued.saturating_sub(s.processed_count);
237 s.progress = if s.total_queued > 0 {
238 ((s.processed_count as f32 / s.total_queued as f32) * 100.0)
239 as u8
240 } else {
241 0
242 };
243 let attempts = current_attempt_count(&s, &file_task.path);
244 s.record_event(file_task.path.clone(), "uploading", None, attempts);
245 let item_label = std::path::Path::new(&file_task.path)
246 .file_name()
247 .map(|name| name.to_string_lossy().to_string())
248 .or_else(|| Some(file_task.path.clone()));
249 let route = s.active_server_route.clone();
250 s.transfer.register_item(
251 TransferDirection::Upload,
252 file_task.path.clone(),
253 std::fs::metadata(&file_task.path)
254 .ok()
255 .map(|meta| meta.len()),
256 item_label,
257 route,
258 );
259 (s.processed_count, s.total_queued)
260 };
261
262 log::info!(
263 "Worker {} uploading [{}/{}]: {}",
264 i,
265 pc + 1,
266 tq,
267 file_task.path
268 );
269
270 let t_start = std::time::Instant::now();
271 let sync_target = tokio::select! {
272 biased;
273 _ = cancel.cancelled() => {
274 log::warn!(
275 "Upload cancelled by shutdown: {}",
276 file_task.path
277 );
278 None
279 }
280 res = handle_upload(
281 &api,
282 &file_task,
283 upload_progress_callback(&state_ref, file_task.path.clone()),
284 ) => res,
285 };
286 let success = sync_target.is_some();
287 let elapsed = t_start.elapsed().as_secs_f32();
288 let active_route = api.active_route_label().await;
289 let latest_issue = api.latest_issue().await;
290
291 if success {
292 log::info!("Upload SUCCESS: {} ({:.2}s)", file_task.path, elapsed);
293 pending_ref.lock().remove(&file_task.path);
294
295 if let Some(target) = sync_target.as_ref()
296 && let Err(err) = sync_index_ref.record_synced(
297 &file_task.path,
298 &file_task.checksum,
299 target,
300 )
301 {
302 log::warn!(
303 "Failed to update sync index for '{}': {}",
304 file_task.path,
305 err
306 );
307 }
308
309 let retries: Vec<FileTask> = {
311 let mut rl = retry_ref.lock();
312 std::mem::take(&mut *rl)
313 };
314 if !retries.is_empty() {
315 log::info!(
316 "Network active. Re-queuing {} retry item(s).",
317 retries.len()
318 );
319 {
320 let mut s = state_ref.lock();
321 let mut batch_state = batch_notify_ref.lock();
322 activate_batch_if_needed(&mut batch_state, &s);
323 s.failed_count =
324 s.failed_count.saturating_sub(retries.len());
325 s.total_queued += retries.len();
326 }
327 for t in retries {
329 let _ = tx_clone.send(t).await;
330 }
331 }
332
333 record_upload_success(
334 &state_ref,
335 &file_task,
336 sync_target.as_ref(),
337 active_route,
338 elapsed,
339 );
340 } else {
341 record_upload_failure(
342 &state_ref,
343 &retry_ref,
344 &file_task,
345 active_route,
346 latest_issue.as_ref(),
347 elapsed,
348 );
349 }
350
351 track_consecutive_failures(
353 success,
354 &consec_fail_ref,
355 &connectivity_notified_ref,
356 );
357
358 let summary_batch = finalize_upload_progress(
360 &state_ref,
361 &batch_notify_ref,
362 &sync_index_ref,
363 &file_task.path,
364 success,
365 );
366
367 if let Some(batch_id) = summary_batch {
368 schedule_batch_notification(
369 state_ref.clone(),
370 batch_notify_ref.clone(),
371 batch_id,
372 );
373 }
374 }
375 None => {
376 log::debug!("Worker {} channel closed, exiting.", i);
377 break;
378 }
379 }
380 }
381 });
382 }
383
384 let sender_clone = qm.sender.clone();
386 let state_ref2 = shared_state.clone();
387 tokio::spawn(async move {
388 tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
389 if !loaded_retries.is_empty() {
390 {
391 let mut s = state_ref2.lock();
392 let mut batch_state = batch_notify_state.lock();
393 activate_batch_if_needed(&mut batch_state, &s);
394 s.failed_count = 0;
396 s.total_queued += loaded_retries.len();
397 }
398 for task in loaded_retries {
399 log::info!("Re-queuing from retry: {}", task.path);
400 let _ = sender_clone.send(task).await;
401 }
402 }
403 });
404
405 qm
406 }
407
408 pub async fn add_to_queue(&self, task: FileTask) -> bool {
410 if !self.accepting_new.load(Ordering::Relaxed) {
411 log::debug!("Refusing new task during shutdown: {}", task.path);
412 return false;
413 }
414 log::debug!("Queuing: {}", task.path);
415 {
416 let mut pending = self.pending_paths.lock();
417 if pending.contains(&task.path) {
418 log::debug!("Skipping already pending task: {}", task.path);
419 return false;
420 }
421 pending.insert(task.path.clone());
422 }
423
424 {
425 let mut s = self.shared_state.lock();
426 let mut batch_state = self.batch_notify_state.lock();
427 activate_batch_if_needed(&mut batch_state, &s);
428 s.total_queued += 1;
429 s.queue_size = s.total_queued.saturating_sub(s.processed_count);
430 let status = s
431 .folder_statuses
432 .entry(task.watch_path.clone())
433 .or_default();
434 status.pending_count += 1;
435 status.target_album = task.album_name.clone();
436
437 let attempts = current_attempt_count(&s, &task.path);
438 s.record_event(
439 task.path.clone(),
440 "pending",
441 task.album_name
442 .clone()
443 .map(|name| format!("Target album: {}", name)),
444 attempts,
445 );
446 }
447 if let Err(e) = self.sender.send(task).await {
448 log::error!("Failed to send task to queue: {}", e);
449 self.pending_paths.lock().remove(&e.0.path);
450
451 let mut s = self.shared_state.lock();
453 s.total_queued = s.total_queued.saturating_sub(1);
454 s.queue_size = s.total_queued.saturating_sub(s.processed_count);
455 let route = s.active_server_route.clone();
456 let completed_batch =
457 s.transfer
458 .finish_item(TransferDirection::Upload, &e.0.path, route);
459 if completed_batch {
460 s.completed_upload_batches = s.completed_upload_batches.saturating_add(1);
461 }
462 let status = s.folder_statuses.entry(e.0.watch_path.clone()).or_default();
463 status.pending_count = status.pending_count.saturating_sub(1);
464 return false;
465 }
466
467 true
468 }
469
470 pub fn set_paused(&self, paused: bool, reason: Option<String>) {
471 let mut state = self.shared_state.lock();
472 state.paused = paused;
473 state.pause_reason = reason;
474 state.status = if paused {
475 "paused".to_string()
476 } else if state.active_workers > 0 {
477 "uploading".to_string()
478 } else {
479 "idle".to_string()
480 };
481 }
482
483 pub fn is_paused(&self) -> bool {
484 self.shared_state.lock().paused
485 }
486
487 pub fn set_worker_limit(&self, workers: u8) {
488 self.worker_limit
489 .store((workers as usize).clamp(1, 10), Ordering::Relaxed);
490 }
491
492 pub fn update_environment_policy(&self, policy: EnvironmentPolicy) {
493 *self.policy.lock() = policy;
494 }
495
496 pub fn recent_events(&self) -> Vec<crate::state_manager::QueueEvent> {
497 self.shared_state.lock().recent_events.clone()
498 }
499
500 pub fn failed_tasks(&self) -> Vec<FileTask> {
501 self.retry_list.lock().clone()
502 }
503
504 pub fn clear_failed(&self) -> usize {
505 let tasks = {
506 let mut retries = self.retry_list.lock();
507 std::mem::take(&mut *retries)
508 };
509 if tasks.is_empty() {
510 return 0;
511 }
512
513 {
514 let mut pending = self.pending_paths.lock();
515 for task in &tasks {
516 pending.remove(&task.path);
517 }
518 }
519
520 let mut state = self.shared_state.lock();
521 state.failed_count = state.failed_count.saturating_sub(tasks.len());
522 for task in &tasks {
523 let attempts = current_attempt_count(&state, &task.path);
524 state.record_event(
525 task.path.clone(),
526 "cleared",
527 Some("Removed from retry queue".to_string()),
528 attempts,
529 );
530 }
531
532 tasks.len()
533 }
534
535 pub async fn retry_all_failed(&self) -> usize {
536 let tasks = {
537 let mut retries = self.retry_list.lock();
538 std::mem::take(&mut *retries)
539 };
540 self.requeue_failed(tasks, "Manual retry".to_string()).await
541 }
542
543 pub async fn retry_failed_path(&self, path: &str) -> bool {
544 let task = {
545 let mut retries = self.retry_list.lock();
546 let index = retries.iter().position(|task| task.path == path);
547 index.map(|index| retries.remove(index))
548 };
549
550 if let Some(task) = task {
551 self.requeue_failed(vec![task], "Manual retry".to_string())
552 .await
553 > 0
554 } else {
555 false
556 }
557 }
558
559 pub async fn shutdown(&self, deadline: Duration) {
566 self.accepting_new.store(false, Ordering::Relaxed);
567 let active_now = self.shared_state.lock().active_workers;
568 if active_now == 0 {
569 self.shutdown_token.cancel();
570 return;
571 }
572 log::info!(
573 "Draining {} active upload(s); waiting up to {:?}...",
574 active_now,
575 deadline
576 );
577
578 let start = Instant::now();
579 while start.elapsed() < deadline {
580 if self.shared_state.lock().active_workers == 0 {
581 log::info!("All active uploads finished within deadline.");
582 self.shutdown_token.cancel();
583 return;
584 }
585 tokio::time::sleep(Duration::from_millis(100)).await;
586 }
587
588 let stuck = self.shared_state.lock().active_workers;
589 log::warn!(
590 "Shutdown deadline exceeded; cancelling {} in-flight upload(s).",
591 stuck
592 );
593 self.shutdown_token.cancel();
594 tokio::time::sleep(Duration::from_millis(250)).await;
596 }
597
598 pub fn flush_retries(&self) {
600 let retries = self.retry_list.lock();
601 if !retries.is_empty() {
602 save_retries(&self.retry_path, &retries);
603 log::info!(
604 "Flushed {} unfinished retry item(s) to disk.",
605 retries.len()
606 );
607 }
608 }
609
610 async fn requeue_failed(&self, tasks: Vec<FileTask>, detail: String) -> usize {
612 if tasks.is_empty() {
613 return 0;
614 }
615
616 {
617 let mut state = self.shared_state.lock();
618 let mut batch_state = self.batch_notify_state.lock();
619 activate_batch_if_needed(&mut batch_state, &state);
620 state.failed_count = state.failed_count.saturating_sub(tasks.len());
621 state.total_queued += tasks.len();
622 state.queue_size = state.total_queued.saturating_sub(state.processed_count);
623 for task in &tasks {
624 let attempts = current_attempt_count(&state, &task.path).saturating_add(1);
625 state.record_event(task.path.clone(), "pending", Some(detail.clone()), attempts);
626 let status = state
627 .folder_statuses
628 .entry(task.watch_path.clone())
629 .or_default();
630 status.pending_count += 1;
631 }
632 }
633
634 let mut queued = 0usize;
635 for task in tasks {
636 if self.sender.send(task).await.is_ok() {
637 queued += 1;
638 }
639 }
640 queued
641 }
642}
643
644fn current_attempt_count(state: &AppState, path: &str) -> u32 {
646 state
647 .recent_events
648 .iter()
649 .find(|event| event.path == path)
650 .map(|event| event.attempts)
651 .unwrap_or(1)
652}
653
654fn record_upload_success(
657 state_ref: &Arc<parking_lot::Mutex<AppState>>,
658 task: &FileTask,
659 sync_target: Option<&SyncTarget>,
660 active_route: Option<String>,
661 elapsed: f32,
662) {
663 let mut s = state_ref.lock();
664 let attempts = current_attempt_count(&s, &task.path);
665 s.active_server_route = active_route;
666 s.last_successful_sync_at = Some(unix_timestamp_now());
667 s.last_completed_file = Some(task.path.clone());
668 s.last_error = None;
669 s.last_error_guidance = None;
670 s.record_event(
671 task.path.clone(),
672 "completed",
673 Some(format!("Finished in {:.2}s", elapsed)),
674 attempts,
675 );
676 if let Some(target) = sync_target {
677 let status = s
678 .folder_statuses
679 .entry(task.watch_path.clone())
680 .or_default();
681 status.pending_count = status.pending_count.saturating_sub(1);
682 status.last_sync_at = Some(unix_timestamp_now());
683 status.last_error = None;
684 status.target_album = target.album_name.clone();
685 }
686}
687
688fn record_upload_failure(
690 state_ref: &Arc<parking_lot::Mutex<AppState>>,
691 retry_ref: &Arc<parking_lot::Mutex<Vec<FileTask>>>,
692 task: &FileTask,
693 active_route: Option<String>,
694 latest_issue: Option<&crate::api_client::ApiIssue>,
695 elapsed: f32,
696) {
697 log::warn!(
698 "Upload FAILED: {} ({:.2}s). Adding to retry queue.",
699 task.path,
700 elapsed
701 );
702 retry_ref.lock().push(task.clone());
703 let mut s = state_ref.lock();
704 s.failed_count += 1;
705 s.active_server_route = active_route;
706 let error_text = latest_issue
707 .map(|issue| issue.summary.clone())
708 .unwrap_or_else(|| format!("Upload failed for {}", task.path));
709 s.last_error = Some(error_text.clone());
710 s.last_error_guidance = latest_issue
711 .map(|issue| issue.guidance.clone())
712 .or_else(|| {
713 Some(
714 "Review the latest server and permission settings, then retry the failed item."
715 .to_string(),
716 )
717 });
718 let status = s
719 .folder_statuses
720 .entry(task.watch_path.clone())
721 .or_default();
722 status.pending_count = status.pending_count.saturating_sub(1);
723 status.last_error = Some(error_text);
724 let attempts = current_attempt_count(&s, &task.path);
725 s.record_event(
726 task.path.clone(),
727 "failed",
728 Some("Queued for retry".to_string()),
729 attempts,
730 );
731}
732
733fn track_consecutive_failures(
735 success: bool,
736 consec_fail_ref: &Arc<parking_lot::Mutex<usize>>,
737 connectivity_notified_ref: &Arc<parking_lot::Mutex<bool>>,
738) {
739 if success {
740 *consec_fail_ref.lock() = 0;
741 return;
742 }
743 let mut cf = consec_fail_ref.lock();
744 *cf += 1;
745 if *cf >= 3 {
746 let mut notified = connectivity_notified_ref.lock();
747 if !*notified {
748 *notified = true;
749 notifications::send_connectivity_lost();
750 }
751 }
752}
753
754fn finalize_upload_progress(
757 state_ref: &Arc<parking_lot::Mutex<AppState>>,
758 batch_notify_ref: &Arc<parking_lot::Mutex<BatchNotifyState>>,
759 sync_index_ref: &Arc<ShardedSyncIndex>,
760 path: &str,
761 success: bool,
762) -> Option<u64> {
763 let mut s = state_ref.lock();
764 if success {
765 s.processed_count += 1;
766 }
767 s.active_workers -= 1;
768 s.current_file = None;
769 let route = s.active_server_route.clone();
770 let completed_batch = s
771 .transfer
772 .finish_item(TransferDirection::Upload, path, route);
773 if completed_batch {
774 s.completed_upload_batches = s.completed_upload_batches.saturating_add(1);
775 }
776 let total_handled = s.processed_count + s.failed_count;
777 if total_handled >= s.total_queued && s.active_workers == 0 {
778 apply_idle_state(&mut s, sync_index_ref);
779 check_batch_notification(&mut batch_notify_ref.lock())
780 } else {
781 apply_active_state(&mut s, total_handled);
782 None
783 }
784}
785
786fn apply_idle_state(s: &mut AppState, sync_index_ref: &Arc<ShardedSyncIndex>) {
787 s.queue_size = 0;
788 s.status = if s.paused {
789 "paused".to_string()
790 } else {
791 "idle".to_string()
792 };
793 s.progress = 100;
794 log::info!("All {} file(s) processed. Idle.", s.total_queued);
795 if let Err(err) = sync_index_ref.flush() {
796 log::warn!("Failed to flush sync index on idle: {}", err);
797 }
798}
799
800fn apply_active_state(s: &mut AppState, total_handled: usize) {
801 s.queue_size = s.total_queued.saturating_sub(total_handled);
802 s.progress = if s.total_queued > 0 {
803 ((total_handled as f32 / s.total_queued as f32) * 100.0) as u8
804 } else {
805 0
806 };
807 s.status = "uploading".to_string();
808}
809
810fn check_batch_notification(batch_state: &mut BatchNotifyState) -> Option<u64> {
811 if batch_state.active
812 && batch_state.current_batch_id != batch_state.last_notified_batch_id
813 && !batch_state.notify_scheduled
814 {
815 batch_state.notify_scheduled = true;
816 Some(batch_state.current_batch_id)
817 } else {
818 None
819 }
820}
821
822fn schedule_batch_notification(
824 state_ref: Arc<parking_lot::Mutex<AppState>>,
825 batch_notify_ref: Arc<parking_lot::Mutex<BatchNotifyState>>,
826 batch_id: u64,
827) {
828 tokio::spawn(async move {
829 tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
830 let summary = batch_notification_summary(&state_ref, &batch_notify_ref, batch_id);
831 if let Some((succeeded, failed)) = summary {
832 notifications::send_sync_summary(succeeded, failed);
833 }
834 });
835}
836
837fn batch_notification_summary(
838 state_ref: &Arc<parking_lot::Mutex<AppState>>,
839 batch_notify_ref: &Arc<parking_lot::Mutex<BatchNotifyState>>,
840 batch_id: u64,
841) -> Option<(usize, usize)> {
842 let s = state_ref.lock();
843 let mut batch_state = batch_notify_ref.lock();
844 if !batch_is_ready_to_notify(&s, &batch_state, batch_id) {
845 clear_stale_batch_schedule(&mut batch_state, batch_id);
846 return None;
847 }
848 let succeeded = s
849 .processed_count
850 .saturating_sub(batch_state.start_processed);
851 let failed = s.failed_count.saturating_sub(batch_state.start_failed);
852 batch_state.last_notified_batch_id = batch_id;
853 batch_state.notify_scheduled = false;
854 batch_state.active = false;
855 Some((succeeded, failed))
856}
857
858fn batch_is_ready_to_notify(
859 state: &AppState,
860 batch_state: &BatchNotifyState,
861 batch_id: u64,
862) -> bool {
863 let total_handled = state.processed_count + state.failed_count;
864 let queue_idle = total_handled >= state.total_queued && state.active_workers == 0;
865 batch_state.active
866 && batch_state.notify_scheduled
867 && batch_state.current_batch_id == batch_id
868 && batch_state.current_batch_id != batch_state.last_notified_batch_id
869 && queue_idle
870}
871
872fn clear_stale_batch_schedule(batch_state: &mut BatchNotifyState, batch_id: u64) {
873 if batch_state.current_batch_id == batch_id {
874 batch_state.notify_scheduled = false;
875 }
876}
877
878fn activate_batch_if_needed(batch_state: &mut BatchNotifyState, state: &AppState) {
879 if batch_state.active {
880 return;
881 }
882
883 batch_state.active = true;
885 batch_state.notify_scheduled = false;
886 batch_state.current_batch_id = batch_state.current_batch_id.saturating_add(1);
887 batch_state.start_processed = state.processed_count;
888 batch_state.start_failed = state.failed_count;
889}
890
891fn unix_timestamp_now() -> f64 {
893 SystemTime::now()
894 .duration_since(UNIX_EPOCH)
895 .unwrap_or_default()
896 .as_secs_f64()
897}
898
899fn is_quiet_hour(start: Option<u8>, end: Option<u8>) -> bool {
903 let (Some(start), Some(end)) = (start, end) else {
904 return false;
905 };
906 let h = chrono::Local::now().hour() as u8;
907 if start <= end {
908 h >= start && h < end
909 } else {
910 h >= start || h < end
912 }
913}
914
915async fn wait_until_allowed(
917 state_ref: &Arc<parking_lot::Mutex<AppState>>,
918 policy_ref: &Arc<parking_lot::Mutex<EnvironmentPolicy>>,
919) {
920 loop {
921 let policy = *policy_ref.lock();
922 let defer_reason = {
923 let state = state_ref.lock();
924 if state.paused {
925 Some(
926 state
927 .pause_reason
928 .clone()
929 .unwrap_or_else(|| "Paused by user".to_string()),
930 )
931 } else if policy.pause_on_metered_network && runtime_env::is_metered_connection() {
932 Some("Deferred on metered network".to_string())
933 } else if policy.pause_on_battery_power && runtime_env::is_on_battery_power() {
934 Some("Deferred while on battery power".to_string())
935 } else if is_quiet_hour(policy.quiet_hours_start, policy.quiet_hours_end) {
936 Some("Deferred during quiet hours".to_string())
937 } else {
938 None
939 }
940 };
941
942 if let Some(reason) = defer_reason {
943 {
944 let mut state = state_ref.lock();
945 state.status = "paused".to_string();
946 state.pause_reason = Some(reason);
947 }
948 tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
949 continue;
950 }
951
952 let mut state = state_ref.lock();
953 if state.status == "paused" && !state.paused {
954 state.status = "idle".to_string();
955 state.pause_reason = None;
956 }
957 break;
958 }
959}
960
961async fn handle_upload(
963 api: &ImmichApiClient,
964 task: &FileTask,
965 progress: TransferProgressCallback,
966) -> Option<SyncTarget> {
967 let asset_id = match api.find_existing_asset_id(&task.checksum).await {
972 Some(existing) => {
973 log::debug!("Asset already on server, skipping upload: {}", task.path);
974 Some(existing)
975 }
976 None => {
977 api.upload_asset(
978 &task.path,
979 &task.checksum,
980 task.sidecar_path.as_deref(),
981 Some(progress.clone()),
982 )
983 .await
984 }
985 };
986
987 let asset_id = match resolve_final_asset_id(api, task, asset_id).await {
988 Ok(id) => id,
989 Err(early_return) => return early_return,
990 };
991
992 if task.skip_album {
996 log::debug!(
997 "Skipping album association for library upload: asset_id={}",
998 asset_id
999 );
1000 return Some(SyncTarget {
1001 album_name: None,
1002 album_id: None,
1003 });
1004 }
1005
1006 let album_name = match (&task.album_name, &task.album_id) {
1008 (Some(name), _) if !name.is_empty() && name != "Default (Folder Name)" => name.clone(),
1009 _ => std::path::Path::new(&task.path)
1010 .parent()
1011 .and_then(|p| p.file_name())
1012 .map(|n| n.to_string_lossy().to_string())
1013 .unwrap_or_else(|| "Mimick".to_string()),
1014 };
1015
1016 log::info!("Adding '{}' to album '{}'", task.path, album_name);
1017
1018 let mut final_album_id = match resolve_album_id(api, task, &album_name).await {
1019 Some(id) => id,
1020 None => {
1021 log::warn!(
1022 "Could not resolve album '{}'. Asset uploaded but not added to album.",
1023 album_name
1024 );
1025 return None;
1026 }
1027 };
1028
1029 final_album_id = match add_to_album_with_retry(api, task, &asset_id, final_album_id).await {
1030 Some(id) => id,
1031 None => return None,
1032 };
1033
1034 Some(SyncTarget {
1035 album_name: Some(album_name),
1036 album_id: Some(final_album_id),
1037 })
1038}
1039
1040async fn resolve_album_id(
1042 api: &ImmichApiClient,
1043 task: &FileTask,
1044 album_name: &str,
1045) -> Option<String> {
1046 if let Some(ref id) = task.album_id
1047 && !id.is_empty()
1048 {
1049 return Some(id.clone());
1050 }
1051 match api.get_or_create_album(album_name).await {
1052 Ok(id) => id,
1053 Err(e) => {
1054 log::warn!("Failed to resolve album '{}': {}", album_name, e);
1055 None
1056 }
1057 }
1058}
1059
1060fn infer_album_name(path: &str) -> Option<String> {
1062 std::path::Path::new(path)
1063 .parent()
1064 .and_then(|p| p.file_name())
1065 .map(|n| n.to_string_lossy().to_string())
1066}
1067
1068fn save_retries(path: &PathBuf, tasks: &[FileTask]) {
1070 if let Some(dir) = path.parent() {
1071 let _ = fs::create_dir_all(dir);
1072 }
1073 if let Ok(content) = serde_json::to_string(tasks) {
1074 let unique_ext = format!(
1075 "tmp.{}",
1076 std::time::SystemTime::now()
1077 .duration_since(std::time::UNIX_EPOCH)
1078 .unwrap_or_default()
1079 .as_nanos()
1080 );
1081 let tmp = path.with_extension(unique_ext);
1082 if fs::write(&tmp, content).is_ok()
1083 && let Err(e) = fs::rename(&tmp, path)
1084 {
1085 let _ = fs::remove_file(&tmp);
1086 log::warn!("Failed to save retries: {}", e);
1087 }
1088 }
1089}
1090
1091fn load_retries(path: &PathBuf) -> Vec<FileTask> {
1093 if !path.exists() {
1094 return Vec::new();
1095 }
1096 match fs::read_to_string(path) {
1097 Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
1098 Err(e) => {
1099 log::error!("Failed to load retries: {}", e);
1100 Vec::new()
1101 }
1102 }
1103}
1104
1105async fn resolve_final_asset_id(
1106 api: &ImmichApiClient,
1107 task: &FileTask,
1108 asset_id: Option<String>,
1109) -> Result<String, Option<SyncTarget>> {
1110 let id = match asset_id {
1111 None => return Err(None),
1112 Some(ref id) if id == "DUPLICATE" => match api.find_existing_asset_id(&task.checksum).await
1113 {
1114 Some(existing) => existing,
1115 None => {
1116 log::info!("Asset already on server: {}", task.path);
1117 return Err(Some(SyncTarget {
1118 album_name: task
1119 .album_name
1120 .clone()
1121 .or_else(|| infer_album_name(&task.path)),
1122 album_id: task.album_id.clone(),
1123 }));
1124 }
1125 },
1126 Some(id) => id,
1127 };
1128 Ok(id)
1129}
1130
1131async fn add_to_album_with_retry(
1132 api: &ImmichApiClient,
1133 task: &FileTask,
1134 asset_id: &str,
1135 mut final_album_id: String,
1136) -> Option<String> {
1137 let asset_id_str = asset_id.to_string();
1138 if !api
1139 .add_assets_to_album(&final_album_id, std::slice::from_ref(&asset_id_str))
1140 .await
1141 {
1142 let name = task
1143 .album_name
1144 .clone()
1145 .or_else(|| infer_album_name(&task.path))?;
1146 log::warn!(
1147 "Album '{}' may be stale or deleted. Refreshing album resolution.",
1148 final_album_id
1149 );
1150 final_album_id = match api.resolve_album_by_name(&name, true).await {
1151 Ok(Some(id)) => id,
1152 Ok(None) | Err(_) => return None,
1153 };
1154 if !api
1155 .add_assets_to_album(&final_album_id, std::slice::from_ref(&asset_id_str))
1156 .await
1157 {
1158 return None;
1159 }
1160 }
1161 Some(final_album_id)
1162}
1163
1164#[cfg(test)]
1165mod tests {
1166 use super::*;
1167 use crate::state_manager::AppState;
1168 use parking_lot::Mutex;
1169 use std::collections::HashSet;
1170 use std::sync::Arc;
1171 use tempfile::tempdir;
1172 use tokio::sync::mpsc;
1173
1174 type TestQueueManagerParts = (
1175 QueueManager,
1176 mpsc::Receiver<FileTask>,
1177 Arc<Mutex<AppState>>,
1178 Arc<Mutex<Vec<FileTask>>>,
1179 Arc<Mutex<HashSet<String>>>,
1180 );
1181
1182 fn test_queue_manager(buffer: usize) -> TestQueueManagerParts {
1183 let (tx, rx) = mpsc::channel(buffer);
1184 let shared_state = Arc::new(Mutex::new(AppState::default()));
1185 let retry_list = Arc::new(Mutex::new(Vec::<FileTask>::new()));
1186 let pending_paths = Arc::new(Mutex::new(HashSet::<String>::new()));
1187 let retry_path = tempdir().unwrap().path().join("retries.json");
1188
1189 (
1190 QueueManager {
1191 sender: tx,
1192 shared_state: shared_state.clone(),
1193 retry_list: retry_list.clone(),
1194 pending_paths: pending_paths.clone(),
1195 retry_path,
1196 policy: Arc::new(Mutex::new(EnvironmentPolicy {
1197 pause_on_metered_network: false,
1198 pause_on_battery_power: false,
1199 quiet_hours_start: None,
1200 quiet_hours_end: None,
1201 })),
1202 worker_limit: Arc::new(AtomicUsize::new(1)),
1203 batch_notify_state: Arc::new(Mutex::new(BatchNotifyState::default())),
1204 accepting_new: Arc::new(AtomicBool::new(true)),
1205 shutdown_token: CancellationToken::new(),
1206 },
1207 rx,
1208 shared_state,
1209 retry_list,
1210 pending_paths,
1211 )
1212 }
1213
1214 #[test]
1215 fn test_filetask_serialization() {
1216 let task = FileTask {
1217 path: "/a/b.jpg".to_string(),
1218 watch_path: "/a".to_string(),
1219 checksum: "sha123".to_string(),
1220 album_id: Some("id1".to_string()),
1221 album_name: Some("Album".to_string()),
1222 reassociate_only: false,
1223 skip_album: false,
1224 sidecar_path: None,
1225 };
1226 let js = serde_json::to_string(&task).unwrap();
1227 assert!(js.contains("sha123"));
1228
1229 let deserialized: FileTask = serde_json::from_str(&js).unwrap();
1230 assert_eq!(deserialized.path, "/a/b.jpg");
1231 assert_eq!(deserialized.album_id.unwrap(), "id1");
1232 }
1233
1234 #[test]
1235 fn test_filetask_skip_album_defaults_false_for_legacy_payloads() {
1236 let legacy_json = r#"{
1239 "path": "/a/b.jpg",
1240 "watch_path": "/a",
1241 "checksum": "sha123",
1242 "album_id": null,
1243 "album_name": null,
1244 "reassociate_only": false
1245 }"#;
1246 let task: FileTask = serde_json::from_str(legacy_json).unwrap();
1247 assert!(
1248 !task.skip_album,
1249 "legacy entries inherit album-creation behaviour"
1250 );
1251 assert!(
1252 task.sidecar_path.is_none(),
1253 "legacy entries default to no sidecar"
1254 );
1255 }
1256
1257 #[test]
1258 fn test_filetask_skip_album_round_trips() {
1259 let task = FileTask {
1260 path: "/x.jpg".into(),
1261 watch_path: String::new(),
1262 checksum: "sha".into(),
1263 album_id: None,
1264 album_name: None,
1265 reassociate_only: false,
1266 skip_album: true,
1267 sidecar_path: None,
1268 };
1269 let js = serde_json::to_string(&task).unwrap();
1270 let restored: FileTask = serde_json::from_str(&js).unwrap();
1271 assert!(restored.skip_album);
1272 }
1273
1274 #[test]
1275 fn test_retry_persistence() {
1276 let dir = tempdir().unwrap();
1277 let retry_path = dir.path().join("retries.json");
1278
1279 let task = FileTask {
1280 path: "/a/1.jpg".to_string(),
1281 watch_path: "/a".to_string(),
1282 checksum: "hash1".to_string(),
1283 album_id: None,
1284 album_name: None,
1285 reassociate_only: false,
1286 skip_album: false,
1287 sidecar_path: None,
1288 };
1289
1290 let tasks = vec![task];
1291 save_retries(&retry_path, &tasks);
1292 let loaded = load_retries(&retry_path);
1293 assert_eq!(loaded.len(), 1);
1294 assert_eq!(loaded[0].path, "/a/1.jpg");
1295 }
1296
1297 #[tokio::test]
1298 async fn test_add_to_queue_rejects_duplicate_pending_path() {
1299 let (qm, mut rx, shared_state, _retry_list, pending_paths) = test_queue_manager(4);
1300 let task = FileTask {
1301 path: "/a/1.jpg".to_string(),
1302 watch_path: "/a".to_string(),
1303 checksum: "hash1".to_string(),
1304 album_id: None,
1305 album_name: Some("Album".into()),
1306 reassociate_only: false,
1307 skip_album: false,
1308 sidecar_path: None,
1309 };
1310
1311 assert!(qm.add_to_queue(task.clone()).await);
1312 assert!(!qm.add_to_queue(task.clone()).await);
1313
1314 let queued = rx.recv().await.unwrap();
1315 assert_eq!(queued.path, task.path);
1316 assert!(pending_paths.lock().contains("/a/1.jpg"));
1317 assert_eq!(shared_state.lock().total_queued, 1);
1318 }
1319
1320 #[tokio::test]
1321 async fn test_retry_failed_path_requeues_and_updates_state() {
1322 let (qm, mut rx, shared_state, retry_list, pending_paths) = test_queue_manager(4);
1323 let task = FileTask {
1324 path: "/a/failed.jpg".to_string(),
1325 watch_path: "/a".to_string(),
1326 checksum: "hash1".to_string(),
1327 album_id: None,
1328 album_name: None,
1329 reassociate_only: false,
1330 skip_album: false,
1331 sidecar_path: None,
1332 };
1333
1334 retry_list.lock().push(task.clone());
1335 pending_paths.lock().insert(task.path.clone());
1336 shared_state.lock().failed_count = 1;
1337
1338 assert!(qm.retry_failed_path(&task.path).await);
1339
1340 let requeued = rx.recv().await.unwrap();
1341 assert_eq!(requeued.path, task.path);
1342 assert!(retry_list.lock().is_empty());
1343
1344 let state = shared_state.lock();
1345 assert_eq!(state.failed_count, 0);
1346 assert_eq!(state.total_queued, 1);
1347 assert_eq!(state.recent_events[0].status, "pending");
1348 assert_eq!(state.recent_events[0].attempts, 2);
1349 }
1350
1351 #[test]
1352 fn test_unix_timestamp_now_is_non_zero() {
1353 assert!(unix_timestamp_now() > 0.0);
1354 }
1355
1356 #[test]
1357 fn test_clear_failed_removes_retry_entries_and_pending_paths() {
1358 let (qm, _rx, shared_state, retry_list, pending_paths) = test_queue_manager(4);
1359 let task = FileTask {
1360 path: "/a/failed.jpg".to_string(),
1361 watch_path: "/a".to_string(),
1362 checksum: "hash1".to_string(),
1363 album_id: None,
1364 album_name: None,
1365 reassociate_only: false,
1366 skip_album: false,
1367 sidecar_path: None,
1368 };
1369
1370 retry_list.lock().push(task.clone());
1371 pending_paths.lock().insert(task.path.clone());
1372 shared_state.lock().failed_count = 1;
1373
1374 assert_eq!(qm.clear_failed(), 1);
1375 assert!(retry_list.lock().is_empty());
1376 assert!(!pending_paths.lock().contains(&task.path));
1377
1378 let state = shared_state.lock();
1379 assert_eq!(state.failed_count, 0);
1380 assert_eq!(state.recent_events[0].status, "cleared");
1381 }
1382}