1use 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
15pub 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#[derive(Clone, Debug)]
43pub struct LocalDeletionRequest {
44 pub local_path: String,
46 pub asset_id: String,
48 pub asset_name: String,
50 pub album_name: String,
52 pub album_id: Option<String>,
54}
55
56pub 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
86pub 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
251async 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}