Skip to main content

mimick/
startup_scan.rs

1//! Performs a startup catch-up scan for files that were missed while Mimick was not running.
2//!
3//! The scan runs in two stages:
4//!   1. **Enumerate (parallel, sync)** -- uses `rayon` to walk all watch paths
5//!      concurrently, collecting candidate files with their filesystem fingerprints.
6//!   2. **Decide + queue (bounded async)** -- resolves album IDs, checks the sync
7//!      index, hashes only when needed, and queues uploads with bounded concurrency.
8
9use crate::api_client::ImmichApiClient;
10use crate::app_context::AppContext;
11use crate::config::FolderSyncMethod;
12use crate::config::StartupCatchupMode;
13use crate::config::WatchPathEntry;
14use crate::library::album_sync;
15use crate::monitor::{compute_sha1_chunked, is_supported_media_path, is_temporary_file};
16use crate::queue_manager::{FileTask, QueueManager};
17use crate::state_manager::AppState;
18use crate::sync_index::{ShardedSyncIndex, SyncDecision, SyncTarget};
19use futures_util::stream::{self, StreamExt};
20use parking_lot::Mutex;
21use rayon::prelude::*;
22use std::collections::{HashMap, HashSet};
23use std::path::Path;
24use std::sync::Arc;
25use std::sync::atomic::{AtomicUsize, Ordering};
26
27/// A file discovered during the parallel enumeration stage.
28struct ScanCandidate {
29    /// Absolute local path of the discovered candidate.
30    path: String,
31    /// Watch path entry prefix that matched the candidate.
32    watch_path: String,
33    /// Inferred or configured album name.
34    album_name: String,
35    /// Whether XMP sidecar attachment is enabled for this candidate.
36    sidecar_enabled: bool,
37}
38
39/// Scans watch folders at startup and queues new, changed, or retargeted files for upload.
40///
41/// Stage 1 parallelises the directory walk and filtering via rayon.
42/// Stage 2 resolves album IDs and processes candidates with bounded async concurrency.
43pub async fn queue_unsynced_files(
44    watch_paths: Vec<WatchPathEntry>,
45    queue_manager: Arc<QueueManager>,
46    sync_index: Arc<ShardedSyncIndex>,
47    api_client: Arc<ImmichApiClient>,
48    catchup_mode: StartupCatchupMode,
49    shared_state: Arc<Mutex<AppState>>,
50    app_ctx: Arc<AppContext>,
51) {
52    if watch_paths.is_empty() {
53        return;
54    }
55
56    // ── Stage 1: Parallel enumerate + fingerprint ────────────────────────
57    //
58    // Read last_sync once, before spawning into rayon, to avoid locking
59    // shared_state on every file.
60    let last_sync = shared_state
61        .lock()
62        .last_successful_sync_at
63        .unwrap_or_default();
64
65    let global_xmp_enabled = app_ctx.config.read().data.upload_xmp_sidecars;
66
67    let (candidates, mut seen_paths, skipped_enum, enum_errors) = enumerate_candidates(
68        &watch_paths,
69        catchup_mode,
70        last_sync,
71        &shared_state,
72        global_xmp_enabled,
73    );
74
75    // ── Stage 2: Async decide + queue ────────────────────────────────────
76
77    let album_id_cache = resolve_album_ids_for_candidates(&candidates, &api_client).await;
78
79    // 2b. Per-candidate: sync_decision -> hash (if needed) -> collect FileTask.
80    //     Tasks are NOT yet queued; we batch a server-side checksum check next
81    //     so files already on Immich never enter the upload pipeline.
82    let prepared: Arc<Mutex<Vec<FileTask>>> = Arc::new(Mutex::new(Vec::new()));
83    let skipped = Arc::new(AtomicUsize::new(skipped_enum));
84    let errors = Arc::new(AtomicUsize::new(enum_errors));
85
86    stream::iter(candidates)
87        .for_each_concurrent(16, |candidate| {
88            let sync_index = sync_index.clone();
89            let api_client = api_client.clone();
90            let album_cache = album_id_cache.clone();
91            let prepared = prepared.clone();
92            let skipped = skipped.clone();
93            let errors = errors.clone();
94
95            async move {
96                let path = Path::new(&candidate.path);
97                let album_name = candidate.album_name.clone();
98
99                // Lookup existing album ID (no lock held across await).
100                let existing_album_id = album_cache.lock().get(&album_name).cloned().flatten();
101
102                let target = SyncTarget {
103                    album_name: Some(album_name.clone()),
104                    album_id: existing_album_id,
105                };
106
107                // sync_decision -- brief shard lock, no await.
108                let decision = match sync_index.sync_decision(path, &target) {
109                    Ok(d) => d,
110                    Err(err) => {
111                        errors.fetch_add(1, Ordering::Relaxed);
112                        log::warn!(
113                            "Startup scan could not inspect '{}': {}",
114                            candidate.path,
115                            err
116                        );
117                        return;
118                    }
119                };
120
121                let (reassociate_only, cached_checksum) = match decision {
122                    SyncDecision::UpToDate => {
123                        skipped.fetch_add(1, Ordering::Relaxed);
124                        return;
125                    }
126                    SyncDecision::NeedsUpload => (false, None),
127                    SyncDecision::NeedsReassociate => {
128                        (true, sync_index.stored_checksum(&candidate.path))
129                    }
130                };
131
132                let album_id = match resolve_album(&api_client, &album_name, &album_cache).await {
133                    Ok(id) => id,
134                    Err(err) => {
135                        errors.fetch_add(1, Ordering::Relaxed);
136                        log::warn!(
137                            "Startup scan skipping '{}': album resolution failed: {}",
138                            candidate.path,
139                            err
140                        );
141                        return;
142                    }
143                };
144
145                match hash_to_task(
146                    candidate.path,
147                    candidate.watch_path,
148                    album_id,
149                    Some(album_name),
150                    reassociate_only,
151                    cached_checksum,
152                    candidate.sidecar_enabled,
153                )
154                .await
155                {
156                    Ok(task) => prepared.lock().push(task),
157                    Err(()) => {
158                        errors.fetch_add(1, Ordering::Relaxed);
159                    }
160                }
161            }
162        })
163        .await;
164
165    // 2c & 2d: Batch pre-flight existing check, split tasks, and inline reassociate hits.
166    let prepared_tasks: Vec<FileTask> = std::mem::take(&mut *prepared.lock());
167    let (to_upload, reassociated_count) =
168        filter_and_reassociate_existing(prepared_tasks, &api_client, &sync_index).await;
169
170    let reassociated = Arc::new(AtomicUsize::new(reassociated_count));
171
172    let queued = Arc::new(AtomicUsize::new(0));
173    for task in to_upload {
174        if queue_manager.add_to_queue(task).await {
175            queued.fetch_add(1, Ordering::Relaxed);
176        }
177    }
178
179    trash_remote_assets_for_missing_local_files(
180        &watch_paths,
181        &seen_paths,
182        sync_index.clone(),
183        api_client.clone(),
184    )
185    .await;
186
187    sync_album_to_folder_entries(&watch_paths, app_ctx).await;
188
189    prune_index_entries_for_missing_files(&watch_paths, &mut seen_paths, &sync_index);
190
191    let total_queued = queued.load(Ordering::Relaxed);
192    let total_skipped = skipped.load(Ordering::Relaxed);
193    let total_errors = errors.load(Ordering::Relaxed);
194    let total_reassociated = reassociated.load(Ordering::Relaxed);
195
196    if total_queued == 0 && total_reassociated == 0 {
197        log::info!(
198            "Startup scan complete: no unsynced files found ({} already current, {} error(s)).",
199            total_skipped,
200            total_errors
201        );
202        return;
203    }
204
205    log::info!(
206        "Startup scan: queued={} reassociated={} skipped={} errors={}",
207        total_queued,
208        total_reassociated,
209        total_skipped,
210        total_errors
211    );
212}
213
214/// Trash remote Immich assets when their corresponding local file has been removed.
215async fn trash_remote_assets_for_missing_local_files(
216    watch_paths: &[WatchPathEntry],
217    seen_paths: &HashSet<String>,
218    sync_index: Arc<ShardedSyncIndex>,
219    api_client: Arc<ImmichApiClient>,
220) {
221    for entry in watch_paths {
222        let rules = entry.rules();
223        if !rules.delete_folder_to_album {
224            continue;
225        }
226
227        let root = Path::new(entry.path());
228        for (path, record) in sync_index.records_under_path(root) {
229            if seen_paths.contains(&path) {
230                continue;
231            }
232
233            let album_name = entry
234                .album_name()
235                .map(|name| name.to_string())
236                .or(record.album_name.clone())
237                .or_else(|| {
238                    Path::new(&path)
239                        .parent()
240                        .and_then(|parent| parent.file_name())
241                        .map(|name| name.to_string_lossy().to_string())
242                })
243                .unwrap_or_else(|| "Mimick".to_string());
244
245            let configured_album_id = match entry {
246                WatchPathEntry::WithConfig { album_id, .. } => album_id.clone(),
247                WatchPathEntry::Simple(_) => None,
248            };
249            let Some(album_id) = configured_album_id.or(record.album_id.clone()) else {
250                match api_client.get_album_id_if_exists(&album_name).await {
251                    Ok(Some(album_id)) => {
252                        if trash_remote_asset_by_checksum(
253                            &api_client,
254                            &sync_index,
255                            &path,
256                            &album_id,
257                            &record.checksum,
258                            &album_name,
259                        )
260                        .await
261                        {
262                            continue;
263                        }
264                    }
265                    Ok(None) => {}
266                    Err(err) => {
267                        log::warn!(
268                            "Startup deletion sync could not resolve album '{}': {}",
269                            album_name,
270                            err
271                        );
272                    }
273                }
274                continue;
275            };
276
277            trash_remote_asset_by_checksum(
278                &api_client,
279                &sync_index,
280                &path,
281                &album_id,
282                &record.checksum,
283                &album_name,
284            )
285            .await;
286        }
287    }
288}
289
290/// Parallel reconcile for each watch path entry against their remote albums.
291async fn sync_album_to_folder_entries(watch_paths: &[WatchPathEntry], app_ctx: Arc<AppContext>) {
292    for entry in watch_paths {
293        reconcile_entry(app_ctx.clone(), entry).await;
294    }
295}
296
297/// Reconcile a single watch entry against its remote album: download new
298/// items, trash local files removed from the album, and trash remote items
299/// missing locally — gated by the entry's per-folder rules.
300pub async fn reconcile_entry(app_ctx: Arc<AppContext>, entry: &WatchPathEntry) {
301    let rules = entry.rules();
302    let download_enabled = rules.sync_method != FolderSyncMethod::UploadOnly;
303    if !download_enabled && !rules.delete_album_to_folder && !rules.delete_folder_to_album {
304        return;
305    }
306
307    let watch_path = Path::new(entry.path()).to_path_buf();
308    if !watch_path.is_dir() {
309        return;
310    }
311
312    let album_name = entry
313        .album_name()
314        .map(|name| name.to_string())
315        .or_else(|| {
316            watch_path
317                .file_name()
318                .map(|name| name.to_string_lossy().to_string())
319        })
320        .unwrap_or_else(|| "Mimick".to_string());
321
322    let configured_album_id = match entry {
323        WatchPathEntry::WithConfig { album_id, .. } => album_id.clone(),
324        WatchPathEntry::Simple(_) => None,
325    };
326    let album_id = match configured_album_id {
327        Some(id) => Some(id),
328        None => match app_ctx.api_client.get_album_id_if_exists(&album_name).await {
329            Ok(id) => id,
330            Err(err) => {
331                log::warn!(
332                    "Album-to-folder sync could not resolve album '{}': {}",
333                    album_name,
334                    err
335                );
336                None
337            }
338        },
339    };
340    let Some(album_id) = album_id else {
341        return;
342    };
343
344    // Per-album lock: drop the reconcile if another instance (periodic or
345    // manual) is already running for this album.
346    let _guard = match app_ctx.reconcile_locks.try_acquire(album_id.clone()) {
347        Some(g) => g,
348        None => {
349            log::debug!(
350                "Reconcile already in progress for album {}, skipping",
351                album_id
352            );
353            return;
354        }
355    };
356
357    let diff = match album_sync::diff_album_vs_folder(
358        app_ctx.clone(),
359        &album_id,
360        &watch_path,
361        &rules,
362        false,
363    )
364    .await
365    {
366        Ok(diff) => diff,
367        Err(err) => {
368            log::warn!(
369                "Album-to-folder sync diff failed for '{}': {}",
370                album_name,
371                err
372            );
373            return;
374        }
375    };
376
377    execute_reconcile_diff(app_ctx.clone(), &album_name, &album_id, watch_path, diff).await;
378}
379
380async fn execute_reconcile_diff(
381    app_ctx: Arc<AppContext>,
382    album_name: &str,
383    album_id: &str,
384    watch_path: std::path::PathBuf,
385    diff: crate::library::album_sync::AlbumDiff,
386) {
387    if !diff.to_download.is_empty() {
388        let (downloaded, failed) = album_sync::execute_downloads(
389            app_ctx.clone(),
390            watch_path.clone(),
391            Some(album_id.to_string()),
392            Some(album_name.to_string()),
393            diff.to_download,
394        )
395        .await;
396        log::info!(
397            "Album-to-folder sync for '{}' downloaded {} item(s), {} failure(s)",
398            album_name,
399            downloaded,
400            failed
401        );
402    }
403
404    if !diff.to_delete_local.is_empty() {
405        let (trashed, failed) =
406            album_sync::execute_local_deletions(app_ctx.clone(), diff.to_delete_local).await;
407        log::info!(
408            "Album-to-folder deletion sync for '{}' moved {} local item(s) to trash, {} failure(s)",
409            album_name,
410            trashed,
411            failed
412        );
413    }
414
415    if !diff.to_delete_remote.is_empty() {
416        let count = diff.to_delete_remote.len();
417        let trashed =
418            album_sync::execute_remote_deletions(app_ctx.clone(), album_id, diff.to_delete_remote)
419                .await;
420        log::info!(
421            "Folder-to-album deletion sync for '{}' moved {} of {} remote item(s) to Immich trash",
422            album_name,
423            trashed,
424            count
425        );
426    }
427}
428
429fn prune_index_entries_for_missing_files(
430    watch_paths: &[WatchPathEntry],
431    seen_paths: &mut HashSet<String>,
432    sync_index: &ShardedSyncIndex,
433) {
434    // Prune index entries for files that no longer exist. If a folder is
435    // configured to mirror local deletions to the album, keep its missing
436    // records so the next album sync can move the remote asset to trash.
437    for entry in watch_paths {
438        let rules = entry.rules();
439        if !rules.delete_folder_to_album {
440            continue;
441        }
442        let root = Path::new(entry.path());
443        for (path, _) in sync_index.records_under_path(root) {
444            seen_paths.insert(path);
445        }
446    }
447    if let Err(err) = sync_index.prune_missing(seen_paths) {
448        log::warn!("Failed to prune sync index after startup scan: {}", err);
449    }
450}
451
452/// Locate and move a remote asset to Immich trash by looking up its checksum in an album.
453async fn trash_remote_asset_by_checksum(
454    api_client: &ImmichApiClient,
455    sync_index: &ShardedSyncIndex,
456    local_path: &str,
457    album_id: &str,
458    checksum: &str,
459    album_name: &str,
460) -> bool {
461    match find_album_asset_id_by_checksum(api_client, album_id, checksum).await {
462        Ok(Some((asset_id, filename))) => {
463            let ids = vec![asset_id];
464            match api_client.delete_assets(&ids).await {
465                Ok(()) => {
466                    if let Err(err) = sync_index.remove_path(local_path) {
467                        log::warn!(
468                            "Startup deletion sync trashed '{}' in album '{}' but could not remove sync record for '{}': {}",
469                            filename,
470                            album_name,
471                            local_path,
472                            err
473                        );
474                    }
475                    log::info!(
476                        "Startup deletion sync moved '{}' in album '{}' to Immich trash",
477                        filename,
478                        album_name
479                    );
480                    true
481                }
482                Err(err) => {
483                    log::warn!(
484                        "Startup deletion sync could not move '{}' in album '{}' to trash: {}",
485                        filename,
486                        album_name,
487                        err
488                    );
489                    false
490                }
491            }
492        }
493        Ok(None) => false,
494        Err(err) => {
495            log::warn!(
496                "Startup deletion sync could not inspect album '{}': {}",
497                album_name,
498                err
499            );
500            false
501        }
502    }
503}
504
505/// Find the remote asset ID matching the given checksum inside an album.
506async fn find_album_asset_id_by_checksum(
507    api_client: &ImmichApiClient,
508    album_id: &str,
509    checksum: &str,
510) -> Result<Option<(String, String)>, String> {
511    let mut page = 1;
512    loop {
513        let (assets, has_more) = api_client
514            .fetch_album_assets(album_id, page, 1000, None)
515            .await?;
516        if let Some(asset) = assets
517            .into_iter()
518            .find(|asset| asset.checksum.as_deref() == Some(checksum))
519        {
520            return Ok(Some((asset.id, asset.filename)));
521        }
522        if !has_more {
523            return Ok(None);
524        }
525        page += 1;
526    }
527}
528
529/// Stage 1: Walk all watch paths in parallel using rayon.
530///
531/// Returns `(candidates, seen_paths, skipped_count, error_count)`.
532fn enumerate_candidates(
533    watch_paths: &[WatchPathEntry],
534    fallback_catchup_mode: StartupCatchupMode,
535    last_sync: f64,
536    shared_state: &Arc<Mutex<AppState>>,
537    global_xmp_enabled: bool,
538) -> (Vec<ScanCandidate>, HashSet<String>, usize, usize) {
539    let skipped = AtomicUsize::new(0);
540    let errors = AtomicUsize::new(0);
541    let seen_paths = Mutex::new(HashSet::new());
542
543    let candidates: Vec<ScanCandidate> = watch_paths
544        .par_iter()
545        .flat_map(|entry| {
546            if entry.sync_method() == FolderSyncMethod::DownloadOnly {
547                return Vec::new();
548            }
549
550            let catchup_mode = entry.startup_catchup_mode(&fallback_catchup_mode);
551            let watch_path_str = entry.path().to_string();
552            let root = Path::new(&watch_path_str);
553            if !root.exists() {
554                log::warn!(
555                    "Startup scan skipped missing watch path: {}",
556                    root.display()
557                );
558                if let Some(mut state) = shared_state.try_lock() {
559                    let status = state.folder_statuses.entry(watch_path_str).or_default();
560                    status.last_error = Some("Permission lost or folder missing".to_string());
561                }
562                return Vec::new();
563            }
564
565            let mut results = Vec::new();
566            let mut stack = vec![root.to_path_buf()];
567            while let Some(dir) = stack.pop() {
568                let read_dir = match std::fs::read_dir(&dir) {
569                    Ok(iter) => iter,
570                    Err(err) => {
571                        errors.fetch_add(1, Ordering::Relaxed);
572                        log::warn!("Startup scan could not read '{}': {}", dir.display(), err);
573                        continue;
574                    }
575                };
576
577                for child in read_dir {
578                    let entry_fs = match child {
579                        Ok(e) => e,
580                        Err(err) => {
581                            errors.fetch_add(1, Ordering::Relaxed);
582                            log::warn!("Startup scan directory entry error: {}", err);
583                            continue;
584                        }
585                    };
586
587                    let path = entry_fs.path();
588                    if path.is_dir() {
589                        stack.push(path);
590                        continue;
591                    }
592
593                    if !is_supported_media_path(&path)
594                        || is_temporary_file(&path)
595                        || !entry.rules().matches(&path)
596                    {
597                        continue;
598                    }
599
600                    if should_skip_for_catchup(&catchup_mode, &entry_fs, last_sync) {
601                        skipped.fetch_add(1, Ordering::Relaxed);
602                        continue;
603                    }
604
605                    let path_str = path.to_string_lossy().into_owned();
606                    seen_paths.lock().insert(path_str.clone());
607                    let album_name = effective_album_name(entry, &path);
608                    let sidecar_enabled = entry.rules().xmp_sidecar_enabled(global_xmp_enabled);
609                    results.push(ScanCandidate {
610                        path: path_str,
611                        watch_path: watch_path_str.clone(),
612                        album_name,
613                        sidecar_enabled,
614                    });
615                }
616            }
617            results
618        })
619        .collect();
620
621    (
622        candidates,
623        seen_paths.into_inner(),
624        skipped.into_inner(),
625        errors.into_inner(),
626    )
627}
628
629fn should_skip_for_catchup(
630    mode: &StartupCatchupMode,
631    entry_fs: &std::fs::DirEntry,
632    last_sync: f64,
633) -> bool {
634    let meta = match entry_fs.metadata() {
635        Ok(m) => m,
636        Err(_) => return false,
637    };
638
639    match mode {
640        StartupCatchupMode::RecentOnly => {
641            let Ok(modified) = meta.modified() else {
642                return false;
643            };
644            let Ok(duration) = std::time::SystemTime::now().duration_since(modified) else {
645                return false;
646            };
647            duration.as_secs() > 7 * 86400
648        }
649        StartupCatchupMode::NewFilesOnly => {
650            let Ok(created) = meta.created().or_else(|_| meta.modified()) else {
651                return false;
652            };
653            let created_secs = created
654                .duration_since(std::time::UNIX_EPOCH)
655                .unwrap_or_default()
656                .as_secs_f64();
657            created_secs < last_sync
658        }
659        _ => false,
660    }
661}
662
663/// Hash a candidate file and submit it to the upload queue.
664async fn hash_to_task(
665    path: String,
666    watch_path: String,
667    album_id: Option<String>,
668    album_name: Option<String>,
669    reassociate_only: bool,
670    checksum: Option<String>,
671    sidecar_enabled: bool,
672) -> Result<FileTask, ()> {
673    let checksum = if let Some(checksum) = checksum {
674        checksum
675    } else {
676        let path_for_hash = path.clone();
677        match tokio::task::spawn_blocking(move || compute_sha1_chunked(&path_for_hash)).await {
678            Ok(Ok(checksum)) => checksum,
679            Ok(Err(err)) => {
680                log::warn!("Startup scan could not checksum '{}': {}", path, err);
681                return Err(());
682            }
683            Err(err) => {
684                log::warn!("Startup scan checksum task failed for '{}': {}", path, err);
685                return Err(());
686            }
687        }
688    };
689
690    let sidecar_path = if sidecar_enabled {
691        crate::sidecar::find_sidecar(std::path::Path::new(&path))
692            .map(|p| p.to_string_lossy().into_owned())
693    } else {
694        None
695    };
696
697    Ok(FileTask {
698        path,
699        watch_path,
700        checksum,
701        album_id,
702        album_name,
703        reassociate_only,
704        skip_album: false,
705        sidecar_path,
706    })
707}
708
709/// Resolve the effective album name for a file using per-folder configuration.
710fn effective_album_name(entry: &WatchPathEntry, path: &Path) -> String {
711    match entry.album_name() {
712        Some(name) if !name.is_empty() && name != "Default (Folder Name)" => name.to_string(),
713        _ => path
714            .parent()
715            .and_then(|p| p.file_name())
716            .map(|n| n.to_string_lossy().to_string())
717            .unwrap_or_else(|| "Mimick".to_string()),
718    }
719}
720
721/// Resolve an album ID, creating the album if it doesn't exist yet.
722/// Caches the result for reuse by other candidates in the same folder.
723async fn resolve_album(
724    api_client: &ImmichApiClient,
725    album_name: &str,
726    cache: &Arc<Mutex<HashMap<String, Option<String>>>>,
727) -> Result<Option<String>, String> {
728    // Fast path: already resolved.
729    if let Some(Some(cached)) = cache.lock().get(album_name) {
730        return Ok(Some(cached.clone()));
731    }
732
733    let resolved = api_client.resolve_album_by_name(album_name, false).await?;
734    cache
735        .lock()
736        .insert(album_name.to_string(), resolved.clone());
737    Ok(resolved)
738}
739
740async fn resolve_album_ids_for_candidates(
741    candidates: &[ScanCandidate],
742    api_client: &Arc<ImmichApiClient>,
743) -> Arc<Mutex<HashMap<String, Option<String>>>> {
744    let unique_albums: Vec<String> = candidates
745        .iter()
746        .map(|c| c.album_name.clone())
747        .collect::<HashSet<_>>()
748        .into_iter()
749        .collect();
750
751    let album_id_cache: Arc<Mutex<HashMap<String, Option<String>>>> =
752        Arc::new(Mutex::new(HashMap::new()));
753
754    let cache = album_id_cache.clone();
755    stream::iter(unique_albums)
756        .for_each_concurrent(8, |name| {
757            let api = api_client.clone();
758            let cache = cache.clone();
759            async move {
760                match api.get_album_id_if_exists(&name).await {
761                    Ok(id) => {
762                        cache.lock().insert(name, id);
763                    }
764                    Err(err) => {
765                        log::warn!("Startup scan: album lookup failed for '{}': {}", name, err);
766                    }
767                }
768            }
769        })
770        .await;
771
772    album_id_cache
773}
774
775async fn filter_and_reassociate_existing(
776    prepared_tasks: Vec<FileTask>,
777    api_client: &Arc<ImmichApiClient>,
778    sync_index: &Arc<ShardedSyncIndex>,
779) -> (Vec<FileTask>, usize) {
780    let unique_checksums: Vec<String> = prepared_tasks
781        .iter()
782        .map(|t| t.checksum.clone())
783        .collect::<HashSet<_>>()
784        .into_iter()
785        .collect();
786    let existing_on_server = if unique_checksums.is_empty() {
787        HashMap::new()
788    } else {
789        api_client.bulk_existing_asset_ids(&unique_checksums).await
790    };
791
792    let mut to_reassociate: Vec<(FileTask, String)> = Vec::new();
793    let mut to_upload: Vec<FileTask> = Vec::new();
794    for task in prepared_tasks {
795        match existing_on_server.get(&task.checksum) {
796            Some(asset_id) => to_reassociate.push((task, asset_id.clone())),
797            None => to_upload.push(task),
798        }
799    }
800
801    let reassociated = Arc::new(AtomicUsize::new(0));
802    process_reassociation(api_client, sync_index, to_reassociate, reassociated.clone()).await;
803
804    (to_upload, reassociated.load(Ordering::Relaxed))
805}
806
807async fn process_reassociation(
808    api_client: &Arc<ImmichApiClient>,
809    sync_index: &Arc<ShardedSyncIndex>,
810    to_reassociate: Vec<(FileTask, String)>,
811    reassociated: Arc<AtomicUsize>,
812) {
813    stream::iter(to_reassociate)
814        .for_each_concurrent(8, |(task, asset_id)| {
815            let api = api_client.clone();
816            let sync_index = sync_index.clone();
817            let reassociated = reassociated.clone();
818            async move {
819                if let Some(ref album_id) = task.album_id
820                    && !album_id.is_empty()
821                {
822                    let _ = api
823                        .add_assets_to_album(album_id, std::slice::from_ref(&asset_id))
824                        .await;
825                }
826                let target = SyncTarget {
827                    album_name: task.album_name.clone(),
828                    album_id: task.album_id.clone(),
829                };
830                if let Err(err) = sync_index.record_synced(&task.path, &task.checksum, &target) {
831                    log::warn!(
832                        "Could not record sync index for pre-existing asset '{}': {}",
833                        task.path,
834                        err
835                    );
836                }
837                reassociated.fetch_add(1, Ordering::Relaxed);
838            }
839        })
840        .await;
841}
842
843#[cfg(test)]
844mod tests {
845    use crate::monitor::is_supported_media_path;
846    use std::path::PathBuf;
847
848    #[test]
849    fn test_supported_media_path_filter() {
850        assert!(is_supported_media_path(&PathBuf::from("image.avif")));
851        assert!(is_supported_media_path(&PathBuf::from("image.jpg")));
852        assert!(is_supported_media_path(&PathBuf::from("movie.mkv")));
853        assert!(is_supported_media_path(&PathBuf::from("movie.mp4")));
854        assert!(!is_supported_media_path(&PathBuf::from("notes.txt")));
855    }
856}