Add async synchronization with progress status display
494cad2 parent: 849fc17 modified
src/app.rs +3 -0 | @@ -117,6 +117,9 @@ impl eframe::App for RssPodcastApp { | ||
| 117 | 117 | Theme::Light => egui::Color32::from_rgb(220, 230, 245), |
| 118 | 118 | }; |
| 119 | 119 | |
| 120 | + // Process sync task messages | |
| 121 | + self.state.sync_task_manager.process_messages(); | |
| 122 | + | |
| 120 | 123 | // Draw notifications panel at the top |
| 121 | 124 | egui::Panel::top("notifications_panel") |
| 122 | 125 | .frame(egui::Frame { |
| @@ -117,6 +117,9 @@ impl eframe::App for RssPodcastApp { | |||
| 117 | Theme::Light => egui::Color32::from_rgb(220, 230, 245), | 117 | Theme::Light => egui::Color32::from_rgb(220, 230, 245), |
| 118 | }; | 118 | }; |
| 119 | 119 | ||
| 120 | + // Process sync task messages | ||
| 121 | + self.state.sync_task_manager.process_messages(); | ||
| 122 | + | ||
| 120 | // Draw notifications panel at the top | 123 | // Draw notifications panel at the top |
| 121 | egui::Panel::top("notifications_panel") | 124 | egui::Panel::top("notifications_panel") |
| 122 | .frame(egui::Frame { | 125 | .frame(egui::Frame { |
modified
src/config/app_state.rs +4 -0 | @@ -7,6 +7,7 @@ use crate::config::history::HistoryManager; | ||
| 7 | 7 | use crate::config::playlist::Playlist; |
| 8 | 8 | use crate::config::search::SearchQuery; |
| 9 | 9 | use crate::config::statistics::AppStatistics; |
| 10 | +use crate::config::sync_task::SyncTaskManager; | |
| 10 | 11 | use crate::ui::notifications::NotificationManager; |
| 11 | 12 | use chrono::{DateTime, Utc}; |
| 12 | 13 | use serde::{Deserialize, Serialize}; |
| @@ -167,6 +168,8 @@ pub struct AppState { | ||
| 167 | 168 | /// History manager for tracking operations |
| 168 | 169 | #[allow(dead_code)] |
| 169 | 170 | pub history_manager: HistoryManager, |
| 171 | + /// Manager for async synchronization tasks | |
| 172 | + pub sync_task_manager: SyncTaskManager, | |
| 170 | 173 | } |
| 171 | 174 | |
| 172 | 175 | impl AppState { |
| @@ -217,6 +220,7 @@ impl AppState { | ||
| 217 | 220 | statistics: AppStatistics::new(), |
| 218 | 221 | history_manager: HistoryManager::new(), |
| 219 | 222 | theme: Theme::default(), |
| 223 | + sync_task_manager: SyncTaskManager::new(), | |
| 220 | 224 | } |
| 221 | 225 | } |
| 222 | 226 | |
| @@ -7,6 +7,7 @@ use crate::config::history::HistoryManager; | |||
| 7 | use crate::config::playlist::Playlist; | 7 | use crate::config::playlist::Playlist; |
| 8 | use crate::config::search::SearchQuery; | 8 | use crate::config::search::SearchQuery; |
| 9 | use crate::config::statistics::AppStatistics; | 9 | use crate::config::statistics::AppStatistics; |
| 10 | +use crate::config::sync_task::SyncTaskManager; | ||
| 10 | use crate::ui::notifications::NotificationManager; | 11 | use crate::ui::notifications::NotificationManager; |
| 11 | use chrono::{DateTime, Utc}; | 12 | use chrono::{DateTime, Utc}; |
| 12 | use serde::{Deserialize, Serialize}; | 13 | use serde::{Deserialize, Serialize}; |
| @@ -167,6 +168,8 @@ pub struct AppState { | |||
| 167 | /// History manager for tracking operations | 168 | /// History manager for tracking operations |
| 168 | #[allow(dead_code)] | 169 | #[allow(dead_code)] |
| 169 | pub history_manager: HistoryManager, | 170 | pub history_manager: HistoryManager, |
| 171 | + /// Manager for async synchronization tasks | ||
| 172 | + pub sync_task_manager: SyncTaskManager, | ||
| 170 | } | 173 | } |
| 171 | 174 | ||
| 172 | impl AppState { | 175 | impl AppState { |
| @@ -217,6 +220,7 @@ impl AppState { | |||
| 217 | statistics: AppStatistics::new(), | 220 | statistics: AppStatistics::new(), |
| 218 | history_manager: HistoryManager::new(), | 221 | history_manager: HistoryManager::new(), |
| 219 | theme: Theme::default(), | 222 | theme: Theme::default(), |
| 223 | + sync_task_manager: SyncTaskManager::new(), | ||
| 220 | } | 224 | } |
| 221 | } | 225 | } |
| 222 | 226 | ||
modified
src/config/mod.rs +4 -0 | @@ -11,6 +11,7 @@ pub mod playlist; | ||
| 11 | 11 | pub mod search; |
| 12 | 12 | pub mod statistics; |
| 13 | 13 | pub mod storage; |
| 14 | +pub mod sync_task; | |
| 14 | 15 | |
| 15 | 16 | // Re-export main types for convenience |
| 16 | 17 | // Note: Some re-exports may not be directly used in the current codebase but are |
| @@ -42,3 +43,6 @@ pub use statistics::{AppStatistics, FeedStatistics, PlaylistStatistics}; | ||
| 42 | 43 | #[allow(dead_code)] |
| 43 | 44 | #[allow(unused_imports)] |
| 44 | 45 | pub use storage::{load_feeds, save_feeds, StorageError}; |
| 46 | +#[allow(dead_code)] | |
| 47 | +#[allow(unused_imports)] | |
| 48 | +pub use sync_task::{EpisodeDownloadPhase, EpisodeDownloadStatus, FeedSyncStatus, SyncMessage, SyncStatus, SyncTaskManager}; | |
| @@ -11,6 +11,7 @@ pub mod playlist; | |||
| 11 | pub mod search; | 11 | pub mod search; |
| 12 | pub mod statistics; | 12 | pub mod statistics; |
| 13 | pub mod storage; | 13 | pub mod storage; |
| 14 | +pub mod sync_task; | ||
| 14 | 15 | ||
| 15 | // Re-export main types for convenience | 16 | // Re-export main types for convenience |
| 16 | // Note: Some re-exports may not be directly used in the current codebase but are | 17 | // Note: Some re-exports may not be directly used in the current codebase but are |
| @@ -42,3 +43,6 @@ pub use statistics::{AppStatistics, FeedStatistics, PlaylistStatistics}; | |||
| 42 | #[allow(dead_code)] | 43 | #[allow(dead_code)] |
| 43 | #[allow(unused_imports)] | 44 | #[allow(unused_imports)] |
| 44 | pub use storage::{load_feeds, save_feeds, StorageError}; | 45 | pub use storage::{load_feeds, save_feeds, StorageError}; |
| 46 | +#[allow(dead_code)] | ||
| 47 | +#[allow(unused_imports)] | ||
| 48 | +pub use sync_task::{EpisodeDownloadPhase, EpisodeDownloadStatus, FeedSyncStatus, SyncMessage, SyncStatus, SyncTaskManager}; | ||
added
src/config/sync_task.rs +375 -0 | new file mode 100644 | ||
| @@ -0,0 +1,375 @@ | ||
| 1 | +//! Sync Task module - Asynchronous synchronization task management | |
| 2 | +//! | |
| 3 | +//! This module provides types and functions for managing asynchronous synchronization tasks. | |
| 4 | + | |
| 5 | +use std::collections::HashMap; | |
| 6 | +use std::sync::mpsc::{channel, Receiver, Sender}; | |
| 7 | +use std::sync::{Arc, Mutex}; | |
| 8 | +use std::thread; | |
| 9 | + | |
| 10 | +/// Message types for communication between async tasks and the UI | |
| 11 | +#[derive(Debug, Clone)] | |
| 12 | +pub enum SyncMessage { | |
| 13 | + /// Sync started for a feed | |
| 14 | + SyncStarted { | |
| 15 | + feed_id: String, | |
| 16 | + feed_name: String, | |
| 17 | + }, | |
| 18 | + /// Sync progress update (0.0 to 1.0) | |
| 19 | + SyncProgress { | |
| 20 | + feed_id: String, | |
| 21 | + progress: f32, | |
| 22 | + message: String, | |
| 23 | + }, | |
| 24 | + /// Sync completed successfully | |
| 25 | + SyncCompleted { | |
| 26 | + feed_id: String, | |
| 27 | + feed_name: String, | |
| 28 | + episodes_found: usize, | |
| 29 | + episodes_downloaded: usize, | |
| 30 | + }, | |
| 31 | + /// Sync failed with an error | |
| 32 | + SyncFailed { | |
| 33 | + feed_id: String, | |
| 34 | + feed_name: String, | |
| 35 | + error: String, | |
| 36 | + }, | |
| 37 | + /// Individual episode download started | |
| 38 | + EpisodeDownloadStarted { | |
| 39 | + feed_id: String, | |
| 40 | + episode_title: String, | |
| 41 | + }, | |
| 42 | + /// Individual episode download progress | |
| 43 | + EpisodeDownloadProgress { | |
| 44 | + feed_id: String, | |
| 45 | + episode_title: String, | |
| 46 | + progress: f32, | |
| 47 | + }, | |
| 48 | + /// Individual episode download completed | |
| 49 | + EpisodeDownloadCompleted { | |
| 50 | + feed_id: String, | |
| 51 | + episode_title: String, | |
| 52 | + }, | |
| 53 | + /// Individual episode download failed | |
| 54 | + EpisodeDownloadFailed { | |
| 55 | + feed_id: String, | |
| 56 | + episode_title: String, | |
| 57 | + error: String, | |
| 58 | + }, | |
| 59 | +} | |
| 60 | + | |
| 61 | +/// Status of a feed synchronization | |
| 62 | +#[derive(Debug, Clone)] | |
| 63 | +pub enum FeedSyncStatus { | |
| 64 | + /// Sync not started or completed | |
| 65 | + Idle, | |
| 66 | + /// Sync in progress | |
| 67 | + InProgress { | |
| 68 | + progress: f32, | |
| 69 | + message: String, | |
| 70 | + }, | |
| 71 | + /// Sync completed successfully | |
| 72 | + Completed { | |
| 73 | + episodes_found: usize, | |
| 74 | + episodes_downloaded: usize, | |
| 75 | + }, | |
| 76 | + /// Sync failed | |
| 77 | + Failed { | |
| 78 | + error: String, | |
| 79 | + }, | |
| 80 | +} | |
| 81 | + | |
| 82 | +impl Default for FeedSyncStatus { | |
| 83 | + fn default() -> Self { | |
| 84 | + Self::Idle | |
| 85 | + } | |
| 86 | +} | |
| 87 | + | |
| 88 | +/// Episode download status | |
| 89 | +#[derive(Debug, Clone)] | |
| 90 | +pub struct EpisodeDownloadStatus { | |
| 91 | + pub status: EpisodeDownloadPhase, | |
| 92 | + pub progress: f32, | |
| 93 | +} | |
| 94 | + | |
| 95 | +impl Default for EpisodeDownloadStatus { | |
| 96 | + fn default() -> Self { | |
| 97 | + Self { | |
| 98 | + status: EpisodeDownloadPhase::NotStarted, | |
| 99 | + progress: 0.0, | |
| 100 | + } | |
| 101 | + } | |
| 102 | +} | |
| 103 | + | |
| 104 | +/// Phase of episode download | |
| 105 | +#[derive(Debug, Clone, PartialEq)] | |
| 106 | +pub enum EpisodeDownloadPhase { | |
| 107 | + NotStarted, | |
| 108 | + Downloading, | |
| 109 | + Completed, | |
| 110 | + Failed, | |
| 111 | +} | |
| 112 | + | |
| 113 | +/// Status of all synchronization tasks | |
| 114 | +#[derive(Debug, Default)] | |
| 115 | +pub struct SyncStatus { | |
| 116 | + /// Global sync in progress (sync all feeds) | |
| 117 | + pub global_sync_in_progress: bool, | |
| 118 | + /// Individual feed sync status (feed_id -> status) | |
| 119 | + pub feed_sync_status: HashMap<String, FeedSyncStatus>, | |
| 120 | + /// Individual episode download status (episode_id -> status) | |
| 121 | + pub episode_download_status: HashMap<String, EpisodeDownloadStatus>, | |
| 122 | + /// Total feeds to sync | |
| 123 | + pub total_feeds: usize, | |
| 124 | + /// Feeds completed | |
| 125 | + pub completed_feeds: usize, | |
| 126 | + /// Current overall progress (0.0 to 1.0) | |
| 127 | + pub overall_progress: f32, | |
| 128 | +} | |
| 129 | + | |
| 130 | +impl SyncStatus { | |
| 131 | + /// Create a new SyncStatus | |
| 132 | + pub fn new() -> Self { | |
| 133 | + Self::default() | |
| 134 | + } | |
| 135 | + | |
| 136 | + /// Start global sync | |
| 137 | + pub fn start_global_sync(&mut self, feed_count: usize) { | |
| 138 | + self.global_sync_in_progress = true; | |
| 139 | + self.total_feeds = feed_count; | |
| 140 | + self.completed_feeds = 0; | |
| 141 | + self.overall_progress = 0.0; | |
| 142 | + self.feed_sync_status.clear(); | |
| 143 | + } | |
| 144 | + | |
| 145 | + /// Complete global sync | |
| 146 | + pub fn complete_global_sync(&mut self) { | |
| 147 | + self.global_sync_in_progress = false; | |
| 148 | + self.overall_progress = 1.0; | |
| 149 | + } | |
| 150 | + | |
| 151 | + /// Update feed sync status | |
| 152 | + pub fn update_feed_status(&mut self, feed_id: String, status: FeedSyncStatus) { | |
| 153 | + self.feed_sync_status.insert(feed_id, status); | |
| 154 | + | |
| 155 | + // Update overall progress | |
| 156 | + if self.global_sync_in_progress && self.total_feeds > 0 { | |
| 157 | + let completed = self.feed_sync_status.values() | |
| 158 | + .filter(|s| matches!(s, FeedSyncStatus::Completed { .. } | FeedSyncStatus::Failed { .. })) | |
| 159 | + .count(); | |
| 160 | + self.completed_feeds = completed; | |
| 161 | + self.overall_progress = (completed as f32 / self.total_feeds as f32).min(1.0); | |
| 162 | + } | |
| 163 | + } | |
| 164 | + | |
| 165 | + /// Get overall sync progress | |
| 166 | + pub fn get_overall_progress(&self) -> f32 { | |
| 167 | + self.overall_progress | |
| 168 | + } | |
| 169 | + | |
| 170 | + /// Check if any sync is in progress | |
| 171 | + pub fn is_syncing(&self) -> bool { | |
| 172 | + self.global_sync_in_progress || | |
| 173 | + self.feed_sync_status.values().any(|s| matches!(s, FeedSyncStatus::InProgress { .. })) | |
| 174 | + } | |
| 175 | + | |
| 176 | + /// Get syncing feed count | |
| 177 | + pub fn syncing_feed_count(&self) -> usize { | |
| 178 | + self.feed_sync_status.values() | |
| 179 | + .filter(|s| matches!(s, FeedSyncStatus::InProgress { .. })) | |
| 180 | + .count() | |
| 181 | + } | |
| 182 | +} | |
| 183 | + | |
| 184 | +/// Manager for async sync tasks | |
| 185 | +#[derive(Debug)] | |
| 186 | +pub struct SyncTaskManager { | |
| 187 | + /// Sender for sync messages | |
| 188 | + sender: Sender<SyncMessage>, | |
| 189 | + /// Receiver for sync messages | |
| 190 | + receiver: Arc<Mutex<Receiver<SyncMessage>>>, | |
| 191 | + /// Current sync status | |
| 192 | + pub status: SyncStatus, | |
| 193 | +} | |
| 194 | + | |
| 195 | +impl SyncTaskManager { | |
| 196 | + /// Create a new SyncTaskManager | |
| 197 | + pub fn new() -> Self { | |
| 198 | + let (sender, receiver) = channel(); | |
| 199 | + Self { | |
| 200 | + sender, | |
| 201 | + receiver: Arc::new(Mutex::new(receiver)), | |
| 202 | + status: SyncStatus::new(), | |
| 203 | + } | |
| 204 | + } | |
| 205 | + | |
| 206 | + /// Get the sender for sending messages from async tasks | |
| 207 | + pub fn sender(&self) -> Sender<SyncMessage> { | |
| 208 | + self.sender.clone() | |
| 209 | + } | |
| 210 | + | |
| 211 | + /// Process incoming messages and update status | |
| 212 | + pub fn process_messages(&mut self) -> Vec<SyncMessage> { | |
| 213 | + let mut messages = Vec::new(); | |
| 214 | + | |
| 215 | + let receiver = self.receiver.clone(); | |
| 216 | + let receiver_guard = receiver.lock().unwrap(); | |
| 217 | + | |
| 218 | + while let Ok(msg) = receiver_guard.try_recv() { | |
| 219 | + self.handle_message(&msg); | |
| 220 | + messages.push(msg); | |
| 221 | + } | |
| 222 | + | |
| 223 | + messages | |
| 224 | + } | |
| 225 | + | |
| 226 | + /// Handle a single sync message | |
| 227 | + fn handle_message(&mut self, message: &SyncMessage) { | |
| 228 | + match message { | |
| 229 | + SyncMessage::SyncStarted { feed_id, .. } => { | |
| 230 | + self.status.update_feed_status( | |
| 231 | + feed_id.clone(), | |
| 232 | + FeedSyncStatus::InProgress { | |
| 233 | + progress: 0.0, | |
| 234 | + message: "Démarrage de la synchronisation...".to_string(), | |
| 235 | + } | |
| 236 | + ); | |
| 237 | + } | |
| 238 | + SyncMessage::SyncProgress { feed_id, progress, message } => { | |
| 239 | + if let Some(status) = self.status.feed_sync_status.get_mut(feed_id) { | |
| 240 | + if let FeedSyncStatus::InProgress { progress: _, message: _ } = status { | |
| 241 | + // Update existing in-progress status | |
| 242 | + } | |
| 243 | + } | |
| 244 | + self.status.update_feed_status( | |
| 245 | + feed_id.clone(), | |
| 246 | + FeedSyncStatus::InProgress { | |
| 247 | + progress: *progress, | |
| 248 | + message: message.clone(), | |
| 249 | + } | |
| 250 | + ); | |
| 251 | + } | |
| 252 | + SyncMessage::SyncCompleted { feed_id, feed_name: _, episodes_found, episodes_downloaded } => { | |
| 253 | + self.status.update_feed_status( | |
| 254 | + feed_id.clone(), | |
| 255 | + FeedSyncStatus::Completed { | |
| 256 | + episodes_found: *episodes_found, | |
| 257 | + episodes_downloaded: *episodes_downloaded, | |
| 258 | + } | |
| 259 | + ); | |
| 260 | + } | |
| 261 | + SyncMessage::SyncFailed { feed_id, feed_name: _, error } => { | |
| 262 | + self.status.update_feed_status( | |
| 263 | + feed_id.clone(), | |
| 264 | + FeedSyncStatus::Failed { | |
| 265 | + error: error.clone(), | |
| 266 | + } | |
| 267 | + ); | |
| 268 | + } | |
| 269 | + SyncMessage::EpisodeDownloadStarted { feed_id: _, episode_title: _ } => { | |
| 270 | + // Episode download started - could track this if needed | |
| 271 | + } | |
| 272 | + SyncMessage::EpisodeDownloadProgress { feed_id: _, episode_title: _, progress: _ } => { | |
| 273 | + // Episode download progress | |
| 274 | + } | |
| 275 | + SyncMessage::EpisodeDownloadCompleted { .. } => { | |
| 276 | + // Episode download completed | |
| 277 | + } | |
| 278 | + SyncMessage::EpisodeDownloadFailed { .. } => { | |
| 279 | + // Episode download failed | |
| 280 | + } | |
| 281 | + } | |
| 282 | + } | |
| 283 | + | |
| 284 | + /// Start syncing all feeds asynchronously | |
| 285 | + pub fn start_sync_all_feeds(&mut self, feeds: Vec<crate::config::feed::Feed>) { | |
| 286 | + self.status.start_global_sync(feeds.len()); | |
| 287 | + | |
| 288 | + let sender = self.sender.clone(); | |
| 289 | + | |
| 290 | + thread::spawn(move || { | |
| 291 | + crate::rss::async_sync::sync_all_feeds_async(feeds, sender); | |
| 292 | + }); | |
| 293 | + } | |
| 294 | + | |
| 295 | + /// Start syncing a single feed asynchronously | |
| 296 | + pub fn start_sync_feed(&mut self, feed: crate::config::feed::Feed) { | |
| 297 | + let sender = self.sender.clone(); | |
| 298 | + let feed_id = feed.id.clone(); | |
| 299 | + | |
| 300 | + // Update status | |
| 301 | + self.status.update_feed_status( | |
| 302 | + feed_id.clone(), | |
| 303 | + FeedSyncStatus::InProgress { | |
| 304 | + progress: 0.0, | |
| 305 | + message: "Démarrage...".to_string(), | |
| 306 | + } | |
| 307 | + ); | |
| 308 | + | |
| 309 | + thread::spawn(move || { | |
| 310 | + crate::rss::async_sync::sync_feed_async(feed, sender); | |
| 311 | + }); | |
| 312 | + } | |
| 313 | +} | |
| 314 | + | |
| 315 | +impl Default for SyncTaskManager { | |
| 316 | + fn default() -> Self { | |
| 317 | + Self::new() | |
| 318 | + } | |
| 319 | +} | |
| 320 | + | |
| 321 | +#[cfg(test)] | |
| 322 | +mod tests { | |
| 323 | + use super::*; | |
| 324 | + | |
| 325 | + #[test] | |
| 326 | + fn test_sync_status_default() { | |
| 327 | + let status = SyncStatus::new(); | |
| 328 | + assert!(!status.global_sync_in_progress); | |
| 329 | + assert!(status.feed_sync_status.is_empty()); | |
| 330 | + assert_eq!(status.get_overall_progress(), 0.0); | |
| 331 | + assert!(!status.is_syncing()); | |
| 332 | + } | |
| 333 | + | |
| 334 | + #[test] | |
| 335 | + fn test_sync_status_start_global_sync() { | |
| 336 | + let mut status = SyncStatus::new(); | |
| 337 | + status.start_global_sync(5); | |
| 338 | + | |
| 339 | + assert!(status.global_sync_in_progress); | |
| 340 | + assert_eq!(status.total_feeds, 5); | |
| 341 | + assert_eq!(status.completed_feeds, 0); | |
| 342 | + assert_eq!(status.get_overall_progress(), 0.0); | |
| 343 | + } | |
| 344 | + | |
| 345 | + #[test] | |
| 346 | + fn test_sync_status_overall_progress() { | |
| 347 | + let mut status = SyncStatus::new(); | |
| 348 | + status.start_global_sync(3); | |
| 349 | + | |
| 350 | + // Add completed feeds | |
| 351 | + status.update_feed_status("feed-1".to_string(), FeedSyncStatus::Completed { episodes_found: 5, episodes_downloaded: 2 }); | |
| 352 | + status.update_feed_status("feed-2".to_string(), FeedSyncStatus::Completed { episodes_found: 3, episodes_downloaded: 1 }); | |
| 353 | + | |
| 354 | + assert_eq!(status.completed_feeds, 2); | |
| 355 | + assert!((status.get_overall_progress() - 0.666).abs() < 0.01); | |
| 356 | + | |
| 357 | + // Add failed feed | |
| 358 | + status.update_feed_status("feed-3".to_string(), FeedSyncStatus::Failed { error: "Error".to_string() }); | |
| 359 | + | |
| 360 | + assert_eq!(status.completed_feeds, 3); | |
| 361 | + assert_eq!(status.get_overall_progress(), 1.0); | |
| 362 | + } | |
| 363 | + | |
| 364 | + #[test] | |
| 365 | + fn test_feed_sync_status_is_idle() { | |
| 366 | + let status = FeedSyncStatus::Idle; | |
| 367 | + assert!(matches!(status, FeedSyncStatus::Idle)); | |
| 368 | + } | |
| 369 | + | |
| 370 | + #[test] | |
| 371 | + fn test_sync_task_manager_new() { | |
| 372 | + let manager = SyncTaskManager::new(); | |
| 373 | + assert!(!manager.status.global_sync_in_progress); | |
| 374 | + } | |
| 375 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,375 @@ | |||
| 1 | +//! Sync Task module - Asynchronous synchronization task management | ||
| 2 | +//! | ||
| 3 | +//! This module provides types and functions for managing asynchronous synchronization tasks. | ||
| 4 | + | ||
| 5 | +use std::collections::HashMap; | ||
| 6 | +use std::sync::mpsc::{channel, Receiver, Sender}; | ||
| 7 | +use std::sync::{Arc, Mutex}; | ||
| 8 | +use std::thread; | ||
| 9 | + | ||
| 10 | +/// Message types for communication between async tasks and the UI | ||
| 11 | +#[derive(Debug, Clone)] | ||
| 12 | +pub enum SyncMessage { | ||
| 13 | + /// Sync started for a feed | ||
| 14 | + SyncStarted { | ||
| 15 | + feed_id: String, | ||
| 16 | + feed_name: String, | ||
| 17 | + }, | ||
| 18 | + /// Sync progress update (0.0 to 1.0) | ||
| 19 | + SyncProgress { | ||
| 20 | + feed_id: String, | ||
| 21 | + progress: f32, | ||
| 22 | + message: String, | ||
| 23 | + }, | ||
| 24 | + /// Sync completed successfully | ||
| 25 | + SyncCompleted { | ||
| 26 | + feed_id: String, | ||
| 27 | + feed_name: String, | ||
| 28 | + episodes_found: usize, | ||
| 29 | + episodes_downloaded: usize, | ||
| 30 | + }, | ||
| 31 | + /// Sync failed with an error | ||
| 32 | + SyncFailed { | ||
| 33 | + feed_id: String, | ||
| 34 | + feed_name: String, | ||
| 35 | + error: String, | ||
| 36 | + }, | ||
| 37 | + /// Individual episode download started | ||
| 38 | + EpisodeDownloadStarted { | ||
| 39 | + feed_id: String, | ||
| 40 | + episode_title: String, | ||
| 41 | + }, | ||
| 42 | + /// Individual episode download progress | ||
| 43 | + EpisodeDownloadProgress { | ||
| 44 | + feed_id: String, | ||
| 45 | + episode_title: String, | ||
| 46 | + progress: f32, | ||
| 47 | + }, | ||
| 48 | + /// Individual episode download completed | ||
| 49 | + EpisodeDownloadCompleted { | ||
| 50 | + feed_id: String, | ||
| 51 | + episode_title: String, | ||
| 52 | + }, | ||
| 53 | + /// Individual episode download failed | ||
| 54 | + EpisodeDownloadFailed { | ||
| 55 | + feed_id: String, | ||
| 56 | + episode_title: String, | ||
| 57 | + error: String, | ||
| 58 | + }, | ||
| 59 | +} | ||
| 60 | + | ||
| 61 | +/// Status of a feed synchronization | ||
| 62 | +#[derive(Debug, Clone)] | ||
| 63 | +pub enum FeedSyncStatus { | ||
| 64 | + /// Sync not started or completed | ||
| 65 | + Idle, | ||
| 66 | + /// Sync in progress | ||
| 67 | + InProgress { | ||
| 68 | + progress: f32, | ||
| 69 | + message: String, | ||
| 70 | + }, | ||
| 71 | + /// Sync completed successfully | ||
| 72 | + Completed { | ||
| 73 | + episodes_found: usize, | ||
| 74 | + episodes_downloaded: usize, | ||
| 75 | + }, | ||
| 76 | + /// Sync failed | ||
| 77 | + Failed { | ||
| 78 | + error: String, | ||
| 79 | + }, | ||
| 80 | +} | ||
| 81 | + | ||
| 82 | +impl Default for FeedSyncStatus { | ||
| 83 | + fn default() -> Self { | ||
| 84 | + Self::Idle | ||
| 85 | + } | ||
| 86 | +} | ||
| 87 | + | ||
| 88 | +/// Episode download status | ||
| 89 | +#[derive(Debug, Clone)] | ||
| 90 | +pub struct EpisodeDownloadStatus { | ||
| 91 | + pub status: EpisodeDownloadPhase, | ||
| 92 | + pub progress: f32, | ||
| 93 | +} | ||
| 94 | + | ||
| 95 | +impl Default for EpisodeDownloadStatus { | ||
| 96 | + fn default() -> Self { | ||
| 97 | + Self { | ||
| 98 | + status: EpisodeDownloadPhase::NotStarted, | ||
| 99 | + progress: 0.0, | ||
| 100 | + } | ||
| 101 | + } | ||
| 102 | +} | ||
| 103 | + | ||
| 104 | +/// Phase of episode download | ||
| 105 | +#[derive(Debug, Clone, PartialEq)] | ||
| 106 | +pub enum EpisodeDownloadPhase { | ||
| 107 | + NotStarted, | ||
| 108 | + Downloading, | ||
| 109 | + Completed, | ||
| 110 | + Failed, | ||
| 111 | +} | ||
| 112 | + | ||
| 113 | +/// Status of all synchronization tasks | ||
| 114 | +#[derive(Debug, Default)] | ||
| 115 | +pub struct SyncStatus { | ||
| 116 | + /// Global sync in progress (sync all feeds) | ||
| 117 | + pub global_sync_in_progress: bool, | ||
| 118 | + /// Individual feed sync status (feed_id -> status) | ||
| 119 | + pub feed_sync_status: HashMap<String, FeedSyncStatus>, | ||
| 120 | + /// Individual episode download status (episode_id -> status) | ||
| 121 | + pub episode_download_status: HashMap<String, EpisodeDownloadStatus>, | ||
| 122 | + /// Total feeds to sync | ||
| 123 | + pub total_feeds: usize, | ||
| 124 | + /// Feeds completed | ||
| 125 | + pub completed_feeds: usize, | ||
| 126 | + /// Current overall progress (0.0 to 1.0) | ||
| 127 | + pub overall_progress: f32, | ||
| 128 | +} | ||
| 129 | + | ||
| 130 | +impl SyncStatus { | ||
| 131 | + /// Create a new SyncStatus | ||
| 132 | + pub fn new() -> Self { | ||
| 133 | + Self::default() | ||
| 134 | + } | ||
| 135 | + | ||
| 136 | + /// Start global sync | ||
| 137 | + pub fn start_global_sync(&mut self, feed_count: usize) { | ||
| 138 | + self.global_sync_in_progress = true; | ||
| 139 | + self.total_feeds = feed_count; | ||
| 140 | + self.completed_feeds = 0; | ||
| 141 | + self.overall_progress = 0.0; | ||
| 142 | + self.feed_sync_status.clear(); | ||
| 143 | + } | ||
| 144 | + | ||
| 145 | + /// Complete global sync | ||
| 146 | + pub fn complete_global_sync(&mut self) { | ||
| 147 | + self.global_sync_in_progress = false; | ||
| 148 | + self.overall_progress = 1.0; | ||
| 149 | + } | ||
| 150 | + | ||
| 151 | + /// Update feed sync status | ||
| 152 | + pub fn update_feed_status(&mut self, feed_id: String, status: FeedSyncStatus) { | ||
| 153 | + self.feed_sync_status.insert(feed_id, status); | ||
| 154 | + | ||
| 155 | + // Update overall progress | ||
| 156 | + if self.global_sync_in_progress && self.total_feeds > 0 { | ||
| 157 | + let completed = self.feed_sync_status.values() | ||
| 158 | + .filter(|s| matches!(s, FeedSyncStatus::Completed { .. } | FeedSyncStatus::Failed { .. })) | ||
| 159 | + .count(); | ||
| 160 | + self.completed_feeds = completed; | ||
| 161 | + self.overall_progress = (completed as f32 / self.total_feeds as f32).min(1.0); | ||
| 162 | + } | ||
| 163 | + } | ||
| 164 | + | ||
| 165 | + /// Get overall sync progress | ||
| 166 | + pub fn get_overall_progress(&self) -> f32 { | ||
| 167 | + self.overall_progress | ||
| 168 | + } | ||
| 169 | + | ||
| 170 | + /// Check if any sync is in progress | ||
| 171 | + pub fn is_syncing(&self) -> bool { | ||
| 172 | + self.global_sync_in_progress || | ||
| 173 | + self.feed_sync_status.values().any(|s| matches!(s, FeedSyncStatus::InProgress { .. })) | ||
| 174 | + } | ||
| 175 | + | ||
| 176 | + /// Get syncing feed count | ||
| 177 | + pub fn syncing_feed_count(&self) -> usize { | ||
| 178 | + self.feed_sync_status.values() | ||
| 179 | + .filter(|s| matches!(s, FeedSyncStatus::InProgress { .. })) | ||
| 180 | + .count() | ||
| 181 | + } | ||
| 182 | +} | ||
| 183 | + | ||
| 184 | +/// Manager for async sync tasks | ||
| 185 | +#[derive(Debug)] | ||
| 186 | +pub struct SyncTaskManager { | ||
| 187 | + /// Sender for sync messages | ||
| 188 | + sender: Sender<SyncMessage>, | ||
| 189 | + /// Receiver for sync messages | ||
| 190 | + receiver: Arc<Mutex<Receiver<SyncMessage>>>, | ||
| 191 | + /// Current sync status | ||
| 192 | + pub status: SyncStatus, | ||
| 193 | +} | ||
| 194 | + | ||
| 195 | +impl SyncTaskManager { | ||
| 196 | + /// Create a new SyncTaskManager | ||
| 197 | + pub fn new() -> Self { | ||
| 198 | + let (sender, receiver) = channel(); | ||
| 199 | + Self { | ||
| 200 | + sender, | ||
| 201 | + receiver: Arc::new(Mutex::new(receiver)), | ||
| 202 | + status: SyncStatus::new(), | ||
| 203 | + } | ||
| 204 | + } | ||
| 205 | + | ||
| 206 | + /// Get the sender for sending messages from async tasks | ||
| 207 | + pub fn sender(&self) -> Sender<SyncMessage> { | ||
| 208 | + self.sender.clone() | ||
| 209 | + } | ||
| 210 | + | ||
| 211 | + /// Process incoming messages and update status | ||
| 212 | + pub fn process_messages(&mut self) -> Vec<SyncMessage> { | ||
| 213 | + let mut messages = Vec::new(); | ||
| 214 | + | ||
| 215 | + let receiver = self.receiver.clone(); | ||
| 216 | + let receiver_guard = receiver.lock().unwrap(); | ||
| 217 | + | ||
| 218 | + while let Ok(msg) = receiver_guard.try_recv() { | ||
| 219 | + self.handle_message(&msg); | ||
| 220 | + messages.push(msg); | ||
| 221 | + } | ||
| 222 | + | ||
| 223 | + messages | ||
| 224 | + } | ||
| 225 | + | ||
| 226 | + /// Handle a single sync message | ||
| 227 | + fn handle_message(&mut self, message: &SyncMessage) { | ||
| 228 | + match message { | ||
| 229 | + SyncMessage::SyncStarted { feed_id, .. } => { | ||
| 230 | + self.status.update_feed_status( | ||
| 231 | + feed_id.clone(), | ||
| 232 | + FeedSyncStatus::InProgress { | ||
| 233 | + progress: 0.0, | ||
| 234 | + message: "Démarrage de la synchronisation...".to_string(), | ||
| 235 | + } | ||
| 236 | + ); | ||
| 237 | + } | ||
| 238 | + SyncMessage::SyncProgress { feed_id, progress, message } => { | ||
| 239 | + if let Some(status) = self.status.feed_sync_status.get_mut(feed_id) { | ||
| 240 | + if let FeedSyncStatus::InProgress { progress: _, message: _ } = status { | ||
| 241 | + // Update existing in-progress status | ||
| 242 | + } | ||
| 243 | + } | ||
| 244 | + self.status.update_feed_status( | ||
| 245 | + feed_id.clone(), | ||
| 246 | + FeedSyncStatus::InProgress { | ||
| 247 | + progress: *progress, | ||
| 248 | + message: message.clone(), | ||
| 249 | + } | ||
| 250 | + ); | ||
| 251 | + } | ||
| 252 | + SyncMessage::SyncCompleted { feed_id, feed_name: _, episodes_found, episodes_downloaded } => { | ||
| 253 | + self.status.update_feed_status( | ||
| 254 | + feed_id.clone(), | ||
| 255 | + FeedSyncStatus::Completed { | ||
| 256 | + episodes_found: *episodes_found, | ||
| 257 | + episodes_downloaded: *episodes_downloaded, | ||
| 258 | + } | ||
| 259 | + ); | ||
| 260 | + } | ||
| 261 | + SyncMessage::SyncFailed { feed_id, feed_name: _, error } => { | ||
| 262 | + self.status.update_feed_status( | ||
| 263 | + feed_id.clone(), | ||
| 264 | + FeedSyncStatus::Failed { | ||
| 265 | + error: error.clone(), | ||
| 266 | + } | ||
| 267 | + ); | ||
| 268 | + } | ||
| 269 | + SyncMessage::EpisodeDownloadStarted { feed_id: _, episode_title: _ } => { | ||
| 270 | + // Episode download started - could track this if needed | ||
| 271 | + } | ||
| 272 | + SyncMessage::EpisodeDownloadProgress { feed_id: _, episode_title: _, progress: _ } => { | ||
| 273 | + // Episode download progress | ||
| 274 | + } | ||
| 275 | + SyncMessage::EpisodeDownloadCompleted { .. } => { | ||
| 276 | + // Episode download completed | ||
| 277 | + } | ||
| 278 | + SyncMessage::EpisodeDownloadFailed { .. } => { | ||
| 279 | + // Episode download failed | ||
| 280 | + } | ||
| 281 | + } | ||
| 282 | + } | ||
| 283 | + | ||
| 284 | + /// Start syncing all feeds asynchronously | ||
| 285 | + pub fn start_sync_all_feeds(&mut self, feeds: Vec<crate::config::feed::Feed>) { | ||
| 286 | + self.status.start_global_sync(feeds.len()); | ||
| 287 | + | ||
| 288 | + let sender = self.sender.clone(); | ||
| 289 | + | ||
| 290 | + thread::spawn(move || { | ||
| 291 | + crate::rss::async_sync::sync_all_feeds_async(feeds, sender); | ||
| 292 | + }); | ||
| 293 | + } | ||
| 294 | + | ||
| 295 | + /// Start syncing a single feed asynchronously | ||
| 296 | + pub fn start_sync_feed(&mut self, feed: crate::config::feed::Feed) { | ||
| 297 | + let sender = self.sender.clone(); | ||
| 298 | + let feed_id = feed.id.clone(); | ||
| 299 | + | ||
| 300 | + // Update status | ||
| 301 | + self.status.update_feed_status( | ||
| 302 | + feed_id.clone(), | ||
| 303 | + FeedSyncStatus::InProgress { | ||
| 304 | + progress: 0.0, | ||
| 305 | + message: "Démarrage...".to_string(), | ||
| 306 | + } | ||
| 307 | + ); | ||
| 308 | + | ||
| 309 | + thread::spawn(move || { | ||
| 310 | + crate::rss::async_sync::sync_feed_async(feed, sender); | ||
| 311 | + }); | ||
| 312 | + } | ||
| 313 | +} | ||
| 314 | + | ||
| 315 | +impl Default for SyncTaskManager { | ||
| 316 | + fn default() -> Self { | ||
| 317 | + Self::new() | ||
| 318 | + } | ||
| 319 | +} | ||
| 320 | + | ||
| 321 | +#[cfg(test)] | ||
| 322 | +mod tests { | ||
| 323 | + use super::*; | ||
| 324 | + | ||
| 325 | + #[test] | ||
| 326 | + fn test_sync_status_default() { | ||
| 327 | + let status = SyncStatus::new(); | ||
| 328 | + assert!(!status.global_sync_in_progress); | ||
| 329 | + assert!(status.feed_sync_status.is_empty()); | ||
| 330 | + assert_eq!(status.get_overall_progress(), 0.0); | ||
| 331 | + assert!(!status.is_syncing()); | ||
| 332 | + } | ||
| 333 | + | ||
| 334 | + #[test] | ||
| 335 | + fn test_sync_status_start_global_sync() { | ||
| 336 | + let mut status = SyncStatus::new(); | ||
| 337 | + status.start_global_sync(5); | ||
| 338 | + | ||
| 339 | + assert!(status.global_sync_in_progress); | ||
| 340 | + assert_eq!(status.total_feeds, 5); | ||
| 341 | + assert_eq!(status.completed_feeds, 0); | ||
| 342 | + assert_eq!(status.get_overall_progress(), 0.0); | ||
| 343 | + } | ||
| 344 | + | ||
| 345 | + #[test] | ||
| 346 | + fn test_sync_status_overall_progress() { | ||
| 347 | + let mut status = SyncStatus::new(); | ||
| 348 | + status.start_global_sync(3); | ||
| 349 | + | ||
| 350 | + // Add completed feeds | ||
| 351 | + status.update_feed_status("feed-1".to_string(), FeedSyncStatus::Completed { episodes_found: 5, episodes_downloaded: 2 }); | ||
| 352 | + status.update_feed_status("feed-2".to_string(), FeedSyncStatus::Completed { episodes_found: 3, episodes_downloaded: 1 }); | ||
| 353 | + | ||
| 354 | + assert_eq!(status.completed_feeds, 2); | ||
| 355 | + assert!((status.get_overall_progress() - 0.666).abs() < 0.01); | ||
| 356 | + | ||
| 357 | + // Add failed feed | ||
| 358 | + status.update_feed_status("feed-3".to_string(), FeedSyncStatus::Failed { error: "Error".to_string() }); | ||
| 359 | + | ||
| 360 | + assert_eq!(status.completed_feeds, 3); | ||
| 361 | + assert_eq!(status.get_overall_progress(), 1.0); | ||
| 362 | + } | ||
| 363 | + | ||
| 364 | + #[test] | ||
| 365 | + fn test_feed_sync_status_is_idle() { | ||
| 366 | + let status = FeedSyncStatus::Idle; | ||
| 367 | + assert!(matches!(status, FeedSyncStatus::Idle)); | ||
| 368 | + } | ||
| 369 | + | ||
| 370 | + #[test] | ||
| 371 | + fn test_sync_task_manager_new() { | ||
| 372 | + let manager = SyncTaskManager::new(); | ||
| 373 | + assert!(!manager.status.global_sync_in_progress); | ||
| 374 | + } | ||
| 375 | +} | ||
added
src/rss/async_sync.rs +277 -0 | new file mode 100644 | ||
| @@ -0,0 +1,277 @@ | ||
| 1 | +//! Async Synchronization module for RSS Podcast Downloader | |
| 2 | +//! | |
| 3 | +//! This module provides asynchronous versions of the synchronization functions | |
| 4 | +//! that can run in the background and send progress updates. | |
| 5 | + | |
| 6 | +use crate::config::{Episode, Feed}; | |
| 7 | +use crate::config::sync_task::SyncMessage; | |
| 8 | +use crate::rss::downloader::download_episode; | |
| 9 | +use crate::rss::episode_extractor::extract_episodes; | |
| 10 | +use crate::rss::parser::{fetch_feed, parse_rss_content}; | |
| 11 | +use chrono::Utc; | |
| 12 | +use log::{error, info, warn}; | |
| 13 | +use std::sync::mpsc::Sender; | |
| 14 | +use std::sync::Arc; | |
| 15 | +use std::sync::atomic::{AtomicUsize, Ordering}; | |
| 16 | + | |
| 17 | +/// Synchronizes a single feed asynchronously | |
| 18 | +/// | |
| 19 | +/// This function runs in a separate thread and sends progress updates via the sender. | |
| 20 | +pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) { | |
| 21 | + let feed_id = feed.id.clone(); | |
| 22 | + let feed_name = feed.name.clone(); | |
| 23 | + | |
| 24 | + // Send sync started message | |
| 25 | + if sender.send(SyncMessage::SyncStarted { | |
| 26 | + feed_id: feed_id.clone(), | |
| 27 | + feed_name: feed_name.clone(), | |
| 28 | + }).is_err() { | |
| 29 | + return; // Channel closed, abort | |
| 30 | + } | |
| 31 | + | |
| 32 | + info!("Starting async synchronization for feed: {} ({})", feed_name, feed_id); | |
| 33 | + | |
| 34 | + // Validate URL | |
| 35 | + if feed.url.is_empty() { | |
| 36 | + let _ = sender.send(SyncMessage::SyncFailed { | |
| 37 | + feed_id: feed_id.clone(), | |
| 38 | + feed_name: feed_name.clone(), | |
| 39 | + error: "URL cannot be empty".to_string(), | |
| 40 | + }); | |
| 41 | + return; | |
| 42 | + } | |
| 43 | + | |
| 44 | + // Validate download path | |
| 45 | + if feed.download_path.to_string_lossy().is_empty() { | |
| 46 | + let _ = sender.send(SyncMessage::SyncFailed { | |
| 47 | + feed_id: feed_id.clone(), | |
| 48 | + feed_name: feed_name.clone(), | |
| 49 | + error: "Download path cannot be empty".to_string(), | |
| 50 | + }); | |
| 51 | + return; | |
| 52 | + } | |
| 53 | + | |
| 54 | + if !feed.enabled { | |
| 55 | + let _ = sender.send(SyncMessage::SyncFailed { | |
| 56 | + feed_id: feed_id.clone(), | |
| 57 | + feed_name: feed_name.clone(), | |
| 58 | + error: "Feed is disabled".to_string(), | |
| 59 | + }); | |
| 60 | + return; | |
| 61 | + } | |
| 62 | + | |
| 63 | + // Update progress | |
| 64 | + let _ = sender.send(SyncMessage::SyncProgress { | |
| 65 | + feed_id: feed_id.clone(), | |
| 66 | + progress: 0.1, | |
| 67 | + message: "Récupération du flux RSS...".to_string(), | |
| 68 | + }); | |
| 69 | + | |
| 70 | + // Fetch the feed content | |
| 71 | + let content = match fetch_feed(&feed.url) { | |
| 72 | + Ok(content) => content, | |
| 73 | + Err(e) => { | |
| 74 | + error!("Failed to fetch feed {}: {}", feed.url, e); | |
| 75 | + let _ = sender.send(SyncMessage::SyncFailed { | |
| 76 | + feed_id: feed_id.clone(), | |
| 77 | + feed_name: feed_name.clone(), | |
| 78 | + error: format!("Failed to fetch feed: {}", e), | |
| 79 | + }); | |
| 80 | + return; | |
| 81 | + } | |
| 82 | + }; | |
| 83 | + | |
| 84 | + let _ = sender.send(SyncMessage::SyncProgress { | |
| 85 | + feed_id: feed_id.clone(), | |
| 86 | + progress: 0.2, | |
| 87 | + message: "Analyse du flux RSS...".to_string(), | |
| 88 | + }); | |
| 89 | + | |
| 90 | + // Parse the RSS content | |
| 91 | + let channel = match parse_rss_content(&content) { | |
| 92 | + Ok(channel) => channel, | |
| 93 | + Err(e) => { | |
| 94 | + error!("Failed to parse feed {}: {}", feed.url, e); | |
| 95 | + let _ = sender.send(SyncMessage::SyncFailed { | |
| 96 | + feed_id: feed_id.clone(), | |
| 97 | + feed_name: feed_name.clone(), | |
| 98 | + error: format!("Failed to parse feed: {}", e), | |
| 99 | + }); | |
| 100 | + return; | |
| 101 | + } | |
| 102 | + }; | |
| 103 | + | |
| 104 | + let _ = sender.send(SyncMessage::SyncProgress { | |
| 105 | + feed_id: feed_id.clone(), | |
| 106 | + progress: 0.4, | |
| 107 | + message: "Extraction des épisodes...".to_string(), | |
| 108 | + }); | |
| 109 | + | |
| 110 | + // Extract episodes from the channel | |
| 111 | + let episodes = match extract_episodes(&channel) { | |
| 112 | + Ok(episodes) => episodes, | |
| 113 | + Err(e) => { | |
| 114 | + error!("Failed to extract episodes from feed {}: {}", feed.url, e); | |
| 115 | + let _ = sender.send(SyncMessage::SyncFailed { | |
| 116 | + feed_id: feed_id.clone(), | |
| 117 | + feed_name: feed_name.clone(), | |
| 118 | + error: format!("Failed to extract episodes: {}", e), | |
| 119 | + }); | |
| 120 | + return; | |
| 121 | + } | |
| 122 | + }; | |
| 123 | + | |
| 124 | + if episodes.is_empty() { | |
| 125 | + warn!("No episodes found in feed: {}", feed.url); | |
| 126 | + let _ = sender.send(SyncMessage::SyncFailed { | |
| 127 | + feed_id: feed_id.clone(), | |
| 128 | + feed_name: feed_name.clone(), | |
| 129 | + error: "No episodes found in feed".to_string(), | |
| 130 | + }); | |
| 131 | + return; | |
| 132 | + } | |
| 133 | + | |
| 134 | + info!("Found {} episodes in feed: {}", episodes.len(), feed.url); | |
| 135 | + | |
| 136 | + let _ = sender.send(SyncMessage::SyncProgress { | |
| 137 | + feed_id: feed_id.clone(), | |
| 138 | + progress: 0.5, | |
| 139 | + message: "Vérification des épisodes déjà téléchargés...".to_string(), | |
| 140 | + }); | |
| 141 | + | |
| 142 | + // Count total episodes to download | |
| 143 | + let mut feed_mut = Feed { ..feed }; | |
| 144 | + let total_episodes = episodes.len(); | |
| 145 | + let mut downloaded_count = 0; | |
| 146 | + let mut current_episode = 0; | |
| 147 | + | |
| 148 | + for episode in &episodes { | |
| 149 | + current_episode += 1; | |
| 150 | + | |
| 151 | + // Skip episodes that are already downloaded | |
| 152 | + if is_episode_downloaded(&feed_mut, episode) { | |
| 153 | + info!("Episode already downloaded: {}", episode.title); | |
| 154 | + continue; | |
| 155 | + } | |
| 156 | + | |
| 157 | + // Update progress per episode | |
| 158 | + let progress = 0.5 + (current_episode as f32 / total_episodes as f32) * 0.4; | |
| 159 | + let _ = sender.send(SyncMessage::SyncProgress { | |
| 160 | + feed_id: feed_id.clone(), | |
| 161 | + progress, | |
| 162 | + message: format!("Téléchargement: {}", episode.title), | |
| 163 | + }); | |
| 164 | + | |
| 165 | + let _ = sender.send(SyncMessage::EpisodeDownloadStarted { | |
| 166 | + feed_id: feed_id.clone(), | |
| 167 | + episode_title: episode.title.clone(), | |
| 168 | + }); | |
| 169 | + | |
| 170 | + // Create a progress callback that sends updates | |
| 171 | + let episode_title = episode.title.clone(); | |
| 172 | + let feed_id_clone = feed_id.clone(); | |
| 173 | + let sender_clone = sender.clone(); | |
| 174 | + | |
| 175 | + let callback = Box::new(move |progress: f32| { | |
| 176 | + let episode_progress = 0.5 + (0.4 * progress); | |
| 177 | + let _ = sender_clone.send(SyncMessage::EpisodeDownloadProgress { | |
| 178 | + feed_id: feed_id_clone.clone(), | |
| 179 | + episode_title: episode_title.clone(), | |
| 180 | + progress: episode_progress, | |
| 181 | + }); | |
| 182 | + }); | |
| 183 | + | |
| 184 | + // Download the episode | |
| 185 | + match download_episode(episode, &feed_mut.download_path, callback) { | |
| 186 | + Ok(_) => { | |
| 187 | + info!("Successfully downloaded episode: {}", episode.title); | |
| 188 | + downloaded_count += 1; | |
| 189 | + let _ = sender.send(SyncMessage::EpisodeDownloadCompleted { | |
| 190 | + feed_id: feed_id.clone(), | |
| 191 | + episode_title: episode.title.clone(), | |
| 192 | + }); | |
| 193 | + } | |
| 194 | + Err(e) => { | |
| 195 | + error!("Failed to download episode {}: {}", episode.title, e); | |
| 196 | + let _ = sender.send(SyncMessage::EpisodeDownloadFailed { | |
| 197 | + feed_id: feed_id.clone(), | |
| 198 | + episode_title: episode.title.clone(), | |
| 199 | + error: e.to_string(), | |
| 200 | + }); | |
| 201 | + } | |
| 202 | + } | |
| 203 | + } | |
| 204 | + | |
| 205 | + // Update feed metadata | |
| 206 | + feed_mut.last_checked = Some(Utc::now()); | |
| 207 | + if let Some(latest) = episodes.iter().max_by(|a, b| a.pub_date.cmp(&b.pub_date)) { | |
| 208 | + feed_mut.last_episode_date = Some(latest.pub_date); | |
| 209 | + } | |
| 210 | + | |
| 211 | + // Save the updated feed | |
| 212 | + // Note: We can't save directly here as we don't have access to storage | |
| 213 | + // The caller will need to save the feeds after sync completes | |
| 214 | + | |
| 215 | + let _ = sender.send(SyncMessage::SyncCompleted { | |
| 216 | + feed_id: feed_id.clone(), | |
| 217 | + feed_name: feed_name.clone(), | |
| 218 | + episodes_found: episodes.len(), | |
| 219 | + episodes_downloaded: downloaded_count, | |
| 220 | + }); | |
| 221 | + | |
| 222 | + info!("Async synchronization completed for feed: {} ({} episodes found, {} downloaded)", | |
| 223 | + feed_name, episodes.len(), downloaded_count); | |
| 224 | +} | |
| 225 | + | |
| 226 | +/// Synchronizes all feeds asynchronously | |
| 227 | +pub fn sync_all_feeds_async(feeds: Vec<Feed>, sender: Sender<SyncMessage>) { | |
| 228 | + info!("Starting async synchronization of all {} feeds", feeds.len()); | |
| 229 | + | |
| 230 | + let _total_feeds = feeds.len(); | |
| 231 | + let completed_count = Arc::new(AtomicUsize::new(0)); | |
| 232 | + | |
| 233 | + // Send global sync started (handled by the manager) | |
| 234 | + | |
| 235 | + for _feed in feeds { | |
| 236 | + let sender_clone = sender.clone(); | |
| 237 | + let completed_count_clone = completed_count.clone(); | |
| 238 | + | |
| 239 | + // Spawn a separate thread for each feed sync | |
| 240 | + std::thread::spawn(move || { | |
| 241 | + sync_feed_async(_feed, sender_clone); | |
| 242 | + completed_count_clone.fetch_add(1, Ordering::SeqCst); | |
| 243 | + }); | |
| 244 | + } | |
| 245 | + | |
| 246 | + // Note: The completion tracking is handled by the SyncTaskManager | |
| 247 | + // which receives messages from individual feed syncs | |
| 248 | +} | |
| 249 | + | |
| 250 | +/// Checks if an episode has already been downloaded | |
| 251 | +fn is_episode_downloaded(feed: &Feed, episode: &Episode) -> bool { | |
| 252 | + !crate::rss::downloader::needs_download(episode, std::path::Path::new(&feed.download_path)) | |
| 253 | +} | |
| 254 | + | |
| 255 | +#[cfg(test)] | |
| 256 | +mod tests { | |
| 257 | + use super::*; | |
| 258 | + use std::sync::mpsc::channel; | |
| 259 | + | |
| 260 | + #[test] | |
| 261 | + fn test_is_episode_downloaded() { | |
| 262 | + use std::path::PathBuf; | |
| 263 | + use crate::config::episode::Episode; | |
| 264 | + use chrono::Utc; | |
| 265 | + | |
| 266 | + let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads"); | |
| 267 | + let episode = Episode::new( | |
| 268 | + "ep-123", | |
| 269 | + "Test Episode", | |
| 270 | + "https://example.com/episode.mp3", | |
| 271 | + Utc::now(), | |
| 272 | + ); | |
| 273 | + | |
| 274 | + // Episode not downloaded (file doesn't exist) | |
| 275 | + assert!(!is_episode_downloaded(&feed, &episode)); | |
| 276 | + } | |
| 277 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,277 @@ | |||
| 1 | +//! Async Synchronization module for RSS Podcast Downloader | ||
| 2 | +//! | ||
| 3 | +//! This module provides asynchronous versions of the synchronization functions | ||
| 4 | +//! that can run in the background and send progress updates. | ||
| 5 | + | ||
| 6 | +use crate::config::{Episode, Feed}; | ||
| 7 | +use crate::config::sync_task::SyncMessage; | ||
| 8 | +use crate::rss::downloader::download_episode; | ||
| 9 | +use crate::rss::episode_extractor::extract_episodes; | ||
| 10 | +use crate::rss::parser::{fetch_feed, parse_rss_content}; | ||
| 11 | +use chrono::Utc; | ||
| 12 | +use log::{error, info, warn}; | ||
| 13 | +use std::sync::mpsc::Sender; | ||
| 14 | +use std::sync::Arc; | ||
| 15 | +use std::sync::atomic::{AtomicUsize, Ordering}; | ||
| 16 | + | ||
| 17 | +/// Synchronizes a single feed asynchronously | ||
| 18 | +/// | ||
| 19 | +/// This function runs in a separate thread and sends progress updates via the sender. | ||
| 20 | +pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) { | ||
| 21 | + let feed_id = feed.id.clone(); | ||
| 22 | + let feed_name = feed.name.clone(); | ||
| 23 | + | ||
| 24 | + // Send sync started message | ||
| 25 | + if sender.send(SyncMessage::SyncStarted { | ||
| 26 | + feed_id: feed_id.clone(), | ||
| 27 | + feed_name: feed_name.clone(), | ||
| 28 | + }).is_err() { | ||
| 29 | + return; // Channel closed, abort | ||
| 30 | + } | ||
| 31 | + | ||
| 32 | + info!("Starting async synchronization for feed: {} ({})", feed_name, feed_id); | ||
| 33 | + | ||
| 34 | + // Validate URL | ||
| 35 | + if feed.url.is_empty() { | ||
| 36 | + let _ = sender.send(SyncMessage::SyncFailed { | ||
| 37 | + feed_id: feed_id.clone(), | ||
| 38 | + feed_name: feed_name.clone(), | ||
| 39 | + error: "URL cannot be empty".to_string(), | ||
| 40 | + }); | ||
| 41 | + return; | ||
| 42 | + } | ||
| 43 | + | ||
| 44 | + // Validate download path | ||
| 45 | + if feed.download_path.to_string_lossy().is_empty() { | ||
| 46 | + let _ = sender.send(SyncMessage::SyncFailed { | ||
| 47 | + feed_id: feed_id.clone(), | ||
| 48 | + feed_name: feed_name.clone(), | ||
| 49 | + error: "Download path cannot be empty".to_string(), | ||
| 50 | + }); | ||
| 51 | + return; | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + if !feed.enabled { | ||
| 55 | + let _ = sender.send(SyncMessage::SyncFailed { | ||
| 56 | + feed_id: feed_id.clone(), | ||
| 57 | + feed_name: feed_name.clone(), | ||
| 58 | + error: "Feed is disabled".to_string(), | ||
| 59 | + }); | ||
| 60 | + return; | ||
| 61 | + } | ||
| 62 | + | ||
| 63 | + // Update progress | ||
| 64 | + let _ = sender.send(SyncMessage::SyncProgress { | ||
| 65 | + feed_id: feed_id.clone(), | ||
| 66 | + progress: 0.1, | ||
| 67 | + message: "Récupération du flux RSS...".to_string(), | ||
| 68 | + }); | ||
| 69 | + | ||
| 70 | + // Fetch the feed content | ||
| 71 | + let content = match fetch_feed(&feed.url) { | ||
| 72 | + Ok(content) => content, | ||
| 73 | + Err(e) => { | ||
| 74 | + error!("Failed to fetch feed {}: {}", feed.url, e); | ||
| 75 | + let _ = sender.send(SyncMessage::SyncFailed { | ||
| 76 | + feed_id: feed_id.clone(), | ||
| 77 | + feed_name: feed_name.clone(), | ||
| 78 | + error: format!("Failed to fetch feed: {}", e), | ||
| 79 | + }); | ||
| 80 | + return; | ||
| 81 | + } | ||
| 82 | + }; | ||
| 83 | + | ||
| 84 | + let _ = sender.send(SyncMessage::SyncProgress { | ||
| 85 | + feed_id: feed_id.clone(), | ||
| 86 | + progress: 0.2, | ||
| 87 | + message: "Analyse du flux RSS...".to_string(), | ||
| 88 | + }); | ||
| 89 | + | ||
| 90 | + // Parse the RSS content | ||
| 91 | + let channel = match parse_rss_content(&content) { | ||
| 92 | + Ok(channel) => channel, | ||
| 93 | + Err(e) => { | ||
| 94 | + error!("Failed to parse feed {}: {}", feed.url, e); | ||
| 95 | + let _ = sender.send(SyncMessage::SyncFailed { | ||
| 96 | + feed_id: feed_id.clone(), | ||
| 97 | + feed_name: feed_name.clone(), | ||
| 98 | + error: format!("Failed to parse feed: {}", e), | ||
| 99 | + }); | ||
| 100 | + return; | ||
| 101 | + } | ||
| 102 | + }; | ||
| 103 | + | ||
| 104 | + let _ = sender.send(SyncMessage::SyncProgress { | ||
| 105 | + feed_id: feed_id.clone(), | ||
| 106 | + progress: 0.4, | ||
| 107 | + message: "Extraction des épisodes...".to_string(), | ||
| 108 | + }); | ||
| 109 | + | ||
| 110 | + // Extract episodes from the channel | ||
| 111 | + let episodes = match extract_episodes(&channel) { | ||
| 112 | + Ok(episodes) => episodes, | ||
| 113 | + Err(e) => { | ||
| 114 | + error!("Failed to extract episodes from feed {}: {}", feed.url, e); | ||
| 115 | + let _ = sender.send(SyncMessage::SyncFailed { | ||
| 116 | + feed_id: feed_id.clone(), | ||
| 117 | + feed_name: feed_name.clone(), | ||
| 118 | + error: format!("Failed to extract episodes: {}", e), | ||
| 119 | + }); | ||
| 120 | + return; | ||
| 121 | + } | ||
| 122 | + }; | ||
| 123 | + | ||
| 124 | + if episodes.is_empty() { | ||
| 125 | + warn!("No episodes found in feed: {}", feed.url); | ||
| 126 | + let _ = sender.send(SyncMessage::SyncFailed { | ||
| 127 | + feed_id: feed_id.clone(), | ||
| 128 | + feed_name: feed_name.clone(), | ||
| 129 | + error: "No episodes found in feed".to_string(), | ||
| 130 | + }); | ||
| 131 | + return; | ||
| 132 | + } | ||
| 133 | + | ||
| 134 | + info!("Found {} episodes in feed: {}", episodes.len(), feed.url); | ||
| 135 | + | ||
| 136 | + let _ = sender.send(SyncMessage::SyncProgress { | ||
| 137 | + feed_id: feed_id.clone(), | ||
| 138 | + progress: 0.5, | ||
| 139 | + message: "Vérification des épisodes déjà téléchargés...".to_string(), | ||
| 140 | + }); | ||
| 141 | + | ||
| 142 | + // Count total episodes to download | ||
| 143 | + let mut feed_mut = Feed { ..feed }; | ||
| 144 | + let total_episodes = episodes.len(); | ||
| 145 | + let mut downloaded_count = 0; | ||
| 146 | + let mut current_episode = 0; | ||
| 147 | + | ||
| 148 | + for episode in &episodes { | ||
| 149 | + current_episode += 1; | ||
| 150 | + | ||
| 151 | + // Skip episodes that are already downloaded | ||
| 152 | + if is_episode_downloaded(&feed_mut, episode) { | ||
| 153 | + info!("Episode already downloaded: {}", episode.title); | ||
| 154 | + continue; | ||
| 155 | + } | ||
| 156 | + | ||
| 157 | + // Update progress per episode | ||
| 158 | + let progress = 0.5 + (current_episode as f32 / total_episodes as f32) * 0.4; | ||
| 159 | + let _ = sender.send(SyncMessage::SyncProgress { | ||
| 160 | + feed_id: feed_id.clone(), | ||
| 161 | + progress, | ||
| 162 | + message: format!("Téléchargement: {}", episode.title), | ||
| 163 | + }); | ||
| 164 | + | ||
| 165 | + let _ = sender.send(SyncMessage::EpisodeDownloadStarted { | ||
| 166 | + feed_id: feed_id.clone(), | ||
| 167 | + episode_title: episode.title.clone(), | ||
| 168 | + }); | ||
| 169 | + | ||
| 170 | + // Create a progress callback that sends updates | ||
| 171 | + let episode_title = episode.title.clone(); | ||
| 172 | + let feed_id_clone = feed_id.clone(); | ||
| 173 | + let sender_clone = sender.clone(); | ||
| 174 | + | ||
| 175 | + let callback = Box::new(move |progress: f32| { | ||
| 176 | + let episode_progress = 0.5 + (0.4 * progress); | ||
| 177 | + let _ = sender_clone.send(SyncMessage::EpisodeDownloadProgress { | ||
| 178 | + feed_id: feed_id_clone.clone(), | ||
| 179 | + episode_title: episode_title.clone(), | ||
| 180 | + progress: episode_progress, | ||
| 181 | + }); | ||
| 182 | + }); | ||
| 183 | + | ||
| 184 | + // Download the episode | ||
| 185 | + match download_episode(episode, &feed_mut.download_path, callback) { | ||
| 186 | + Ok(_) => { | ||
| 187 | + info!("Successfully downloaded episode: {}", episode.title); | ||
| 188 | + downloaded_count += 1; | ||
| 189 | + let _ = sender.send(SyncMessage::EpisodeDownloadCompleted { | ||
| 190 | + feed_id: feed_id.clone(), | ||
| 191 | + episode_title: episode.title.clone(), | ||
| 192 | + }); | ||
| 193 | + } | ||
| 194 | + Err(e) => { | ||
| 195 | + error!("Failed to download episode {}: {}", episode.title, e); | ||
| 196 | + let _ = sender.send(SyncMessage::EpisodeDownloadFailed { | ||
| 197 | + feed_id: feed_id.clone(), | ||
| 198 | + episode_title: episode.title.clone(), | ||
| 199 | + error: e.to_string(), | ||
| 200 | + }); | ||
| 201 | + } | ||
| 202 | + } | ||
| 203 | + } | ||
| 204 | + | ||
| 205 | + // Update feed metadata | ||
| 206 | + feed_mut.last_checked = Some(Utc::now()); | ||
| 207 | + if let Some(latest) = episodes.iter().max_by(|a, b| a.pub_date.cmp(&b.pub_date)) { | ||
| 208 | + feed_mut.last_episode_date = Some(latest.pub_date); | ||
| 209 | + } | ||
| 210 | + | ||
| 211 | + // Save the updated feed | ||
| 212 | + // Note: We can't save directly here as we don't have access to storage | ||
| 213 | + // The caller will need to save the feeds after sync completes | ||
| 214 | + | ||
| 215 | + let _ = sender.send(SyncMessage::SyncCompleted { | ||
| 216 | + feed_id: feed_id.clone(), | ||
| 217 | + feed_name: feed_name.clone(), | ||
| 218 | + episodes_found: episodes.len(), | ||
| 219 | + episodes_downloaded: downloaded_count, | ||
| 220 | + }); | ||
| 221 | + | ||
| 222 | + info!("Async synchronization completed for feed: {} ({} episodes found, {} downloaded)", | ||
| 223 | + feed_name, episodes.len(), downloaded_count); | ||
| 224 | +} | ||
| 225 | + | ||
| 226 | +/// Synchronizes all feeds asynchronously | ||
| 227 | +pub fn sync_all_feeds_async(feeds: Vec<Feed>, sender: Sender<SyncMessage>) { | ||
| 228 | + info!("Starting async synchronization of all {} feeds", feeds.len()); | ||
| 229 | + | ||
| 230 | + let _total_feeds = feeds.len(); | ||
| 231 | + let completed_count = Arc::new(AtomicUsize::new(0)); | ||
| 232 | + | ||
| 233 | + // Send global sync started (handled by the manager) | ||
| 234 | + | ||
| 235 | + for _feed in feeds { | ||
| 236 | + let sender_clone = sender.clone(); | ||
| 237 | + let completed_count_clone = completed_count.clone(); | ||
| 238 | + | ||
| 239 | + // Spawn a separate thread for each feed sync | ||
| 240 | + std::thread::spawn(move || { | ||
| 241 | + sync_feed_async(_feed, sender_clone); | ||
| 242 | + completed_count_clone.fetch_add(1, Ordering::SeqCst); | ||
| 243 | + }); | ||
| 244 | + } | ||
| 245 | + | ||
| 246 | + // Note: The completion tracking is handled by the SyncTaskManager | ||
| 247 | + // which receives messages from individual feed syncs | ||
| 248 | +} | ||
| 249 | + | ||
| 250 | +/// Checks if an episode has already been downloaded | ||
| 251 | +fn is_episode_downloaded(feed: &Feed, episode: &Episode) -> bool { | ||
| 252 | + !crate::rss::downloader::needs_download(episode, std::path::Path::new(&feed.download_path)) | ||
| 253 | +} | ||
| 254 | + | ||
| 255 | +#[cfg(test)] | ||
| 256 | +mod tests { | ||
| 257 | + use super::*; | ||
| 258 | + use std::sync::mpsc::channel; | ||
| 259 | + | ||
| 260 | + #[test] | ||
| 261 | + fn test_is_episode_downloaded() { | ||
| 262 | + use std::path::PathBuf; | ||
| 263 | + use crate::config::episode::Episode; | ||
| 264 | + use chrono::Utc; | ||
| 265 | + | ||
| 266 | + let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads"); | ||
| 267 | + let episode = Episode::new( | ||
| 268 | + "ep-123", | ||
| 269 | + "Test Episode", | ||
| 270 | + "https://example.com/episode.mp3", | ||
| 271 | + Utc::now(), | ||
| 272 | + ); | ||
| 273 | + | ||
| 274 | + // Episode not downloaded (file doesn't exist) | ||
| 275 | + assert!(!is_episode_downloaded(&feed, &episode)); | ||
| 276 | + } | ||
| 277 | +} | ||
modified
src/rss/mod.rs +4 -0 | @@ -2,6 +2,7 @@ | ||
| 2 | 2 | //! |
| 3 | 3 | //! This module handles RSS feed parsing, episode extraction, and downloading. |
| 4 | 4 | |
| 5 | +pub mod async_sync; | |
| 5 | 6 | pub mod audio_metadata; |
| 6 | 7 | pub mod downloader; |
| 7 | 8 | pub mod episode_extractor; |
| @@ -16,6 +17,9 @@ pub mod sync; | ||
| 16 | 17 | pub use audio_metadata::{AudioMetadata, Id3Tags, MetadataError, format_duration, get_audio_duration, is_supported_audio_file, read_audio_metadata, read_id3_tags, write_id3_tags, ensure_id3_tags}; |
| 17 | 18 | #[allow(dead_code)] |
| 18 | 19 | #[allow(unused_imports)] |
| 20 | +pub use async_sync::{sync_all_feeds_async, sync_feed_async}; | |
| 21 | +#[allow(dead_code)] | |
| 22 | +#[allow(unused_imports)] | |
| 19 | 23 | pub use downloader::{DownloadClient, DownloadError, ProgressCallback, download_episode, download_episodes, download_file, get_download_dir, needs_download, remove_downloaded_episode}; |
| 20 | 24 | #[allow(dead_code)] |
| 21 | 25 | #[allow(unused_imports)] |
| @@ -2,6 +2,7 @@ | |||
| 2 | //! | 2 | //! |
| 3 | //! This module handles RSS feed parsing, episode extraction, and downloading. | 3 | //! This module handles RSS feed parsing, episode extraction, and downloading. |
| 4 | 4 | ||
| 5 | +pub mod async_sync; | ||
| 5 | pub mod audio_metadata; | 6 | pub mod audio_metadata; |
| 6 | pub mod downloader; | 7 | pub mod downloader; |
| 7 | pub mod episode_extractor; | 8 | pub mod episode_extractor; |
| @@ -16,6 +17,9 @@ pub mod sync; | |||
| 16 | pub use audio_metadata::{AudioMetadata, Id3Tags, MetadataError, format_duration, get_audio_duration, is_supported_audio_file, read_audio_metadata, read_id3_tags, write_id3_tags, ensure_id3_tags}; | 17 | pub use audio_metadata::{AudioMetadata, Id3Tags, MetadataError, format_duration, get_audio_duration, is_supported_audio_file, read_audio_metadata, read_id3_tags, write_id3_tags, ensure_id3_tags}; |
| 17 | #[allow(dead_code)] | 18 | #[allow(dead_code)] |
| 18 | #[allow(unused_imports)] | 19 | #[allow(unused_imports)] |
| 20 | +pub use async_sync::{sync_all_feeds_async, sync_feed_async}; | ||
| 21 | +#[allow(dead_code)] | ||
| 22 | +#[allow(unused_imports)] | ||
| 19 | pub use downloader::{DownloadClient, DownloadError, ProgressCallback, download_episode, download_episodes, download_file, get_download_dir, needs_download, remove_downloaded_episode}; | 23 | pub use downloader::{DownloadClient, DownloadError, ProgressCallback, download_episode, download_episodes, download_file, get_download_dir, needs_download, remove_downloaded_episode}; |
| 20 | #[allow(dead_code)] | 24 | #[allow(dead_code)] |
| 21 | #[allow(unused_imports)] | 25 | #[allow(unused_imports)] |
modified
src/ui/feed_list.rs +10 -21 | @@ -118,7 +118,7 @@ fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) { | ||
| 118 | 118 | if ui.button(egui::RichText::new("🔄").size(18.0)) |
| 119 | 119 | .on_hover_text("Synchroniser ce flux") |
| 120 | 120 | .clicked() { |
| 121 | - sync_feed(state, index); | |
| 121 | + sync_feed_async(state, index); | |
| 122 | 122 | } |
| 123 | 123 | |
| 124 | 124 | // Bouton Éditer |
| @@ -152,31 +152,20 @@ fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) { | ||
| 152 | 152 | ui.separator(); |
| 153 | 153 | } |
| 154 | 154 | |
| 155 | -/// Synchronizes a single feed | |
| 156 | -fn sync_feed(state: &mut AppState, index: usize) { | |
| 155 | +/// Synchronizes a single feed asynchronously | |
| 156 | +fn sync_feed_async(state: &mut AppState, index: usize) { | |
| 157 | 157 | if index >= state.feeds.len() { |
| 158 | 158 | state.notification_manager.error("Index de flux invalide"); |
| 159 | 159 | return; |
| 160 | 160 | } |
| 161 | 161 | |
| 162 | - state.set_sync_state(true); | |
| 163 | - state.notification_manager.info(format!("Synchronisation du flux: {}", state.feeds[index].name)); | |
| 164 | - | |
| 165 | - log::info!("Starting synchronization of feed: {}", state.feeds[index].name); | |
| 166 | - | |
| 167 | - // Perform synchronization for single feed | |
| 168 | - let result = crate::rss::sync::sync_feed(&mut state.feeds[index]); | |
| 162 | + log::info!("Starting asynchronous synchronization of feed: {}", state.feeds[index].name); | |
| 169 | 163 | |
| 170 | - state.set_sync_state(false); | |
| 164 | + // Clone the feed for async task | |
| 165 | + let feed_to_sync = state.feeds[index].clone(); | |
| 171 | 166 | |
| 172 | - match result { | |
| 173 | - Ok(episodes) => { | |
| 174 | - state.notification_manager.success(format!("Flux synchronisé: {} épisodes trouvés", episodes.len())); | |
| 175 | - // Save the updated feeds | |
| 176 | - super::super::config::storage::save_feeds_default(&state.feeds).ok(); | |
| 177 | - } | |
| 178 | - Err(e) => { | |
| 179 | - state.notification_manager.error(format!("Erreur de synchronisation: {}", e)); | |
| 180 | - } | |
| 181 | - } | |
| 167 | + // Start async sync | |
| 168 | + state.sync_task_manager.start_sync_feed(feed_to_sync); | |
| 169 | + | |
| 170 | + state.notification_manager.info(format!("Synchronisation du flux en cours: {}", state.feeds[index].name)); | |
| 182 | 171 | } |
| @@ -118,7 +118,7 @@ fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) { | |||
| 118 | if ui.button(egui::RichText::new("🔄").size(18.0)) | 118 | if ui.button(egui::RichText::new("🔄").size(18.0)) |
| 119 | .on_hover_text("Synchroniser ce flux") | 119 | .on_hover_text("Synchroniser ce flux") |
| 120 | .clicked() { | 120 | .clicked() { |
| 121 | - sync_feed(state, index); | 121 | + sync_feed_async(state, index); |
| 122 | } | 122 | } |
| 123 | 123 | ||
| 124 | // Bouton Éditer | 124 | // Bouton Éditer |
| @@ -152,31 +152,20 @@ fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) { | |||
| 152 | ui.separator(); | 152 | ui.separator(); |
| 153 | } | 153 | } |
| 154 | 154 | ||
| 155 | -/// Synchronizes a single feed | 155 | +/// Synchronizes a single feed asynchronously |
| 156 | -fn sync_feed(state: &mut AppState, index: usize) { | 156 | +fn sync_feed_async(state: &mut AppState, index: usize) { |
| 157 | if index >= state.feeds.len() { | 157 | if index >= state.feeds.len() { |
| 158 | state.notification_manager.error("Index de flux invalide"); | 158 | state.notification_manager.error("Index de flux invalide"); |
| 159 | return; | 159 | return; |
| 160 | } | 160 | } |
| 161 | 161 | ||
| 162 | - state.set_sync_state(true); | 162 | + log::info!("Starting asynchronous synchronization of feed: {}", state.feeds[index].name); |
| 163 | - state.notification_manager.info(format!("Synchronisation du flux: {}", state.feeds[index].name)); | ||
| 164 | - | ||
| 165 | - log::info!("Starting synchronization of feed: {}", state.feeds[index].name); | ||
| 166 | - | ||
| 167 | - // Perform synchronization for single feed | ||
| 168 | - let result = crate::rss::sync::sync_feed(&mut state.feeds[index]); | ||
| 169 | 163 | ||
| 170 | - state.set_sync_state(false); | 164 | + // Clone the feed for async task |
| 165 | + let feed_to_sync = state.feeds[index].clone(); | ||
| 171 | 166 | ||
| 172 | - match result { | 167 | + // Start async sync |
| 173 | - Ok(episodes) => { | 168 | + state.sync_task_manager.start_sync_feed(feed_to_sync); |
| 174 | - state.notification_manager.success(format!("Flux synchronisé: {} épisodes trouvés", episodes.len())); | 169 | + |
| 175 | - // Save the updated feeds | 170 | + state.notification_manager.info(format!("Synchronisation du flux en cours: {}", state.feeds[index].name)); |
| 176 | - super::super::config::storage::save_feeds_default(&state.feeds).ok(); | ||
| 177 | - } | ||
| 178 | - Err(e) => { | ||
| 179 | - state.notification_manager.error(format!("Erreur de synchronisation: {}", e)); | ||
| 180 | - } | ||
| 181 | - } | ||
| 182 | } | 171 | } |
modified
src/ui/home.rs +11 -19 | @@ -33,6 +33,9 @@ pub fn draw_home(state: &mut AppState, ui: &mut Ui) { | ||
| 33 | 33 | draw_action_buttons(state, ui); |
| 34 | 34 | }); |
| 35 | 35 | |
| 36 | + // Sync status panel (shown when sync is in progress) | |
| 37 | + crate::ui::sync_status::draw_sync_status_panel(state, ui); | |
| 38 | + | |
| 36 | 39 | ui.add_space(16.0); |
| 37 | 40 | ui.separator(); |
| 38 | 41 | |
| @@ -109,31 +112,20 @@ pub fn draw_feed_list(state: &mut AppState, ui: &mut Ui) { | ||
| 109 | 112 | crate::ui::feed_list::draw_feed_list(state, ui); |
| 110 | 113 | } |
| 111 | 114 | |
| 112 | -/// Synchronizes all feeds | |
| 115 | +/// Synchronizes all feeds asynchronously | |
| 113 | 116 | fn sync_all_feeds(state: &mut AppState) { |
| 114 | 117 | if !state.has_feeds() { |
| 115 | 118 | state.notification_manager.info("Aucun flux à synchroniser"); |
| 116 | 119 | return; |
| 117 | 120 | } |
| 118 | 121 | |
| 119 | - state.set_sync_state(true); | |
| 120 | - state.notification_manager.info("Synchronisation de tous les flux..."); | |
| 121 | - | |
| 122 | - log::info!("Starting synchronization of all feeds"); | |
| 123 | - | |
| 124 | - // Perform synchronization | |
| 125 | - let result = crate::rss::sync::sync_all_feeds(&mut state.feeds); | |
| 122 | + log::info!("Starting asynchronous synchronization of all feeds"); | |
| 126 | 123 | |
| 127 | - state.set_sync_state(false); | |
| 124 | + // Clone feeds for async task (we'll update state.feeds from messages) | |
| 125 | + let feeds_to_sync = state.feeds.clone(); | |
| 128 | 126 | |
| 129 | - match result { | |
| 130 | - Ok(success_count) => { | |
| 131 | - state.notification_manager.success(format!("{} flux synchronisés avec succès", success_count)); | |
| 132 | - // Save the updated feeds | |
| 133 | - super::super::config::storage::save_feeds_default(&state.feeds).ok(); | |
| 134 | - } | |
| 135 | - Err(e) => { | |
| 136 | - state.notification_manager.error(format!("Erreur de synchronisation: {}", e)); | |
| 137 | - } | |
| 138 | - } | |
| 127 | + // Start async sync | |
| 128 | + state.sync_task_manager.start_sync_all_feeds(feeds_to_sync); | |
| 129 | + | |
| 130 | + state.notification_manager.info("Synchronisation de tous les flux en cours..."); | |
| 139 | 131 | } |
| @@ -33,6 +33,9 @@ pub fn draw_home(state: &mut AppState, ui: &mut Ui) { | |||
| 33 | draw_action_buttons(state, ui); | 33 | draw_action_buttons(state, ui); |
| 34 | }); | 34 | }); |
| 35 | 35 | ||
| 36 | + // Sync status panel (shown when sync is in progress) | ||
| 37 | + crate::ui::sync_status::draw_sync_status_panel(state, ui); | ||
| 38 | + | ||
| 36 | ui.add_space(16.0); | 39 | ui.add_space(16.0); |
| 37 | ui.separator(); | 40 | ui.separator(); |
| 38 | 41 | ||
| @@ -109,31 +112,20 @@ pub fn draw_feed_list(state: &mut AppState, ui: &mut Ui) { | |||
| 109 | crate::ui::feed_list::draw_feed_list(state, ui); | 112 | crate::ui::feed_list::draw_feed_list(state, ui); |
| 110 | } | 113 | } |
| 111 | 114 | ||
| 112 | -/// Synchronizes all feeds | 115 | +/// Synchronizes all feeds asynchronously |
| 113 | fn sync_all_feeds(state: &mut AppState) { | 116 | fn sync_all_feeds(state: &mut AppState) { |
| 114 | if !state.has_feeds() { | 117 | if !state.has_feeds() { |
| 115 | state.notification_manager.info("Aucun flux à synchroniser"); | 118 | state.notification_manager.info("Aucun flux à synchroniser"); |
| 116 | return; | 119 | return; |
| 117 | } | 120 | } |
| 118 | 121 | ||
| 119 | - state.set_sync_state(true); | 122 | + log::info!("Starting asynchronous synchronization of all feeds"); |
| 120 | - state.notification_manager.info("Synchronisation de tous les flux..."); | ||
| 121 | - | ||
| 122 | - log::info!("Starting synchronization of all feeds"); | ||
| 123 | - | ||
| 124 | - // Perform synchronization | ||
| 125 | - let result = crate::rss::sync::sync_all_feeds(&mut state.feeds); | ||
| 126 | 123 | ||
| 127 | - state.set_sync_state(false); | 124 | + // Clone feeds for async task (we'll update state.feeds from messages) |
| 125 | + let feeds_to_sync = state.feeds.clone(); | ||
| 128 | 126 | ||
| 129 | - match result { | 127 | + // Start async sync |
| 130 | - Ok(success_count) => { | 128 | + state.sync_task_manager.start_sync_all_feeds(feeds_to_sync); |
| 131 | - state.notification_manager.success(format!("{} flux synchronisés avec succès", success_count)); | 129 | + |
| 132 | - // Save the updated feeds | 130 | + state.notification_manager.info("Synchronisation de tous les flux en cours..."); |
| 133 | - super::super::config::storage::save_feeds_default(&state.feeds).ok(); | ||
| 134 | - } | ||
| 135 | - Err(e) => { | ||
| 136 | - state.notification_manager.error(format!("Erreur de synchronisation: {}", e)); | ||
| 137 | - } | ||
| 138 | - } | ||
| 139 | } | 131 | } |
modified
src/ui/mod.rs +1 -0 | @@ -14,3 +14,4 @@ pub mod export_import; | ||
| 14 | 14 | pub mod history_panel; |
| 15 | 15 | pub mod search_bar; |
| 16 | 16 | pub mod stats_panel; |
| 17 | +pub mod sync_status; | |
| @@ -14,3 +14,4 @@ pub mod export_import; | |||
| 14 | pub mod history_panel; | 14 | pub mod history_panel; |
| 15 | pub mod search_bar; | 15 | pub mod search_bar; |
| 16 | pub mod stats_panel; | 16 | pub mod stats_panel; |
| 17 | +pub mod sync_status; | ||
modified
src/ui/status_bar.rs +9 -5 | @@ -58,15 +58,19 @@ pub fn draw_status_bar(state: &mut AppState, ui: &mut Ui) { | ||
| 58 | 58 | } |
| 59 | 59 | |
| 60 | 60 | // Sync status avec icône |
| 61 | - let sync_in_progress = state.sync_in_progress; | |
| 62 | 61 | let feed_count = state.feed_count(); |
| 63 | 62 | let enabled_count = state.enabled_feed_count(); |
| 64 | 63 | |
| 64 | + // Process sync messages and get status | |
| 65 | + let is_syncing = state.sync_task_manager.status.is_syncing(); | |
| 66 | + let global_sync_in_progress = state.sync_task_manager.status.global_sync_in_progress; | |
| 67 | + | |
| 65 | 68 | ui.horizontal(|ui| { |
| 66 | - if sync_in_progress { | |
| 67 | - ui.label(egui::RichText::new("🔄 ").color(egui::Color32::from_rgb(250, 200, 100))); | |
| 68 | - ui.colored_label(egui::Color32::from_rgb(250, 200, 100), "Synchronisation en cours..."); | |
| 69 | - } else { | |
| 69 | + // Use the sync indicator from sync_status module | |
| 70 | + crate::ui::sync_status::draw_sync_indicator(state, ui); | |
| 71 | + | |
| 72 | + // Show ready status if no sync in progress | |
| 73 | + if !is_syncing && !global_sync_in_progress { | |
| 70 | 74 | ui.label(egui::RichText::new("✅ ").color(egui::Color32::from_rgb(100, 220, 100))); |
| 71 | 75 | ui.colored_label(egui::Color32::from_rgb(150, 200, 150), "Prêt"); |
| 72 | 76 | } |
| @@ -58,15 +58,19 @@ pub fn draw_status_bar(state: &mut AppState, ui: &mut Ui) { | |||
| 58 | } | 58 | } |
| 59 | 59 | ||
| 60 | // Sync status avec icône | 60 | // Sync status avec icône |
| 61 | - let sync_in_progress = state.sync_in_progress; | ||
| 62 | let feed_count = state.feed_count(); | 61 | let feed_count = state.feed_count(); |
| 63 | let enabled_count = state.enabled_feed_count(); | 62 | let enabled_count = state.enabled_feed_count(); |
| 64 | 63 | ||
| 64 | + // Process sync messages and get status | ||
| 65 | + let is_syncing = state.sync_task_manager.status.is_syncing(); | ||
| 66 | + let global_sync_in_progress = state.sync_task_manager.status.global_sync_in_progress; | ||
| 67 | + | ||
| 65 | ui.horizontal(|ui| { | 68 | ui.horizontal(|ui| { |
| 66 | - if sync_in_progress { | 69 | + // Use the sync indicator from sync_status module |
| 67 | - ui.label(egui::RichText::new("🔄 ").color(egui::Color32::from_rgb(250, 200, 100))); | 70 | + crate::ui::sync_status::draw_sync_indicator(state, ui); |
| 68 | - ui.colored_label(egui::Color32::from_rgb(250, 200, 100), "Synchronisation en cours..."); | 71 | + |
| 69 | - } else { | 72 | + // Show ready status if no sync in progress |
| 73 | + if !is_syncing && !global_sync_in_progress { | ||
| 70 | ui.label(egui::RichText::new("✅ ").color(egui::Color32::from_rgb(100, 220, 100))); | 74 | ui.label(egui::RichText::new("✅ ").color(egui::Color32::from_rgb(100, 220, 100))); |
| 71 | ui.colored_label(egui::Color32::from_rgb(150, 200, 150), "Prêt"); | 75 | ui.colored_label(egui::Color32::from_rgb(150, 200, 150), "Prêt"); |
| 72 | } | 76 | } |
added
src/ui/sync_status.rs +235 -0 | new file mode 100644 | ||
| @@ -0,0 +1,235 @@ | ||
| 1 | +//! Sync Status Panel UI component | |
| 2 | +//! | |
| 3 | +//! This module displays the synchronization status and progress. | |
| 4 | + | |
| 5 | +use crate::config::AppState; | |
| 6 | +use crate::config::sync_task::FeedSyncStatus; | |
| 7 | +use egui::{Color32, Frame, RichText, Ui}; | |
| 8 | + | |
| 9 | +/// Draws the sync status panel | |
| 10 | +pub fn draw_sync_status_panel(state: &mut AppState, ui: &mut Ui) { | |
| 11 | + let status = &state.sync_task_manager.status; | |
| 12 | + | |
| 13 | + // Only show panel if there's sync activity | |
| 14 | + if !status.is_syncing() && !status.global_sync_in_progress { | |
| 15 | + return; | |
| 16 | + } | |
| 17 | + | |
| 18 | + // Create a frame for the sync status panel | |
| 19 | + Frame { | |
| 20 | + fill: Color32::from_rgb(40, 42, 54), | |
| 21 | + stroke: egui::Stroke::new(1.0, Color32::from_rgb(130, 170, 250)), | |
| 22 | + corner_radius: 8.0.into(), | |
| 23 | + inner_margin: egui::Margin::symmetric(12, 8), | |
| 24 | + ..Default::default() | |
| 25 | + } | |
| 26 | + .show(ui, |ui| { | |
| 27 | + ui.vertical(|ui| { | |
| 28 | + // Header | |
| 29 | + ui.horizontal(|ui| { | |
| 30 | + if status.global_sync_in_progress { | |
| 31 | + ui.label(RichText::new("🔄 Synchronisation en cours").size(16.0).strong()); | |
| 32 | + } else { | |
| 33 | + ui.label(RichText::new("📊 Statut de synchronisation").size(16.0).strong()); | |
| 34 | + } | |
| 35 | + | |
| 36 | + // Overall progress bar for global sync | |
| 37 | + if status.global_sync_in_progress { | |
| 38 | + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { | |
| 39 | + draw_progress_bar(ui, status.get_overall_progress(), 120.0); | |
| 40 | + ui.label(RichText::new(format!("{:.0}%", status.get_overall_progress() * 100.0)) | |
| 41 | + .size(14.0) | |
| 42 | + .color(Color32::from_rgb(220, 230, 245))); | |
| 43 | + }); | |
| 44 | + } | |
| 45 | + }); | |
| 46 | + | |
| 47 | + ui.separator(); | |
| 48 | + ui.add_space(8.0); | |
| 49 | + | |
| 50 | + // Global progress info | |
| 51 | + if status.global_sync_in_progress { | |
| 52 | + ui.label(RichText::new(format!( | |
| 53 | + "Synchronisation de {} flux: {}/{} terminés", | |
| 54 | + status.total_feeds, | |
| 55 | + status.completed_feeds, | |
| 56 | + status.total_feeds | |
| 57 | + )).size(14.0).color(Color32::from_rgb(200, 210, 220))); | |
| 58 | + ui.add_space(8.0); | |
| 59 | + } | |
| 60 | + | |
| 61 | + // Individual feed status | |
| 62 | + ui.label(RichText::new("Statut par flux:").size(14.0).strong()); | |
| 63 | + ui.add_space(4.0); | |
| 64 | + | |
| 65 | + for (feed_id, feed_status) in &status.feed_sync_status { | |
| 66 | + draw_feed_sync_status(ui, feed_id, feed_status); | |
| 67 | + } | |
| 68 | + }); | |
| 69 | + }); | |
| 70 | +} | |
| 71 | + | |
| 72 | +/// Draws a progress bar | |
| 73 | +fn draw_progress_bar(ui: &mut Ui, progress: f32, width: f32) { | |
| 74 | + let progress_clamped = progress.clamp(0.0, 1.0); | |
| 75 | + | |
| 76 | + // Background | |
| 77 | + let bg_rect = egui::Rect::from_min_size( | |
| 78 | + ui.cursor().min, | |
| 79 | + egui::Vec2::new(width, 8.0) | |
| 80 | + ); | |
| 81 | + | |
| 82 | + // Progress fill | |
| 83 | + let fill_width = width * progress_clamped; | |
| 84 | + let fill_rect = egui::Rect::from_min_size( | |
| 85 | + bg_rect.min, | |
| 86 | + egui::Vec2::new(fill_width, 8.0) | |
| 87 | + ); | |
| 88 | + | |
| 89 | + // Paint background | |
| 90 | + ui.painter().rect_filled( | |
| 91 | + bg_rect, | |
| 92 | + 4.0, | |
| 93 | + Color32::from_rgb(60, 64, 80) | |
| 94 | + ); | |
| 95 | + | |
| 96 | + // Paint progress fill | |
| 97 | + let fill_color = if progress_clamped >= 1.0 { | |
| 98 | + Color32::from_rgb(80, 220, 100) // Green for complete | |
| 99 | + } else { | |
| 100 | + Color32::from_rgb(130, 170, 250) // Blue for in progress | |
| 101 | + }; | |
| 102 | + | |
| 103 | + ui.painter().rect_filled( | |
| 104 | + fill_rect, | |
| 105 | + 4.0, | |
| 106 | + fill_color | |
| 107 | + ); | |
| 108 | + | |
| 109 | + // Advance cursor | |
| 110 | + ui.advance_cursor_after_rect(bg_rect); | |
| 111 | +} | |
| 112 | + | |
| 113 | +/// Draws the sync status for a single feed | |
| 114 | +fn draw_feed_sync_status(ui: &mut Ui, feed_id: &str, status: &FeedSyncStatus) { | |
| 115 | + ui.horizontal(|ui| { | |
| 116 | + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 4.0); | |
| 117 | + | |
| 118 | + // Feed ID/name (truncated if too long) | |
| 119 | + let display_id = if feed_id.len() > 20 { | |
| 120 | + format!("{}...", &feed_id[..17]) | |
| 121 | + } else { | |
| 122 | + feed_id.to_string() | |
| 123 | + }; | |
| 124 | + | |
| 125 | + match status { | |
| 126 | + FeedSyncStatus::Idle => { | |
| 127 | + ui.label(RichText::new(format!("⏸️ {}", display_id)) | |
| 128 | + .size(13.0) | |
| 129 | + .color(Color32::from_rgb(150, 160, 180))); | |
| 130 | + } | |
| 131 | + FeedSyncStatus::InProgress { progress, message } => { | |
| 132 | + ui.label(RichText::new(format!("🔄 {}", display_id)) | |
| 133 | + .size(13.0) | |
| 134 | + .color(Color32::from_rgb(180, 200, 255))); | |
| 135 | + | |
| 136 | + // Progress bar | |
| 137 | + draw_progress_bar(ui, *progress, 80.0); | |
| 138 | + ui.add_space(4.0); | |
| 139 | + | |
| 140 | + // Status message | |
| 141 | + ui.label(RichText::new(format!("{}", message)) | |
| 142 | + .size(12.0) | |
| 143 | + .color(Color32::from_rgb(180, 190, 210))); | |
| 144 | + } | |
| 145 | + FeedSyncStatus::Completed { episodes_found, episodes_downloaded } => { | |
| 146 | + ui.label(RichText::new(format!("✅ {}", display_id)) | |
| 147 | + .size(13.0) | |
| 148 | + .color(Color32::from_rgb(80, 220, 100))); | |
| 149 | + | |
| 150 | + ui.label(RichText::new(format!("{} épisodes, {} téléchargés", episodes_found, episodes_downloaded)) | |
| 151 | + .size(12.0) | |
| 152 | + .color(Color32::from_rgb(160, 180, 200))); | |
| 153 | + } | |
| 154 | + FeedSyncStatus::Failed { error } => { | |
| 155 | + ui.label(RichText::new(format!("❌ {}", display_id)) | |
| 156 | + .size(13.0) | |
| 157 | + .color(Color32::from_rgb(220, 80, 80))); | |
| 158 | + | |
| 159 | + ui.label(RichText::new(format!("Erreur: {}", error)) | |
| 160 | + .size(12.0) | |
| 161 | + .color(Color32::from_rgb(200, 100, 100))); | |
| 162 | + } | |
| 163 | + } | |
| 164 | + }); | |
| 165 | + | |
| 166 | + ui.add_space(4.0); | |
| 167 | +} | |
| 168 | + | |
| 169 | +/// Draws a compact sync indicator for the status bar | |
| 170 | +pub fn draw_sync_indicator(state: &AppState, ui: &mut Ui) { | |
| 171 | + let status = &state.sync_task_manager.status; | |
| 172 | + | |
| 173 | + if !status.is_syncing() { | |
| 174 | + return; | |
| 175 | + } | |
| 176 | + | |
| 177 | + ui.horizontal(|ui| { | |
| 178 | + ui.spacing_mut().item_spacing = egui::Vec2::new(4.0, 0.0); | |
| 179 | + | |
| 180 | + if status.global_sync_in_progress { | |
| 181 | + // Global sync indicator | |
| 182 | + ui.label(RichText::new("🔄").size(16.0)); | |
| 183 | + ui.label(RichText::new(format!("Synchro: {}/{} flux", | |
| 184 | + status.completed_feeds, | |
| 185 | + status.total_feeds)) | |
| 186 | + .size(13.0) | |
| 187 | + .color(Color32::from_rgb(180, 200, 255))); | |
| 188 | + | |
| 189 | + // Overall progress bar | |
| 190 | + draw_progress_bar(ui, status.get_overall_progress(), 60.0); | |
| 191 | + } else { | |
| 192 | + // Individual feed sync indicator | |
| 193 | + let syncing_count = status.syncing_feed_count(); | |
| 194 | + if syncing_count > 0 { | |
| 195 | + ui.label(RichText::new("🔄").size(16.0)); | |
| 196 | + ui.label(RichText::new(format!("{} flux en synchro", syncing_count)) | |
| 197 | + .size(13.0) | |
| 198 | + .color(Color32::from_rgb(180, 200, 255))); | |
| 199 | + } | |
| 200 | + } | |
| 201 | + }); | |
| 202 | +} | |
| 203 | + | |
| 204 | +/// Draws a detailed sync progress popup/panel | |
| 205 | +pub fn draw_sync_progress_panel(state: &mut AppState, ui: &mut Ui) { | |
| 206 | + // Process any pending messages first | |
| 207 | + state.sync_task_manager.process_messages(); | |
| 208 | + | |
| 209 | + let status = &state.sync_task_manager.status; | |
| 210 | + | |
| 211 | + // Only show detailed panel if there's active sync | |
| 212 | + if !status.is_syncing() && !status.global_sync_in_progress { | |
| 213 | + return; | |
| 214 | + } | |
| 215 | + | |
| 216 | + // Create a popup-style panel | |
| 217 | + egui::Window::new("📊 Progression de la synchronisation") | |
| 218 | + .collapsible(false) | |
| 219 | + .resizable(true) | |
| 220 | + .default_size(egui::Vec2::new(300.0, 200.0)) | |
| 221 | + .show(ui.ctx(), |ui| { | |
| 222 | + draw_sync_status_panel(state, ui); | |
| 223 | + }); | |
| 224 | +} | |
| 225 | + | |
| 226 | +#[cfg(test)] | |
| 227 | +mod tests { | |
| 228 | + use super::*; | |
| 229 | + | |
| 230 | + #[test] | |
| 231 | + fn test_draw_progress_bar() { | |
| 232 | + // This is a UI test, can't easily test rendering | |
| 233 | + // Just verify the function doesn't panic | |
| 234 | + } | |
| 235 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,235 @@ | |||
| 1 | +//! Sync Status Panel UI component | ||
| 2 | +//! | ||
| 3 | +//! This module displays the synchronization status and progress. | ||
| 4 | + | ||
| 5 | +use crate::config::AppState; | ||
| 6 | +use crate::config::sync_task::FeedSyncStatus; | ||
| 7 | +use egui::{Color32, Frame, RichText, Ui}; | ||
| 8 | + | ||
| 9 | +/// Draws the sync status panel | ||
| 10 | +pub fn draw_sync_status_panel(state: &mut AppState, ui: &mut Ui) { | ||
| 11 | + let status = &state.sync_task_manager.status; | ||
| 12 | + | ||
| 13 | + // Only show panel if there's sync activity | ||
| 14 | + if !status.is_syncing() && !status.global_sync_in_progress { | ||
| 15 | + return; | ||
| 16 | + } | ||
| 17 | + | ||
| 18 | + // Create a frame for the sync status panel | ||
| 19 | + Frame { | ||
| 20 | + fill: Color32::from_rgb(40, 42, 54), | ||
| 21 | + stroke: egui::Stroke::new(1.0, Color32::from_rgb(130, 170, 250)), | ||
| 22 | + corner_radius: 8.0.into(), | ||
| 23 | + inner_margin: egui::Margin::symmetric(12, 8), | ||
| 24 | + ..Default::default() | ||
| 25 | + } | ||
| 26 | + .show(ui, |ui| { | ||
| 27 | + ui.vertical(|ui| { | ||
| 28 | + // Header | ||
| 29 | + ui.horizontal(|ui| { | ||
| 30 | + if status.global_sync_in_progress { | ||
| 31 | + ui.label(RichText::new("🔄 Synchronisation en cours").size(16.0).strong()); | ||
| 32 | + } else { | ||
| 33 | + ui.label(RichText::new("📊 Statut de synchronisation").size(16.0).strong()); | ||
| 34 | + } | ||
| 35 | + | ||
| 36 | + // Overall progress bar for global sync | ||
| 37 | + if status.global_sync_in_progress { | ||
| 38 | + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { | ||
| 39 | + draw_progress_bar(ui, status.get_overall_progress(), 120.0); | ||
| 40 | + ui.label(RichText::new(format!("{:.0}%", status.get_overall_progress() * 100.0)) | ||
| 41 | + .size(14.0) | ||
| 42 | + .color(Color32::from_rgb(220, 230, 245))); | ||
| 43 | + }); | ||
| 44 | + } | ||
| 45 | + }); | ||
| 46 | + | ||
| 47 | + ui.separator(); | ||
| 48 | + ui.add_space(8.0); | ||
| 49 | + | ||
| 50 | + // Global progress info | ||
| 51 | + if status.global_sync_in_progress { | ||
| 52 | + ui.label(RichText::new(format!( | ||
| 53 | + "Synchronisation de {} flux: {}/{} terminés", | ||
| 54 | + status.total_feeds, | ||
| 55 | + status.completed_feeds, | ||
| 56 | + status.total_feeds | ||
| 57 | + )).size(14.0).color(Color32::from_rgb(200, 210, 220))); | ||
| 58 | + ui.add_space(8.0); | ||
| 59 | + } | ||
| 60 | + | ||
| 61 | + // Individual feed status | ||
| 62 | + ui.label(RichText::new("Statut par flux:").size(14.0).strong()); | ||
| 63 | + ui.add_space(4.0); | ||
| 64 | + | ||
| 65 | + for (feed_id, feed_status) in &status.feed_sync_status { | ||
| 66 | + draw_feed_sync_status(ui, feed_id, feed_status); | ||
| 67 | + } | ||
| 68 | + }); | ||
| 69 | + }); | ||
| 70 | +} | ||
| 71 | + | ||
| 72 | +/// Draws a progress bar | ||
| 73 | +fn draw_progress_bar(ui: &mut Ui, progress: f32, width: f32) { | ||
| 74 | + let progress_clamped = progress.clamp(0.0, 1.0); | ||
| 75 | + | ||
| 76 | + // Background | ||
| 77 | + let bg_rect = egui::Rect::from_min_size( | ||
| 78 | + ui.cursor().min, | ||
| 79 | + egui::Vec2::new(width, 8.0) | ||
| 80 | + ); | ||
| 81 | + | ||
| 82 | + // Progress fill | ||
| 83 | + let fill_width = width * progress_clamped; | ||
| 84 | + let fill_rect = egui::Rect::from_min_size( | ||
| 85 | + bg_rect.min, | ||
| 86 | + egui::Vec2::new(fill_width, 8.0) | ||
| 87 | + ); | ||
| 88 | + | ||
| 89 | + // Paint background | ||
| 90 | + ui.painter().rect_filled( | ||
| 91 | + bg_rect, | ||
| 92 | + 4.0, | ||
| 93 | + Color32::from_rgb(60, 64, 80) | ||
| 94 | + ); | ||
| 95 | + | ||
| 96 | + // Paint progress fill | ||
| 97 | + let fill_color = if progress_clamped >= 1.0 { | ||
| 98 | + Color32::from_rgb(80, 220, 100) // Green for complete | ||
| 99 | + } else { | ||
| 100 | + Color32::from_rgb(130, 170, 250) // Blue for in progress | ||
| 101 | + }; | ||
| 102 | + | ||
| 103 | + ui.painter().rect_filled( | ||
| 104 | + fill_rect, | ||
| 105 | + 4.0, | ||
| 106 | + fill_color | ||
| 107 | + ); | ||
| 108 | + | ||
| 109 | + // Advance cursor | ||
| 110 | + ui.advance_cursor_after_rect(bg_rect); | ||
| 111 | +} | ||
| 112 | + | ||
| 113 | +/// Draws the sync status for a single feed | ||
| 114 | +fn draw_feed_sync_status(ui: &mut Ui, feed_id: &str, status: &FeedSyncStatus) { | ||
| 115 | + ui.horizontal(|ui| { | ||
| 116 | + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 4.0); | ||
| 117 | + | ||
| 118 | + // Feed ID/name (truncated if too long) | ||
| 119 | + let display_id = if feed_id.len() > 20 { | ||
| 120 | + format!("{}...", &feed_id[..17]) | ||
| 121 | + } else { | ||
| 122 | + feed_id.to_string() | ||
| 123 | + }; | ||
| 124 | + | ||
| 125 | + match status { | ||
| 126 | + FeedSyncStatus::Idle => { | ||
| 127 | + ui.label(RichText::new(format!("⏸️ {}", display_id)) | ||
| 128 | + .size(13.0) | ||
| 129 | + .color(Color32::from_rgb(150, 160, 180))); | ||
| 130 | + } | ||
| 131 | + FeedSyncStatus::InProgress { progress, message } => { | ||
| 132 | + ui.label(RichText::new(format!("🔄 {}", display_id)) | ||
| 133 | + .size(13.0) | ||
| 134 | + .color(Color32::from_rgb(180, 200, 255))); | ||
| 135 | + | ||
| 136 | + // Progress bar | ||
| 137 | + draw_progress_bar(ui, *progress, 80.0); | ||
| 138 | + ui.add_space(4.0); | ||
| 139 | + | ||
| 140 | + // Status message | ||
| 141 | + ui.label(RichText::new(format!("{}", message)) | ||
| 142 | + .size(12.0) | ||
| 143 | + .color(Color32::from_rgb(180, 190, 210))); | ||
| 144 | + } | ||
| 145 | + FeedSyncStatus::Completed { episodes_found, episodes_downloaded } => { | ||
| 146 | + ui.label(RichText::new(format!("✅ {}", display_id)) | ||
| 147 | + .size(13.0) | ||
| 148 | + .color(Color32::from_rgb(80, 220, 100))); | ||
| 149 | + | ||
| 150 | + ui.label(RichText::new(format!("{} épisodes, {} téléchargés", episodes_found, episodes_downloaded)) | ||
| 151 | + .size(12.0) | ||
| 152 | + .color(Color32::from_rgb(160, 180, 200))); | ||
| 153 | + } | ||
| 154 | + FeedSyncStatus::Failed { error } => { | ||
| 155 | + ui.label(RichText::new(format!("❌ {}", display_id)) | ||
| 156 | + .size(13.0) | ||
| 157 | + .color(Color32::from_rgb(220, 80, 80))); | ||
| 158 | + | ||
| 159 | + ui.label(RichText::new(format!("Erreur: {}", error)) | ||
| 160 | + .size(12.0) | ||
| 161 | + .color(Color32::from_rgb(200, 100, 100))); | ||
| 162 | + } | ||
| 163 | + } | ||
| 164 | + }); | ||
| 165 | + | ||
| 166 | + ui.add_space(4.0); | ||
| 167 | +} | ||
| 168 | + | ||
| 169 | +/// Draws a compact sync indicator for the status bar | ||
| 170 | +pub fn draw_sync_indicator(state: &AppState, ui: &mut Ui) { | ||
| 171 | + let status = &state.sync_task_manager.status; | ||
| 172 | + | ||
| 173 | + if !status.is_syncing() { | ||
| 174 | + return; | ||
| 175 | + } | ||
| 176 | + | ||
| 177 | + ui.horizontal(|ui| { | ||
| 178 | + ui.spacing_mut().item_spacing = egui::Vec2::new(4.0, 0.0); | ||
| 179 | + | ||
| 180 | + if status.global_sync_in_progress { | ||
| 181 | + // Global sync indicator | ||
| 182 | + ui.label(RichText::new("🔄").size(16.0)); | ||
| 183 | + ui.label(RichText::new(format!("Synchro: {}/{} flux", | ||
| 184 | + status.completed_feeds, | ||
| 185 | + status.total_feeds)) | ||
| 186 | + .size(13.0) | ||
| 187 | + .color(Color32::from_rgb(180, 200, 255))); | ||
| 188 | + | ||
| 189 | + // Overall progress bar | ||
| 190 | + draw_progress_bar(ui, status.get_overall_progress(), 60.0); | ||
| 191 | + } else { | ||
| 192 | + // Individual feed sync indicator | ||
| 193 | + let syncing_count = status.syncing_feed_count(); | ||
| 194 | + if syncing_count > 0 { | ||
| 195 | + ui.label(RichText::new("🔄").size(16.0)); | ||
| 196 | + ui.label(RichText::new(format!("{} flux en synchro", syncing_count)) | ||
| 197 | + .size(13.0) | ||
| 198 | + .color(Color32::from_rgb(180, 200, 255))); | ||
| 199 | + } | ||
| 200 | + } | ||
| 201 | + }); | ||
| 202 | +} | ||
| 203 | + | ||
| 204 | +/// Draws a detailed sync progress popup/panel | ||
| 205 | +pub fn draw_sync_progress_panel(state: &mut AppState, ui: &mut Ui) { | ||
| 206 | + // Process any pending messages first | ||
| 207 | + state.sync_task_manager.process_messages(); | ||
| 208 | + | ||
| 209 | + let status = &state.sync_task_manager.status; | ||
| 210 | + | ||
| 211 | + // Only show detailed panel if there's active sync | ||
| 212 | + if !status.is_syncing() && !status.global_sync_in_progress { | ||
| 213 | + return; | ||
| 214 | + } | ||
| 215 | + | ||
| 216 | + // Create a popup-style panel | ||
| 217 | + egui::Window::new("📊 Progression de la synchronisation") | ||
| 218 | + .collapsible(false) | ||
| 219 | + .resizable(true) | ||
| 220 | + .default_size(egui::Vec2::new(300.0, 200.0)) | ||
| 221 | + .show(ui.ctx(), |ui| { | ||
| 222 | + draw_sync_status_panel(state, ui); | ||
| 223 | + }); | ||
| 224 | +} | ||
| 225 | + | ||
| 226 | +#[cfg(test)] | ||
| 227 | +mod tests { | ||
| 228 | + use super::*; | ||
| 229 | + | ||
| 230 | + #[test] | ||
| 231 | + fn test_draw_progress_bar() { | ||
| 232 | + // This is a UI test, can't easily test rendering | ||
| 233 | + // Just verify the function doesn't panic | ||
| 234 | + } | ||
| 235 | +} | ||