Skip to main content

mimick/
queue_manager.rs

1//! Manages upload queue orchestration, retry persistence, and sync-index updates.
2//!
3//! A configurable pool of async workers drains the upload queue in parallel.
4//! Failed tasks are moved to a retry queue with exponential back-off. The
5//! queue pauses automatically when environment policies (metered network,
6//! battery power, quiet hours) are active, and resumes when conditions clear.
7
8use 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
23/// Helper to generate progress callback callbacks that update global transfer metrics.
24fn 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/// Represents a unit of work for the upload queue.
50#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
51pub struct FileTask {
52    /// Absolute local file path.
53    pub path: String,
54    /// Absolute watch path prefix under which the file resides.
55    #[serde(default)]
56    pub watch_path: String,
57    /// Precomputed file SHA-1 checksum.
58    pub checksum: String,
59    /// Target Immich album identifier if already resolved.
60    #[serde(default)]
61    pub album_id: Option<String>,
62    /// Mapped Immich album name.
63    #[serde(default)]
64    pub album_name: Option<String>,
65    /// True if the asset is already on the server and only needs album addition.
66    #[serde(default)]
67    pub reassociate_only: bool,
68    /// Manual / ad-hoc uploads that must land in the library without being
69    /// pinned to any album. Watch-folder syncs leave this false so the
70    /// existing parent-dir-as-album fallback still applies.
71    #[serde(default)]
72    pub skip_album: bool,
73    /// Absolute path to an XMP sidecar file to attach during upload.
74    /// `None` when no companion sidecar exists or sidecar upload is disabled.
75    #[serde(default)]
76    pub sidecar_path: Option<String>,
77}
78
79pub struct QueueManager {
80    /// Internal task sender channel.
81    sender: mpsc::Sender<FileTask>,
82    /// Shared in-memory state accessed by both workers and the UI.
83    shared_state: Arc<parking_lot::Mutex<AppState>>,
84    /// Failed tasks accumulated in memory and flushed during graceful shutdown.
85    retry_list: Arc<parking_lot::Mutex<Vec<FileTask>>>,
86    /// Paths already queued or awaiting retry to avoid duplication.
87    pending_paths: Arc<parking_lot::Mutex<HashSet<String>>>,
88    /// Path of persistent retries file.
89    retry_path: PathBuf,
90    /// Shared current transmission limits and environmental constraints.
91    policy: Arc<parking_lot::Mutex<EnvironmentPolicy>>,
92    /// Active worker concurrency limit wrapper.
93    worker_limit: Arc<AtomicUsize>,
94    /// Sync summary notification batch states tracker.
95    batch_notify_state: Arc<parking_lot::Mutex<BatchNotifyState>>,
96    /// Flag indicating whether new tasks are accepted.
97    accepting_new: Arc<AtomicBool>,
98    /// Internal shutdown cancellation coordinator.
99    shutdown_token: CancellationToken,
100}
101
102#[derive(Debug, Default)]
103struct BatchNotifyState {
104    /// Active status indicating whether a batch notifications window is open.
105    active: bool,
106    /// True if a notification summary sweep is already planned.
107    notify_scheduled: bool,
108    /// Unique identifier of the current batch.
109    current_batch_id: u64,
110    /// Last unique identifier that triggered desktop notification.
111    last_notified_batch_id: u64,
112    /// Processed count recorded at beginning of batch.
113    start_processed: usize,
114    /// Failed count recorded at beginning of batch.
115    start_failed: usize,
116}
117
118#[derive(Debug, Clone, Copy)]
119pub struct EnvironmentPolicy {
120    /// True if syncing should pause when metered connections are detected.
121    pub pause_on_metered_network: bool,
122    /// True if syncing should pause when battery power is detected.
123    pub pause_on_battery_power: bool,
124    /// First local clock hour (0–23) of the quiet window. `None` = disabled.
125    pub quiet_hours_start: Option<u8>,
126    /// Last local clock hour (0–23, exclusive) of the quiet window.
127    pub quiet_hours_end: Option<u8>,
128}
129
130impl QueueManager {
131    /// Initialize a new QueueManager and kick off worker threads.
132    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        // Load persisted retries and clear the file so only current-session failures are kept.
151        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        // Retry state stays in memory during the session to avoid per-failure disk writes.
162        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                            // Update the shared progress snapshot before handing off to the API.
230                            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                                // Drain retries and requeue them once connectivity is working again.
310                                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                                    // Release all locks before await
328                                    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 for connectivity-lost detection.
352                            track_consecutive_failures(
353                                success,
354                                &consec_fail_ref,
355                                &connectivity_notified_ref,
356                            );
357
358                            // Update processed count and determine idle state.
359                            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        // Re-queue persisted retries after startup so the main daemon can settle first.
385        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                    // Retry items are now being actively queued — reset failed_count.
395                    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    /// Add a file task to the upload queue and return whether it was accepted.
409    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            // Revert the total_queued increment since it will never be processed.
452            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    /// Stop accepting new tasks, wait up to `deadline` for active uploads
560    /// to finish, then hard-cancel anything still in flight.
561    ///
562    /// In-flight uploads that get cancelled at the deadline land in the retry
563    /// list (treated as failures) so `flush_retries` will persist them for the
564    /// next session.
565    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        // Brief grace so cancelled futures can record themselves as failed before flush.
595        tokio::time::sleep(Duration::from_millis(250)).await;
596    }
597
598    /// Persist any in-memory retry items so they survive a clean shutdown.
599    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    /// Requeue failed task lists with specified details string.
611    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
644/// Get current sync attempt count for a path based on recent events history.
645fn 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
654/// Activate a batch tracking window if none is currently active.
655/// Record state updates after a successful upload.
656fn 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
688/// Record state updates after a failed upload.
689fn 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
733/// Track consecutive failures and fire a connectivity-lost notification after 3+.
734fn 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
754/// Finalize upload progress: update counters, detect queue idle, return batch ID if
755/// a summary notification should be scheduled.
756fn 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
822/// Spawn a debounced batch-notification task.
823fn 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    // Start a fresh notification batch when work is (re)introduced.
884    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
891/// Return current epoch timestamp in seconds.
892fn unix_timestamp_now() -> f64 {
893    SystemTime::now()
894        .duration_since(UNIX_EPOCH)
895        .unwrap_or_default()
896        .as_secs_f64()
897}
898
899/// Returns true if the current local clock hour falls inside the configured quiet window.
900///
901/// Wrapping windows (e.g. 23:00 to 06:00) are supported.
902fn 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        // Wrapping window: e.g. 23 → 06
911        h >= start || h < end
912    }
913}
914
915/// Pause worker processing loops until environment checks and manual pauses permit resume.
916async 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
961/// Upload or reassociate a file, then ensure the resulting asset is present in the target album.
962async fn handle_upload(
963    api: &ImmichApiClient,
964    task: &FileTask,
965    progress: TransferProgressCallback,
966) -> Option<SyncTarget> {
967    // Pre-flight: ask the server whether this checksum already exists. When it
968    // does, we skip streaming the file body entirely and reuse the existing
969    // asset id for album assignment. The 409 DUPLICATE path below still acts
970    // as a safety net for races (e.g. concurrent upload by another client).
971    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    // Manual / library uploads skip album association entirely. The asset is
993    // already on the server at this point; returning early avoids the
994    // parent-dir-as-album fallback below.
995    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    // Fall back to the parent directory name when no explicit album name is configured.
1007    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
1040/// Resolve the album ID from either an explicit task ID or by name lookup.
1041async 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
1060/// Infer target album name from directory structure segment.
1061fn 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
1068/// Persist current list of failed items to retries cache.
1069fn 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
1091/// Load historical list of failed tasks from retries cache.
1092fn 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        // Retry-queue entries written before `skip_album` existed must still
1237        // deserialise — they keep the old watch-folder behaviour.
1238        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}