1use 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
20const 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 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 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 confirm_pending_deletions(&ctx, album_id, &mut to_delete_local, &mut to_delete_remote);
115
116 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 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 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 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 }
491
492#[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
652fn 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}