Skip to main content

mimick/library/
album_sync.rs

1//! Album-folder bidirectional sync diff and execution.
2//!
3//! Compares the set of assets in an Immich album against the files in
4//! a linked local folder to produce upload, download, and delete diffs.
5//! Executes the resolved diff actions with progress feedback.
6
7use std::collections::HashSet;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use crate::api_client::{LibraryAsset, TransferProgressCallback};
12use crate::app_context::AppContext;
13use crate::config::{FolderRules, FolderSyncMethod};
14use crate::library::local_source::{LocalAsset, enumerate_local};
15use crate::monitor::compute_sha1_chunked;
16use crate::queue_manager::FileTask;
17use crate::state_manager::TransferDirection;
18use crate::sync_index::SyncTarget;
19
20/// Gate album→folder deletion: portal trash currently fails on FUSE
21/// document-portal paths (upstream bug). Flip to `true` when fixed.
22const ALBUM_TO_FOLDER_TRASH_AVAILABLE: bool = false;
23
24#[derive(Debug, Default, Clone)]
25pub struct AlbumDiff {
26    pub to_upload: Vec<LocalEntry>,
27    pub to_download: Vec<LibraryAsset>,
28    pub to_delete_remote: Vec<LibraryAsset>,
29    pub to_delete_local: Vec<LocalEntry>,
30    pub remote_unhashed: usize,
31}
32
33#[derive(Debug, Clone)]
34pub struct LocalEntry {
35    pub local: LocalAsset,
36    pub checksum: String,
37}
38
39pub async fn diff_album_vs_folder(
40    ctx: Arc<AppContext>,
41    album_id: &str,
42    watch_path: &Path,
43    rules: &FolderRules,
44    manual_sync: bool,
45) -> Result<AlbumDiff, String> {
46    let remote = fetch_remote_album_assets(&ctx, album_id).await?;
47
48    let watch_root = watch_path.to_path_buf();
49    let locals: Vec<LocalAsset> = enumerate_local(ctx.clone())
50        .await
51        .into_iter()
52        .filter(|asset| asset.path.starts_with(&watch_root))
53        .collect();
54
55    let local_entries = resolve_local_checksums(ctx.clone(), locals).await;
56    let local_set: HashSet<String> = local_entries.iter().map(|e| e.checksum.clone()).collect();
57    let local_paths: HashSet<String> = local_entries
58        .iter()
59        .map(|e| e.local.path.to_string_lossy().to_string())
60        .collect();
61
62    // Orphan records (path gone) keyed by checksum — drives rename detection
63    // and suppresses to_download for assets headed for to_delete_remote.
64    let mut orphan_by_checksum = compute_orphan_records(&ctx, album_id, watch_path, &local_paths);
65
66    let mut to_download = Vec::new();
67    let mut remote_by_checksum = std::collections::HashMap::new();
68    let mut remote_set = HashSet::new();
69    let mut remote_unhashed = 0usize;
70    for asset in &remote {
71        match &asset.checksum {
72            Some(c) if !c.is_empty() => {
73                remote_set.insert(c.clone());
74                remote_by_checksum
75                    .entry(c.clone())
76                    .or_insert_with(|| asset.clone());
77                if !local_set.contains(c) {
78                    // Skip if to_delete_remote will trash this asset (avoids
79                    // download-then-trash conflict on retried deletions).
80                    if rules.delete_folder_to_album && orphan_by_checksum.contains_key(c) {
81                        continue;
82                    }
83                    to_download.push(asset.clone());
84                }
85            }
86            _ => remote_unhashed += 1,
87        }
88    }
89
90    let classify_ctx = ClassifyContext {
91        album_id,
92        remote_unhashed,
93        rules,
94        manual_sync,
95    };
96    let (mut to_upload, mut to_delete_local) = classify_local_entries(
97        &ctx,
98        local_entries,
99        &remote_set,
100        &mut orphan_by_checksum,
101        &classify_ctx,
102    );
103
104    let mut to_delete_remote = compute_remote_deletions(
105        &ctx,
106        album_id,
107        watch_path,
108        &local_paths,
109        &remote_by_checksum,
110        rules,
111    );
112
113    // Two-tick confirmation (#7) for mass deletes
114    confirm_pending_deletions(&ctx, album_id, &mut to_delete_local, &mut to_delete_remote);
115
116    // Clear pending-deletion confirmations for items that came back
117    clear_pending_deletions(&ctx, album_id, &remote_set, &to_upload, &local_paths);
118
119    if !to_upload.is_empty()
120        || !to_download.is_empty()
121        || !to_delete_local.is_empty()
122        || !to_delete_remote.is_empty()
123    {
124        log::info!(
125            "Album sync diff: upload={} download={} trash_local={} trash_remote={}",
126            to_upload.len(),
127            to_download.len(),
128            to_delete_local.len(),
129            to_delete_remote.len()
130        );
131    }
132
133    if rules.sync_method == FolderSyncMethod::UploadOnly {
134        to_download.clear();
135    } else if rules.sync_method == FolderSyncMethod::DownloadOnly {
136        to_upload.clear();
137    }
138
139    Ok(AlbumDiff {
140        to_upload,
141        to_download,
142        to_delete_remote,
143        to_delete_local,
144        remote_unhashed,
145    })
146}
147
148struct ClassifyContext<'a> {
149    album_id: &'a str,
150    remote_unhashed: usize,
151    rules: &'a FolderRules,
152    manual_sync: bool,
153}
154
155fn classify_local_entries(
156    ctx: &Arc<AppContext>,
157    local_entries: Vec<LocalEntry>,
158    remote_set: &HashSet<String>,
159    orphan_by_checksum: &mut std::collections::HashMap<String, String>,
160    cc: &ClassifyContext<'_>,
161) -> (Vec<LocalEntry>, Vec<LocalEntry>) {
162    let mut to_upload = Vec::new();
163    let mut to_delete_local = Vec::new();
164
165    for entry in local_entries {
166        match classify_local_entry(ctx, entry, remote_set, orphan_by_checksum, cc) {
167            LocalDecision::Upload(entry) => to_upload.push(entry),
168            LocalDecision::Delete(entry) => to_delete_local.push(entry),
169            LocalDecision::Ignore => {}
170        }
171    }
172
173    (to_upload, to_delete_local)
174}
175
176enum LocalDecision {
177    Upload(LocalEntry),
178    Delete(LocalEntry),
179    Ignore,
180}
181
182fn classify_local_entry(
183    ctx: &Arc<AppContext>,
184    entry: LocalEntry,
185    remote_set: &HashSet<String>,
186    orphan_by_checksum: &mut std::collections::HashMap<String, String>,
187    cc: &ClassifyContext<'_>,
188) -> LocalDecision {
189    let path_str = entry.local.path.to_string_lossy().to_string();
190    if remote_set.contains(&entry.checksum) {
191        migrate_orphan_if_needed(ctx, orphan_by_checksum, &entry, &path_str);
192        return LocalDecision::Ignore;
193    }
194    if !was_previously_synced_to(ctx, &path_str, &entry.checksum, cc.album_id) {
195        return LocalDecision::Upload(entry);
196    }
197    synced_entry_decision(entry, cc)
198}
199
200fn migrate_orphan_if_needed(
201    ctx: &Arc<AppContext>,
202    orphan_by_checksum: &mut std::collections::HashMap<String, String>,
203    entry: &LocalEntry,
204    path_str: &str,
205) {
206    if let Some(old_path) = orphan_by_checksum.remove(&entry.checksum) {
207        migrate_renamed_record(ctx, &old_path, path_str, &entry.checksum);
208    }
209}
210
211fn synced_entry_decision(entry: LocalEntry, cc: &ClassifyContext<'_>) -> LocalDecision {
212    if cc.remote_unhashed > 0 {
213        log::debug!(
214            "Skipping local delete decision for {} because {} remote album item(s) have no checksum",
215            entry.local.path.display(),
216            cc.remote_unhashed
217        );
218        LocalDecision::Ignore
219    } else if cc.rules.delete_album_to_folder && ALBUM_TO_FOLDER_TRASH_AVAILABLE {
220        LocalDecision::Delete(entry)
221    } else if cc.manual_sync {
222        LocalDecision::Upload(entry)
223    } else {
224        LocalDecision::Ignore
225    }
226}
227
228fn was_previously_synced_to(
229    ctx: &Arc<AppContext>,
230    path: &str,
231    checksum: &str,
232    album_id: &str,
233) -> bool {
234    ctx.sync_index.record_for_path(path).is_some_and(|record| {
235        record.checksum == checksum && record.album_id.as_deref().is_none_or(|id| id == album_id)
236    })
237}
238
239async fn resolve_local_checksums(ctx: Arc<AppContext>, locals: Vec<LocalAsset>) -> Vec<LocalEntry> {
240    let mut out = Vec::with_capacity(locals.len());
241    let mut to_compute: Vec<LocalAsset> = Vec::new();
242
243    {
244        for asset in locals {
245            match ctx.sync_index.fresh_checksum(&asset.path) {
246                Some(c) => out.push(LocalEntry {
247                    local: asset,
248                    checksum: c,
249                }),
250                None => to_compute.push(asset),
251            }
252        }
253    }
254
255    for asset in to_compute {
256        let path_str = asset.path.to_string_lossy().to_string();
257        let hashed = tokio::task::spawn_blocking(move || compute_sha1_chunked(&path_str))
258            .await
259            .map_err(|err| err.to_string())
260            .and_then(|r| r.map_err(|err| err.to_string()));
261        match hashed {
262            Ok(checksum) => out.push(LocalEntry {
263                local: asset,
264                checksum,
265            }),
266            Err(err) => log::warn!("Skipping {} during diff: {}", asset.path.display(), err),
267        }
268    }
269
270    out
271}
272
273fn migrate_renamed_record(ctx: &Arc<AppContext>, old_path: &str, new_path: &str, checksum: &str) {
274    let target = ctx
275        .sync_index
276        .record_for_path(old_path)
277        .map(|record| SyncTarget {
278            album_name: record.album_name,
279            album_id: record.album_id,
280        })
281        .unwrap_or_else(|| SyncTarget {
282            album_name: None,
283            album_id: None,
284        });
285
286    if let Err(err) = ctx.sync_index.remove_path(old_path) {
287        log::warn!(
288            "Could not migrate sync record from {} during rename: {}",
289            old_path,
290            err
291        );
292        return;
293    }
294    if let Err(err) = ctx.sync_index.record_synced(new_path, checksum, &target) {
295        log::warn!(
296            "Could not record sync entry for renamed file {}: {}",
297            new_path,
298            err
299        );
300        return;
301    }
302    log::debug!("Renamed: {} -> {}", old_path, new_path);
303}
304
305pub async fn execute_uploads(
306    ctx: Arc<AppContext>,
307    album_id: String,
308    album_name: String,
309    watch_path: PathBuf,
310    entries: Vec<LocalEntry>,
311) -> usize {
312    let folder_xmp = {
313        let cfg = ctx.config.read();
314        let global_xmp = cfg.data.upload_xmp_sidecars;
315        cfg.data
316            .watch_paths
317            .iter()
318            .find(|e| e.path() == watch_path.to_string_lossy())
319            .map(|e| e.rules().xmp_sidecar_enabled(global_xmp))
320            .unwrap_or(global_xmp)
321    };
322
323    let mut queued = 0;
324    for entry in entries {
325        let sidecar_path = if folder_xmp {
326            crate::sidecar::find_sidecar(&entry.local.path)
327                .map(|p| p.to_string_lossy().into_owned())
328        } else {
329            None
330        };
331        let task = FileTask {
332            path: entry.local.path.to_string_lossy().to_string(),
333            watch_path: watch_path.to_string_lossy().to_string(),
334            checksum: entry.checksum,
335            album_id: Some(album_id.clone()),
336            album_name: Some(album_name.clone()),
337            reassociate_only: false,
338            skip_album: false,
339            sidecar_path,
340        };
341        if ctx.queue_manager.add_to_queue(task).await {
342            queued += 1;
343        }
344    }
345    queued
346}
347
348pub async fn execute_downloads(
349    ctx: Arc<AppContext>,
350    watch_path: PathBuf,
351    album_id: Option<String>,
352    album_name: Option<String>,
353    assets: Vec<LibraryAsset>,
354) -> (usize, usize) {
355    let mut ok = 0;
356    let mut failed = 0;
357    {
358        let mut state = ctx.state.lock();
359        let route = state.active_server_route.clone();
360        state.transfer.begin_group(
361            TransferDirection::Download,
362            Some(format!("{} album item(s)", assets.len())),
363            route,
364        );
365    }
366    for asset in assets {
367        let safe_name =
368            crate::sanitize::safe_filename(&asset.filename).unwrap_or_else(|| asset.id.clone());
369        let dest = unique_destination(&watch_path, &safe_name);
370        // Mark before the bytes land so the watcher's Create event finds the
371        // path in the suppression set even if delivery beats our post-download
372        // index write. The live monitor consumes the entry and skips queuing.
373        ctx.expected_self_downloads.mark(&dest.to_string_lossy());
374        let progress = album_download_progress(&ctx, asset.id.clone(), asset.filename.clone());
375        match ctx
376            .api_client
377            .download_original_to_file(&asset.id, &dest, Some(progress))
378            .await
379        {
380            Ok(_) => {
381                if let Some(checksum) = asset.checksum.as_deref()
382                    && let Err(err) = ctx.sync_index.record_synced(
383                        &dest.to_string_lossy(),
384                        checksum,
385                        &SyncTarget {
386                            album_name: album_name.clone(),
387                            album_id: album_id.clone(),
388                        },
389                    )
390                {
391                    log::warn!(
392                        "Downloaded {} but could not record sync index for {}: {}",
393                        asset.filename,
394                        dest.display(),
395                        err
396                    );
397                }
398                finish_album_download(&ctx, &asset.id);
399                ok += 1
400            }
401            Err(err) => {
402                finish_album_download(&ctx, &asset.id);
403                log::warn!("Download {} ({}) failed: {}", asset.filename, asset.id, err);
404                failed += 1;
405            }
406        }
407    }
408    (ok, failed)
409}
410
411pub async fn execute_remote_deletions(
412    ctx: Arc<AppContext>,
413    album_id: &str,
414    assets: Vec<LibraryAsset>,
415) -> usize {
416    if assets.is_empty() {
417        return 0;
418    }
419    // Ask the server how many albums each asset is a member of. If >1, the
420    // asset lives in another album somewhere on Immich — unlink only from
421    // this album, don't destroy it. If 1 (just this album) or unknown, trash
422    // the asset. The fallback to trash on lookup failure preserves the prior
423    // behaviour rather than silently doing nothing.
424    let mut to_trash: Vec<String> = Vec::new();
425    let mut to_unalbum: Vec<String> = Vec::new();
426    for asset in &assets {
427        let album_count = ctx.api_client.count_albums_for_asset(&asset.id).await;
428        match album_count {
429            Some(n) if n > 1 => to_unalbum.push(asset.id.clone()),
430            _ => to_trash.push(asset.id.clone()),
431        }
432    }
433    let mut ok = 0;
434    if !to_trash.is_empty() {
435        match ctx.api_client.delete_assets(&to_trash).await {
436            Ok(()) => ok += to_trash.len(),
437            Err(err) => log::warn!("Remote trash failed: {}", err),
438        }
439    }
440    if !to_unalbum.is_empty()
441        && ctx
442            .api_client
443            .remove_assets_from_album(album_id, &to_unalbum)
444            .await
445    {
446        ok += to_unalbum.len();
447    }
448    ok
449}
450
451pub async fn execute_local_deletions(
452    _ctx: Arc<AppContext>,
453    entries: Vec<LocalEntry>,
454) -> (usize, usize) {
455    // Album-to-folder deletion (Mirror Album Deletions to Folder) is currently
456    // disabled because the only acceptable trash path on Flatpak
457    if !entries.is_empty() {
458        log::warn!(
459            "Album-to-folder deletion currently disabled (Flatpak portal trash limitation). \
460             {} local file(s) left in place; sync_index records kept so a future tick can retry once trash is re-enabled.",
461            entries.len()
462        );
463    }
464    (0, 0)
465
466    // Original implementation kept for reactivation:
467    //
468    // let mut ok = 0;
469    // let mut failed = 0;
470    // for entry in entries {
471    //     let path = entry.local.path.clone();
472    //     _ctx.expected_self_deletions.mark(&path.to_string_lossy());
473    //     match move_to_trash(entry.local.path.clone()).await {
474    //         Ok(()) => {
475    //             if let Err(err) = _ctx.sync_index.remove_path(&path.to_string_lossy()) {
476    //                 log::warn!(
477    //                     "Local trash succeeded but sync index cleanup failed for {}: {}",
478    //                     path.display(), err
479    //                 );
480    //             }
481    //             ok += 1;
482    //         }
483    //         Err(err) => {
484    //             log::warn!("Local trash operation failed for {}: {}", path.display(), err);
485    //             failed += 1;
486    //         }
487    //     }
488    // }
489    // (ok, failed)
490}
491
492/// Portal-only trash. Single implementation, no fallbacks. Currently unused
493#[allow(dead_code)]
494async fn move_to_trash(path: PathBuf) -> Result<(), String> {
495    let file = std::fs::OpenOptions::new()
496        .read(true)
497        .write(true)
498        .open(&path)
499        .map_err(|err| format!("open for trash: {}", err))?;
500    let proxy = ashpd::desktop::trash::TrashProxy::new()
501        .await
502        .map_err(|err| format!("trash proxy: {}", err))?;
503    proxy
504        .trash_file(&std::os::fd::AsFd::as_fd(&file))
505        .await
506        .map_err(|err| format!("trash_file: {}", err))
507}
508
509fn unique_destination(folder: &Path, filename: &str) -> PathBuf {
510    let safe = crate::sanitize::safe_filename(filename).unwrap_or_else(|| "download".to_string());
511    let mut candidate = folder.join(&safe);
512    if !candidate.exists() {
513        return candidate;
514    }
515    let stem = Path::new(filename)
516        .file_stem()
517        .and_then(|s| s.to_str())
518        .unwrap_or("download");
519    let ext = Path::new(filename)
520        .extension()
521        .and_then(|s| s.to_str())
522        .unwrap_or("");
523    for n in 1..1000 {
524        let alt = if ext.is_empty() {
525            format!("{} ({})", stem, n)
526        } else {
527            format!("{} ({}).{}", stem, n, ext)
528        };
529        candidate = folder.join(alt);
530        if !candidate.exists() {
531            return candidate;
532        }
533    }
534    candidate
535}
536
537fn album_download_progress(
538    ctx: &Arc<AppContext>,
539    item_id: String,
540    item_label: String,
541) -> TransferProgressCallback {
542    let state_ref = ctx.state.clone();
543    {
544        let mut state = state_ref.lock();
545        let route = state.active_server_route.clone();
546        state.transfer.register_item(
547            TransferDirection::Download,
548            item_id.clone(),
549            None,
550            Some(item_label),
551            route,
552        );
553    }
554
555    Arc::new(move |bytes_done, total_bytes| {
556        let mut state = state_ref.lock();
557        if let Some(total_bytes) = total_bytes {
558            let current = state
559                .transfer
560                .active_item_totals
561                .get(&item_id)
562                .copied()
563                .unwrap_or(0);
564            if current == 0 {
565                state.transfer.update_item_total(&item_id, total_bytes);
566            }
567        }
568        let route = state.active_server_route.clone();
569        state
570            .transfer
571            .update_item_bytes(TransferDirection::Download, &item_id, bytes_done, route);
572    })
573}
574
575fn finish_album_download(ctx: &Arc<AppContext>, item_id: &str) {
576    let mut state = ctx.state.lock();
577    let route = state.active_server_route.clone();
578    state
579        .transfer
580        .finish_item(TransferDirection::Download, item_id, route);
581}
582
583async fn fetch_remote_album_assets(
584    ctx: &Arc<AppContext>,
585    album_id: &str,
586) -> Result<Vec<LibraryAsset>, String> {
587    let mut remote = Vec::new();
588    let mut page: u32 = 1;
589    loop {
590        let (chunk, has_more) = ctx
591            .api_client
592            .fetch_album_assets(album_id, page, 1000, None)
593            .await?;
594        remote.extend(chunk);
595        if !has_more {
596            break;
597        }
598        page += 1;
599    }
600    Ok(remote)
601}
602
603fn compute_orphan_records(
604    ctx: &Arc<AppContext>,
605    album_id: &str,
606    watch_path: &Path,
607    local_paths: &HashSet<String>,
608) -> std::collections::HashMap<String, String> {
609    let mut orphan_by_checksum: std::collections::HashMap<String, String> =
610        std::collections::HashMap::new();
611    for (path, record) in ctx.sync_index.records_under_path(watch_path) {
612        if local_paths.contains(&path) {
613            continue;
614        }
615        if Path::new(&path).exists() {
616            continue;
617        }
618        if record.album_id.as_deref().is_some_and(|id| id != album_id) {
619            continue;
620        }
621        orphan_by_checksum.entry(record.checksum).or_insert(path);
622    }
623    orphan_by_checksum
624}
625
626fn compute_remote_deletions(
627    ctx: &Arc<AppContext>,
628    album_id: &str,
629    watch_path: &Path,
630    local_paths: &HashSet<String>,
631    remote_by_checksum: &std::collections::HashMap<String, LibraryAsset>,
632    rules: &FolderRules,
633) -> Vec<LibraryAsset> {
634    if !rules.delete_folder_to_album {
635        return Vec::new();
636    }
637    let mut to_delete_remote = Vec::new();
638    let mut seen_ids = HashSet::new();
639    for (path, record) in ctx.sync_index.records_under_path(watch_path) {
640        if !is_orphan_record(&path, local_paths, album_id, &record) {
641            continue;
642        }
643        if let Some(asset) = remote_by_checksum.get(&record.checksum)
644            && seen_ids.insert(asset.id.clone())
645        {
646            to_delete_remote.push(asset.clone());
647        }
648    }
649    to_delete_remote
650}
651
652/// True when a sync record's file is gone from disk and belongs to this album.
653fn is_orphan_record(
654    path: &str,
655    local_paths: &HashSet<String>,
656    album_id: &str,
657    record: &crate::sync_index::SyncedFileRecord,
658) -> bool {
659    !local_paths.contains(path)
660        && !Path::new(path).exists()
661        && record.album_id.as_deref().is_none_or(|id| id == album_id)
662}
663
664fn confirm_pending_deletions(
665    ctx: &Arc<AppContext>,
666    album_id: &str,
667    to_delete_local: &mut Vec<LocalEntry>,
668    to_delete_remote: &mut Vec<LibraryAsset>,
669) {
670    if to_delete_local.len() > 5 {
671        let album = album_id.to_string();
672        let pending = ctx.pending_deletions.clone();
673        to_delete_local
674            .retain(|entry| pending.confirm(&format!("local:{}:{}", album, entry.checksum)));
675    }
676    if to_delete_remote.len() > 5 {
677        let album = album_id.to_string();
678        let pending = ctx.pending_deletions.clone();
679        to_delete_remote.retain(|asset| {
680            let key = asset
681                .checksum
682                .as_deref()
683                .map(|c| format!("remote:{}:{}", album, c))
684                .unwrap_or_else(|| format!("remote:{}:id:{}", album, asset.id));
685            pending.confirm(&key)
686        });
687    }
688}
689
690fn clear_pending_deletions(
691    ctx: &Arc<AppContext>,
692    album_id: &str,
693    remote_set: &HashSet<String>,
694    to_upload: &[LocalEntry],
695    local_paths: &HashSet<String>,
696) {
697    let album = album_id.to_string();
698    let pending = ctx.pending_deletions.clone();
699    for checksum in remote_set {
700        pending.clear(&format!("local:{}:{}", album, checksum));
701    }
702    for entry in to_upload {
703        pending.clear(&format!("remote:{}:{}", album, entry.checksum));
704    }
705    for path in local_paths {
706        if let Some(record) = ctx.sync_index.record_for_path(path) {
707            pending.clear(&format!("remote:{}:{}", album, record.checksum));
708        }
709    }
710}