Skip to main content

mimick/
remote_sync.rs

1//! Periodic remote-album reconciler.
2//!
3//! Re-runs the per-folder album↔folder diff on a fixed interval so changes
4//! made directly in Immich (asset deletions, additions) propagate to local
5//! folders without requiring an app restart.
6
7use std::sync::Arc;
8use std::time::Duration;
9
10use crate::app_context::AppContext;
11use crate::startup_scan::reconcile_entry;
12
13const REMOTE_POLL_INTERVAL: Duration = Duration::from_secs(5 * 60);
14
15/// Periodic task loop that performs remote-to-local and local-to-remote sync reconciliations.
16pub async fn run_album_reconciler(ctx: Arc<AppContext>) {
17    loop {
18        tokio::time::sleep(REMOTE_POLL_INTERVAL).await;
19
20        if !ctx.config.read().data.background_sync_enabled {
21            continue;
22        }
23        if ctx.queue_manager.is_paused() {
24            continue;
25        }
26        if !ctx.api_client.check_connection().await {
27            continue;
28        }
29
30        let watch_paths = ctx.config.read().data.watch_paths.clone();
31        for entry in &watch_paths {
32            reconcile_entry(ctx.clone(), entry).await;
33        }
34    }
35}
36
37use crate::api_client::{ImmichApiClient, LibraryAsset};
38use crate::config;
39use crate::sync_index::SyncedFileRecord;
40
41/// Description of a local deletion event request targeting the remote album.
42#[derive(Clone, Debug)]
43pub struct LocalDeletionRequest {
44    /// Absolute local path of deleted asset.
45    pub local_path: String,
46    /// Target Immich asset identifier.
47    pub asset_id: String,
48    /// Absolute filename of deleted asset.
49    pub asset_name: String,
50    /// Mapped Immich album name.
51    pub album_name: String,
52    /// Mapped Immich album identifier.
53    pub album_id: Option<String>,
54}
55
56/// Check if local deletion matches validation criteria for remote mirror sweep.
57pub async fn build_local_deletion_request(
58    ctx: Arc<AppContext>,
59    path: String,
60) -> Option<LocalDeletionRequest> {
61    let record = deletion_record(&ctx, &path)?;
62    let path_obj = std::path::Path::new(&path);
63    let entry = deletion_watch_entry(&ctx, path_obj, &path)?;
64    let album_name = deletion_album_name(&entry, &record, path_obj);
65    let album_id = resolve_deletion_album_id(&ctx, &entry, &record, &album_name).await?;
66
67    match find_album_asset_by_checksum(ctx.api_client.clone(), &album_id, &record.checksum).await {
68        Ok(Some(asset)) => Some(LocalDeletionRequest {
69            local_path: path,
70            asset_id: asset.id,
71            asset_name: asset.filename,
72            album_name,
73            album_id: Some(album_id),
74        }),
75        Ok(None) => {
76            log::debug!("No matching album asset for deleted file: {}", path);
77            None
78        }
79        Err(err) => {
80            log::warn!("Could not inspect album for deletion sync: {}", err);
81            None
82        }
83    }
84}
85
86/// Mirrors local filesystem deletion by unlinking or trashing assets on Immich.
87pub async fn trash_remote_after_local_delete(ctx: Arc<AppContext>, request: LocalDeletionRequest) {
88    let asset_ids = vec![request.asset_id.clone()];
89    let album_count = ctx
90        .api_client
91        .count_albums_for_asset(&request.asset_id)
92        .await;
93    let Some(action_log) = mirror_remote_delete(&ctx, &request, &asset_ids, album_count).await
94    else {
95        return;
96    };
97
98    cleanup_deleted_sync_record(&ctx, &request);
99    log::info!("{}", action_log);
100}
101
102fn deletion_record(ctx: &AppContext, path: &str) -> Option<SyncedFileRecord> {
103    let record = ctx.sync_index.record_for_path(path);
104    if record.is_none() {
105        log::debug!("No sync record for deleted file: {}", path);
106    }
107    record
108}
109
110fn deletion_watch_entry(
111    ctx: &AppContext,
112    path_obj: &std::path::Path,
113    path: &str,
114) -> Option<config::WatchPathEntry> {
115    let entry = {
116        let entries = ctx.live_watch_paths.lock();
117        config::best_matching_watch_entry(path_obj, &entries).cloned()
118    };
119    let Some(entry) = entry else {
120        log::debug!("Deleted file is not under any watch folder: {}", path);
121        return None;
122    };
123    if !entry.rules().delete_folder_to_album {
124        log::debug!("Folder-to-album deletion disabled for: {}", path);
125        return None;
126    }
127    Some(entry)
128}
129
130fn deletion_album_name(
131    entry: &config::WatchPathEntry,
132    record: &SyncedFileRecord,
133    path_obj: &std::path::Path,
134) -> String {
135    entry
136        .album_name()
137        .map(|name| name.to_string())
138        .or(record.album_name.clone())
139        .or_else(|| parent_folder_name(path_obj))
140        .unwrap_or_else(|| "Mimick".to_string())
141}
142
143fn parent_folder_name(path_obj: &std::path::Path) -> Option<String> {
144    path_obj
145        .parent()
146        .and_then(|parent| parent.file_name())
147        .map(|name| name.to_string_lossy().to_string())
148}
149
150async fn resolve_deletion_album_id(
151    ctx: &AppContext,
152    entry: &config::WatchPathEntry,
153    record: &SyncedFileRecord,
154    album_name: &str,
155) -> Option<String> {
156    if let Some(id) = configured_album_id(entry).or(record.album_id.clone()) {
157        Some(id)
158    } else {
159        resolve_album_by_name(ctx, album_name).await
160    }
161}
162
163async fn resolve_album_by_name(ctx: &AppContext, album_name: &str) -> Option<String> {
164    match ctx.api_client.get_album_id_if_exists(album_name).await {
165        Ok(id) => id,
166        Err(err) => {
167            log::warn!(
168                "Could not resolve album '{}' for deletion sync: {}",
169                album_name,
170                err
171            );
172            None
173        }
174    }
175}
176
177fn configured_album_id(entry: &config::WatchPathEntry) -> Option<String> {
178    match entry {
179        config::WatchPathEntry::WithConfig { album_id, .. } => album_id.clone(),
180        config::WatchPathEntry::Simple(_) => None,
181    }
182}
183
184async fn mirror_remote_delete(
185    ctx: &AppContext,
186    request: &LocalDeletionRequest,
187    asset_ids: &[String],
188    album_count: Option<usize>,
189) -> Option<String> {
190    if let (Some(n), Some(album_id)) = (album_count, request.album_id.as_deref())
191        && n > 1
192    {
193        return unlink_from_album(ctx, request, asset_ids, album_id, n).await;
194    }
195    trash_remote_asset(ctx, request, asset_ids).await
196}
197
198async fn unlink_from_album(
199    ctx: &AppContext,
200    request: &LocalDeletionRequest,
201    asset_ids: &[String],
202    album_id: &str,
203    album_count: usize,
204) -> Option<String> {
205    let succeeded = ctx
206        .api_client
207        .remove_assets_from_album(album_id, asset_ids)
208        .await;
209    if !succeeded {
210        log::warn!(
211            "Could not mirror local delete of '{}'; sync record kept for retry",
212            request.asset_name
213        );
214        return None;
215    }
216    Some(format!(
217        "Unlinked '{}' from album '{}' (asset belongs to {} albums; preserved on server)",
218        request.asset_name, request.album_name, album_count
219    ))
220}
221
222async fn trash_remote_asset(
223    ctx: &AppContext,
224    request: &LocalDeletionRequest,
225    asset_ids: &[String],
226) -> Option<String> {
227    if let Err(err) = ctx.api_client.delete_assets(asset_ids).await {
228        log::warn!(
229            "Could not mirror local delete of '{}': {}; sync record kept for retry",
230            request.asset_name,
231            err
232        );
233        return None;
234    }
235    Some(format!(
236        "Mirrored local delete of '{}' to album '{}' (asset trashed on server)",
237        request.asset_name, request.album_name
238    ))
239}
240
241fn cleanup_deleted_sync_record(ctx: &AppContext, request: &LocalDeletionRequest) {
242    if let Err(err) = ctx.sync_index.remove_path(&request.local_path) {
243        log::warn!(
244            "Server-side delete succeeded but sync record cleanup failed for '{}': {}",
245            request.local_path,
246            err
247        );
248    }
249}
250
251/// Iterate through album assets matching checksum to find matching Immich library record.
252async fn find_album_asset_by_checksum(
253    api_client: Arc<ImmichApiClient>,
254    album_id: &str,
255    checksum: &str,
256) -> Result<Option<LibraryAsset>, String> {
257    let mut page = 1;
258    loop {
259        let (assets, has_more) = api_client
260            .fetch_album_assets(album_id, page, 1000, None)
261            .await?;
262        if let Some(asset) = assets
263            .into_iter()
264            .find(|asset| asset.checksum.as_deref() == Some(checksum))
265        {
266            return Ok(Some(asset));
267        }
268        if !has_more {
269            return Ok(None);
270        }
271        page += 1;
272    }
273}