msansm1/rss-podcast-dlpublic Fork
⑂ 494cad2
Commits
⬇ Clone ▾
git clone https://git.rickub.com/msansm1/rss-podcast-dl.git
git clone ssh://git@rickub.com/msansm1/rss-podcast-dl.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

Add async synchronization with progress status display

msansm1 committed 2026-07-10T19:16:31+02:00 Browse files
494cad2 parent: 849fc17
modified src/app.rs +3 -0
@@ -117,6 +117,9 @@ impl eframe::App for RssPodcastApp {
117117 Theme::Light => egui::Color32::from_rgb(220, 230, 245),
118118 };
119119
120+ // Process sync task messages
121+ self.state.sync_task_manager.process_messages();
122+
120123 // Draw notifications panel at the top
121124 egui::Panel::top("notifications_panel")
122125 .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 top123 // 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;
77 use crate::config::playlist::Playlist;
88 use crate::config::search::SearchQuery;
99 use crate::config::statistics::AppStatistics;
10+use crate::config::sync_task::SyncTaskManager;
1011 use crate::ui::notifications::NotificationManager;
1112 use chrono::{DateTime, Utc};
1213 use serde::{Deserialize, Serialize};
@@ -167,6 +168,8 @@ pub struct AppState {
167168 /// History manager for tracking operations
168169 #[allow(dead_code)]
169170 pub history_manager: HistoryManager,
171+ /// Manager for async synchronization tasks
172+ pub sync_task_manager: SyncTaskManager,
170173 }
171174
172175 impl AppState {
@@ -217,6 +220,7 @@ impl AppState {
217220 statistics: AppStatistics::new(),
218221 history_manager: HistoryManager::new(),
219222 theme: Theme::default(),
223+ sync_task_manager: SyncTaskManager::new(),
220224 }
221225 }
222226
@@ -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 operations168 /// 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;
1111 pub mod search;
1212 pub mod statistics;
1313 pub mod storage;
14+pub mod sync_task;
1415
1516 // Re-export main types for convenience
1617 // 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};
4243 #[allow(dead_code)]
4344 #[allow(unused_imports)]
4445 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 convenience16 // Re-export main types for convenience
16 // Note: Some re-exports may not be directly used in the current codebase but are17 // 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 @@
22 //!
33 //! This module handles RSS feed parsing, episode extraction, and downloading.
44
5+pub mod async_sync;
56 pub mod audio_metadata;
67 pub mod downloader;
78 pub mod episode_extractor;
@@ -16,6 +17,9 @@ pub mod sync;
1617 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};
1718 #[allow(dead_code)]
1819 #[allow(unused_imports)]
20+pub use async_sync::{sync_all_feeds_async, sync_feed_async};
21+#[allow(dead_code)]
22+#[allow(unused_imports)]
1923 pub use downloader::{DownloadClient, DownloadError, ProgressCallback, download_episode, download_episodes, download_file, get_download_dir, needs_download, remove_downloaded_episode};
2024 #[allow(dead_code)]
2125 #[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) {
118118 if ui.button(egui::RichText::new("🔄").size(18.0))
119119 .on_hover_text("Synchroniser ce flux")
120120 .clicked() {
121- sync_feed(state, index);
121+ sync_feed_async(state, index);
122122 }
123123
124124 // Bouton Éditer
@@ -152,31 +152,20 @@ fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) {
152152 ui.separator();
153153 }
154154
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) {
157157 if index >= state.feeds.len() {
158158 state.notification_manager.error("Index de flux invalide");
159159 return;
160160 }
161161
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);
169163
170- state.set_sync_state(false);
164+ // Clone the feed for async task
165+ let feed_to_sync = state.feeds[index].clone();
171166
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));
182171 }
@@ -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 Éditer124 // 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 feed155+/// 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 feeds170+ 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) {
3333 draw_action_buttons(state, ui);
3434 });
3535
36+ // Sync status panel (shown when sync is in progress)
37+ crate::ui::sync_status::draw_sync_status_panel(state, ui);
38+
3639 ui.add_space(16.0);
3740 ui.separator();
3841
@@ -109,31 +112,20 @@ pub fn draw_feed_list(state: &mut AppState, ui: &mut Ui) {
109112 crate::ui::feed_list::draw_feed_list(state, ui);
110113 }
111114
112-/// Synchronizes all feeds
115+/// Synchronizes all feeds asynchronously
113116 fn sync_all_feeds(state: &mut AppState) {
114117 if !state.has_feeds() {
115118 state.notification_manager.info("Aucun flux à synchroniser");
116119 return;
117120 }
118121
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");
126123
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();
128126
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...");
139131 }
@@ -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 feeds115+/// 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 feeds130+ 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;
1414 pub mod history_panel;
1515 pub mod search_bar;
1616 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) {
5858 }
5959
6060 // Sync status avec icône
61- let sync_in_progress = state.sync_in_progress;
6261 let feed_count = state.feed_count();
6362 let enabled_count = state.enabled_feed_count();
6463
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+
6568 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 {
7074 ui.label(egui::RichText::new("✅ ").color(egui::Color32::from_rgb(100, 220, 100)));
7175 ui.colored_label(egui::Color32::from_rgb(150, 200, 150), "Prêt");
7276 }
@@ -58,15 +58,19 @@ pub fn draw_status_bar(state: &mut AppState, ui: &mut Ui) {
58 }58 }
59 59
60 // Sync status avec icône60 // 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+}