1use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::fs;
10use std::path::PathBuf;
11use std::time::SystemTime;
12
13#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
15pub struct QueueEvent {
16 pub path: String,
18 pub status: String,
20 #[serde(default)]
22 pub detail: Option<String>,
23 #[serde(default)]
25 pub attempts: u32,
26 pub timestamp: f64,
28}
29
30#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
32pub struct FolderSyncStatus {
33 pub last_sync_at: Option<f64>,
35 pub pending_count: usize,
37 pub target_album: Option<String>,
39 pub last_error: Option<String>,
41}
42
43#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
45pub enum TransferDirection {
46 #[default]
48 Upload,
49 Download,
51}
52
53#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
55pub struct TransferSnapshot {
56 #[serde(default)]
58 pub active: bool,
59 #[serde(default)]
61 pub direction: TransferDirection,
62 #[serde(default)]
64 pub current_bytes: u64,
65 #[serde(default)]
67 pub total_bytes: Option<u64>,
68 #[serde(default)]
70 pub session_total_bytes: u64,
71 #[serde(default)]
73 pub session_transferred_bytes: u64,
74 #[serde(default)]
76 pub completed_total_bytes: u64,
77 #[serde(default)]
79 pub completed_transferred_bytes: u64,
80 #[serde(default)]
82 pub instant_bps: f64,
83 #[serde(default)]
85 pub session_avg_bps: f64,
86 #[serde(default)]
88 pub session_started_at: Option<f64>,
89 #[serde(default)]
91 pub session_bytes_done: u64,
92 #[serde(default)]
94 pub active_item_label: Option<String>,
95 #[serde(default)]
97 pub active_route: Option<String>,
98 #[serde(default)]
100 pub last_upload_avg_bps: f64,
101 #[serde(default)]
103 pub last_download_avg_bps: f64,
104 #[serde(skip)]
106 pub last_tick_at: Option<f64>,
107 #[serde(skip)]
109 pub last_tick_bytes: u64,
110 #[serde(skip)]
112 pub active_uploads: usize,
113 #[serde(skip)]
115 pub active_downloads: usize,
116 #[serde(skip)]
118 pub active_item_bytes: HashMap<String, u64>,
119 #[serde(skip)]
121 pub active_item_totals: HashMap<String, u64>,
122}
123
124#[derive(Serialize, Deserialize, Clone, Debug)]
126pub struct AppState {
127 pub queue_size: usize,
129 pub total_queued: usize,
131 pub processed_count: usize,
133 #[serde(default)]
135 pub failed_count: usize,
136 #[serde(skip)]
138 pub active_workers: usize,
139 pub current_file: Option<String>,
141 pub status: String,
143 pub progress: u8,
145 pub timestamp: f64,
147 #[serde(default)]
149 pub paused: bool,
150 #[serde(default)]
152 pub pause_reason: Option<String>,
153 #[serde(default)]
155 pub watched_folder_count: usize,
156 #[serde(default)]
158 pub active_server_route: Option<String>,
159 #[serde(default)]
161 pub last_successful_sync_at: Option<f64>,
162 #[serde(default)]
164 pub last_error: Option<String>,
165 #[serde(default)]
167 pub last_error_guidance: Option<String>,
168 #[serde(default)]
170 pub last_completed_file: Option<String>,
171 #[serde(default)]
173 pub diagnostics_exports: usize,
174 #[serde(default)]
176 pub recent_events: Vec<QueueEvent>,
177 #[serde(default)]
179 pub folder_statuses: std::collections::HashMap<String, FolderSyncStatus>,
180 #[serde(default)]
182 pub transfer: TransferSnapshot,
183 #[serde(skip)]
185 pub completed_upload_batches: u64,
186}
187
188impl Default for AppState {
189 fn default() -> Self {
190 Self {
191 queue_size: 0,
192 total_queued: 0,
193 processed_count: 0,
194 failed_count: 0,
195 active_workers: 0,
196 current_file: None,
197 status: "idle".to_string(),
198 progress: 0,
199 timestamp: 0.0,
200 paused: false,
201 pause_reason: None,
202 watched_folder_count: 0,
203 active_server_route: None,
204 last_successful_sync_at: None,
205 last_error: None,
206 last_error_guidance: None,
207 last_completed_file: None,
208 diagnostics_exports: 0,
209 recent_events: Vec::new(),
210 folder_statuses: std::collections::HashMap::new(),
211 transfer: TransferSnapshot::default(),
212 completed_upload_batches: 0,
213 }
214 }
215}
216
217impl AppState {
218 const MAX_EVENTS: usize = 80;
219
220 pub fn record_event(
222 &mut self,
223 path: impl Into<String>,
224 status: impl Into<String>,
225 detail: Option<String>,
226 attempts: u32,
227 ) {
228 let path = path.into();
229 let status = status.into();
230 let timestamp = SystemTime::now()
231 .duration_since(SystemTime::UNIX_EPOCH)
232 .unwrap_or_default()
233 .as_secs_f64();
234
235 if let Some(existing) = self.recent_events.iter_mut().find(|evt| evt.path == path) {
236 existing.status = status;
237 existing.detail = detail;
238 existing.attempts = attempts;
239 existing.timestamp = timestamp;
240 } else {
241 self.recent_events.push(QueueEvent {
242 path,
243 status,
244 detail,
245 attempts,
246 timestamp,
247 });
248 }
249
250 self.recent_events
251 .sort_by(|a, b| b.timestamp.total_cmp(&a.timestamp));
252 self.recent_events.truncate(Self::MAX_EVENTS);
253 }
254
255 pub fn reset_runtime_state(&mut self) {
257 self.transfer.reset_runtime();
258 }
259}
260
261impl TransferSnapshot {
262 pub fn reset_runtime(&mut self) {
264 self.active = false;
265 self.direction = TransferDirection::Upload;
266 self.current_bytes = 0;
267 self.total_bytes = None;
268 self.session_total_bytes = 0;
269 self.session_transferred_bytes = 0;
270 self.completed_total_bytes = 0;
271 self.completed_transferred_bytes = 0;
272 self.instant_bps = 0.0;
273 self.session_avg_bps = 0.0;
274 self.session_started_at = None;
275 self.session_bytes_done = 0;
276 self.active_item_label = None;
277 self.active_route = None;
278 self.last_tick_at = None;
279 self.last_tick_bytes = 0;
280 self.active_uploads = 0;
281 self.active_downloads = 0;
282 self.active_item_bytes.clear();
283 self.active_item_totals.clear();
284 }
285
286 fn should_switch_to(&self, direction: &TransferDirection) -> bool {
288 if !self.active {
289 return true;
290 }
291 match (&self.direction, direction) {
292 (TransferDirection::Upload, TransferDirection::Download) => self.active_uploads == 0,
293 (_, TransferDirection::Upload) => true,
294 _ => self.direction == *direction,
295 }
296 }
297
298 fn ensure_session(&mut self, direction: TransferDirection, route: Option<String>) {
300 if self.should_switch_to(&direction) {
301 self.active = true;
302 self.direction = direction;
303 self.current_bytes = 0;
304 self.total_bytes = Some(0);
305 self.session_total_bytes = 0;
306 self.session_transferred_bytes = 0;
307 self.completed_total_bytes = 0;
308 self.completed_transferred_bytes = 0;
309 self.instant_bps = 0.0;
310 self.session_avg_bps = 0.0;
311 self.session_started_at = None;
312 self.session_bytes_done = 0;
313 self.active_item_label = None;
314 self.active_route = route;
315 self.last_tick_at = None;
316 self.last_tick_bytes = 0;
317 self.active_item_bytes.clear();
318 self.active_item_totals.clear();
319 } else if route.is_some() {
320 self.active_route = route;
321 }
322 }
323
324 pub fn register_item(
326 &mut self,
327 direction: TransferDirection,
328 item_id: impl Into<String>,
329 total_bytes: Option<u64>,
330 item_label: Option<String>,
331 route: Option<String>,
332 ) {
333 self.ensure_session(direction.clone(), route.clone());
334 let item_id = item_id.into();
335 let previous_total = self
336 .active_item_totals
337 .insert(item_id.clone(), total_bytes.unwrap_or(0));
338 if previous_total.is_none() {
339 match direction {
340 TransferDirection::Upload => self.active_uploads += 1,
341 TransferDirection::Download => self.active_downloads += 1,
342 }
343 }
344 self.active_item_bytes.entry(item_id).or_insert(0);
345 let active_totals: u64 = self.active_item_totals.values().copied().sum();
346 self.session_total_bytes = self.completed_total_bytes + active_totals;
347 self.total_bytes = Some(self.session_total_bytes);
348 if self.active_item_label.is_none() {
349 self.active_item_label = item_label;
350 }
351 }
352
353 pub fn begin_group(
355 &mut self,
356 direction: TransferDirection,
357 item_label: Option<String>,
358 route: Option<String>,
359 ) {
360 self.ensure_session(direction, route.clone());
361 if self.active_item_label.is_none() {
362 self.active_item_label = item_label;
363 }
364 if route.is_some() {
365 self.active_route = route;
366 }
367 }
368
369 pub fn update_item_total(&mut self, item_id: &str, total_bytes: u64) {
371 self.active_item_totals
372 .insert(item_id.to_string(), total_bytes);
373 let active_totals: u64 = self.active_item_totals.values().copied().sum();
374 self.session_total_bytes = self.completed_total_bytes + active_totals;
375 self.total_bytes = Some(self.session_total_bytes);
376 }
377
378 pub fn update_item_bytes(
380 &mut self,
381 direction: TransferDirection,
382 item_id: &str,
383 bytes_done: u64,
384 route: Option<String>,
385 ) {
386 if !self.active || self.direction != direction {
387 return;
388 }
389
390 self.active_item_bytes
391 .insert(item_id.to_string(), bytes_done);
392 let active_bytes: u64 = self.active_item_bytes.values().copied().sum();
393 self.session_transferred_bytes = self.completed_transferred_bytes + active_bytes;
394 self.current_bytes = self.session_transferred_bytes;
395 self.session_bytes_done = self.session_transferred_bytes;
396 self.total_bytes = Some(self.session_total_bytes);
397
398 let now = unix_timestamp_now();
399 if self.session_started_at.is_none() {
400 self.session_started_at = Some(now);
401 self.last_tick_at = Some(now);
402 self.last_tick_bytes = self.session_transferred_bytes;
403 self.instant_bps = 0.0;
404 self.session_avg_bps = 0.0;
405 if route.is_some() {
406 self.active_route = route;
407 }
408 return;
409 }
410 let mut do_update_speed = false;
411 let mut previous_bytes = 0;
412
413 let previous_tick = self.last_tick_at.unwrap_or(now);
414 let elapsed = now - previous_tick;
415 if elapsed >= 0.25 {
416 do_update_speed = true;
417 previous_bytes = self.last_tick_bytes;
418 }
419
420 if let Some(started) = self.session_started_at {
421 self.session_avg_bps =
422 self.session_transferred_bytes as f64 / (now - started).max(0.001);
423 }
424
425 if do_update_speed {
426 let delta = self
427 .session_transferred_bytes
428 .saturating_sub(previous_bytes);
429 self.instant_bps = delta as f64 / elapsed;
430 self.last_tick_at = Some(now);
431 self.last_tick_bytes = self.session_transferred_bytes;
432 }
433
434 if route.is_some() {
435 self.active_route = route;
436 }
437 }
438
439 pub fn finish_item(
441 &mut self,
442 direction: TransferDirection,
443 item_id: &str,
444 route: Option<String>,
445 ) -> bool {
446 let completed_avg = self.session_avg_bps;
447 let item_final_bytes = self.active_item_bytes.remove(item_id).unwrap_or(0);
448 let item_final_total = self.active_item_totals.remove(item_id).unwrap_or(0);
449
450 self.completed_transferred_bytes += item_final_bytes;
451 self.completed_total_bytes += item_final_total;
452
453 let active_bytes: u64 = self.active_item_bytes.values().copied().sum();
454 let active_total: u64 = self.active_item_totals.values().copied().sum();
455
456 self.session_transferred_bytes = self.completed_transferred_bytes + active_bytes;
457 self.session_total_bytes = self.completed_total_bytes + active_total;
458
459 self.current_bytes = self.session_transferred_bytes;
460 self.total_bytes = Some(self.session_total_bytes);
461
462 match direction {
463 TransferDirection::Upload => {
464 self.active_uploads = self.active_uploads.saturating_sub(1)
465 }
466 TransferDirection::Download => {
467 self.active_downloads = self.active_downloads.saturating_sub(1)
468 }
469 }
470
471 match direction {
472 TransferDirection::Upload => self.last_upload_avg_bps = completed_avg,
473 TransferDirection::Download => self.last_download_avg_bps = completed_avg,
474 }
475
476 if route.is_some() {
477 self.active_route = route;
478 }
479
480 if self.active_uploads == 0 && self.active_downloads == 0 {
481 self.reset_runtime();
482 return true;
483 } else if self.active_uploads == 0 && self.direction == TransferDirection::Upload {
484 self.direction = TransferDirection::Download;
485 self.current_bytes = 0;
486 self.total_bytes = Some(self.session_total_bytes);
487 self.instant_bps = 0.0;
488 self.session_avg_bps = 0.0;
489 self.session_started_at = None;
490 self.session_bytes_done = self.session_transferred_bytes;
491 self.active_item_label = None;
492 self.last_tick_at = None;
493 self.last_tick_bytes = 0;
494 }
495 false
496 }
497}
498
499fn unix_timestamp_now() -> f64 {
501 SystemTime::now()
502 .duration_since(SystemTime::UNIX_EPOCH)
503 .unwrap_or_default()
504 .as_secs_f64()
505}
506
507pub struct StateManager {
509 state_file: PathBuf,
511}
512
513impl StateManager {
514 pub fn new() -> Self {
516 let cache_dir = crate::profile::cache_dir().unwrap_or_else(|| {
517 std::path::PathBuf::from("~/.cache").join(crate::profile::dir_segment())
518 });
519
520 let state_file = cache_dir.join("status.json");
521 Self { state_file }
522 }
523
524 pub fn write_state(&self, mut state: AppState) {
526 state.timestamp = SystemTime::now()
527 .duration_since(SystemTime::UNIX_EPOCH)
528 .unwrap_or_default()
529 .as_secs_f64();
530
531 if let Some(parent) = self.state_file.parent() {
532 let _ = fs::create_dir_all(parent);
533 }
534
535 if let Ok(content) = serde_json::to_string(&state) {
536 let unique_ext = format!(
537 "tmp.{}",
538 SystemTime::now()
539 .duration_since(SystemTime::UNIX_EPOCH)
540 .unwrap_or_default()
541 .as_nanos()
542 );
543 let tmp_file = self.state_file.with_extension(unique_ext);
544 if fs::write(&tmp_file, &content).is_ok() {
545 if fs::rename(&tmp_file, &self.state_file).is_ok() {
546 log::debug!(
547 "State written: status={} progress={} processed={}/{}",
548 state.status,
549 state.progress,
550 state.processed_count,
551 state.total_queued
552 );
553 } else {
554 let _ = fs::remove_file(&tmp_file); log::warn!("Failed to atomically rename state file");
556 }
557 } else {
558 log::warn!("Failed to write temp state file");
559 }
560 }
561 }
562
563 pub fn read_state(&self) -> AppState {
565 match fs::read_to_string(&self.state_file) {
566 Ok(content) => match serde_json::from_str::<AppState>(&content) {
567 Ok(mut state) => {
568 state.reset_runtime_state();
569 log::debug!("State read: status={}", state.status);
570 state
571 }
572 Err(e) => {
573 log::warn!("Failed to parse state file: {}", e);
574 AppState::default()
575 }
576 },
577 Err(_) => AppState::default(),
578 }
579 }
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585 use tempfile::tempdir;
586
587 #[test]
588 fn test_app_state_default() {
589 let state = AppState::default();
590 assert_eq!(state.queue_size, 0);
591 assert_eq!(state.status, "idle");
592 assert_eq!(state.progress, 0);
593 assert_eq!(state.watched_folder_count, 0);
594 assert!(state.active_server_route.is_none());
595 assert!(state.last_successful_sync_at.is_none());
596 assert!(state.last_error_guidance.is_none());
597 assert!(!state.transfer.active);
598 }
599
600 #[test]
601 fn test_state_manager_write_read() {
602 let dir = tempdir().unwrap();
603 let file_path = dir.path().join("status.json");
604
605 let manager = StateManager {
607 state_file: file_path.clone(),
608 };
609
610 let state = AppState {
611 status: "syncing".to_string(),
612 progress: 50,
613 ..AppState::default()
614 };
615
616 manager.write_state(state.clone());
617
618 assert!(file_path.exists());
619
620 let read_state = manager.read_state();
621 assert_eq!(read_state.status, "syncing");
622 assert_eq!(read_state.progress, 50);
623 assert!(!read_state.transfer.active);
624 }
625
626 #[test]
627 fn test_state_manager_preserves_health_dashboard_fields() {
628 let dir = tempdir().unwrap();
629 let file_path = dir.path().join("status.json");
630 let manager = StateManager {
631 state_file: file_path,
632 };
633
634 let state = AppState {
635 watched_folder_count: 3,
636 active_server_route: Some("LAN".into()),
637 last_successful_sync_at: Some(1234.5),
638 last_error: Some("Immich rejected the API key".into()),
639 last_error_guidance: Some("Update the API key in Settings.".into()),
640 ..AppState::default()
641 };
642
643 manager.write_state(state);
644 let read_state = manager.read_state();
645
646 assert_eq!(read_state.watched_folder_count, 3);
647 assert_eq!(read_state.active_server_route.as_deref(), Some("LAN"));
648 assert_eq!(read_state.last_successful_sync_at, Some(1234.5));
649 assert_eq!(
650 read_state.last_error.as_deref(),
651 Some("Immich rejected the API key")
652 );
653 assert_eq!(
654 read_state.last_error_guidance.as_deref(),
655 Some("Update the API key in Settings.")
656 );
657 assert!(!read_state.transfer.active);
658 }
659
660 #[test]
661 fn test_record_event_updates_existing_entry() {
662 let mut state = AppState::default();
663 state.record_event("/tmp/a.jpg", "pending", Some("queued".into()), 1);
664 state.record_event("/tmp/a.jpg", "failed", Some("retry".into()), 2);
665
666 assert_eq!(state.recent_events.len(), 1);
667 assert_eq!(state.recent_events[0].status, "failed");
668 assert_eq!(state.recent_events[0].attempts, 2);
669 assert_eq!(state.recent_events[0].detail.as_deref(), Some("retry"));
670 }
671
672 #[test]
673 fn test_record_event_truncates_history() {
674 let mut state = AppState::default();
675 for i in 0..100 {
676 state.record_event(format!("/tmp/{i}.jpg"), "pending", None, 1);
677 }
678
679 assert_eq!(state.recent_events.len(), 80);
680 }
681
682 #[test]
683 fn test_transfer_snapshot_speed_updates() {
684 let mut transfer = TransferSnapshot::default();
685 transfer.register_item(
686 TransferDirection::Upload,
687 "file.jpg",
688 Some(1_000),
689 Some("file.jpg".into()),
690 Some("LAN".into()),
691 );
692 let start = unix_timestamp_now() - 1.0;
693 transfer.session_started_at = Some(start);
694 transfer.last_tick_at = Some(start);
695 transfer.last_tick_bytes = 0;
696 transfer.update_item_bytes(
697 TransferDirection::Upload,
698 "file.jpg",
699 512,
700 Some("LAN".into()),
701 );
702
703 assert!(transfer.active);
704 assert_eq!(transfer.current_bytes, 512);
705 assert!(transfer.instant_bps > 0.0);
706 assert!(transfer.session_avg_bps > 0.0);
707
708 transfer.finish_item(TransferDirection::Upload, "file.jpg", Some("LAN".into()));
709 assert!(!transfer.active);
710 }
711}