msansm1/rss-podcast-dlpublic Fork
⑂ 2e074e1
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

Clippy sans warning et tri du code mort (127 -> 11 allow(dead_code))

- cargo clippy --fix puis correction manuelle : sort_by_key,
  field_reassign_with_default dans les tests ; 0 warning clippy
  sur toutes les cibles
- suppression des 114 #[allow(dead_code)] dispersés et du code
  mort révélé : placeholders DownloadClient/FeedClient, variantes
  d'erreur jamais construites, méthodes orphelines (Feed/Episode/
  Playlist/AppState), filter_episodes, outillage audio_metadata
  non branché, re-exports inutilisés de config/mod.rs et rss/mod.rs
- suppression du système de messages error_message/info_message
  de AppState et de son affichage dans status_bar (aucun producteur
  vivant, doublon de NotificationManager)
- SyncMessage/FeedSyncStatus allégés des champs jamais lus ;
  EpisodeDownloadFailed est désormais affiché en notification
  (home.rs) et les erreurs de sync en tooltip (sync_status.rs)
- fonctionnalités prévues mais non branchées (search, history,
  statistics, backup + panneaux UI associés) conservées sous un
  unique #![allow(dead_code)] documenté par module
- tests : ajout d'un round-trip ID3 et de validate_feed ;
  suppression des tests du code mort (76 -> 61)
msansm1 committed 2026-09-28T22:20:57+02:00 Browse files
2e074e1 parent: 90dae95
modified src/config/app_state.rs +14 -309
@@ -11,23 +11,18 @@ use crate::config::sync_task::SyncTaskManager;
1111 use crate::ui::notifications::NotificationManager;
1212 use chrono::{DateTime, Utc};
1313 use serde::{Deserialize, Serialize};
14-use std::collections::HashMap;
15-use std::path::PathBuf;
1614
1715 /// Theme options for the application
1816 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17+#[derive(Default)]
1918 pub enum Theme {
2019 /// Dark theme (default)
20+ #[default]
2121 Dark,
2222 /// Light theme
2323 Light,
2424 }
2525
26-impl Default for Theme {
27- fn default() -> Self {
28- Theme::Dark
29- }
30-}
3126
3227 impl Theme {
3328 /// Toggles between light and dark theme
@@ -37,15 +32,6 @@ impl Theme {
3732 Theme::Light => Theme::Dark,
3833 };
3934 }
40-
41- /// Returns the opposite theme
42- #[allow(dead_code)]
43- pub fn opposite(&self) -> Self {
44- match self {
45- Theme::Dark => Theme::Light,
46- Theme::Light => Theme::Dark,
47- }
48- }
4935 }
5036
5137 /// Main application state
@@ -80,41 +66,19 @@ pub struct AppState {
8066 /// Index of the feed being edited (if any)
8167 pub editing_feed_index: Option<usize>,
8268 /// Whether the add playlist dialog is visible
83- #[allow(dead_code)]
8469 pub show_add_playlist_dialog: bool,
8570 /// Temporary storage for add playlist name
86- #[allow(dead_code)]
8771 pub add_playlist_name: String,
8872 /// Temporary storage for add playlist description
89- #[allow(dead_code)]
9073 pub add_playlist_description: String,
9174 /// Whether the edit playlist dialog is visible
92- #[allow(dead_code)]
9375 pub show_edit_playlist_dialog: bool,
9476 /// Temporary storage for edit playlist name
95- #[allow(dead_code)]
9677 pub edit_playlist_name: String,
9778 /// Temporary storage for edit playlist description
98- #[allow(dead_code)]
9979 pub edit_playlist_description: String,
10080 /// Index of the playlist being edited (if any)
101- #[allow(dead_code)]
10281 pub editing_playlist_index: Option<usize>,
103- /// Whether a synchronization is currently in progress
104- pub sync_in_progress: bool,
105- /// Download progress for each feed (feed_id -> progress 0.0-1.0)
106- #[allow(dead_code)]
107- pub download_progress: HashMap<String, f32>,
108- /// Current error message to display (if any)
109- pub error_message: Option<String>,
110- /// Current info message to display (if any)
111- pub info_message: Option<String>,
112- /// Whether the settings dialog is visible
113- #[allow(dead_code)]
114- pub show_settings: bool,
115- /// Path to the configuration file
116- #[allow(dead_code)]
117- pub config_path: PathBuf,
11882 /// Manager for displaying notifications to the user
11983 pub notification_manager: NotificationManager,
12084 /// List of playlists for grouping feeds
@@ -123,49 +87,37 @@ pub struct AppState {
12387 pub theme: Theme,
12488 // Filter state
12589 /// Filter: date from (inclusive)
126- #[allow(dead_code)]
12790 pub filter_date_from: Option<DateTime<Utc>>,
12891 /// Filter: date to (inclusive)
129- #[allow(dead_code)]
13092 pub filter_date_to: Option<DateTime<Utc>>,
13193 /// Filter: episode number
132- #[allow(dead_code)]
13394 pub filter_episode_number: String,
13495 /// Whether to show the date picker for 'from' date
135- #[allow(dead_code)]
13696 pub show_date_picker_from: bool,
13797 /// Whether to show the date picker for 'to' date
138- #[allow(dead_code)]
13998 pub show_date_picker_to: bool,
14099 /// Whether the export dialog is visible
141- #[allow(dead_code)]
142100 pub show_export_dialog: bool,
143101 /// Whether the import dialog is visible
144- #[allow(dead_code)]
145102 pub show_import_dialog: bool,
146103 /// Last export/import error message
147- #[allow(dead_code)]
148104 pub export_import_error: Option<String>,
149105 // Phase 9: Search functionality
150106 /// Current search query
107+ /// Not yet wired into the UI (see ui/search_bar.rs)
151108 #[allow(dead_code)]
152109 pub search_query: SearchQuery,
153110 /// Whether search panel is visible
154111 #[allow(dead_code)]
155112 pub show_search: bool,
156- // Phase 9: Sort functionality
157- /// Sort field for feeds (none, name, date, episodes)
158- #[allow(dead_code)]
159- pub sort_field: String,
160- /// Sort direction (asc or desc)
161- #[allow(dead_code)]
162- pub sort_direction: String,
163113 // Phase 9: Statistics
164114 /// Application statistics
115+ /// Not yet wired into the UI (see ui/stats_panel.rs)
165116 #[allow(dead_code)]
166117 pub statistics: AppStatistics,
167118 // Phase 9: History
168119 /// History manager for tracking operations
120+ /// Not yet wired into the UI (see ui/history_panel.rs)
169121 #[allow(dead_code)]
170122 pub history_manager: HistoryManager,
171123 /// Manager for async synchronization tasks
@@ -173,8 +125,8 @@ pub struct AppState {
173125 }
174126
175127 impl AppState {
176- /// Creates a new AppState with the given configuration path
177- pub fn new(config_path: impl Into<PathBuf>) -> Self {
128+ /// Creates a new empty AppState
129+ pub fn new() -> Self {
178130 Self {
179131 feeds: Vec::new(),
180132 current_feed: None,
@@ -196,12 +148,6 @@ impl AppState {
196148 edit_playlist_name: String::new(),
197149 edit_playlist_description: String::new(),
198150 editing_playlist_index: None,
199- sync_in_progress: false,
200- download_progress: HashMap::new(),
201- error_message: None,
202- info_message: None,
203- show_settings: false,
204- config_path: config_path.into(),
205151 notification_manager: NotificationManager::new(),
206152 playlists: Vec::new(),
207153 filter_date_from: None,
@@ -215,8 +161,6 @@ impl AppState {
215161 // Phase 9 fields
216162 search_query: SearchQuery::default(),
217163 show_search: false,
218- sort_field: "name".to_string(),
219- sort_direction: "asc".to_string(),
220164 statistics: AppStatistics::new(),
221165 history_manager: HistoryManager::new(),
222166 theme: Theme::default(),
@@ -226,26 +170,7 @@ impl AppState {
226170
227171 /// Creates a new AppState with default configuration path
228172 pub fn with_default_config() -> Self {
229- Self::new(Self::default_config_path())
230- }
231-
232- /// Returns the default configuration path
233- pub fn default_config_path() -> PathBuf {
234- // On Windows: %APPDATA%/rss-podcast-dl/config.json
235- // On Unix: ~/.config/rss-podcast-dl/config.json
236- if cfg!(windows) {
237- if let Some(app_data) = std::env::var_os("APPDATA") {
238- PathBuf::from(app_data).join("rss-podcast-dl").join("config.json")
239- } else {
240- PathBuf::from("config.json")
241- }
242- } else {
243- if let Some(home) = std::env::var_os("HOME") {
244- PathBuf::from(home).join(".config").join("rss-podcast-dl").join("config.json")
245- } else {
246- PathBuf::from("config.json")
247- }
248- }
173+ Self::new()
249174 }
250175
251176 /// Adds a new feed to the application state
@@ -254,79 +179,17 @@ impl AppState {
254179 self.notification_manager.success("Flux ajouté avec succès");
255180 }
256181
257- /// Removes a feed by index
258- #[allow(dead_code)]
259- pub fn remove_feed(&mut self, index: usize) -> Option<Feed> {
260- if index < self.feeds.len() {
261- let feed = self.feeds.remove(index);
262-
263- // Clear current selection if it was the removed feed
264- if let Some(current) = self.current_feed {
265- if current >= index {
266- self.current_feed = if current > 0 { Some(current - 1) } else { None };
267- }
268- }
269-
270- self.info_message = Some("Flux supprimé avec succès".to_string());
271- Some(feed)
272- } else {
273- None
274- }
275- }
276-
277- /// Gets the currently selected feed
278- #[allow(dead_code)]
279- pub fn get_current_feed(&self) -> Option<&Feed> {
280- self.current_feed.and_then(|idx| self.feeds.get(idx))
281- }
282-
283- /// Gets the currently selected feed as mutable
284- #[allow(dead_code)]
285- pub fn get_current_feed_mut(&mut self) -> Option<&mut Feed> {
286- self.current_feed.and_then(|idx| self.feeds.get_mut(idx))
287- }
288-
289182 /// Selects a feed by index
290183 pub fn select_feed(&mut self, index: Option<usize>) {
291184 self.current_feed = index;
292185 }
293186
294- /// Toggles the enabled state of a feed
295- #[allow(dead_code)]
296- pub fn toggle_feed_enabled(&mut self, index: usize) {
297- if let Some(feed) = self.feeds.get_mut(index) {
298- feed.enabled = !feed.enabled;
299- let status = if feed.enabled { "activé" } else { "désactivé" };
300- self.info_message = Some(format!("Flux {}", status));
301- }
302- }
303-
304- /// Clears all messages
305- #[allow(dead_code)]
306- pub fn clear_messages(&mut self) {
307- self.error_message = None;
308- self.info_message = None;
309- }
310-
311- /// Clears the error message
312- #[allow(dead_code)]
313- pub fn clear_error(&mut self) {
314- self.error_message = None;
315- }
316-
317- /// Clears the info message
318- #[allow(dead_code)]
319- pub fn clear_info(&mut self) {
320- self.info_message = None;
321- }
322-
323187 /// Returns the number of feeds
324188 pub fn feed_count(&self) -> usize {
325189 self.feeds.len()
326190 }
327191
328192 /// Returns the number of enabled feeds
329- #[allow(dead_code)]
330193 pub fn enabled_feed_count(&self) -> usize {
331194 self.feeds.iter().filter(|f| f.enabled).count()
332195 }
@@ -336,30 +199,6 @@ impl AppState {
336199 !self.feeds.is_empty()
337200 }
338201
339- /// Returns true if synchronization is in progress
340- #[allow(dead_code)]
341- pub fn is_syncing(&self) -> bool {
342- self.sync_in_progress
343- }
344-
345- /// Updates the download progress for a feed
346- #[allow(dead_code)]
347- pub fn update_download_progress(&mut self, feed_id: &str, progress: f32) {
348- self.download_progress.insert(feed_id.to_string(), progress.clamp(0.0, 1.0));
349- }
350-
351- /// Removes the download progress for a feed
352- #[allow(dead_code)]
353- pub fn remove_download_progress(&mut self, feed_id: &str) {
354- self.download_progress.remove(feed_id);
355- }
356-
357- /// Gets the download progress for a feed
358- #[allow(dead_code)]
359- pub fn get_download_progress(&self, feed_id: &str) -> f32 {
360- *self.download_progress.get(feed_id).unwrap_or(&0.0)
361- }
362-
363202 /// Clears the new feed form fields
364203 pub fn clear_new_feed_form(&mut self) {
365204 self.new_feed_name.clear();
@@ -391,14 +230,12 @@ impl AppState {
391230 // Playlist methods
392231
393232 /// Adds a new playlist
394- #[allow(dead_code)]
395233 pub fn add_playlist(&mut self, playlist: Playlist) {
396234 self.playlists.push(playlist);
397235 self.notification_manager.success("Playlist ajoutée");
398236 }
399237
400238 /// Removes a playlist by index
401- #[allow(dead_code)]
402239 pub fn remove_playlist(&mut self, index: usize) -> Option<Playlist> {
403240 if index < self.playlists.len() {
404241 let playlist = self.playlists.remove(index);
@@ -409,83 +246,20 @@ impl AppState {
409246 }
410247 }
411248
412- /// Gets a playlist by index
413- #[allow(dead_code)]
414- pub fn get_playlist(&self, index: usize) -> Option<&Playlist> {
415- self.playlists.get(index)
416- }
417-
418- /// Gets a mutable playlist by index
419- #[allow(dead_code)]
420- pub fn get_playlist_mut(&mut self, index: usize) -> Option<&mut Playlist> {
421- self.playlists.get_mut(index)
422- }
423-
424- /// Returns the number of playlists
425- #[allow(dead_code)]
426- pub fn playlist_count(&self) -> usize {
427- self.playlists.len()
428- }
429-
430- /// Returns true if there are any playlists
431- #[allow(dead_code)]
432- pub fn has_playlists(&self) -> bool {
433- !self.playlists.is_empty()
434- }
435-
436- /// Adds a feed to a playlist by index
437- #[allow(dead_code)]
438- pub fn add_feed_to_playlist(&mut self, playlist_index: usize, feed_id: &str) {
439- if let Some(playlist) = self.playlists.get_mut(playlist_index) {
440- playlist.add_feed(feed_id);
441- }
442- }
443-
444- /// Removes a feed from a playlist by index
445- #[allow(dead_code)]
446- pub fn remove_feed_from_playlist(&mut self, playlist_index: usize, feed_id: &str) {
447- if let Some(playlist) = self.playlists.get_mut(playlist_index) {
448- playlist.remove_feed(feed_id);
449- }
450- }
451-
452- /// Gets all playlists that contain a specific feed
453- #[allow(dead_code)]
454- pub fn get_playlists_containing_feed(&self, feed_id: &str) -> Vec<usize> {
455- self.playlists
456- .iter()
457- .enumerate()
458- .filter(|(_, p)| p.contains_feed(feed_id))
459- .map(|(i, _)| i)
460- .collect()
461- }
462-
463249 /// Clears the add playlist form fields
464- #[allow(dead_code)]
465250 pub fn clear_add_playlist_form(&mut self) {
466251 self.add_playlist_name.clear();
467252 self.add_playlist_description.clear();
468253 }
469254
470255 /// Clears the edit playlist form fields
471- #[allow(dead_code)]
472256 pub fn clear_edit_playlist_form(&mut self) {
473257 self.edit_playlist_name.clear();
474258 self.edit_playlist_description.clear();
475259 self.editing_playlist_index = None;
476260 }
477261
478- /// Loads playlist data into edit form fields
479- #[allow(dead_code)]
480- pub fn load_edit_playlist_form(&mut self, index: usize) {
481- if let Some(playlist) = self.playlists.get(index) {
482- self.edit_playlist_name = playlist.name.clone();
483- self.edit_playlist_description = playlist.description.clone().unwrap_or_default();
484- }
485- }
486-
487262 /// Clears all filter criteria
488- #[allow(dead_code)]
489263 pub fn clear_filters(&mut self) {
490264 self.filter_date_from = None;
491265 self.filter_date_to = None;
@@ -494,7 +268,6 @@ impl AppState {
494268 }
495269
496270 /// Sets an export/import error message
497- #[allow(dead_code)]
498271 pub fn set_export_import_error(&mut self, message: impl Into<String>) {
499272 let msg: String = message.into();
500273 self.export_import_error = Some(msg.clone());
@@ -502,7 +275,6 @@ impl AppState {
502275 }
503276
504277 /// Clears the export/import error message
505- #[allow(dead_code)]
506278 pub fn clear_export_import_error(&mut self) {
507279 self.export_import_error = None;
508280 }
@@ -517,12 +289,6 @@ impl AppState {
517289 self.notification_manager.info(format!("Thème basculé vers: {}", theme_name));
518290 }
519291
520- /// Sets the theme to a specific value
521- #[allow(dead_code)]
522- pub fn set_theme(&mut self, theme: Theme) {
523- self.theme = theme;
524- }
525-
526292 /// Gets the current theme
527293 pub fn get_theme(&self) -> Theme {
528294 self.theme
@@ -536,22 +302,14 @@ mod tests {
536302
537303 #[test]
538304 fn test_app_state_new() {
539- let app_state = AppState::new("test/config.json");
305+ let app_state = AppState::new();
540306 assert!(app_state.feeds.is_empty());
541307 assert!(app_state.current_feed.is_none());
542- assert!(!app_state.sync_in_progress);
543- }
544-
545- #[test]
546- fn test_app_state_default_config_path_windows() {
547- // This test just verifies the function doesn't panic
548- let path = AppState::default_config_path();
549- assert!(!path.to_string_lossy().is_empty());
550308 }
551309
552310 #[test]
553311 fn test_add_feed() {
554- let mut app_state = AppState::new("test/config.json");
312+ let mut app_state = AppState::new();
555313 let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads");
556314
557315 app_state.add_feed(feed);
@@ -560,56 +318,21 @@ mod tests {
560318 assert_eq!(app_state.feeds[0].name, "Test Podcast");
561319 }
562320
563- #[test]
564- fn test_remove_feed() {
565- let mut app_state = AppState::new("test/config.json");
566- let feed1 = Feed::new("https://example.com/rss1", "Podcast 1", "/downloads1");
567- let feed2 = Feed::new("https://example.com/rss2", "Podcast 2", "/downloads2");
568-
569- app_state.add_feed(feed1);
570- app_state.add_feed(feed2);
571-
572- let removed = app_state.remove_feed(0);
573- assert!(removed.is_some());
574- assert_eq!(removed.unwrap().name, "Podcast 1");
575- assert_eq!(app_state.feeds.len(), 1);
576- assert_eq!(app_state.feeds[0].name, "Podcast 2");
577- }
578-
579321 #[test]
580322 fn test_select_feed() {
581- let mut app_state = AppState::new("test/config.json");
323+ let mut app_state = AppState::new();
582324 let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads");
583325
584326 app_state.add_feed(feed);
585327 app_state.select_feed(Some(0));
586328
587329 assert_eq!(app_state.current_feed, Some(0));
588- assert_eq!(app_state.get_current_feed().unwrap().name, "Test Podcast");
589- }
590-
591- #[test]
592- fn test_toggle_feed_enabled() {
593- let mut app_state = AppState::new("test/config.json");
594- let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads");
595-
596- app_state.add_feed(feed);
597-
598- // Initially enabled
599- assert!(app_state.feeds[0].enabled);
600-
601- // Toggle to disabled
602- app_state.toggle_feed_enabled(0);
603- assert!(!app_state.feeds[0].enabled);
604-
605- // Toggle back to enabled
606- app_state.toggle_feed_enabled(0);
607- assert!(app_state.feeds[0].enabled);
330+ assert_eq!(app_state.feeds[0].name, "Test Podcast");
608331 }
609332
610333 #[test]
611334 fn test_feed_count() {
612- let mut app_state = AppState::new("test/config.json");
335+ let mut app_state = AppState::new();
613336 assert_eq!(app_state.feed_count(), 0);
614337
615338 app_state.add_feed(Feed::new("url1", "Podcast 1", "path1"));
@@ -621,7 +344,7 @@ mod tests {
621344
622345 #[test]
623346 fn test_enabled_feed_count() {
624- let mut app_state = AppState::new("test/config.json");
347+ let mut app_state = AppState::new();
625348
626349 app_state.add_feed(Feed::new("url1", "Podcast 1", "path1")); // enabled
627350 app_state.add_feed(Feed::new("url2", "Podcast 2", "path2")); // enabled
@@ -631,22 +354,4 @@ mod tests {
631354
632355 assert_eq!(app_state.enabled_feed_count(), 2);
633356 }
634-
635- #[test]
636- fn test_download_progress() {
637- let mut app_state = AppState::new("test/config.json");
638-
639- app_state.update_download_progress("feed-1", 0.5);
640- assert_eq!(app_state.get_download_progress("feed-1"), 0.5);
641-
642- // Test clamping
643- app_state.update_download_progress("feed-2", 1.5);
644- assert_eq!(app_state.get_download_progress("feed-2"), 1.0);
645-
646- app_state.update_download_progress("feed-3", -0.5);
647- assert_eq!(app_state.get_download_progress("feed-3"), 0.0);
648-
649- // Non-existent feed
650- assert_eq!(app_state.get_download_progress("feed-4"), 0.0);
651- }
652357 }
@@ -11,23 +11,18 @@ use crate::config::sync_task::SyncTaskManager;
11 use crate::ui::notifications::NotificationManager;11 use crate::ui::notifications::NotificationManager;
12 use chrono::{DateTime, Utc};12 use chrono::{DateTime, Utc};
13 use serde::{Deserialize, Serialize};13 use serde::{Deserialize, Serialize};
14-use std::collections::HashMap;
15-use std::path::PathBuf;
16 14
17 /// Theme options for the application15 /// Theme options for the application
18 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]16 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17+#[derive(Default)]
19 pub enum Theme {18 pub enum Theme {
20 /// Dark theme (default)19 /// Dark theme (default)
20+ #[default]
21 Dark,21 Dark,
22 /// Light theme22 /// Light theme
23 Light,23 Light,
24 }24 }
25 25
26-impl Default for Theme {
27- fn default() -> Self {
28- Theme::Dark
29- }
30-}
31 26
32 impl Theme {27 impl Theme {
33 /// Toggles between light and dark theme28 /// Toggles between light and dark theme
@@ -37,15 +32,6 @@ impl Theme {
37 Theme::Light => Theme::Dark,32 Theme::Light => Theme::Dark,
38 };33 };
39 }34 }
40-
41- /// Returns the opposite theme
42- #[allow(dead_code)]
43- pub fn opposite(&self) -> Self {
44- match self {
45- Theme::Dark => Theme::Light,
46- Theme::Light => Theme::Dark,
47- }
48- }
49 }35 }
50 36
51 /// Main application state37 /// Main application state
@@ -80,41 +66,19 @@ pub struct AppState {
80 /// Index of the feed being edited (if any)66 /// Index of the feed being edited (if any)
81 pub editing_feed_index: Option<usize>,67 pub editing_feed_index: Option<usize>,
82 /// Whether the add playlist dialog is visible68 /// Whether the add playlist dialog is visible
83- #[allow(dead_code)]
84 pub show_add_playlist_dialog: bool,69 pub show_add_playlist_dialog: bool,
85 /// Temporary storage for add playlist name70 /// Temporary storage for add playlist name
86- #[allow(dead_code)]
87 pub add_playlist_name: String,71 pub add_playlist_name: String,
88 /// Temporary storage for add playlist description72 /// Temporary storage for add playlist description
89- #[allow(dead_code)]
90 pub add_playlist_description: String,73 pub add_playlist_description: String,
91 /// Whether the edit playlist dialog is visible74 /// Whether the edit playlist dialog is visible
92- #[allow(dead_code)]
93 pub show_edit_playlist_dialog: bool,75 pub show_edit_playlist_dialog: bool,
94 /// Temporary storage for edit playlist name76 /// Temporary storage for edit playlist name
95- #[allow(dead_code)]
96 pub edit_playlist_name: String,77 pub edit_playlist_name: String,
97 /// Temporary storage for edit playlist description78 /// Temporary storage for edit playlist description
98- #[allow(dead_code)]
99 pub edit_playlist_description: String,79 pub edit_playlist_description: String,
100 /// Index of the playlist being edited (if any)80 /// Index of the playlist being edited (if any)
101- #[allow(dead_code)]
102 pub editing_playlist_index: Option<usize>,81 pub editing_playlist_index: Option<usize>,
103- /// Whether a synchronization is currently in progress
104- pub sync_in_progress: bool,
105- /// Download progress for each feed (feed_id -> progress 0.0-1.0)
106- #[allow(dead_code)]
107- pub download_progress: HashMap<String, f32>,
108- /// Current error message to display (if any)
109- pub error_message: Option<String>,
110- /// Current info message to display (if any)
111- pub info_message: Option<String>,
112- /// Whether the settings dialog is visible
113- #[allow(dead_code)]
114- pub show_settings: bool,
115- /// Path to the configuration file
116- #[allow(dead_code)]
117- pub config_path: PathBuf,
118 /// Manager for displaying notifications to the user82 /// Manager for displaying notifications to the user
119 pub notification_manager: NotificationManager,83 pub notification_manager: NotificationManager,
120 /// List of playlists for grouping feeds84 /// List of playlists for grouping feeds
@@ -123,49 +87,37 @@ pub struct AppState {
123 pub theme: Theme,87 pub theme: Theme,
124 // Filter state88 // Filter state
125 /// Filter: date from (inclusive)89 /// Filter: date from (inclusive)
126- #[allow(dead_code)]
127 pub filter_date_from: Option<DateTime<Utc>>,90 pub filter_date_from: Option<DateTime<Utc>>,
128 /// Filter: date to (inclusive)91 /// Filter: date to (inclusive)
129- #[allow(dead_code)]
130 pub filter_date_to: Option<DateTime<Utc>>,92 pub filter_date_to: Option<DateTime<Utc>>,
131 /// Filter: episode number93 /// Filter: episode number
132- #[allow(dead_code)]
133 pub filter_episode_number: String,94 pub filter_episode_number: String,
134 /// Whether to show the date picker for 'from' date95 /// Whether to show the date picker for 'from' date
135- #[allow(dead_code)]
136 pub show_date_picker_from: bool,96 pub show_date_picker_from: bool,
137 /// Whether to show the date picker for 'to' date97 /// Whether to show the date picker for 'to' date
138- #[allow(dead_code)]
139 pub show_date_picker_to: bool,98 pub show_date_picker_to: bool,
140 /// Whether the export dialog is visible99 /// Whether the export dialog is visible
141- #[allow(dead_code)]
142 pub show_export_dialog: bool,100 pub show_export_dialog: bool,
143 /// Whether the import dialog is visible101 /// Whether the import dialog is visible
144- #[allow(dead_code)]
145 pub show_import_dialog: bool,102 pub show_import_dialog: bool,
146 /// Last export/import error message103 /// Last export/import error message
147- #[allow(dead_code)]
148 pub export_import_error: Option<String>,104 pub export_import_error: Option<String>,
149 // Phase 9: Search functionality105 // Phase 9: Search functionality
150 /// Current search query106 /// Current search query
107+ /// Not yet wired into the UI (see ui/search_bar.rs)
151 #[allow(dead_code)]108 #[allow(dead_code)]
152 pub search_query: SearchQuery,109 pub search_query: SearchQuery,
153 /// Whether search panel is visible110 /// Whether search panel is visible
154 #[allow(dead_code)]111 #[allow(dead_code)]
155 pub show_search: bool,112 pub show_search: bool,
156- // Phase 9: Sort functionality
157- /// Sort field for feeds (none, name, date, episodes)
158- #[allow(dead_code)]
159- pub sort_field: String,
160- /// Sort direction (asc or desc)
161- #[allow(dead_code)]
162- pub sort_direction: String,
163 // Phase 9: Statistics113 // Phase 9: Statistics
164 /// Application statistics114 /// Application statistics
115+ /// Not yet wired into the UI (see ui/stats_panel.rs)
165 #[allow(dead_code)]116 #[allow(dead_code)]
166 pub statistics: AppStatistics,117 pub statistics: AppStatistics,
167 // Phase 9: History118 // Phase 9: History
168 /// History manager for tracking operations119 /// History manager for tracking operations
120+ /// Not yet wired into the UI (see ui/history_panel.rs)
169 #[allow(dead_code)]121 #[allow(dead_code)]
170 pub history_manager: HistoryManager,122 pub history_manager: HistoryManager,
171 /// Manager for async synchronization tasks123 /// Manager for async synchronization tasks
@@ -173,8 +125,8 @@ pub struct AppState {
173 }125 }
174 126
175 impl AppState {127 impl AppState {
176- /// Creates a new AppState with the given configuration path128+ /// Creates a new empty AppState
177- pub fn new(config_path: impl Into<PathBuf>) -> Self {129+ pub fn new() -> Self {
178 Self {130 Self {
179 feeds: Vec::new(),131 feeds: Vec::new(),
180 current_feed: None,132 current_feed: None,
@@ -196,12 +148,6 @@ impl AppState {
196 edit_playlist_name: String::new(),148 edit_playlist_name: String::new(),
197 edit_playlist_description: String::new(),149 edit_playlist_description: String::new(),
198 editing_playlist_index: None,150 editing_playlist_index: None,
199- sync_in_progress: false,
200- download_progress: HashMap::new(),
201- error_message: None,
202- info_message: None,
203- show_settings: false,
204- config_path: config_path.into(),
205 notification_manager: NotificationManager::new(),151 notification_manager: NotificationManager::new(),
206 playlists: Vec::new(),152 playlists: Vec::new(),
207 filter_date_from: None,153 filter_date_from: None,
@@ -215,8 +161,6 @@ impl AppState {
215 // Phase 9 fields161 // Phase 9 fields
216 search_query: SearchQuery::default(),162 search_query: SearchQuery::default(),
217 show_search: false,163 show_search: false,
218- sort_field: "name".to_string(),
219- sort_direction: "asc".to_string(),
220 statistics: AppStatistics::new(),164 statistics: AppStatistics::new(),
221 history_manager: HistoryManager::new(),165 history_manager: HistoryManager::new(),
222 theme: Theme::default(),166 theme: Theme::default(),
@@ -226,26 +170,7 @@ impl AppState {
226 170
227 /// Creates a new AppState with default configuration path171 /// Creates a new AppState with default configuration path
228 pub fn with_default_config() -> Self {172 pub fn with_default_config() -> Self {
229- Self::new(Self::default_config_path())173+ Self::new()
230- }
231-
232- /// Returns the default configuration path
233- pub fn default_config_path() -> PathBuf {
234- // On Windows: %APPDATA%/rss-podcast-dl/config.json
235- // On Unix: ~/.config/rss-podcast-dl/config.json
236- if cfg!(windows) {
237- if let Some(app_data) = std::env::var_os("APPDATA") {
238- PathBuf::from(app_data).join("rss-podcast-dl").join("config.json")
239- } else {
240- PathBuf::from("config.json")
241- }
242- } else {
243- if let Some(home) = std::env::var_os("HOME") {
244- PathBuf::from(home).join(".config").join("rss-podcast-dl").join("config.json")
245- } else {
246- PathBuf::from("config.json")
247- }
248- }
249 }174 }
250 175
251 /// Adds a new feed to the application state176 /// Adds a new feed to the application state
@@ -254,79 +179,17 @@ impl AppState {
254 self.notification_manager.success("Flux ajouté avec succès");179 self.notification_manager.success("Flux ajouté avec succès");
255 }180 }
256 181
257- /// Removes a feed by index
258- #[allow(dead_code)]
259- pub fn remove_feed(&mut self, index: usize) -> Option<Feed> {
260- if index < self.feeds.len() {
261- let feed = self.feeds.remove(index);
262-
263- // Clear current selection if it was the removed feed
264- if let Some(current) = self.current_feed {
265- if current >= index {
266- self.current_feed = if current > 0 { Some(current - 1) } else { None };
267- }
268- }
269-
270- self.info_message = Some("Flux supprimé avec succès".to_string());
271- Some(feed)
272- } else {
273- None
274- }
275- }
276-
277- /// Gets the currently selected feed
278- #[allow(dead_code)]
279- pub fn get_current_feed(&self) -> Option<&Feed> {
280- self.current_feed.and_then(|idx| self.feeds.get(idx))
281- }
282-
283- /// Gets the currently selected feed as mutable
284- #[allow(dead_code)]
285- pub fn get_current_feed_mut(&mut self) -> Option<&mut Feed> {
286- self.current_feed.and_then(|idx| self.feeds.get_mut(idx))
287- }
288-
289 /// Selects a feed by index182 /// Selects a feed by index
290 pub fn select_feed(&mut self, index: Option<usize>) {183 pub fn select_feed(&mut self, index: Option<usize>) {
291 self.current_feed = index;184 self.current_feed = index;
292 }185 }
293 186
294- /// Toggles the enabled state of a feed
295- #[allow(dead_code)]
296- pub fn toggle_feed_enabled(&mut self, index: usize) {
297- if let Some(feed) = self.feeds.get_mut(index) {
298- feed.enabled = !feed.enabled;
299- let status = if feed.enabled { "activé" } else { "désactivé" };
300- self.info_message = Some(format!("Flux {}", status));
301- }
302- }
303-
304- /// Clears all messages
305- #[allow(dead_code)]
306- pub fn clear_messages(&mut self) {
307- self.error_message = None;
308- self.info_message = None;
309- }
310-
311- /// Clears the error message
312- #[allow(dead_code)]
313- pub fn clear_error(&mut self) {
314- self.error_message = None;
315- }
316-
317- /// Clears the info message
318- #[allow(dead_code)]
319- pub fn clear_info(&mut self) {
320- self.info_message = None;
321- }
322-
323 /// Returns the number of feeds187 /// Returns the number of feeds
324 pub fn feed_count(&self) -> usize {188 pub fn feed_count(&self) -> usize {
325 self.feeds.len()189 self.feeds.len()
326 }190 }
327 191
328 /// Returns the number of enabled feeds192 /// Returns the number of enabled feeds
329- #[allow(dead_code)]
330 pub fn enabled_feed_count(&self) -> usize {193 pub fn enabled_feed_count(&self) -> usize {
331 self.feeds.iter().filter(|f| f.enabled).count()194 self.feeds.iter().filter(|f| f.enabled).count()
332 }195 }
@@ -336,30 +199,6 @@ impl AppState {
336 !self.feeds.is_empty()199 !self.feeds.is_empty()
337 }200 }
338 201
339- /// Returns true if synchronization is in progress
340- #[allow(dead_code)]
341- pub fn is_syncing(&self) -> bool {
342- self.sync_in_progress
343- }
344-
345- /// Updates the download progress for a feed
346- #[allow(dead_code)]
347- pub fn update_download_progress(&mut self, feed_id: &str, progress: f32) {
348- self.download_progress.insert(feed_id.to_string(), progress.clamp(0.0, 1.0));
349- }
350-
351- /// Removes the download progress for a feed
352- #[allow(dead_code)]
353- pub fn remove_download_progress(&mut self, feed_id: &str) {
354- self.download_progress.remove(feed_id);
355- }
356-
357- /// Gets the download progress for a feed
358- #[allow(dead_code)]
359- pub fn get_download_progress(&self, feed_id: &str) -> f32 {
360- *self.download_progress.get(feed_id).unwrap_or(&0.0)
361- }
362-
363 /// Clears the new feed form fields202 /// Clears the new feed form fields
364 pub fn clear_new_feed_form(&mut self) {203 pub fn clear_new_feed_form(&mut self) {
365 self.new_feed_name.clear();204 self.new_feed_name.clear();
@@ -391,14 +230,12 @@ impl AppState {
391 // Playlist methods230 // Playlist methods
392 231
393 /// Adds a new playlist232 /// Adds a new playlist
394- #[allow(dead_code)]
395 pub fn add_playlist(&mut self, playlist: Playlist) {233 pub fn add_playlist(&mut self, playlist: Playlist) {
396 self.playlists.push(playlist);234 self.playlists.push(playlist);
397 self.notification_manager.success("Playlist ajoutée");235 self.notification_manager.success("Playlist ajoutée");
398 }236 }
399 237
400 /// Removes a playlist by index238 /// Removes a playlist by index
401- #[allow(dead_code)]
402 pub fn remove_playlist(&mut self, index: usize) -> Option<Playlist> {239 pub fn remove_playlist(&mut self, index: usize) -> Option<Playlist> {
403 if index < self.playlists.len() {240 if index < self.playlists.len() {
404 let playlist = self.playlists.remove(index);241 let playlist = self.playlists.remove(index);
@@ -409,83 +246,20 @@ impl AppState {
409 }246 }
410 }247 }
411 248
412- /// Gets a playlist by index
413- #[allow(dead_code)]
414- pub fn get_playlist(&self, index: usize) -> Option<&Playlist> {
415- self.playlists.get(index)
416- }
417-
418- /// Gets a mutable playlist by index
419- #[allow(dead_code)]
420- pub fn get_playlist_mut(&mut self, index: usize) -> Option<&mut Playlist> {
421- self.playlists.get_mut(index)
422- }
423-
424- /// Returns the number of playlists
425- #[allow(dead_code)]
426- pub fn playlist_count(&self) -> usize {
427- self.playlists.len()
428- }
429-
430- /// Returns true if there are any playlists
431- #[allow(dead_code)]
432- pub fn has_playlists(&self) -> bool {
433- !self.playlists.is_empty()
434- }
435-
436- /// Adds a feed to a playlist by index
437- #[allow(dead_code)]
438- pub fn add_feed_to_playlist(&mut self, playlist_index: usize, feed_id: &str) {
439- if let Some(playlist) = self.playlists.get_mut(playlist_index) {
440- playlist.add_feed(feed_id);
441- }
442- }
443-
444- /// Removes a feed from a playlist by index
445- #[allow(dead_code)]
446- pub fn remove_feed_from_playlist(&mut self, playlist_index: usize, feed_id: &str) {
447- if let Some(playlist) = self.playlists.get_mut(playlist_index) {
448- playlist.remove_feed(feed_id);
449- }
450- }
451-
452- /// Gets all playlists that contain a specific feed
453- #[allow(dead_code)]
454- pub fn get_playlists_containing_feed(&self, feed_id: &str) -> Vec<usize> {
455- self.playlists
456- .iter()
457- .enumerate()
458- .filter(|(_, p)| p.contains_feed(feed_id))
459- .map(|(i, _)| i)
460- .collect()
461- }
462-
463 /// Clears the add playlist form fields249 /// Clears the add playlist form fields
464- #[allow(dead_code)]
465 pub fn clear_add_playlist_form(&mut self) {250 pub fn clear_add_playlist_form(&mut self) {
466 self.add_playlist_name.clear();251 self.add_playlist_name.clear();
467 self.add_playlist_description.clear();252 self.add_playlist_description.clear();
468 }253 }
469 254
470 /// Clears the edit playlist form fields255 /// Clears the edit playlist form fields
471- #[allow(dead_code)]
472 pub fn clear_edit_playlist_form(&mut self) {256 pub fn clear_edit_playlist_form(&mut self) {
473 self.edit_playlist_name.clear();257 self.edit_playlist_name.clear();
474 self.edit_playlist_description.clear();258 self.edit_playlist_description.clear();
475 self.editing_playlist_index = None;259 self.editing_playlist_index = None;
476 }260 }
477 261
478- /// Loads playlist data into edit form fields
479- #[allow(dead_code)]
480- pub fn load_edit_playlist_form(&mut self, index: usize) {
481- if let Some(playlist) = self.playlists.get(index) {
482- self.edit_playlist_name = playlist.name.clone();
483- self.edit_playlist_description = playlist.description.clone().unwrap_or_default();
484- }
485- }
486-
487 /// Clears all filter criteria262 /// Clears all filter criteria
488- #[allow(dead_code)]
489 pub fn clear_filters(&mut self) {263 pub fn clear_filters(&mut self) {
490 self.filter_date_from = None;264 self.filter_date_from = None;
491 self.filter_date_to = None;265 self.filter_date_to = None;
@@ -494,7 +268,6 @@ impl AppState {
494 }268 }
495 269
496 /// Sets an export/import error message270 /// Sets an export/import error message
497- #[allow(dead_code)]
498 pub fn set_export_import_error(&mut self, message: impl Into<String>) {271 pub fn set_export_import_error(&mut self, message: impl Into<String>) {
499 let msg: String = message.into();272 let msg: String = message.into();
500 self.export_import_error = Some(msg.clone());273 self.export_import_error = Some(msg.clone());
@@ -502,7 +275,6 @@ impl AppState {
502 }275 }
503 276
504 /// Clears the export/import error message277 /// Clears the export/import error message
505- #[allow(dead_code)]
506 pub fn clear_export_import_error(&mut self) {278 pub fn clear_export_import_error(&mut self) {
507 self.export_import_error = None;279 self.export_import_error = None;
508 }280 }
@@ -517,12 +289,6 @@ impl AppState {
517 self.notification_manager.info(format!("Thème basculé vers: {}", theme_name));289 self.notification_manager.info(format!("Thème basculé vers: {}", theme_name));
518 }290 }
519 291
520- /// Sets the theme to a specific value
521- #[allow(dead_code)]
522- pub fn set_theme(&mut self, theme: Theme) {
523- self.theme = theme;
524- }
525-
526 /// Gets the current theme292 /// Gets the current theme
527 pub fn get_theme(&self) -> Theme {293 pub fn get_theme(&self) -> Theme {
528 self.theme294 self.theme
@@ -536,22 +302,14 @@ mod tests {
536 302
537 #[test]303 #[test]
538 fn test_app_state_new() {304 fn test_app_state_new() {
539- let app_state = AppState::new("test/config.json");305+ let app_state = AppState::new();
540 assert!(app_state.feeds.is_empty());306 assert!(app_state.feeds.is_empty());
541 assert!(app_state.current_feed.is_none());307 assert!(app_state.current_feed.is_none());
542- assert!(!app_state.sync_in_progress);
543- }
544-
545- #[test]
546- fn test_app_state_default_config_path_windows() {
547- // This test just verifies the function doesn't panic
548- let path = AppState::default_config_path();
549- assert!(!path.to_string_lossy().is_empty());
550 }308 }
551 309
552 #[test]310 #[test]
553 fn test_add_feed() {311 fn test_add_feed() {
554- let mut app_state = AppState::new("test/config.json");312+ let mut app_state = AppState::new();
555 let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads");313 let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads");
556 314
557 app_state.add_feed(feed);315 app_state.add_feed(feed);
@@ -560,56 +318,21 @@ mod tests {
560 assert_eq!(app_state.feeds[0].name, "Test Podcast");318 assert_eq!(app_state.feeds[0].name, "Test Podcast");
561 }319 }
562 320
563- #[test]
564- fn test_remove_feed() {
565- let mut app_state = AppState::new("test/config.json");
566- let feed1 = Feed::new("https://example.com/rss1", "Podcast 1", "/downloads1");
567- let feed2 = Feed::new("https://example.com/rss2", "Podcast 2", "/downloads2");
568-
569- app_state.add_feed(feed1);
570- app_state.add_feed(feed2);
571-
572- let removed = app_state.remove_feed(0);
573- assert!(removed.is_some());
574- assert_eq!(removed.unwrap().name, "Podcast 1");
575- assert_eq!(app_state.feeds.len(), 1);
576- assert_eq!(app_state.feeds[0].name, "Podcast 2");
577- }
578-
579 #[test]321 #[test]
580 fn test_select_feed() {322 fn test_select_feed() {
581- let mut app_state = AppState::new("test/config.json");323+ let mut app_state = AppState::new();
582 let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads");324 let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads");
583 325
584 app_state.add_feed(feed);326 app_state.add_feed(feed);
585 app_state.select_feed(Some(0));327 app_state.select_feed(Some(0));
586 328
587 assert_eq!(app_state.current_feed, Some(0));329 assert_eq!(app_state.current_feed, Some(0));
588- assert_eq!(app_state.get_current_feed().unwrap().name, "Test Podcast");330+ assert_eq!(app_state.feeds[0].name, "Test Podcast");
589- }
590-
591- #[test]
592- fn test_toggle_feed_enabled() {
593- let mut app_state = AppState::new("test/config.json");
594- let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads");
595-
596- app_state.add_feed(feed);
597-
598- // Initially enabled
599- assert!(app_state.feeds[0].enabled);
600-
601- // Toggle to disabled
602- app_state.toggle_feed_enabled(0);
603- assert!(!app_state.feeds[0].enabled);
604-
605- // Toggle back to enabled
606- app_state.toggle_feed_enabled(0);
607- assert!(app_state.feeds[0].enabled);
608 }331 }
609 332
610 #[test]333 #[test]
611 fn test_feed_count() {334 fn test_feed_count() {
612- let mut app_state = AppState::new("test/config.json");335+ let mut app_state = AppState::new();
613 assert_eq!(app_state.feed_count(), 0);336 assert_eq!(app_state.feed_count(), 0);
614 337
615 app_state.add_feed(Feed::new("url1", "Podcast 1", "path1"));338 app_state.add_feed(Feed::new("url1", "Podcast 1", "path1"));
@@ -621,7 +344,7 @@ mod tests {
621 344
622 #[test]345 #[test]
623 fn test_enabled_feed_count() {346 fn test_enabled_feed_count() {
624- let mut app_state = AppState::new("test/config.json");347+ let mut app_state = AppState::new();
625 348
626 app_state.add_feed(Feed::new("url1", "Podcast 1", "path1")); // enabled349 app_state.add_feed(Feed::new("url1", "Podcast 1", "path1")); // enabled
627 app_state.add_feed(Feed::new("url2", "Podcast 2", "path2")); // enabled350 app_state.add_feed(Feed::new("url2", "Podcast 2", "path2")); // enabled
@@ -631,22 +354,4 @@ mod tests {
631 354
632 assert_eq!(app_state.enabled_feed_count(), 2);355 assert_eq!(app_state.enabled_feed_count(), 2);
633 }356 }
634-
635- #[test]
636- fn test_download_progress() {
637- let mut app_state = AppState::new("test/config.json");
638-
639- app_state.update_download_progress("feed-1", 0.5);
640- assert_eq!(app_state.get_download_progress("feed-1"), 0.5);
641-
642- // Test clamping
643- app_state.update_download_progress("feed-2", 1.5);
644- assert_eq!(app_state.get_download_progress("feed-2"), 1.0);
645-
646- app_state.update_download_progress("feed-3", -0.5);
647- assert_eq!(app_state.get_download_progress("feed-3"), 0.0);
648-
649- // Non-existent feed
650- assert_eq!(app_state.get_download_progress("feed-4"), 0.0);
651- }
652 }357 }
modified src/config/backup.rs +3 -3
@@ -1,4 +1,4 @@
1-#![allow(dead_code)]
1+#![allow(dead_code)] // Fonctionnalité prévue dans la spec (Phase 9+), pas encore branchée dans l'UI
22 use crate::config::{feed::Feed, storage::{self, StorageError}};
33 use chrono::{DateTime, Utc};
44 use serde::{Deserialize, Serialize};
@@ -186,7 +186,7 @@ impl BackupManager {
186186 }
187187
188188 // Trier par date de création (plus récent en premier)
189- backups.sort_by(|a, b| b.created_at.cmp(&a.created_at));
189+ backups.sort_by_key(|b| std::cmp::Reverse(b.created_at));
190190
191191 Ok(backups)
192192 }
@@ -272,7 +272,7 @@ mod tests {
272272 #[test]
273273 fn test_backup_manager_creation() {
274274 let manager = BackupManager::new();
275- assert!(manager.backup_dir.exists() == false); // Le dossier par défaut n'existe pas
275+ assert!(!manager.backup_dir.exists()); // Le dossier par défaut n'existe pas
276276 }
277277
278278 #[test]
@@ -1,4 +1,4 @@
1-#![allow(dead_code)]1+#![allow(dead_code)] // Fonctionnalité prévue dans la spec (Phase 9+), pas encore branchée dans l'UI
2 use crate::config::{feed::Feed, storage::{self, StorageError}};2 use crate::config::{feed::Feed, storage::{self, StorageError}};
3 use chrono::{DateTime, Utc};3 use chrono::{DateTime, Utc};
4 use serde::{Deserialize, Serialize};4 use serde::{Deserialize, Serialize};
@@ -186,7 +186,7 @@ impl BackupManager {
186 }186 }
187 187
188 // Trier par date de création (plus récent en premier)188 // Trier par date de création (plus récent en premier)
189- backups.sort_by(|a, b| b.created_at.cmp(&a.created_at));189+ backups.sort_by_key(|b| std::cmp::Reverse(b.created_at));
190 190
191 Ok(backups)191 Ok(backups)
192 }192 }
@@ -272,7 +272,7 @@ mod tests {
272 #[test]272 #[test]
273 fn test_backup_manager_creation() {273 fn test_backup_manager_creation() {
274 let manager = BackupManager::new();274 let manager = BackupManager::new();
275- assert!(manager.backup_dir.exists() == false); // Le dossier par défaut n'existe pas275+ assert!(!manager.backup_dir.exists()); // Le dossier par défaut n'existe pas
276 }276 }
277 277
278 #[test]278 #[test]
modified src/config/episode.rs +0 -143
@@ -87,103 +87,10 @@ impl Episode {
8787 }
8888 }
8989
90- /// Creates a new Episode with a custom filename
91- #[allow(dead_code)]
92- pub fn with_filename(
93- id: impl Into<String>,
94- title: impl Into<String>,
95- audio_url: impl Into<String>,
96- pub_date: DateTime<Utc>,
97- audio_filename: impl Into<String>,
98- ) -> Self {
99- Self {
100- id: id.into(),
101- title: title.into(),
102- description: None,
103- pub_date,
104- audio_url: audio_url.into(),
105- audio_filename: audio_filename.into(),
106- downloaded: false,
107- file_size: None,
108- mime_type: None,
109- duration_seconds: None,
110- artist: None,
111- album: None,
112- }
113- }
114-
11590 /// Returns the full path where the episode should be saved
11691 pub fn get_save_path(&self, download_dir: &Path) -> PathBuf {
11792 download_dir.join(&self.audio_filename)
11893 }
119-
120- /// Marks the episode as downloaded
121- #[allow(dead_code)]
122- pub fn mark_as_downloaded(&mut self) {
123- self.downloaded = true;
124- }
125-
126- /// Returns true if the episode is an audio file based on URL
127- #[allow(dead_code)]
128- pub fn is_audio_url(&self) -> bool {
129- let url = self.audio_url.to_lowercase();
130- url.ends_with(".mp3")
131- || url.ends_with(".m4a")
132- || url.ends_with(".aac")
133- || url.ends_with(".wav")
134- || url.ends_with(".ogg")
135- || url.ends_with(".flac")
136- || url.ends_with(".opus")
137- }
138-
139- /// Formats the duration as a human-readable string (HH:MM:SS)
140- #[allow(dead_code)]
141- pub fn format_duration(&self) -> String {
142- match self.duration_seconds {
143- Some(seconds) => {
144- let hours = seconds / 3600;
145- let minutes = (seconds % 3600) / 60;
146- let secs = seconds % 60;
147- if hours > 0 {
148- format!("{:02}:{:02}:{:02}", hours, minutes, secs)
149- } else {
150- format!("{:02}:{:02}", minutes, secs)
151- }
152- }
153- None => "Unknown".to_string(),
154- }
155- }
156-
157- /// Formats the file size as a human-readable string
158- #[allow(dead_code)]
159- pub fn format_file_size(&self) -> String {
160- match self.file_size {
161- Some(bytes) => {
162- if bytes < 1024 {
163- format!("{} B", bytes)
164- } else if bytes < 1024 * 1024 {
165- format!(" {:.2} KB", bytes as f64 / 1024.0)
166- } else if bytes < 1024 * 1024 * 1024 {
167- format!(" {:.2} MB", bytes as f64 / (1024.0 * 1024.0))
168- } else {
169- format!(" {:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
170- }
171- }
172- None => "Unknown".to_string(),
173- }
174- }
175-
176- /// Returns the artist name, falling back to a default if not set
177- #[allow(dead_code)]
178- pub fn get_artist(&self) -> String {
179- self.artist.clone().unwrap_or_else(|| "Unknown Artist".to_string())
180- }
181-
182- /// Returns the album name, falling back to a default if not set
183- #[allow(dead_code)]
184- pub fn get_album(&self) -> String {
185- self.album.clone().unwrap_or_else(|| "Unknown Album".to_string())
186- }
18794 }
18895
18996 #[cfg(test)]
@@ -241,54 +148,4 @@ mod tests {
241148 let save_path = episode.get_save_path(&download_dir);
242149 assert_eq!(save_path, PathBuf::from("/downloads/podcasts/episode.mp3"));
243150 }
244-
245- #[test]
246- fn test_is_audio_url() {
247- let pub_date = Utc::now();
248-
249- let mp3_episode = Episode::new("1", "Test", "https://example.com/episode.mp3", pub_date);
250- assert!(mp3_episode.is_audio_url());
251-
252- let m4a_episode = Episode::new("2", "Test", "https://example.com/episode.m4a", pub_date);
253- assert!(m4a_episode.is_audio_url());
254-
255- let non_audio = Episode::new("3", "Test", "https://example.com/episode.txt", pub_date);
256- assert!(!non_audio.is_audio_url());
257- }
258-
259- #[test]
260- fn test_format_duration() {
261- let pub_date = Utc::now();
262- let mut episode = Episode::new("1", "Test", "url", pub_date);
263-
264- episode.duration_seconds = Some(3661); // 1h 1m 1s
265- assert_eq!(episode.format_duration(), "01:01:01");
266-
267- episode.duration_seconds = Some(125); // 2m 5s
268- assert_eq!(episode.format_duration(), "02:05");
269-
270- episode.duration_seconds = None;
271- assert_eq!(episode.format_duration(), "Unknown");
272- }
273-
274- #[test]
275- fn test_format_file_size() {
276- let pub_date = Utc::now();
277- let mut episode = Episode::new("1", "Test", "url", pub_date);
278-
279- episode.file_size = Some(512);
280- assert_eq!(episode.format_file_size(), "512 B");
281-
282- episode.file_size = Some(1536);
283- assert_eq!(episode.format_file_size(), " 1.50 KB");
284-
285- episode.file_size = Some(1_572_864); // ~1.5 MB
286- assert_eq!(episode.format_file_size(), " 1.50 MB");
287-
288- episode.file_size = Some(1_610_612_736); // ~1.5 GB
289- assert_eq!(episode.format_file_size(), " 1.50 GB");
290-
291- episode.file_size = None;
292- assert_eq!(episode.format_file_size(), "Unknown");
293- }
294151 }
@@ -87,103 +87,10 @@ impl Episode {
87 }87 }
88 }88 }
89 89
90- /// Creates a new Episode with a custom filename
91- #[allow(dead_code)]
92- pub fn with_filename(
93- id: impl Into<String>,
94- title: impl Into<String>,
95- audio_url: impl Into<String>,
96- pub_date: DateTime<Utc>,
97- audio_filename: impl Into<String>,
98- ) -> Self {
99- Self {
100- id: id.into(),
101- title: title.into(),
102- description: None,
103- pub_date,
104- audio_url: audio_url.into(),
105- audio_filename: audio_filename.into(),
106- downloaded: false,
107- file_size: None,
108- mime_type: None,
109- duration_seconds: None,
110- artist: None,
111- album: None,
112- }
113- }
114-
115 /// Returns the full path where the episode should be saved90 /// Returns the full path where the episode should be saved
116 pub fn get_save_path(&self, download_dir: &Path) -> PathBuf {91 pub fn get_save_path(&self, download_dir: &Path) -> PathBuf {
117 download_dir.join(&self.audio_filename)92 download_dir.join(&self.audio_filename)
118 }93 }
119-
120- /// Marks the episode as downloaded
121- #[allow(dead_code)]
122- pub fn mark_as_downloaded(&mut self) {
123- self.downloaded = true;
124- }
125-
126- /// Returns true if the episode is an audio file based on URL
127- #[allow(dead_code)]
128- pub fn is_audio_url(&self) -> bool {
129- let url = self.audio_url.to_lowercase();
130- url.ends_with(".mp3")
131- || url.ends_with(".m4a")
132- || url.ends_with(".aac")
133- || url.ends_with(".wav")
134- || url.ends_with(".ogg")
135- || url.ends_with(".flac")
136- || url.ends_with(".opus")
137- }
138-
139- /// Formats the duration as a human-readable string (HH:MM:SS)
140- #[allow(dead_code)]
141- pub fn format_duration(&self) -> String {
142- match self.duration_seconds {
143- Some(seconds) => {
144- let hours = seconds / 3600;
145- let minutes = (seconds % 3600) / 60;
146- let secs = seconds % 60;
147- if hours > 0 {
148- format!("{:02}:{:02}:{:02}", hours, minutes, secs)
149- } else {
150- format!("{:02}:{:02}", minutes, secs)
151- }
152- }
153- None => "Unknown".to_string(),
154- }
155- }
156-
157- /// Formats the file size as a human-readable string
158- #[allow(dead_code)]
159- pub fn format_file_size(&self) -> String {
160- match self.file_size {
161- Some(bytes) => {
162- if bytes < 1024 {
163- format!("{} B", bytes)
164- } else if bytes < 1024 * 1024 {
165- format!(" {:.2} KB", bytes as f64 / 1024.0)
166- } else if bytes < 1024 * 1024 * 1024 {
167- format!(" {:.2} MB", bytes as f64 / (1024.0 * 1024.0))
168- } else {
169- format!(" {:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
170- }
171- }
172- None => "Unknown".to_string(),
173- }
174- }
175-
176- /// Returns the artist name, falling back to a default if not set
177- #[allow(dead_code)]
178- pub fn get_artist(&self) -> String {
179- self.artist.clone().unwrap_or_else(|| "Unknown Artist".to_string())
180- }
181-
182- /// Returns the album name, falling back to a default if not set
183- #[allow(dead_code)]
184- pub fn get_album(&self) -> String {
185- self.album.clone().unwrap_or_else(|| "Unknown Album".to_string())
186- }
187 }94 }
188 95
189 #[cfg(test)]96 #[cfg(test)]
@@ -241,54 +148,4 @@ mod tests {
241 let save_path = episode.get_save_path(&download_dir);148 let save_path = episode.get_save_path(&download_dir);
242 assert_eq!(save_path, PathBuf::from("/downloads/podcasts/episode.mp3"));149 assert_eq!(save_path, PathBuf::from("/downloads/podcasts/episode.mp3"));
243 }150 }
244-
245- #[test]
246- fn test_is_audio_url() {
247- let pub_date = Utc::now();
248-
249- let mp3_episode = Episode::new("1", "Test", "https://example.com/episode.mp3", pub_date);
250- assert!(mp3_episode.is_audio_url());
251-
252- let m4a_episode = Episode::new("2", "Test", "https://example.com/episode.m4a", pub_date);
253- assert!(m4a_episode.is_audio_url());
254-
255- let non_audio = Episode::new("3", "Test", "https://example.com/episode.txt", pub_date);
256- assert!(!non_audio.is_audio_url());
257- }
258-
259- #[test]
260- fn test_format_duration() {
261- let pub_date = Utc::now();
262- let mut episode = Episode::new("1", "Test", "url", pub_date);
263-
264- episode.duration_seconds = Some(3661); // 1h 1m 1s
265- assert_eq!(episode.format_duration(), "01:01:01");
266-
267- episode.duration_seconds = Some(125); // 2m 5s
268- assert_eq!(episode.format_duration(), "02:05");
269-
270- episode.duration_seconds = None;
271- assert_eq!(episode.format_duration(), "Unknown");
272- }
273-
274- #[test]
275- fn test_format_file_size() {
276- let pub_date = Utc::now();
277- let mut episode = Episode::new("1", "Test", "url", pub_date);
278-
279- episode.file_size = Some(512);
280- assert_eq!(episode.format_file_size(), "512 B");
281-
282- episode.file_size = Some(1536);
283- assert_eq!(episode.format_file_size(), " 1.50 KB");
284-
285- episode.file_size = Some(1_572_864); // ~1.5 MB
286- assert_eq!(episode.format_file_size(), " 1.50 MB");
287-
288- episode.file_size = Some(1_610_612_736); // ~1.5 GB
289- assert_eq!(episode.format_file_size(), " 1.50 GB");
290-
291- episode.file_size = None;
292- assert_eq!(episode.format_file_size(), "Unknown");
293- }
294 }151 }
modified src/config/feed.rs +0 -94
@@ -70,68 +70,7 @@ impl Feed {
7070 }
7171 }
7272
73- /// Validates the feed URL
74- #[allow(dead_code)]
75- pub fn validate_url(&self) -> Result<(), String> {
76- if self.url.is_empty() {
77- return Err("URL cannot be empty".to_string());
78- }
79-
80- // Basic URL validation - check for http:// or https:// prefix
81- if !self.url.starts_with("http://") && !self.url.starts_with("https://") {
82- return Err("URL must start with http:// or https://".to_string());
83- }
84-
85- Ok(())
86- }
87-
88- /// Validates the download path
89- #[allow(dead_code)]
90- pub fn validate_path(&self) -> Result<(), String> {
91- if self.download_path.to_string_lossy().is_empty() {
92- return Err("Download path cannot be empty".to_string());
93- }
94-
95- Ok(())
96- }
97-
98- /// Validates both URL and path
99- #[allow(dead_code)]
100- pub fn validate(&self) -> Result<(), String> {
101- self.validate_url()?;
102- self.validate_path()?;
103- Ok(())
104- }
105-
106- /// Returns true if the feed is valid
107- #[allow(dead_code)]
108- pub fn is_valid(&self) -> bool {
109- self.validate().is_ok()
110- }
111-
112- /// Adds a tag to the feed if not already present
113- #[allow(dead_code)]
114- pub fn add_tag(&mut self, tag: impl Into<String>) {
115- let tag = tag.into();
116- if !self.tags.contains(&tag) {
117- self.tags.push(tag);
118- }
119- }
120-
121- /// Removes a tag from the feed
122- #[allow(dead_code)]
123- pub fn remove_tag(&mut self, tag: &str) {
124- self.tags.retain(|t| t != tag);
125- }
126-
127- /// Returns true if the feed has the given tag
128- #[allow(dead_code)]
129- pub fn has_tag(&self, tag: &str) -> bool {
130- self.tags.contains(&tag.to_string())
131- }
132-
13373 /// Returns the tags as a comma-separated string
134- #[allow(dead_code)]
13574 pub fn tags_string(&self) -> String {
13675 self.tags.join(", ")
13776 }
@@ -158,37 +97,4 @@ mod tests {
15897 assert_eq!(feed.download_path, PathBuf::from("/downloads"));
15998 assert!(feed.enabled);
16099 }
161-
162- #[test]
163- fn test_validate_url_valid() {
164- let feed = Feed::new("https://example.com/rss", "Test", "/path");
165- assert!(feed.validate_url().is_ok());
166- }
167-
168- #[test]
169- fn test_validate_url_invalid_empty() {
170- let feed = Feed::new("", "Test", "/path");
171- assert!(feed.validate_url().is_err());
172- }
173-
174- #[test]
175- fn test_validate_url_invalid_no_protocol() {
176- let feed = Feed::new("example.com/rss", "Test", "/path");
177- assert!(feed.validate_url().is_err());
178- }
179-
180- #[test]
181- fn test_validate_path_valid() {
182- let feed = Feed::new("https://example.com/rss", "Test", "/downloads");
183- assert!(feed.validate_path().is_ok());
184- }
185-
186- #[test]
187- fn test_is_valid() {
188- let valid_feed = Feed::new("https://example.com/rss", "Test", "/downloads");
189- assert!(valid_feed.is_valid());
190-
191- let invalid_feed = Feed::new("", "Test", "");
192- assert!(!invalid_feed.is_valid());
193- }
194100 }
@@ -70,68 +70,7 @@ impl Feed {
70 }70 }
71 }71 }
72 72
73- /// Validates the feed URL
74- #[allow(dead_code)]
75- pub fn validate_url(&self) -> Result<(), String> {
76- if self.url.is_empty() {
77- return Err("URL cannot be empty".to_string());
78- }
79-
80- // Basic URL validation - check for http:// or https:// prefix
81- if !self.url.starts_with("http://") && !self.url.starts_with("https://") {
82- return Err("URL must start with http:// or https://".to_string());
83- }
84-
85- Ok(())
86- }
87-
88- /// Validates the download path
89- #[allow(dead_code)]
90- pub fn validate_path(&self) -> Result<(), String> {
91- if self.download_path.to_string_lossy().is_empty() {
92- return Err("Download path cannot be empty".to_string());
93- }
94-
95- Ok(())
96- }
97-
98- /// Validates both URL and path
99- #[allow(dead_code)]
100- pub fn validate(&self) -> Result<(), String> {
101- self.validate_url()?;
102- self.validate_path()?;
103- Ok(())
104- }
105-
106- /// Returns true if the feed is valid
107- #[allow(dead_code)]
108- pub fn is_valid(&self) -> bool {
109- self.validate().is_ok()
110- }
111-
112- /// Adds a tag to the feed if not already present
113- #[allow(dead_code)]
114- pub fn add_tag(&mut self, tag: impl Into<String>) {
115- let tag = tag.into();
116- if !self.tags.contains(&tag) {
117- self.tags.push(tag);
118- }
119- }
120-
121- /// Removes a tag from the feed
122- #[allow(dead_code)]
123- pub fn remove_tag(&mut self, tag: &str) {
124- self.tags.retain(|t| t != tag);
125- }
126-
127- /// Returns true if the feed has the given tag
128- #[allow(dead_code)]
129- pub fn has_tag(&self, tag: &str) -> bool {
130- self.tags.contains(&tag.to_string())
131- }
132-
133 /// Returns the tags as a comma-separated string73 /// Returns the tags as a comma-separated string
134- #[allow(dead_code)]
135 pub fn tags_string(&self) -> String {74 pub fn tags_string(&self) -> String {
136 self.tags.join(", ")75 self.tags.join(", ")
137 }76 }
@@ -158,37 +97,4 @@ mod tests {
158 assert_eq!(feed.download_path, PathBuf::from("/downloads"));97 assert_eq!(feed.download_path, PathBuf::from("/downloads"));
159 assert!(feed.enabled);98 assert!(feed.enabled);
160 }99 }
161-
162- #[test]
163- fn test_validate_url_valid() {
164- let feed = Feed::new("https://example.com/rss", "Test", "/path");
165- assert!(feed.validate_url().is_ok());
166- }
167-
168- #[test]
169- fn test_validate_url_invalid_empty() {
170- let feed = Feed::new("", "Test", "/path");
171- assert!(feed.validate_url().is_err());
172- }
173-
174- #[test]
175- fn test_validate_url_invalid_no_protocol() {
176- let feed = Feed::new("example.com/rss", "Test", "/path");
177- assert!(feed.validate_url().is_err());
178- }
179-
180- #[test]
181- fn test_validate_path_valid() {
182- let feed = Feed::new("https://example.com/rss", "Test", "/downloads");
183- assert!(feed.validate_path().is_ok());
184- }
185-
186- #[test]
187- fn test_is_valid() {
188- let valid_feed = Feed::new("https://example.com/rss", "Test", "/downloads");
189- assert!(valid_feed.is_valid());
190-
191- let invalid_feed = Feed::new("", "Test", "");
192- assert!(!invalid_feed.is_valid());
193- }
194 }100 }
modified src/config/history.rs +1 -1
@@ -1,4 +1,4 @@
1-#![allow(dead_code)]
1+#![allow(dead_code)] // Fonctionnalité prévue dans la spec (Phase 9+), pas encore branchée dans l'UI
22 use chrono::{DateTime, Utc};
33 use serde::{Deserialize, Serialize};
44 use std::collections::VecDeque;
@@ -1,4 +1,4 @@
1-#![allow(dead_code)]1+#![allow(dead_code)] // Fonctionnalité prévue dans la spec (Phase 9+), pas encore branchée dans l'UI
2 use chrono::{DateTime, Utc};2 use chrono::{DateTime, Utc};
3 use serde::{Deserialize, Serialize};3 use serde::{Deserialize, Serialize};
4 use std::collections::VecDeque;4 use std::collections::VecDeque;
modified src/config/mod.rs +1 -30
@@ -13,36 +13,7 @@ pub mod statistics;
1313 pub mod storage;
1414 pub mod sync_task;
1515
16-// Re-export main types for convenience
17-// Note: Some re-exports may not be directly used in the current codebase but are
18-// kept for future extensibility and API completeness
19-#[allow(dead_code)]
20-#[allow(unused_imports)]
16+// Re-export the most commonly used types
2117 pub use app_state::{AppState, Theme};
22-#[allow(dead_code)]
23-#[allow(unused_imports)]
24-pub use backup::{BackupInfo, BackupManager};
25-#[allow(dead_code)]
26-#[allow(unused_imports)]
2718 pub use episode::Episode;
28-#[allow(dead_code)]
29-#[allow(unused_imports)]
3019 pub use feed::Feed;
31-#[allow(dead_code)]
32-#[allow(unused_imports)]
33-pub use history::{HistoryEntry, HistoryManager, OperationStatus, OperationType};
34-#[allow(dead_code)]
35-#[allow(unused_imports)]
36-pub use playlist::Playlist;
37-#[allow(dead_code)]
38-#[allow(unused_imports)]
39-pub use search::{SearchQuery, SearchResult, SearchType};
40-#[allow(dead_code)]
41-#[allow(unused_imports)]
42-pub use statistics::{AppStatistics, FeedStatistics, PlaylistStatistics};
43-#[allow(dead_code)]
44-#[allow(unused_imports)]
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};
@@ -13,36 +13,7 @@ pub mod statistics;
13 pub mod storage;13 pub mod storage;
14 pub mod sync_task;14 pub mod sync_task;
15 15
16-// Re-export main types for convenience16+// Re-export the most commonly used types
17-// Note: Some re-exports may not be directly used in the current codebase but are
18-// kept for future extensibility and API completeness
19-#[allow(dead_code)]
20-#[allow(unused_imports)]
21 pub use app_state::{AppState, Theme};17 pub use app_state::{AppState, Theme};
22-#[allow(dead_code)]
23-#[allow(unused_imports)]
24-pub use backup::{BackupInfo, BackupManager};
25-#[allow(dead_code)]
26-#[allow(unused_imports)]
27 pub use episode::Episode;18 pub use episode::Episode;
28-#[allow(dead_code)]
29-#[allow(unused_imports)]
30 pub use feed::Feed;19 pub use feed::Feed;
31-#[allow(dead_code)]
32-#[allow(unused_imports)]
33-pub use history::{HistoryEntry, HistoryManager, OperationStatus, OperationType};
34-#[allow(dead_code)]
35-#[allow(unused_imports)]
36-pub use playlist::Playlist;
37-#[allow(dead_code)]
38-#[allow(unused_imports)]
39-pub use search::{SearchQuery, SearchResult, SearchType};
40-#[allow(dead_code)]
41-#[allow(unused_imports)]
42-pub use statistics::{AppStatistics, FeedStatistics, PlaylistStatistics};
43-#[allow(dead_code)]
44-#[allow(unused_imports)]
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};
modified src/config/playlist.rs +2 -60
@@ -40,35 +40,6 @@ impl Playlist {
4040 }
4141 }
4242
43- /// Creates a new playlist with the given name and feed IDs
44- #[allow(dead_code)]
45- pub fn with_feeds(name: impl Into<String>, feed_ids: Vec<String>) -> Self {
46- Self {
47- id: Uuid::new_v4().to_string(),
48- name: name.into(),
49- feed_ids,
50- description: None,
51- }
52- }
53-
54- /// Adds a feed ID to the playlist if not already present
55- pub fn add_feed(&mut self, feed_id: impl Into<String>) {
56- let feed_id = feed_id.into();
57- if !self.feed_ids.contains(&feed_id) {
58- self.feed_ids.push(feed_id);
59- }
60- }
61-
62- /// Removes a feed ID from the playlist
63- pub fn remove_feed(&mut self, feed_id: &str) {
64- self.feed_ids.retain(|id| id != feed_id);
65- }
66-
67- /// Returns true if the playlist contains the given feed ID
68- pub fn contains_feed(&self, feed_id: &str) -> bool {
69- self.feed_ids.contains(&feed_id.to_string())
70- }
71-
7243 /// Returns the number of feeds in the playlist
7344 pub fn feed_count(&self) -> usize {
7445 self.feed_ids.len()
@@ -105,39 +76,10 @@ mod tests {
10576 assert!(playlist.feed_ids.is_empty());
10677 }
10778
108- #[test]
109- fn test_playlist_with_feeds() {
110- let feed_ids = vec!["feed-1".to_string(), "feed-2".to_string()];
111- let playlist = Playlist::with_feeds("Test Playlist", feed_ids.clone());
112- assert_eq!(playlist.name, "Test Playlist");
113- assert_eq!(playlist.feed_ids, feed_ids);
114- }
115-
116- #[test]
117- fn test_add_feed() {
118- let mut playlist = Playlist::new("Test");
119- playlist.add_feed("feed-1");
120- playlist.add_feed("feed-2");
121- playlist.add_feed("feed-1"); // Duplicate - should not be added
122-
123- assert_eq!(playlist.feed_ids.len(), 2);
124- assert!(playlist.contains_feed("feed-1"));
125- assert!(playlist.contains_feed("feed-2"));
126- }
127-
128- #[test]
129- fn test_remove_feed() {
130- let mut playlist = Playlist::with_feeds("Test", vec!["feed-1".to_string(), "feed-2".to_string()]);
131- playlist.remove_feed("feed-1");
132-
133- assert_eq!(playlist.feed_ids.len(), 1);
134- assert!(!playlist.contains_feed("feed-1"));
135- assert!(playlist.contains_feed("feed-2"));
136- }
137-
13879 #[test]
13980 fn test_feed_count() {
140- let playlist = Playlist::with_feeds("Test", vec!["feed-1".to_string(), "feed-2".to_string(), "feed-3".to_string()]);
81+ let mut playlist = Playlist::new("Test");
82+ playlist.feed_ids = vec!["feed-1".to_string(), "feed-2".to_string(), "feed-3".to_string()];
14183 assert_eq!(playlist.feed_count(), 3);
14284 }
14385
@@ -40,35 +40,6 @@ impl Playlist {
40 }40 }
41 }41 }
42 42
43- /// Creates a new playlist with the given name and feed IDs
44- #[allow(dead_code)]
45- pub fn with_feeds(name: impl Into<String>, feed_ids: Vec<String>) -> Self {
46- Self {
47- id: Uuid::new_v4().to_string(),
48- name: name.into(),
49- feed_ids,
50- description: None,
51- }
52- }
53-
54- /// Adds a feed ID to the playlist if not already present
55- pub fn add_feed(&mut self, feed_id: impl Into<String>) {
56- let feed_id = feed_id.into();
57- if !self.feed_ids.contains(&feed_id) {
58- self.feed_ids.push(feed_id);
59- }
60- }
61-
62- /// Removes a feed ID from the playlist
63- pub fn remove_feed(&mut self, feed_id: &str) {
64- self.feed_ids.retain(|id| id != feed_id);
65- }
66-
67- /// Returns true if the playlist contains the given feed ID
68- pub fn contains_feed(&self, feed_id: &str) -> bool {
69- self.feed_ids.contains(&feed_id.to_string())
70- }
71-
72 /// Returns the number of feeds in the playlist43 /// Returns the number of feeds in the playlist
73 pub fn feed_count(&self) -> usize {44 pub fn feed_count(&self) -> usize {
74 self.feed_ids.len()45 self.feed_ids.len()
@@ -105,39 +76,10 @@ mod tests {
105 assert!(playlist.feed_ids.is_empty());76 assert!(playlist.feed_ids.is_empty());
106 }77 }
107 78
108- #[test]
109- fn test_playlist_with_feeds() {
110- let feed_ids = vec!["feed-1".to_string(), "feed-2".to_string()];
111- let playlist = Playlist::with_feeds("Test Playlist", feed_ids.clone());
112- assert_eq!(playlist.name, "Test Playlist");
113- assert_eq!(playlist.feed_ids, feed_ids);
114- }
115-
116- #[test]
117- fn test_add_feed() {
118- let mut playlist = Playlist::new("Test");
119- playlist.add_feed("feed-1");
120- playlist.add_feed("feed-2");
121- playlist.add_feed("feed-1"); // Duplicate - should not be added
122-
123- assert_eq!(playlist.feed_ids.len(), 2);
124- assert!(playlist.contains_feed("feed-1"));
125- assert!(playlist.contains_feed("feed-2"));
126- }
127-
128- #[test]
129- fn test_remove_feed() {
130- let mut playlist = Playlist::with_feeds("Test", vec!["feed-1".to_string(), "feed-2".to_string()]);
131- playlist.remove_feed("feed-1");
132-
133- assert_eq!(playlist.feed_ids.len(), 1);
134- assert!(!playlist.contains_feed("feed-1"));
135- assert!(playlist.contains_feed("feed-2"));
136- }
137-
138 #[test]79 #[test]
139 fn test_feed_count() {80 fn test_feed_count() {
140- let playlist = Playlist::with_feeds("Test", vec!["feed-1".to_string(), "feed-2".to_string(), "feed-3".to_string()]);81+ let mut playlist = Playlist::new("Test");
82+ playlist.feed_ids = vec!["feed-1".to_string(), "feed-2".to_string(), "feed-3".to_string()];
141 assert_eq!(playlist.feed_count(), 3);83 assert_eq!(playlist.feed_count(), 3);
142 }84 }
143 85
modified src/config/search.rs +5 -8
@@ -1,8 +1,9 @@
1-#![allow(dead_code)]
1+#![allow(dead_code)] // Fonctionnalité prévue dans la spec (Phase 9+), pas encore branchée dans l'UI
22 use crate::config::{episode::Episode, feed::Feed};
33
44 /// Type de recherche possible
55 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
6+#[derive(Default)]
67 pub enum SearchType {
78 /// Recherche dans les noms de flux
89 FeedName,
@@ -15,14 +16,10 @@ pub enum SearchType {
1516 /// Recherche dans les descriptions d'épisodes
1617 EpisodeDescription,
1718 /// Recherche dans tous les champs
19+ #[default]
1820 All,
1921 }
2022
21-impl Default for SearchType {
22- fn default() -> Self {
23- SearchType::All
24- }
25-}
2623
2724 /// Requête de recherche
2825 #[derive(Debug, Clone, Default)]
@@ -238,8 +235,8 @@ pub fn search_feeds(
238235 }
239236
240237 // Trier les résultats par score (descendant)
241- result.feed_results.sort_by(|a, b| b.score.cmp(&a.score));
242- result.episode_results.sort_by(|a, b| b.score.cmp(&a.score));
238+ result.feed_results.sort_by_key(|r| std::cmp::Reverse(r.score));
239+ result.episode_results.sort_by_key(|r| std::cmp::Reverse(r.score));
243240
244241 result
245242 }
@@ -1,8 +1,9 @@
1-#![allow(dead_code)]1+#![allow(dead_code)] // Fonctionnalité prévue dans la spec (Phase 9+), pas encore branchée dans l'UI
2 use crate::config::{episode::Episode, feed::Feed};2 use crate::config::{episode::Episode, feed::Feed};
3 3
4 /// Type de recherche possible4 /// Type de recherche possible
5 #[derive(Debug, Clone, Copy, PartialEq, Eq)]5 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
6+#[derive(Default)]
6 pub enum SearchType {7 pub enum SearchType {
7 /// Recherche dans les noms de flux8 /// Recherche dans les noms de flux
8 FeedName,9 FeedName,
@@ -15,14 +16,10 @@ pub enum SearchType {
15 /// Recherche dans les descriptions d'épisodes16 /// Recherche dans les descriptions d'épisodes
16 EpisodeDescription,17 EpisodeDescription,
17 /// Recherche dans tous les champs18 /// Recherche dans tous les champs
19+ #[default]
18 All,20 All,
19 }21 }
20 22
21-impl Default for SearchType {
22- fn default() -> Self {
23- SearchType::All
24- }
25-}
26 23
27 /// Requête de recherche24 /// Requête de recherche
28 #[derive(Debug, Clone, Default)]25 #[derive(Debug, Clone, Default)]
@@ -238,8 +235,8 @@ pub fn search_feeds(
238 }235 }
239 236
240 // Trier les résultats par score (descendant)237 // Trier les résultats par score (descendant)
241- result.feed_results.sort_by(|a, b| b.score.cmp(&a.score));238+ result.feed_results.sort_by_key(|r| std::cmp::Reverse(r.score));
242- result.episode_results.sort_by(|a, b| b.score.cmp(&a.score));239+ result.episode_results.sort_by_key(|r| std::cmp::Reverse(r.score));
243 240
244 result241 result
245 }242 }
modified src/config/statistics.rs +1 -1
@@ -1,4 +1,4 @@
1-#![allow(dead_code)]
1+#![allow(dead_code)] // Fonctionnalité prévue dans la spec (Phase 9+), pas encore branchée dans l'UI
22 use crate::config::{episode::Episode, feed::Feed};
33 use chrono::{DateTime, Utc};
44 use std::collections::HashMap;
@@ -1,4 +1,4 @@
1-#![allow(dead_code)]1+#![allow(dead_code)] // Fonctionnalité prévue dans la spec (Phase 9+), pas encore branchée dans l'UI
2 use crate::config::{episode::Episode, feed::Feed};2 use crate::config::{episode::Episode, feed::Feed};
3 use chrono::{DateTime, Utc};3 use chrono::{DateTime, Utc};
4 use std::collections::HashMap;4 use std::collections::HashMap;
modified src/config/storage.rs +1 -19
@@ -5,8 +5,6 @@
55 use crate::config::feed::Feed;
66 use crate::config::playlist::Playlist;
77 use log::{info, warn};
8-#[allow(unused_imports)]
9-use serde::{Deserialize, Serialize};
108 use std::fs;
119 use std::path::Path;
1210 use thiserror::Error;
@@ -30,11 +28,6 @@ pub enum StorageError {
3028 #[error("Failed to serialize configuration: {0}")]
3129 SerializeError(String),
3230
33- /// Configuration file not found
34- #[error("Configuration file not found: {0}")]
35- #[allow(dead_code)]
36- NotFound(String),
37-
3831 /// Invalid configuration directory
3932 #[error("Invalid configuration directory: {0}")]
4033 InvalidDirectory(String),
@@ -210,33 +203,22 @@ pub fn load_playlists(path: &Path) -> Result<Vec<Playlist>, StorageError> {
210203 }
211204
212205 /// Saves playlists to the default configuration path
213-#[allow(dead_code)]
214206 pub fn save_playlists_default(playlists: &[Playlist]) -> Result<(), StorageError> {
215207 save_playlists(playlists, &get_playlists_path())
216208 }
217209
218210 /// Loads playlists from the default configuration path
219-#[allow(dead_code)]
220211 pub fn load_playlists_default() -> Result<Vec<Playlist>, StorageError> {
221212 load_playlists(&get_playlists_path())
222213 }
223214
224-/// Exports feeds to a custom JSON file (user-selected path)
225-/// This is the same as save_feeds but with export semantics
226-#[allow(dead_code)]
227-pub fn export_feeds(feeds: &[Feed], path: &Path) -> Result<(), StorageError> {
228- save_feeds(feeds, path)
229-}
230-
231215 /// Imports feeds from a custom JSON file (user-selected path)
232216 /// This is the same as load_feeds but with import semantics
233-#[allow(dead_code)]
234217 pub fn import_feeds(path: &Path) -> Result<Vec<Feed>, StorageError> {
235218 load_feeds(path)
236219 }
237220
238221 /// Exports feeds to a custom JSON file with a pretty format suitable for sharing
239-#[allow(dead_code)]
240222 pub fn export_feeds_pretty(feeds: &[Feed], path: &Path) -> Result<(), StorageError> {
241223 info!("Exporting {} feeds to {}", feeds.len(), path.display());
242224
@@ -331,6 +313,6 @@ mod tests {
331313 fn test_get_config_path() {
332314 let path = get_config_path();
333315 assert!(!path.to_string_lossy().is_empty());
334- assert!(path.extension().map_or(false, |ext| ext == "json"));
316+ assert!(path.extension().is_some_and(|ext| ext == "json"));
335317 }
336318 }
@@ -5,8 +5,6 @@
5 use crate::config::feed::Feed;5 use crate::config::feed::Feed;
6 use crate::config::playlist::Playlist;6 use crate::config::playlist::Playlist;
7 use log::{info, warn};7 use log::{info, warn};
8-#[allow(unused_imports)]
9-use serde::{Deserialize, Serialize};
10 use std::fs;8 use std::fs;
11 use std::path::Path;9 use std::path::Path;
12 use thiserror::Error;10 use thiserror::Error;
@@ -30,11 +28,6 @@ pub enum StorageError {
30 #[error("Failed to serialize configuration: {0}")]28 #[error("Failed to serialize configuration: {0}")]
31 SerializeError(String),29 SerializeError(String),
32 30
33- /// Configuration file not found
34- #[error("Configuration file not found: {0}")]
35- #[allow(dead_code)]
36- NotFound(String),
37-
38 /// Invalid configuration directory31 /// Invalid configuration directory
39 #[error("Invalid configuration directory: {0}")]32 #[error("Invalid configuration directory: {0}")]
40 InvalidDirectory(String),33 InvalidDirectory(String),
@@ -210,33 +203,22 @@ pub fn load_playlists(path: &Path) -> Result<Vec<Playlist>, StorageError> {
210 }203 }
211 204
212 /// Saves playlists to the default configuration path205 /// Saves playlists to the default configuration path
213-#[allow(dead_code)]
214 pub fn save_playlists_default(playlists: &[Playlist]) -> Result<(), StorageError> {206 pub fn save_playlists_default(playlists: &[Playlist]) -> Result<(), StorageError> {
215 save_playlists(playlists, &get_playlists_path())207 save_playlists(playlists, &get_playlists_path())
216 }208 }
217 209
218 /// Loads playlists from the default configuration path210 /// Loads playlists from the default configuration path
219-#[allow(dead_code)]
220 pub fn load_playlists_default() -> Result<Vec<Playlist>, StorageError> {211 pub fn load_playlists_default() -> Result<Vec<Playlist>, StorageError> {
221 load_playlists(&get_playlists_path())212 load_playlists(&get_playlists_path())
222 }213 }
223 214
224-/// Exports feeds to a custom JSON file (user-selected path)
225-/// This is the same as save_feeds but with export semantics
226-#[allow(dead_code)]
227-pub fn export_feeds(feeds: &[Feed], path: &Path) -> Result<(), StorageError> {
228- save_feeds(feeds, path)
229-}
230-
231 /// Imports feeds from a custom JSON file (user-selected path)215 /// Imports feeds from a custom JSON file (user-selected path)
232 /// This is the same as load_feeds but with import semantics216 /// This is the same as load_feeds but with import semantics
233-#[allow(dead_code)]
234 pub fn import_feeds(path: &Path) -> Result<Vec<Feed>, StorageError> {217 pub fn import_feeds(path: &Path) -> Result<Vec<Feed>, StorageError> {
235 load_feeds(path)218 load_feeds(path)
236 }219 }
237 220
238 /// Exports feeds to a custom JSON file with a pretty format suitable for sharing221 /// Exports feeds to a custom JSON file with a pretty format suitable for sharing
239-#[allow(dead_code)]
240 pub fn export_feeds_pretty(feeds: &[Feed], path: &Path) -> Result<(), StorageError> {222 pub fn export_feeds_pretty(feeds: &[Feed], path: &Path) -> Result<(), StorageError> {
241 info!("Exporting {} feeds to {}", feeds.len(), path.display());223 info!("Exporting {} feeds to {}", feeds.len(), path.display());
242 224
@@ -331,6 +313,6 @@ mod tests {
331 fn test_get_config_path() {313 fn test_get_config_path() {
332 let path = get_config_path();314 let path = get_config_path();
333 assert!(!path.to_string_lossy().is_empty());315 assert!(!path.to_string_lossy().is_empty());
334- assert!(path.extension().map_or(false, |ext| ext == "json"));316+ assert!(path.extension().is_some_and(|ext| ext == "json"));
335 }317 }
336 }318 }
modified src/config/sync_task.rs +19 -129
@@ -13,20 +13,10 @@ pub enum SyncMessage {
1313 /// Sync started for a feed
1414 SyncStarted {
1515 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,
2316 },
2417 /// Sync completed successfully
2518 SyncCompleted {
2619 feed_id: String,
27- feed_name: String,
28- episodes_found: usize,
29- episodes_downloaded: usize,
3020 /// Updated download statistics for the feed
3121 downloaded_episodes_count: usize,
3222 last_downloaded_episode_name: Option<String>,
@@ -37,25 +27,8 @@ pub enum SyncMessage {
3727 /// Sync failed with an error
3828 SyncFailed {
3929 feed_id: String,
40- feed_name: String,
4130 error: String,
4231 },
43- /// Individual episode download started
44- EpisodeDownloadStarted {
45- feed_id: String,
46- episode_title: String,
47- },
48- /// Individual episode download progress
49- EpisodeDownloadProgress {
50- feed_id: String,
51- episode_title: String,
52- progress: f32,
53- },
54- /// Individual episode download completed
55- EpisodeDownloadCompleted {
56- feed_id: String,
57- episode_title: String,
58- },
5932 /// Individual episode download failed
6033 EpisodeDownloadFailed {
6134 feed_id: String,
@@ -65,57 +38,21 @@ pub enum SyncMessage {
6538 }
6639
6740 /// Status of a feed synchronization
68-#[derive(Debug, Clone)]
41+#[derive(Debug, Clone, Default)]
6942 pub enum FeedSyncStatus {
7043 /// Sync not started or completed
44+ #[default]
7145 Idle,
7246 /// Sync in progress
73- InProgress {
74- progress: f32,
75- message: String,
76- },
47+ InProgress,
7748 /// Sync completed successfully
78- Completed {
79- episodes_found: usize,
80- episodes_downloaded: usize,
81- },
49+ Completed,
8250 /// Sync failed
8351 Failed {
8452 error: String,
8553 },
8654 }
8755
88-impl Default for FeedSyncStatus {
89- fn default() -> Self {
90- Self::Idle
91- }
92-}
93-
94-/// Episode download status
95-#[derive(Debug, Clone)]
96-pub struct EpisodeDownloadStatus {
97- pub status: EpisodeDownloadPhase,
98- pub progress: f32,
99-}
100-
101-impl Default for EpisodeDownloadStatus {
102- fn default() -> Self {
103- Self {
104- status: EpisodeDownloadPhase::NotStarted,
105- progress: 0.0,
106- }
107- }
108-}
109-
110-/// Phase of episode download
111-#[derive(Debug, Clone, PartialEq)]
112-pub enum EpisodeDownloadPhase {
113- NotStarted,
114- Downloading,
115- Completed,
116- Failed,
117-}
118-
11956 /// Status of all synchronization tasks
12057 #[derive(Debug, Default)]
12158 pub struct SyncStatus {
@@ -123,8 +60,6 @@ pub struct SyncStatus {
12360 pub global_sync_in_progress: bool,
12461 /// Individual feed sync status (feed_id -> status)
12562 pub feed_sync_status: HashMap<String, FeedSyncStatus>,
126- /// Individual episode download status (episode_id -> status)
127- pub episode_download_status: HashMap<String, EpisodeDownloadStatus>,
12863 /// Total feeds to sync
12964 pub total_feeds: usize,
13065 /// Feeds completed
@@ -161,7 +96,7 @@ impl SyncStatus {
16196 // Update overall progress
16297 if self.global_sync_in_progress && self.total_feeds > 0 {
16398 let completed = self.feed_sync_status.values()
164- .filter(|s| matches!(s, FeedSyncStatus::Completed { .. } | FeedSyncStatus::Failed { .. }))
99+ .filter(|s| matches!(s, FeedSyncStatus::Completed | FeedSyncStatus::Failed { .. }))
165100 .count();
166101 self.completed_feeds = completed;
167102 self.overall_progress = (completed as f32 / self.total_feeds as f32).min(1.0);
@@ -172,12 +107,6 @@ impl SyncStatus {
172107 pub fn get_overall_progress(&self) -> f32 {
173108 self.overall_progress
174109 }
175-
176- /// Check if any sync is in progress
177- pub fn is_syncing(&self) -> bool {
178- self.global_sync_in_progress ||
179- self.feed_sync_status.values().any(|s| matches!(s, FeedSyncStatus::InProgress { .. }))
180- }
181110
182111 /// Check if there are any sync activities (in progress or recently completed)
183112 /// Used to determine if we should show sync-related UI elements
@@ -189,7 +118,7 @@ impl SyncStatus {
189118 /// Get syncing feed count
190119 pub fn syncing_feed_count(&self) -> usize {
191120 self.feed_sync_status.values()
192- .filter(|s| matches!(s, FeedSyncStatus::InProgress { .. }))
121+ .filter(|s| matches!(s, FeedSyncStatus::InProgress))
193122 .count()
194123 }
195124 }
@@ -216,11 +145,6 @@ impl SyncTaskManager {
216145 }
217146 }
218147
219- /// Get the sender for sending messages from async tasks
220- pub fn sender(&self) -> Sender<SyncMessage> {
221- self.sender.clone()
222- }
223-
224148 /// Process incoming messages and update status
225149 pub fn process_messages(&mut self) -> Vec<SyncMessage> {
226150 let mut messages = Vec::new();
@@ -239,44 +163,29 @@ impl SyncTaskManager {
239163 /// Handle a single sync message
240164 fn handle_message(&mut self, message: &SyncMessage) {
241165 match message {
242- SyncMessage::SyncStarted { feed_id, .. } => {
166+ SyncMessage::SyncStarted { feed_id } => {
243167 self.status.update_feed_status(
244168 feed_id.clone(),
245- FeedSyncStatus::InProgress {
246- progress: 0.0,
247- message: "Démarrage de la synchronisation...".to_string(),
248- }
249- );
250- }
251- SyncMessage::SyncProgress { feed_id, progress, message } => {
252- self.status.update_feed_status(
253- feed_id.clone(),
254- FeedSyncStatus::InProgress {
255- progress: *progress,
256- message: message.clone(),
257- }
169+ FeedSyncStatus::InProgress,
258170 );
259171 }
260- SyncMessage::SyncCompleted { feed_id, feed_name: _, episodes_found, episodes_downloaded, .. } => {
172+ SyncMessage::SyncCompleted { feed_id, .. } => {
261173 self.status.update_feed_status(
262174 feed_id.clone(),
263- FeedSyncStatus::Completed {
264- episodes_found: *episodes_found,
265- episodes_downloaded: *episodes_downloaded,
266- }
175+ FeedSyncStatus::Completed,
267176 );
268177
269178 // Check if all feeds are completed
270179 if self.status.global_sync_in_progress {
271180 let all_completed = self.status.feed_sync_status.values().all(|s| {
272- matches!(s, FeedSyncStatus::Completed { .. } | FeedSyncStatus::Failed { .. })
181+ matches!(s, FeedSyncStatus::Completed | FeedSyncStatus::Failed { .. })
273182 });
274183 if all_completed {
275184 self.status.complete_global_sync();
276185 }
277186 }
278187 }
279- SyncMessage::SyncFailed { feed_id, feed_name: _, error } => {
188+ SyncMessage::SyncFailed { feed_id, error } => {
280189 self.status.update_feed_status(
281190 feed_id.clone(),
282191 FeedSyncStatus::Failed {
@@ -287,28 +196,15 @@ impl SyncTaskManager {
287196 // Check if all feeds are completed (including failed ones)
288197 if self.status.global_sync_in_progress {
289198 let all_completed = self.status.feed_sync_status.values().all(|s| {
290- matches!(s, FeedSyncStatus::Completed { .. } | FeedSyncStatus::Failed { .. })
199+ matches!(s, FeedSyncStatus::Completed | FeedSyncStatus::Failed { .. })
291200 });
292201 if all_completed {
293202 self.status.complete_global_sync();
294203 }
295204 }
296-
297- // For single feed sync, we should also clean up after a failure
298- // Remove the feed from tracking after a reasonable delay
299- // This is handled by the is_syncing() check which looks at InProgress status
300- }
301- SyncMessage::EpisodeDownloadStarted { feed_id: _, episode_title: _ } => {
302- // Episode download started - could track this if needed
303- }
304- SyncMessage::EpisodeDownloadProgress { feed_id: _, episode_title: _, progress: _ } => {
305- // Episode download progress
306- }
307- SyncMessage::EpisodeDownloadCompleted { .. } => {
308- // Episode download completed
309205 }
310206 SyncMessage::EpisodeDownloadFailed { .. } => {
311- // Episode download failed
207+ // Displayed by the caller (home.rs), no status update needed
312208 }
313209 }
314210 }
@@ -330,13 +226,7 @@ impl SyncTaskManager {
330226 let feed_id = feed.id.clone();
331227
332228 // Update status
333- self.status.update_feed_status(
334- feed_id.clone(),
335- FeedSyncStatus::InProgress {
336- progress: 0.0,
337- message: "Démarrage...".to_string(),
338- }
339- );
229+ self.status.update_feed_status(feed_id, FeedSyncStatus::InProgress);
340230
341231 thread::spawn(move || {
342232 crate::rss::async_sync::sync_feed_async(feed, sender);
@@ -346,7 +236,7 @@ impl SyncTaskManager {
346236 /// Clears completed and failed sync statuses
347237 pub fn clear_completed_sync_status(&mut self) {
348238 self.status.feed_sync_status.retain(|_, status| {
349- matches!(status, FeedSyncStatus::InProgress { .. })
239+ matches!(status, FeedSyncStatus::InProgress)
350240 });
351241 }
352242 }
@@ -367,7 +257,7 @@ mod tests {
367257 assert!(!status.global_sync_in_progress);
368258 assert!(status.feed_sync_status.is_empty());
369259 assert_eq!(status.get_overall_progress(), 0.0);
370- assert!(!status.is_syncing());
260+ assert!(!status.has_sync_activity());
371261 }
372262
373263 #[test]
@@ -387,8 +277,8 @@ mod tests {
387277 status.start_global_sync(3);
388278
389279 // Add completed feeds
390- status.update_feed_status("feed-1".to_string(), FeedSyncStatus::Completed { episodes_found: 5, episodes_downloaded: 2 });
391- status.update_feed_status("feed-2".to_string(), FeedSyncStatus::Completed { episodes_found: 3, episodes_downloaded: 1 });
280+ status.update_feed_status("feed-1".to_string(), FeedSyncStatus::Completed);
281+ status.update_feed_status("feed-2".to_string(), FeedSyncStatus::Completed);
392282
393283 assert_eq!(status.completed_feeds, 2);
394284 assert!((status.get_overall_progress() - 0.666).abs() < 0.01);
@@ -13,20 +13,10 @@ pub enum SyncMessage {
13 /// Sync started for a feed13 /// Sync started for a feed
14 SyncStarted {14 SyncStarted {
15 feed_id: String,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 },16 },
24 /// Sync completed successfully17 /// Sync completed successfully
25 SyncCompleted {18 SyncCompleted {
26 feed_id: String,19 feed_id: String,
27- feed_name: String,
28- episodes_found: usize,
29- episodes_downloaded: usize,
30 /// Updated download statistics for the feed20 /// Updated download statistics for the feed
31 downloaded_episodes_count: usize,21 downloaded_episodes_count: usize,
32 last_downloaded_episode_name: Option<String>,22 last_downloaded_episode_name: Option<String>,
@@ -37,25 +27,8 @@ pub enum SyncMessage {
37 /// Sync failed with an error27 /// Sync failed with an error
38 SyncFailed {28 SyncFailed {
39 feed_id: String,29 feed_id: String,
40- feed_name: String,
41 error: String,30 error: String,
42 },31 },
43- /// Individual episode download started
44- EpisodeDownloadStarted {
45- feed_id: String,
46- episode_title: String,
47- },
48- /// Individual episode download progress
49- EpisodeDownloadProgress {
50- feed_id: String,
51- episode_title: String,
52- progress: f32,
53- },
54- /// Individual episode download completed
55- EpisodeDownloadCompleted {
56- feed_id: String,
57- episode_title: String,
58- },
59 /// Individual episode download failed32 /// Individual episode download failed
60 EpisodeDownloadFailed {33 EpisodeDownloadFailed {
61 feed_id: String,34 feed_id: String,
@@ -65,57 +38,21 @@ pub enum SyncMessage {
65 }38 }
66 39
67 /// Status of a feed synchronization40 /// Status of a feed synchronization
68-#[derive(Debug, Clone)]41+#[derive(Debug, Clone, Default)]
69 pub enum FeedSyncStatus {42 pub enum FeedSyncStatus {
70 /// Sync not started or completed43 /// Sync not started or completed
44+ #[default]
71 Idle,45 Idle,
72 /// Sync in progress46 /// Sync in progress
73- InProgress {47+ InProgress,
74- progress: f32,
75- message: String,
76- },
77 /// Sync completed successfully48 /// Sync completed successfully
78- Completed {49+ Completed,
79- episodes_found: usize,
80- episodes_downloaded: usize,
81- },
82 /// Sync failed50 /// Sync failed
83 Failed {51 Failed {
84 error: String,52 error: String,
85 },53 },
86 }54 }
87 55
88-impl Default for FeedSyncStatus {
89- fn default() -> Self {
90- Self::Idle
91- }
92-}
93-
94-/// Episode download status
95-#[derive(Debug, Clone)]
96-pub struct EpisodeDownloadStatus {
97- pub status: EpisodeDownloadPhase,
98- pub progress: f32,
99-}
100-
101-impl Default for EpisodeDownloadStatus {
102- fn default() -> Self {
103- Self {
104- status: EpisodeDownloadPhase::NotStarted,
105- progress: 0.0,
106- }
107- }
108-}
109-
110-/// Phase of episode download
111-#[derive(Debug, Clone, PartialEq)]
112-pub enum EpisodeDownloadPhase {
113- NotStarted,
114- Downloading,
115- Completed,
116- Failed,
117-}
118-
119 /// Status of all synchronization tasks56 /// Status of all synchronization tasks
120 #[derive(Debug, Default)]57 #[derive(Debug, Default)]
121 pub struct SyncStatus {58 pub struct SyncStatus {
@@ -123,8 +60,6 @@ pub struct SyncStatus {
123 pub global_sync_in_progress: bool,60 pub global_sync_in_progress: bool,
124 /// Individual feed sync status (feed_id -> status)61 /// Individual feed sync status (feed_id -> status)
125 pub feed_sync_status: HashMap<String, FeedSyncStatus>,62 pub feed_sync_status: HashMap<String, FeedSyncStatus>,
126- /// Individual episode download status (episode_id -> status)
127- pub episode_download_status: HashMap<String, EpisodeDownloadStatus>,
128 /// Total feeds to sync63 /// Total feeds to sync
129 pub total_feeds: usize,64 pub total_feeds: usize,
130 /// Feeds completed65 /// Feeds completed
@@ -161,7 +96,7 @@ impl SyncStatus {
161 // Update overall progress96 // Update overall progress
162 if self.global_sync_in_progress && self.total_feeds > 0 {97 if self.global_sync_in_progress && self.total_feeds > 0 {
163 let completed = self.feed_sync_status.values()98 let completed = self.feed_sync_status.values()
164- .filter(|s| matches!(s, FeedSyncStatus::Completed { .. } | FeedSyncStatus::Failed { .. }))99+ .filter(|s| matches!(s, FeedSyncStatus::Completed | FeedSyncStatus::Failed { .. }))
165 .count();100 .count();
166 self.completed_feeds = completed;101 self.completed_feeds = completed;
167 self.overall_progress = (completed as f32 / self.total_feeds as f32).min(1.0);102 self.overall_progress = (completed as f32 / self.total_feeds as f32).min(1.0);
@@ -172,12 +107,6 @@ impl SyncStatus {
172 pub fn get_overall_progress(&self) -> f32 {107 pub fn get_overall_progress(&self) -> f32 {
173 self.overall_progress108 self.overall_progress
174 }109 }
175-
176- /// Check if any sync is in progress
177- pub fn is_syncing(&self) -> bool {
178- self.global_sync_in_progress ||
179- self.feed_sync_status.values().any(|s| matches!(s, FeedSyncStatus::InProgress { .. }))
180- }
181 110
182 /// Check if there are any sync activities (in progress or recently completed)111 /// Check if there are any sync activities (in progress or recently completed)
183 /// Used to determine if we should show sync-related UI elements112 /// Used to determine if we should show sync-related UI elements
@@ -189,7 +118,7 @@ impl SyncStatus {
189 /// Get syncing feed count118 /// Get syncing feed count
190 pub fn syncing_feed_count(&self) -> usize {119 pub fn syncing_feed_count(&self) -> usize {
191 self.feed_sync_status.values()120 self.feed_sync_status.values()
192- .filter(|s| matches!(s, FeedSyncStatus::InProgress { .. }))121+ .filter(|s| matches!(s, FeedSyncStatus::InProgress))
193 .count()122 .count()
194 }123 }
195 }124 }
@@ -216,11 +145,6 @@ impl SyncTaskManager {
216 }145 }
217 }146 }
218 147
219- /// Get the sender for sending messages from async tasks
220- pub fn sender(&self) -> Sender<SyncMessage> {
221- self.sender.clone()
222- }
223-
224 /// Process incoming messages and update status148 /// Process incoming messages and update status
225 pub fn process_messages(&mut self) -> Vec<SyncMessage> {149 pub fn process_messages(&mut self) -> Vec<SyncMessage> {
226 let mut messages = Vec::new();150 let mut messages = Vec::new();
@@ -239,44 +163,29 @@ impl SyncTaskManager {
239 /// Handle a single sync message163 /// Handle a single sync message
240 fn handle_message(&mut self, message: &SyncMessage) {164 fn handle_message(&mut self, message: &SyncMessage) {
241 match message {165 match message {
242- SyncMessage::SyncStarted { feed_id, .. } => {166+ SyncMessage::SyncStarted { feed_id } => {
243 self.status.update_feed_status(167 self.status.update_feed_status(
244 feed_id.clone(),168 feed_id.clone(),
245- FeedSyncStatus::InProgress {169+ FeedSyncStatus::InProgress,
246- progress: 0.0,
247- message: "Démarrage de la synchronisation...".to_string(),
248- }
249- );
250- }
251- SyncMessage::SyncProgress { feed_id, progress, message } => {
252- self.status.update_feed_status(
253- feed_id.clone(),
254- FeedSyncStatus::InProgress {
255- progress: *progress,
256- message: message.clone(),
257- }
258 );170 );
259 }171 }
260- SyncMessage::SyncCompleted { feed_id, feed_name: _, episodes_found, episodes_downloaded, .. } => {172+ SyncMessage::SyncCompleted { feed_id, .. } => {
261 self.status.update_feed_status(173 self.status.update_feed_status(
262 feed_id.clone(),174 feed_id.clone(),
263- FeedSyncStatus::Completed {175+ FeedSyncStatus::Completed,
264- episodes_found: *episodes_found,
265- episodes_downloaded: *episodes_downloaded,
266- }
267 );176 );
268 177
269 // Check if all feeds are completed178 // Check if all feeds are completed
270 if self.status.global_sync_in_progress {179 if self.status.global_sync_in_progress {
271 let all_completed = self.status.feed_sync_status.values().all(|s| {180 let all_completed = self.status.feed_sync_status.values().all(|s| {
272- matches!(s, FeedSyncStatus::Completed { .. } | FeedSyncStatus::Failed { .. })181+ matches!(s, FeedSyncStatus::Completed | FeedSyncStatus::Failed { .. })
273 });182 });
274 if all_completed {183 if all_completed {
275 self.status.complete_global_sync();184 self.status.complete_global_sync();
276 }185 }
277 }186 }
278 }187 }
279- SyncMessage::SyncFailed { feed_id, feed_name: _, error } => {188+ SyncMessage::SyncFailed { feed_id, error } => {
280 self.status.update_feed_status(189 self.status.update_feed_status(
281 feed_id.clone(),190 feed_id.clone(),
282 FeedSyncStatus::Failed {191 FeedSyncStatus::Failed {
@@ -287,28 +196,15 @@ impl SyncTaskManager {
287 // Check if all feeds are completed (including failed ones)196 // Check if all feeds are completed (including failed ones)
288 if self.status.global_sync_in_progress {197 if self.status.global_sync_in_progress {
289 let all_completed = self.status.feed_sync_status.values().all(|s| {198 let all_completed = self.status.feed_sync_status.values().all(|s| {
290- matches!(s, FeedSyncStatus::Completed { .. } | FeedSyncStatus::Failed { .. })199+ matches!(s, FeedSyncStatus::Completed | FeedSyncStatus::Failed { .. })
291 });200 });
292 if all_completed {201 if all_completed {
293 self.status.complete_global_sync();202 self.status.complete_global_sync();
294 }203 }
295 }204 }
296-
297- // For single feed sync, we should also clean up after a failure
298- // Remove the feed from tracking after a reasonable delay
299- // This is handled by the is_syncing() check which looks at InProgress status
300- }
301- SyncMessage::EpisodeDownloadStarted { feed_id: _, episode_title: _ } => {
302- // Episode download started - could track this if needed
303- }
304- SyncMessage::EpisodeDownloadProgress { feed_id: _, episode_title: _, progress: _ } => {
305- // Episode download progress
306- }
307- SyncMessage::EpisodeDownloadCompleted { .. } => {
308- // Episode download completed
309 }205 }
310 SyncMessage::EpisodeDownloadFailed { .. } => {206 SyncMessage::EpisodeDownloadFailed { .. } => {
311- // Episode download failed207+ // Displayed by the caller (home.rs), no status update needed
312 }208 }
313 }209 }
314 }210 }
@@ -330,13 +226,7 @@ impl SyncTaskManager {
330 let feed_id = feed.id.clone();226 let feed_id = feed.id.clone();
331 227
332 // Update status228 // Update status
333- self.status.update_feed_status(229+ self.status.update_feed_status(feed_id, FeedSyncStatus::InProgress);
334- feed_id.clone(),
335- FeedSyncStatus::InProgress {
336- progress: 0.0,
337- message: "Démarrage...".to_string(),
338- }
339- );
340 230
341 thread::spawn(move || {231 thread::spawn(move || {
342 crate::rss::async_sync::sync_feed_async(feed, sender);232 crate::rss::async_sync::sync_feed_async(feed, sender);
@@ -346,7 +236,7 @@ impl SyncTaskManager {
346 /// Clears completed and failed sync statuses236 /// Clears completed and failed sync statuses
347 pub fn clear_completed_sync_status(&mut self) {237 pub fn clear_completed_sync_status(&mut self) {
348 self.status.feed_sync_status.retain(|_, status| {238 self.status.feed_sync_status.retain(|_, status| {
349- matches!(status, FeedSyncStatus::InProgress { .. })239+ matches!(status, FeedSyncStatus::InProgress)
350 });240 });
351 }241 }
352 }242 }
@@ -367,7 +257,7 @@ mod tests {
367 assert!(!status.global_sync_in_progress);257 assert!(!status.global_sync_in_progress);
368 assert!(status.feed_sync_status.is_empty());258 assert!(status.feed_sync_status.is_empty());
369 assert_eq!(status.get_overall_progress(), 0.0);259 assert_eq!(status.get_overall_progress(), 0.0);
370- assert!(!status.is_syncing());260+ assert!(!status.has_sync_activity());
371 }261 }
372 262
373 #[test]263 #[test]
@@ -387,8 +277,8 @@ mod tests {
387 status.start_global_sync(3);277 status.start_global_sync(3);
388 278
389 // Add completed feeds279 // Add completed feeds
390- status.update_feed_status("feed-1".to_string(), FeedSyncStatus::Completed { episodes_found: 5, episodes_downloaded: 2 });280+ status.update_feed_status("feed-1".to_string(), FeedSyncStatus::Completed);
391- status.update_feed_status("feed-2".to_string(), FeedSyncStatus::Completed { episodes_found: 3, episodes_downloaded: 1 });281+ status.update_feed_status("feed-2".to_string(), FeedSyncStatus::Completed);
392 282
393 assert_eq!(status.completed_feeds, 2);283 assert_eq!(status.completed_feeds, 2);
394 assert!((status.get_overall_progress() - 0.666).abs() < 0.01);284 assert!((status.get_overall_progress() - 0.666).abs() < 0.01);
modified src/rss/async_sync.rs +84 -162
@@ -1,7 +1,7 @@
11 //! Async Synchronization module for RSS Podcast Downloader
22 //!
33 //! This module provides asynchronous versions of the synchronization functions
4-//! that can run in the background and send progress updates.
4+//! that can run in the background and send updates.
55
66 use crate::config::{Episode, Feed};
77 use crate::config::sync_task::SyncMessage;
@@ -11,191 +11,92 @@ use crate::rss::parser::{fetch_feed, parse_rss_content};
1111 use chrono::Utc;
1212 use log::{error, info, warn};
1313 use std::sync::mpsc::Sender;
14-use std::sync::Arc;
15-use std::sync::atomic::{AtomicUsize, Ordering};
1614
1715 /// Synchronizes a single feed asynchronously
18-///
19-/// This function runs in a separate thread and sends progress updates via the sender.
16+///
17+/// This function runs in a separate thread and sends updates via the sender.
2018 pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) {
2119 let feed_id = feed.id.clone();
22- let feed_name = feed.name.clone();
23-
20+
2421 // 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 {
22+ if sender
23+ .send(SyncMessage::SyncStarted {
3724 feed_id: feed_id.clone(),
38- feed_name: feed_name.clone(),
39- error: "URL cannot be empty".to_string(),
40- });
41- return;
25+ })
26+ .is_err()
27+ {
28+ return; // Channel closed, abort
4229 }
4330
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- }
31+ info!("Starting async synchronization for feed: {}", feed_id);
5332
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- });
33+ // Validate the feed configuration
34+ if let Err(err) = validate_feed(&feed) {
35+ send_failed(&sender, &feed_id, err);
6036 return;
6137 }
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- });
6938
7039 // Fetch the feed content
7140 let content = match fetch_feed(&feed.url) {
7241 Ok(content) => content,
7342 Err(e) => {
7443 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- });
44+ send_failed(&sender, &feed_id, format!("Failed to fetch feed: {}", e));
8045 return;
8146 }
8247 };
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- });
8948
9049 // Parse the RSS content
9150 let channel = match parse_rss_content(&content) {
9251 Ok(channel) => channel,
9352 Err(e) => {
9453 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- });
54+ send_failed(&sender, &feed_id, format!("Failed to parse feed: {}", e));
10055 return;
10156 }
10257 };
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- });
10958
11059 // Extract episodes from the channel
11160 let episodes = match extract_episodes(&channel) {
11261 Ok(episodes) => episodes,
11362 Err(e) => {
11463 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- });
64+ send_failed(
65+ &sender,
66+ &feed_id,
67+ format!("Failed to extract episodes: {}", e),
68+ );
12069 return;
12170 }
12271 };
12372
12473 if episodes.is_empty() {
12574 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- });
75+ send_failed(&sender, &feed_id, "No episodes found in feed".to_string());
13176 return;
13277 }
133-
78+
13479 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();
80+
81+ let mut feed_mut = feed;
14582 let mut downloaded_count = 0;
146- let mut current_episode = 0;
147-
83+
14884 for episode in &episodes {
149- current_episode += 1;
150-
15185 // Skip episodes that are already downloaded
15286 if is_episode_downloaded(&feed_mut, episode) {
15387 info!("Episode already downloaded: {}", episode.title);
15488 continue;
15589 }
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) {
90+
91+ match download_episode(episode, &feed_mut.download_path, Box::new(|_| {})) {
18692 Ok(_) => {
18793 info!("Successfully downloaded episode: {}", episode.title);
18894 downloaded_count += 1;
189-
95+
19096 // Update feed download statistics
19197 feed_mut.downloaded_episodes_count += 1;
19298 feed_mut.last_downloaded_episode_name = Some(episode.title.clone());
19399 feed_mut.last_downloaded_episode_date = Some(Utc::now());
194-
195- let _ = sender.send(SyncMessage::EpisodeDownloadCompleted {
196- feed_id: feed_id.clone(),
197- episode_title: episode.title.clone(),
198- });
199100 }
200101 Err(e) => {
201102 error!("Failed to download episode {}: {}", episode.title, e);
@@ -207,54 +108,64 @@ pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) {
207108 }
208109 }
209110 }
210-
111+
211112 // Update feed metadata
212113 feed_mut.last_checked = Some(Utc::now());
213- if let Some(latest) = episodes.iter().max_by(|a, b| a.pub_date.cmp(&b.pub_date)) {
114+ if let Some(latest) = episodes.iter().max_by_key(|e| e.pub_date) {
214115 feed_mut.last_episode_date = Some(latest.pub_date);
215116 }
216-
217- // Save the updated feed
218- // Note: We can't save directly here as we don't have access to storage
219- // The caller will need to save the feeds after sync completes
220-
117+
118+ // The caller saves the feeds once sync completes (see ui/home.rs)
221119 let _ = sender.send(SyncMessage::SyncCompleted {
222- feed_id: feed_id.clone(),
223- feed_name: feed_name.clone(),
224- episodes_found: episodes.len(),
225- episodes_downloaded: downloaded_count,
120+ feed_id,
226121 downloaded_episodes_count: feed_mut.downloaded_episodes_count,
227122 last_downloaded_episode_name: feed_mut.last_downloaded_episode_name.clone(),
228123 last_downloaded_episode_date: feed_mut.last_downloaded_episode_date,
229124 last_checked: feed_mut.last_checked,
230125 });
231-
232- info!("Async synchronization completed for feed: {} ({} episodes found, {} downloaded)",
233- feed_name, episodes.len(), downloaded_count);
126+
127+ info!(
128+ "Async synchronization completed for feed: {} ({} episodes found, {} downloaded)",
129+ feed_mut.name,
130+ episodes.len(),
131+ downloaded_count
132+ );
133+}
134+
135+/// Validates feed configuration, returning an error message if invalid
136+fn validate_feed(feed: &Feed) -> Result<(), String> {
137+ if feed.url.is_empty() {
138+ return Err("URL cannot be empty".to_string());
139+ }
140+ if feed.download_path.to_string_lossy().is_empty() {
141+ return Err("Download path cannot be empty".to_string());
142+ }
143+ if !feed.enabled {
144+ return Err("Feed is disabled".to_string());
145+ }
146+ Ok(())
147+}
148+
149+/// Sends a sync failure message
150+fn send_failed(sender: &Sender<SyncMessage>, feed_id: &str, error: String) {
151+ let _ = sender.send(SyncMessage::SyncFailed {
152+ feed_id: feed_id.to_string(),
153+ error,
154+ });
234155 }
235156
236157 /// Synchronizes all feeds asynchronously
237158 pub fn sync_all_feeds_async(feeds: Vec<Feed>, sender: Sender<SyncMessage>) {
238159 info!("Starting async synchronization of all {} feeds", feeds.len());
239-
240- let _total_feeds = feeds.len();
241- let completed_count = Arc::new(AtomicUsize::new(0));
242-
243- // Send global sync started (handled by the manager)
244-
245- for _feed in feeds {
160+
161+ for feed in feeds {
246162 let sender_clone = sender.clone();
247- let completed_count_clone = completed_count.clone();
248-
163+
249164 // Spawn a separate thread for each feed sync
250165 std::thread::spawn(move || {
251- sync_feed_async(_feed, sender_clone);
252- completed_count_clone.fetch_add(1, Ordering::SeqCst);
166+ sync_feed_async(feed, sender_clone);
253167 });
254168 }
255-
256- // Note: The completion tracking is handled by the SyncTaskManager
257- // which receives messages from individual feed syncs
258169 }
259170
260171 /// Checks if an episode has already been downloaded
@@ -265,14 +176,10 @@ fn is_episode_downloaded(feed: &Feed, episode: &Episode) -> bool {
265176 #[cfg(test)]
266177 mod tests {
267178 use super::*;
268- use std::sync::mpsc::channel;
179+ use chrono::Utc;
269180
270181 #[test]
271182 fn test_is_episode_downloaded() {
272- use std::path::PathBuf;
273- use crate::config::episode::Episode;
274- use chrono::Utc;
275-
276183 let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads");
277184 let episode = Episode::new(
278185 "ep-123",
@@ -280,8 +187,23 @@ mod tests {
280187 "https://example.com/episode.mp3",
281188 Utc::now(),
282189 );
283-
190+
284191 // Episode not downloaded (file doesn't exist)
285192 assert!(!is_episode_downloaded(&feed, &episode));
286193 }
194+
195+ #[test]
196+ fn test_validate_feed() {
197+ let mut feed = Feed::new("https://example.com/rss", "Test", "/downloads");
198+ assert!(validate_feed(&feed).is_ok());
199+
200+ feed.enabled = false;
201+ assert!(validate_feed(&feed).is_err());
202+
203+ let mut feed = Feed::new("", "Test", "/downloads");
204+ assert!(validate_feed(&feed).is_err());
205+
206+ feed = Feed::new("https://example.com/rss", "Test", "");
207+ assert!(validate_feed(&feed).is_err());
208+ }
287209 }
@@ -1,7 +1,7 @@
1 //! Async Synchronization module for RSS Podcast Downloader1 //! Async Synchronization module for RSS Podcast Downloader
2 //!2 //!
3 //! This module provides asynchronous versions of the synchronization functions3 //! This module provides asynchronous versions of the synchronization functions
4-//! that can run in the background and send progress updates.4+//! that can run in the background and send updates.
5 5
6 use crate::config::{Episode, Feed};6 use crate::config::{Episode, Feed};
7 use crate::config::sync_task::SyncMessage;7 use crate::config::sync_task::SyncMessage;
@@ -11,191 +11,92 @@ use crate::rss::parser::{fetch_feed, parse_rss_content};
11 use chrono::Utc;11 use chrono::Utc;
12 use log::{error, info, warn};12 use log::{error, info, warn};
13 use std::sync::mpsc::Sender;13 use std::sync::mpsc::Sender;
14-use std::sync::Arc;
15-use std::sync::atomic::{AtomicUsize, Ordering};
16 14
17 /// Synchronizes a single feed asynchronously15 /// Synchronizes a single feed asynchronously
18-/// 16+///
19-/// This function runs in a separate thread and sends progress updates via the sender.17+/// This function runs in a separate thread and sends updates via the sender.
20 pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) {18 pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) {
21 let feed_id = feed.id.clone();19 let feed_id = feed.id.clone();
22- let feed_name = feed.name.clone();20+
23-
24 // Send sync started message21 // Send sync started message
25- if sender.send(SyncMessage::SyncStarted {22+ if sender
26- feed_id: feed_id.clone(),23+ .send(SyncMessage::SyncStarted {
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(),24 feed_id: feed_id.clone(),
38- feed_name: feed_name.clone(),25+ })
39- error: "URL cannot be empty".to_string(),26+ .is_err()
40- });27+ {
41- return;28+ return; // Channel closed, abort
42 }29 }
43 30
44- // Validate download path31+ info!("Starting async synchronization for feed: {}", feed_id);
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 32
54- if !feed.enabled {33+ // Validate the feed configuration
55- let _ = sender.send(SyncMessage::SyncFailed {34+ if let Err(err) = validate_feed(&feed) {
56- feed_id: feed_id.clone(),35+ send_failed(&sender, &feed_id, err);
57- feed_name: feed_name.clone(),
58- error: "Feed is disabled".to_string(),
59- });
60 return;36 return;
61 }37 }
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 38
70 // Fetch the feed content39 // Fetch the feed content
71 let content = match fetch_feed(&feed.url) {40 let content = match fetch_feed(&feed.url) {
72 Ok(content) => content,41 Ok(content) => content,
73 Err(e) => {42 Err(e) => {
74 error!("Failed to fetch feed {}: {}", feed.url, e);43 error!("Failed to fetch feed {}: {}", feed.url, e);
75- let _ = sender.send(SyncMessage::SyncFailed {44+ send_failed(&sender, &feed_id, format!("Failed to fetch feed: {}", e));
76- feed_id: feed_id.clone(),
77- feed_name: feed_name.clone(),
78- error: format!("Failed to fetch feed: {}", e),
79- });
80 return;45 return;
81 }46 }
82 };47 };
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 48
90 // Parse the RSS content49 // Parse the RSS content
91 let channel = match parse_rss_content(&content) {50 let channel = match parse_rss_content(&content) {
92 Ok(channel) => channel,51 Ok(channel) => channel,
93 Err(e) => {52 Err(e) => {
94 error!("Failed to parse feed {}: {}", feed.url, e);53 error!("Failed to parse feed {}: {}", feed.url, e);
95- let _ = sender.send(SyncMessage::SyncFailed {54+ send_failed(&sender, &feed_id, format!("Failed to parse feed: {}", e));
96- feed_id: feed_id.clone(),
97- feed_name: feed_name.clone(),
98- error: format!("Failed to parse feed: {}", e),
99- });
100 return;55 return;
101 }56 }
102 };57 };
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 58
110 // Extract episodes from the channel59 // Extract episodes from the channel
111 let episodes = match extract_episodes(&channel) {60 let episodes = match extract_episodes(&channel) {
112 Ok(episodes) => episodes,61 Ok(episodes) => episodes,
113 Err(e) => {62 Err(e) => {
114 error!("Failed to extract episodes from feed {}: {}", feed.url, e);63 error!("Failed to extract episodes from feed {}: {}", feed.url, e);
115- let _ = sender.send(SyncMessage::SyncFailed {64+ send_failed(
116- feed_id: feed_id.clone(),65+ &sender,
117- feed_name: feed_name.clone(),66+ &feed_id,
118- error: format!("Failed to extract episodes: {}", e),67+ format!("Failed to extract episodes: {}", e),
119- });68+ );
120 return;69 return;
121 }70 }
122 };71 };
123 72
124 if episodes.is_empty() {73 if episodes.is_empty() {
125 warn!("No episodes found in feed: {}", feed.url);74 warn!("No episodes found in feed: {}", feed.url);
126- let _ = sender.send(SyncMessage::SyncFailed {75+ send_failed(&sender, &feed_id, "No episodes found in feed".to_string());
127- feed_id: feed_id.clone(),
128- feed_name: feed_name.clone(),
129- error: "No episodes found in feed".to_string(),
130- });
131 return;76 return;
132 }77 }
133- 78+
134 info!("Found {} episodes in feed: {}", episodes.len(), feed.url);79 info!("Found {} episodes in feed: {}", episodes.len(), feed.url);
135- 80+
136- let _ = sender.send(SyncMessage::SyncProgress {81+ let mut feed_mut = feed;
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;82 let mut downloaded_count = 0;
146- let mut current_episode = 0;83+
147-
148 for episode in &episodes {84 for episode in &episodes {
149- current_episode += 1;
150-
151 // Skip episodes that are already downloaded85 // Skip episodes that are already downloaded
152 if is_episode_downloaded(&feed_mut, episode) {86 if is_episode_downloaded(&feed_mut, episode) {
153 info!("Episode already downloaded: {}", episode.title);87 info!("Episode already downloaded: {}", episode.title);
154 continue;88 continue;
155 }89 }
156- 90+
157- // Update progress per episode91+ match download_episode(episode, &feed_mut.download_path, Box::new(|_| {})) {
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(_) => {92 Ok(_) => {
187 info!("Successfully downloaded episode: {}", episode.title);93 info!("Successfully downloaded episode: {}", episode.title);
188 downloaded_count += 1;94 downloaded_count += 1;
189- 95+
190 // Update feed download statistics96 // Update feed download statistics
191 feed_mut.downloaded_episodes_count += 1;97 feed_mut.downloaded_episodes_count += 1;
192 feed_mut.last_downloaded_episode_name = Some(episode.title.clone());98 feed_mut.last_downloaded_episode_name = Some(episode.title.clone());
193 feed_mut.last_downloaded_episode_date = Some(Utc::now());99 feed_mut.last_downloaded_episode_date = Some(Utc::now());
194-
195- let _ = sender.send(SyncMessage::EpisodeDownloadCompleted {
196- feed_id: feed_id.clone(),
197- episode_title: episode.title.clone(),
198- });
199 }100 }
200 Err(e) => {101 Err(e) => {
201 error!("Failed to download episode {}: {}", episode.title, e);102 error!("Failed to download episode {}: {}", episode.title, e);
@@ -207,54 +108,64 @@ pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) {
207 }108 }
208 }109 }
209 }110 }
210- 111+
211 // Update feed metadata112 // Update feed metadata
212 feed_mut.last_checked = Some(Utc::now());113 feed_mut.last_checked = Some(Utc::now());
213- if let Some(latest) = episodes.iter().max_by(|a, b| a.pub_date.cmp(&b.pub_date)) {114+ if let Some(latest) = episodes.iter().max_by_key(|e| e.pub_date) {
214 feed_mut.last_episode_date = Some(latest.pub_date);115 feed_mut.last_episode_date = Some(latest.pub_date);
215 }116 }
216- 117+
217- // Save the updated feed118+ // The caller saves the feeds once sync completes (see ui/home.rs)
218- // Note: We can't save directly here as we don't have access to storage
219- // The caller will need to save the feeds after sync completes
220-
221 let _ = sender.send(SyncMessage::SyncCompleted {119 let _ = sender.send(SyncMessage::SyncCompleted {
222- feed_id: feed_id.clone(),120+ feed_id,
223- feed_name: feed_name.clone(),
224- episodes_found: episodes.len(),
225- episodes_downloaded: downloaded_count,
226 downloaded_episodes_count: feed_mut.downloaded_episodes_count,121 downloaded_episodes_count: feed_mut.downloaded_episodes_count,
227 last_downloaded_episode_name: feed_mut.last_downloaded_episode_name.clone(),122 last_downloaded_episode_name: feed_mut.last_downloaded_episode_name.clone(),
228 last_downloaded_episode_date: feed_mut.last_downloaded_episode_date,123 last_downloaded_episode_date: feed_mut.last_downloaded_episode_date,
229 last_checked: feed_mut.last_checked,124 last_checked: feed_mut.last_checked,
230 });125 });
231- 126+
232- info!("Async synchronization completed for feed: {} ({} episodes found, {} downloaded)", 127+ info!(
233- feed_name, episodes.len(), downloaded_count);128+ "Async synchronization completed for feed: {} ({} episodes found, {} downloaded)",
129+ feed_mut.name,
130+ episodes.len(),
131+ downloaded_count
132+ );
133+}
134+
135+/// Validates feed configuration, returning an error message if invalid
136+fn validate_feed(feed: &Feed) -> Result<(), String> {
137+ if feed.url.is_empty() {
138+ return Err("URL cannot be empty".to_string());
139+ }
140+ if feed.download_path.to_string_lossy().is_empty() {
141+ return Err("Download path cannot be empty".to_string());
142+ }
143+ if !feed.enabled {
144+ return Err("Feed is disabled".to_string());
145+ }
146+ Ok(())
147+}
148+
149+/// Sends a sync failure message
150+fn send_failed(sender: &Sender<SyncMessage>, feed_id: &str, error: String) {
151+ let _ = sender.send(SyncMessage::SyncFailed {
152+ feed_id: feed_id.to_string(),
153+ error,
154+ });
234 }155 }
235 156
236 /// Synchronizes all feeds asynchronously157 /// Synchronizes all feeds asynchronously
237 pub fn sync_all_feeds_async(feeds: Vec<Feed>, sender: Sender<SyncMessage>) {158 pub fn sync_all_feeds_async(feeds: Vec<Feed>, sender: Sender<SyncMessage>) {
238 info!("Starting async synchronization of all {} feeds", feeds.len());159 info!("Starting async synchronization of all {} feeds", feeds.len());
239- 160+
240- let _total_feeds = feeds.len();161+ for feed in feeds {
241- let completed_count = Arc::new(AtomicUsize::new(0));
242-
243- // Send global sync started (handled by the manager)
244-
245- for _feed in feeds {
246 let sender_clone = sender.clone();162 let sender_clone = sender.clone();
247- let completed_count_clone = completed_count.clone();163+
248-
249 // Spawn a separate thread for each feed sync164 // Spawn a separate thread for each feed sync
250 std::thread::spawn(move || {165 std::thread::spawn(move || {
251- sync_feed_async(_feed, sender_clone);166+ sync_feed_async(feed, sender_clone);
252- completed_count_clone.fetch_add(1, Ordering::SeqCst);
253 });167 });
254 }168 }
255-
256- // Note: The completion tracking is handled by the SyncTaskManager
257- // which receives messages from individual feed syncs
258 }169 }
259 170
260 /// Checks if an episode has already been downloaded171 /// Checks if an episode has already been downloaded
@@ -265,14 +176,10 @@ fn is_episode_downloaded(feed: &Feed, episode: &Episode) -> bool {
265 #[cfg(test)]176 #[cfg(test)]
266 mod tests {177 mod tests {
267 use super::*;178 use super::*;
268- use std::sync::mpsc::channel;179+ use chrono::Utc;
269 180
270 #[test]181 #[test]
271 fn test_is_episode_downloaded() {182 fn test_is_episode_downloaded() {
272- use std::path::PathBuf;
273- use crate::config::episode::Episode;
274- use chrono::Utc;
275-
276 let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads");183 let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads");
277 let episode = Episode::new(184 let episode = Episode::new(
278 "ep-123",185 "ep-123",
@@ -280,8 +187,23 @@ mod tests {
280 "https://example.com/episode.mp3",187 "https://example.com/episode.mp3",
281 Utc::now(),188 Utc::now(),
282 );189 );
283- 190+
284 // Episode not downloaded (file doesn't exist)191 // Episode not downloaded (file doesn't exist)
285 assert!(!is_episode_downloaded(&feed, &episode));192 assert!(!is_episode_downloaded(&feed, &episode));
286 }193 }
194+
195+ #[test]
196+ fn test_validate_feed() {
197+ let mut feed = Feed::new("https://example.com/rss", "Test", "/downloads");
198+ assert!(validate_feed(&feed).is_ok());
199+
200+ feed.enabled = false;
201+ assert!(validate_feed(&feed).is_err());
202+
203+ let mut feed = Feed::new("", "Test", "/downloads");
204+ assert!(validate_feed(&feed).is_err());
205+
206+ feed = Feed::new("https://example.com/rss", "Test", "");
207+ assert!(validate_feed(&feed).is_err());
208+ }
287 }209 }
modified src/rss/audio_metadata.rs +74 -285
@@ -1,48 +1,11 @@
11 //! Module pour la lecture et l'écriture des métadonnées audio (ID3 tags)
22 //!
3-//! Ce module fournit des fonctions pour extraire et modifier la durée, l'artiste,
4-//! l'album et le titre des fichiers audio MP3.
3+//! Ce module fournit des fonctions pour lire et écrire les tags ID3
4+//! (titre, artiste, album) des fichiers audio MP3.
55
66 use id3::{Tag, TagLike, Error as Id3Error};
77 use std::fs::File;
8-use std::path::{Path, PathBuf};
9-
10-/// Métadonnées d'un fichier audio
11-#[derive(Debug, Clone, Default)]
12-pub struct AudioMetadata {
13- /// Durée en secondes
14- pub duration_seconds: Option<u64>,
15- /// Titre (si disponible)
16- pub title: Option<String>,
17- /// Artiste (si disponible)
18- pub artist: Option<String>,
19- /// Album (si disponible)
20- pub album: Option<String>,
21- /// Année (si disponible)
22- pub year: Option<u32>,
23- /// Genre (si disponible)
24- pub genre: Option<String>,
25- /// Bitrate en kbps (si disponible)
26- pub bitrate_kbps: Option<u32>,
27- /// Taille du fichier en octets
28- pub file_size: u64,
29-}
30-
31-impl AudioMetadata {
32- pub fn new() -> Self {
33- Self::default()
34- }
35-
36- /// Obtenir la durée formatée (HH:MM:SS ou MM:SS)
37- #[allow(dead_code)]
38- pub fn get_formatted_duration(&self) -> String {
39- if let Some(duration) = self.duration_seconds {
40- format_duration(duration)
41- } else {
42- String::new()
43- }
44- }
45-}
8+use std::path::Path;
469
4710 /// Erreur de lecture des métadonnées
4811 #[derive(Debug, thiserror::Error)]
@@ -50,24 +13,15 @@ pub enum MetadataError {
5013 /// Fichier introuvable
5114 #[error("Fichier introuvable: {0}")]
5215 FileNotFound(String),
53-
16+
5417 /// Erreur de lecture du fichier
5518 #[error("Erreur de lecture du fichier: {0}")]
5619 IoError(#[from] std::io::Error),
57-
58- /// Format non supporté
59- #[error("Format audio non supporté: {0}")]
60- #[allow(dead_code)]
61- UnsupportedFormat(String),
62-
63- /// Erreur de parsing des métadonnées
64- #[error("Erreur de parsing des métadonnées: {0}")]
65- ParseError(String),
66-
20+
6721 /// Erreur d'écriture des tags ID3
6822 #[error("Erreur d'écriture des tags ID3: {0}")]
6923 Id3WriteError(String),
70-
24+
7125 /// Erreur de lecture des tags ID3
7226 #[error("Erreur de lecture des tags ID3: {0}")]
7327 Id3ReadError(String),
@@ -79,110 +33,12 @@ impl From<Id3Error> for MetadataError {
7933 }
8034 }
8135
82-/// Bitrate moyen par défaut pour les fichiers audio (en kbps)
83-const DEFAULT_BITRATE_KBPS: u32 = 128;
84-
85-/// Lire les métadonnées d'un fichier audio en utilisant mp3-metadata pour les info techniques
86-/// et id3 pour les tags (titre, artiste, album)
87-pub fn read_audio_metadata(path: &Path) -> Result<AudioMetadata, MetadataError> {
88- if !path.exists() {
89- return Err(MetadataError::FileNotFound(path.display().to_string()));
90- }
91-
92- let metadata = std::fs::metadata(path)
93- .map_err(|e| MetadataError::IoError(e))?;
94- let file_size = metadata.len();
95-
96- let mut audio_metadata = AudioMetadata::new();
97- audio_metadata.file_size = file_size;
98-
99- // Lire les tags ID3
100- let id3_tags = read_id3_tags(path)?;
101-
102- // Remplir les champs à partir des tags ID3
103- if let Some(title) = id3_tags.title {
104- audio_metadata.title = Some(title);
105- }
106- if let Some(artist) = id3_tags.artist {
107- audio_metadata.artist = Some(artist);
108- }
109- if let Some(album) = id3_tags.album {
110- audio_metadata.album = Some(album);
111- }
112- if let Some(year) = id3_tags.year {
113- audio_metadata.year = Some(year);
114- }
115- if let Some(genre) = id3_tags.genre {
116- audio_metadata.genre = Some(genre);
117- }
118-
119- // Pour l'instant, estimer la durée à partir de la taille du fichier
120- // La lecture des métadonnées MP3 techniques peut être ajoutée plus tard
121- if file_size > 0 {
122- let duration = estimate_duration_from_size(file_size, DEFAULT_BITRATE_KBPS);
123- audio_metadata.duration_seconds = Some(duration);
124- audio_metadata.bitrate_kbps = Some(DEFAULT_BITRATE_KBPS);
125- }
126-
127- // Essayer d'extraire le nom du fichier comme titre si pas de tag
128- if audio_metadata.title.is_none() {
129- if let Some(file_stem) = path.file_stem() {
130- audio_metadata.title = Some(file_stem.to_string_lossy().into_owned());
131- }
132- }
133-
134- Ok(audio_metadata)
135-}
136-
137-
138-
139-/// Lire les tags ID3 d'un fichier MP3
140-pub fn read_id3_tags(path: &Path) -> Result<Id3Tags, MetadataError> {
141- if !path.exists() {
142- return Err(MetadataError::FileNotFound(path.display().to_string()));
143- }
144-
145- let file = File::open(path)
146- .map_err(|e| MetadataError::IoError(e))?;
147-
148- let tag = Tag::read_from2(file)
149- .map_err(MetadataError::from)?;
150-
151- Ok(Id3Tags::from(tag))
152-}
153-
15436 /// Structure pour stocker les tags ID3 extraits
15537 #[derive(Debug, Clone, Default)]
15638 pub struct Id3Tags {
15739 pub title: Option<String>,
15840 pub artist: Option<String>,
15941 pub album: Option<String>,
160- pub year: Option<u32>,
161- pub genre: Option<String>,
162-}
163-
164-impl Id3Tags {
165- /// Vérifie si tous les tags principaux sont présents
166- #[allow(dead_code)]
167- pub fn has_all_required_tags(&self) -> bool {
168- self.title.is_some() && self.artist.is_some() && self.album.is_some()
169- }
170-
171- /// Retourne les tags manquants
172- #[allow(dead_code)]
173- pub fn missing_tags(&self) -> Vec<&'static str> {
174- let mut missing = Vec::new();
175- if self.title.is_none() {
176- missing.push("title");
177- }
178- if self.artist.is_none() {
179- missing.push("artist");
180- }
181- if self.album.is_none() {
182- missing.push("album");
183- }
184- missing
185- }
18642 }
18743
18844 impl From<Tag> for Id3Tags {
@@ -191,12 +47,25 @@ impl From<Tag> for Id3Tags {
19147 title: tag.title().map(|s| s.to_string()),
19248 artist: tag.artist().map(|s| s.to_string()),
19349 album: tag.album().map(|s| s.to_string()),
194- year: tag.year().map(|y| y as u32),
195- genre: tag.genre().map(|s| s.to_string()),
19650 }
19751 }
19852 }
19953
54+/// Lire les tags ID3 d'un fichier MP3
55+pub fn read_id3_tags(path: &Path) -> Result<Id3Tags, MetadataError> {
56+ if !path.exists() {
57+ return Err(MetadataError::FileNotFound(path.display().to_string()));
58+ }
59+
60+ let file = File::open(path)
61+ .map_err(MetadataError::IoError)?;
62+
63+ let tag = Tag::read_from2(file)
64+ .map_err(MetadataError::from)?;
65+
66+ Ok(Id3Tags::from(tag))
67+}
68+
20069 /// Écrire les tags ID3 sur un fichier MP3
20170 pub fn write_id3_tags(
20271 path: &Path,
@@ -210,11 +79,11 @@ pub fn write_id3_tags(
21079
21180 // Lire le tag existant
21281 let file_read = File::open(path)
213- .map_err(|e| MetadataError::IoError(e))?;
214-
82+ .map_err(MetadataError::IoError)?;
83+
21584 let mut tag = Tag::read_from2(file_read)
21685 .unwrap_or_default();
217-
86+
21887 // Mettre à jour les champs
21988 if let Some(title) = title {
22089 tag.set_title(title);
@@ -230,8 +99,8 @@ pub fn write_id3_tags(
23099 let mut file_write = std::fs::OpenOptions::new()
231100 .write(true)
232101 .open(path)
233- .map_err(|e| MetadataError::IoError(e))?;
234-
102+ .map_err(MetadataError::IoError)?;
103+
235104 tag.write_to(&mut file_write, id3::Version::Id3v24)
236105 .map_err(|e| MetadataError::Id3WriteError(e.to_string()))?;
237106
@@ -240,13 +109,13 @@ pub fn write_id3_tags(
240109
241110 /// Assurer que les tags ID3 (titre, artiste, album) sont présents sur le fichier
242111 /// Si des tags sont manquants, les remplir avec les informations fournies
243-///
112+///
244113 /// # Arguments
245114 /// * `file_path` - Chemin vers le fichier audio
246115 /// * `episode_title` - Titre de l'épisode (pour le tag titre)
247116 /// * `episode_artist` - Artiste de l'épisode (pour le tag artiste)
248117 /// * `episode_album` - Album de l'épisode (pour le tag album)
249-///
118+///
250119 /// # Retourne
251120 /// Ok(()) si les tags ont été vérifiés/écrits avec succès
252121 /// Erreur si le fichier n'existe pas ou si l'écriture échoue
@@ -257,34 +126,34 @@ pub fn ensure_id3_tags(
257126 episode_album: Option<&str>,
258127 ) -> Result<(), MetadataError> {
259128 log::info!("Checking ID3 tags for file: {}", file_path.display());
260-
129+
261130 // Lire les tags existants
262131 let tags = read_id3_tags(file_path)?;
263-
132+
264133 let mut needs_write = false;
265134 let mut title_to_write = None;
266135 let mut artist_to_write = None;
267136 let mut album_to_write = None;
268137
269138 // Vérifier et préparer les valeurs à écrire
270- if tags.title.is_none() || tags.title.as_ref().map_or(true, |t| t.is_empty()) {
139+ if tags.title.as_ref().is_none_or(|t| t.is_empty()) {
271140 title_to_write = Some(episode_title);
272141 needs_write = true;
273142 log::info!(" Missing or empty title tag, will set to: {}", episode_title);
274143 }
275-
144+
276145 // Utiliser l'artiste de l'épisode si disponible, sinon ne pas écrire
277146 if let Some(artist) = episode_artist {
278- if tags.artist.is_none() || tags.artist.as_ref().map_or(true, |a| a.is_empty()) {
147+ if tags.artist.as_ref().is_none_or(|a| a.is_empty()) {
279148 artist_to_write = Some(artist);
280149 needs_write = true;
281150 log::info!(" Missing or empty artist tag, will set to: {}", artist);
282151 }
283152 }
284-
153+
285154 // Utiliser l'album de l'épisode si disponible, sinon ne pas écrire
286155 if let Some(album) = episode_album {
287- if tags.album.is_none() || tags.album.as_ref().map_or(true, |a| a.is_empty()) {
156+ if tags.album.as_ref().is_none_or(|a| a.is_empty()) {
288157 album_to_write = Some(album);
289158 needs_write = true;
290159 log::info!(" Missing or empty album tag, will set to: {}", album);
@@ -295,9 +164,9 @@ pub fn ensure_id3_tags(
295164 if needs_write {
296165 write_id3_tags(
297166 file_path,
298- title_to_write.as_deref(),
299- artist_to_write.as_deref(),
300- album_to_write.as_deref(),
167+ title_to_write,
168+ artist_to_write,
169+ album_to_write,
301170 )?;
302171 log::info!(" ID3 tags updated successfully for: {}", file_path.display());
303172 } else {
@@ -307,136 +176,56 @@ pub fn ensure_id3_tags(
307176 Ok(())
308177 }
309178
310-/// Lire uniquement la durée d'un fichier audio
311-pub fn get_audio_duration(path: &Path) -> Result<u64, MetadataError> {
312- let metadata = read_audio_metadata(path)?;
313- metadata.duration_seconds.ok_or(MetadataError::ParseError("Durée non disponible".to_string()))
314-}
315-
316-/// Estimer la durée d'un fichier audio à partir de sa taille et du bitrate moyen
317-pub fn estimate_duration_from_size(file_size: u64, bitrate_kbps: u32) -> u64 {
318- if file_size == 0 || bitrate_kbps == 0 {
319- return 0;
320- }
321-
322- // Bitrate en bps
323- let bitrate_bps = bitrate_kbps as f64 * 1000.0;
324-
325- // Durée en secondes = (taille en octets * 8) / bitrate en bps
326- let duration_seconds = (file_size as f64 * 8.0) / bitrate_bps;
327-
328- duration_seconds as u64
329-}
330-
331-/// Lire la durée de plusieurs fichiers audio
332-#[allow(dead_code)]
333-pub fn get_multiple_audio_durations(paths: &[impl AsRef<Path>]) -> Vec<(PathBuf, Option<u64>)> {
334- paths.iter()
335- .map(|path| {
336- let path_ref = path.as_ref();
337- let path_buf = path_ref.to_path_buf();
338- match get_audio_duration(path_ref) {
339- Ok(duration) => (path_buf, Some(duration)),
340- Err(_) => (path_buf, None),
341- }
342- })
343- .collect()
344-}
345-
346-/// Vérifier si un fichier est un fichier audio supporté
347-pub fn is_supported_audio_file(path: &Path) -> bool {
348- let ext = path.extension()
349- .and_then(|s| s.to_str())
350- .map(|s| s.to_lowercase());
351-
352- matches!(
353- ext.as_deref(),
354- Some("mp3") | Some("m4a") | Some("wav") | Some("flac") | Some("ogg") | Some("aac") | Some("mp4")
355- )
356-}
357-
358-/// Formater une durée en secondes en une chaîne lisible
359-pub fn format_duration(seconds: u64) -> String {
360- if seconds < 60 {
361- format!("{}s", seconds)
362- } else if seconds < 3600 {
363- let minutes = seconds / 60;
364- let secs = seconds % 60;
365- format!("{}m {}s", minutes, secs)
366- } else {
367- let hours = seconds / 3600;
368- let minutes = (seconds % 3600) / 60;
369- let secs = seconds % 60;
370- format!("{}h {}m {}s", hours, minutes, secs)
371- }
372-}
373-
374179 #[cfg(test)]
375180 mod tests {
376181 use super::*;
182+ use tempfile::tempdir;
377183
378184 #[test]
379- fn test_is_supported_audio_file() {
380- let mp3_path = Path::new("test.mp3");
381- let wav_path = Path::new("test.wav");
382- let txt_path = Path::new("test.txt");
383-
384- assert!(is_supported_audio_file(mp3_path));
385- assert!(is_supported_audio_file(wav_path));
386- assert!(!is_supported_audio_file(txt_path));
185+ fn test_read_id3_tags_file_not_found() {
186+ let result = read_id3_tags(Path::new("nonexistent/file.mp3"));
187+ assert!(result.is_err());
387188 }
388189
389190 #[test]
390- fn test_estimate_duration() {
391- // Un fichier de 5 Mo à 128 kbps devrait faire environ 320 secondes
392- let file_size = 5 * 1024 * 1024; // 5 Mo
393- let bitrate = 128; // 128 kbps
394-
395- let duration = estimate_duration_from_size(file_size, bitrate);
396- assert!(duration > 320 && duration < 335);
191+ fn test_write_id3_tags_file_not_found() {
192+ let result = write_id3_tags(Path::new("nonexistent/file.mp3"), Some("Title"), None, None);
193+ assert!(result.is_err());
397194 }
398195
399196 #[test]
400- fn test_format_duration() {
401- assert_eq!(format_duration(0), "0s");
402- assert_eq!(format_duration(30), "30s");
403- assert_eq!(format_duration(90), "1m 30s");
404- assert_eq!(format_duration(3661), "1h 1m 1s");
197+ fn test_id3_tags_default() {
198+ let tags = Id3Tags::default();
199+ assert!(tags.title.is_none());
200+ assert!(tags.artist.is_none());
201+ assert!(tags.album.is_none());
405202 }
406-
203+
407204 #[test]
408- fn test_id3_tags_has_all_required_tags() {
409- let tags = Id3Tags {
410- title: Some("Test".to_string()),
411- artist: Some("Artist".to_string()),
412- album: Some("Album".to_string()),
413- year: None,
414- genre: None,
415- };
416- assert!(tags.has_all_required_tags());
417-
418- let tags_missing = Id3Tags {
419- title: Some("Test".to_string()),
420- artist: None,
421- album: Some("Album".to_string()),
422- year: None,
423- genre: None,
424- };
425- assert!(!tags_missing.has_all_required_tags());
205+ fn test_ensure_id3_tags_file_not_found() {
206+ let result = ensure_id3_tags(Path::new("nonexistent/file.mp3"), "Title", None, None);
207+ assert!(result.is_err());
426208 }
427-
209+
428210 #[test]
429- fn test_id3_tags_missing_tags() {
430- let tags = Id3Tags {
431- title: None,
432- artist: Some("Artist".to_string()),
433- album: None,
434- year: None,
435- genre: None,
436- };
437- let missing = tags.missing_tags();
438- assert!(missing.contains(&"title"));
439- assert!(missing.contains(&"album"));
440- assert!(!missing.contains(&"artist"));
211+ fn test_write_and_read_id3_tags_roundtrip() {
212+ let temp_dir = tempdir().unwrap();
213+ let file_path = temp_dir.path().join("test.mp3");
214+
215+ // Créer un fichier MP3 valide (en-tête ID3 vide + quelques octets)
216+ let mut tag = Tag::new();
217+ tag.set_title("Initial Title");
218+ let mut file = std::fs::File::create(&file_path).unwrap();
219+ tag.write_to(&mut file, id3::Version::Id3v24).unwrap();
220+ drop(file);
221+
222+ // Écrire de nouveaux tags
223+ write_id3_tags(&file_path, Some("New Title"), Some("Artist"), Some("Album")).unwrap();
224+
225+ // Relire et vérifier
226+ let tags = read_id3_tags(&file_path).unwrap();
227+ assert_eq!(tags.title.as_deref(), Some("New Title"));
228+ assert_eq!(tags.artist.as_deref(), Some("Artist"));
229+ assert_eq!(tags.album.as_deref(), Some("Album"));
441230 }
442231 }
@@ -1,48 +1,11 @@
1 //! Module pour la lecture et l'écriture des métadonnées audio (ID3 tags)1 //! Module pour la lecture et l'écriture des métadonnées audio (ID3 tags)
2 //!2 //!
3-//! Ce module fournit des fonctions pour extraire et modifier la durée, l'artiste,3+//! Ce module fournit des fonctions pour lire et écrire les tags ID3
4-//! l'album et le titre des fichiers audio MP3.4+//! (titre, artiste, album) des fichiers audio MP3.
5 5
6 use id3::{Tag, TagLike, Error as Id3Error};6 use id3::{Tag, TagLike, Error as Id3Error};
7 use std::fs::File;7 use std::fs::File;
8-use std::path::{Path, PathBuf};8+use std::path::Path;
9-
10-/// Métadonnées d'un fichier audio
11-#[derive(Debug, Clone, Default)]
12-pub struct AudioMetadata {
13- /// Durée en secondes
14- pub duration_seconds: Option<u64>,
15- /// Titre (si disponible)
16- pub title: Option<String>,
17- /// Artiste (si disponible)
18- pub artist: Option<String>,
19- /// Album (si disponible)
20- pub album: Option<String>,
21- /// Année (si disponible)
22- pub year: Option<u32>,
23- /// Genre (si disponible)
24- pub genre: Option<String>,
25- /// Bitrate en kbps (si disponible)
26- pub bitrate_kbps: Option<u32>,
27- /// Taille du fichier en octets
28- pub file_size: u64,
29-}
30-
31-impl AudioMetadata {
32- pub fn new() -> Self {
33- Self::default()
34- }
35-
36- /// Obtenir la durée formatée (HH:MM:SS ou MM:SS)
37- #[allow(dead_code)]
38- pub fn get_formatted_duration(&self) -> String {
39- if let Some(duration) = self.duration_seconds {
40- format_duration(duration)
41- } else {
42- String::new()
43- }
44- }
45-}
46 9
47 /// Erreur de lecture des métadonnées10 /// Erreur de lecture des métadonnées
48 #[derive(Debug, thiserror::Error)]11 #[derive(Debug, thiserror::Error)]
@@ -50,24 +13,15 @@ pub enum MetadataError {
50 /// Fichier introuvable13 /// Fichier introuvable
51 #[error("Fichier introuvable: {0}")]14 #[error("Fichier introuvable: {0}")]
52 FileNotFound(String),15 FileNotFound(String),
53- 16+
54 /// Erreur de lecture du fichier17 /// Erreur de lecture du fichier
55 #[error("Erreur de lecture du fichier: {0}")]18 #[error("Erreur de lecture du fichier: {0}")]
56 IoError(#[from] std::io::Error),19 IoError(#[from] std::io::Error),
57- 20+
58- /// Format non supporté
59- #[error("Format audio non supporté: {0}")]
60- #[allow(dead_code)]
61- UnsupportedFormat(String),
62-
63- /// Erreur de parsing des métadonnées
64- #[error("Erreur de parsing des métadonnées: {0}")]
65- ParseError(String),
66-
67 /// Erreur d'écriture des tags ID321 /// Erreur d'écriture des tags ID3
68 #[error("Erreur d'écriture des tags ID3: {0}")]22 #[error("Erreur d'écriture des tags ID3: {0}")]
69 Id3WriteError(String),23 Id3WriteError(String),
70- 24+
71 /// Erreur de lecture des tags ID325 /// Erreur de lecture des tags ID3
72 #[error("Erreur de lecture des tags ID3: {0}")]26 #[error("Erreur de lecture des tags ID3: {0}")]
73 Id3ReadError(String),27 Id3ReadError(String),
@@ -79,110 +33,12 @@ impl From<Id3Error> for MetadataError {
79 }33 }
80 }34 }
81 35
82-/// Bitrate moyen par défaut pour les fichiers audio (en kbps)
83-const DEFAULT_BITRATE_KBPS: u32 = 128;
84-
85-/// Lire les métadonnées d'un fichier audio en utilisant mp3-metadata pour les info techniques
86-/// et id3 pour les tags (titre, artiste, album)
87-pub fn read_audio_metadata(path: &Path) -> Result<AudioMetadata, MetadataError> {
88- if !path.exists() {
89- return Err(MetadataError::FileNotFound(path.display().to_string()));
90- }
91-
92- let metadata = std::fs::metadata(path)
93- .map_err(|e| MetadataError::IoError(e))?;
94- let file_size = metadata.len();
95-
96- let mut audio_metadata = AudioMetadata::new();
97- audio_metadata.file_size = file_size;
98-
99- // Lire les tags ID3
100- let id3_tags = read_id3_tags(path)?;
101-
102- // Remplir les champs à partir des tags ID3
103- if let Some(title) = id3_tags.title {
104- audio_metadata.title = Some(title);
105- }
106- if let Some(artist) = id3_tags.artist {
107- audio_metadata.artist = Some(artist);
108- }
109- if let Some(album) = id3_tags.album {
110- audio_metadata.album = Some(album);
111- }
112- if let Some(year) = id3_tags.year {
113- audio_metadata.year = Some(year);
114- }
115- if let Some(genre) = id3_tags.genre {
116- audio_metadata.genre = Some(genre);
117- }
118-
119- // Pour l'instant, estimer la durée à partir de la taille du fichier
120- // La lecture des métadonnées MP3 techniques peut être ajoutée plus tard
121- if file_size > 0 {
122- let duration = estimate_duration_from_size(file_size, DEFAULT_BITRATE_KBPS);
123- audio_metadata.duration_seconds = Some(duration);
124- audio_metadata.bitrate_kbps = Some(DEFAULT_BITRATE_KBPS);
125- }
126-
127- // Essayer d'extraire le nom du fichier comme titre si pas de tag
128- if audio_metadata.title.is_none() {
129- if let Some(file_stem) = path.file_stem() {
130- audio_metadata.title = Some(file_stem.to_string_lossy().into_owned());
131- }
132- }
133-
134- Ok(audio_metadata)
135-}
136-
137-
138-
139-/// Lire les tags ID3 d'un fichier MP3
140-pub fn read_id3_tags(path: &Path) -> Result<Id3Tags, MetadataError> {
141- if !path.exists() {
142- return Err(MetadataError::FileNotFound(path.display().to_string()));
143- }
144-
145- let file = File::open(path)
146- .map_err(|e| MetadataError::IoError(e))?;
147-
148- let tag = Tag::read_from2(file)
149- .map_err(MetadataError::from)?;
150-
151- Ok(Id3Tags::from(tag))
152-}
153-
154 /// Structure pour stocker les tags ID3 extraits36 /// Structure pour stocker les tags ID3 extraits
155 #[derive(Debug, Clone, Default)]37 #[derive(Debug, Clone, Default)]
156 pub struct Id3Tags {38 pub struct Id3Tags {
157 pub title: Option<String>,39 pub title: Option<String>,
158 pub artist: Option<String>,40 pub artist: Option<String>,
159 pub album: Option<String>,41 pub album: Option<String>,
160- pub year: Option<u32>,
161- pub genre: Option<String>,
162-}
163-
164-impl Id3Tags {
165- /// Vérifie si tous les tags principaux sont présents
166- #[allow(dead_code)]
167- pub fn has_all_required_tags(&self) -> bool {
168- self.title.is_some() && self.artist.is_some() && self.album.is_some()
169- }
170-
171- /// Retourne les tags manquants
172- #[allow(dead_code)]
173- pub fn missing_tags(&self) -> Vec<&'static str> {
174- let mut missing = Vec::new();
175- if self.title.is_none() {
176- missing.push("title");
177- }
178- if self.artist.is_none() {
179- missing.push("artist");
180- }
181- if self.album.is_none() {
182- missing.push("album");
183- }
184- missing
185- }
186 }42 }
187 43
188 impl From<Tag> for Id3Tags {44 impl From<Tag> for Id3Tags {
@@ -191,12 +47,25 @@ impl From<Tag> for Id3Tags {
191 title: tag.title().map(|s| s.to_string()),47 title: tag.title().map(|s| s.to_string()),
192 artist: tag.artist().map(|s| s.to_string()),48 artist: tag.artist().map(|s| s.to_string()),
193 album: tag.album().map(|s| s.to_string()),49 album: tag.album().map(|s| s.to_string()),
194- year: tag.year().map(|y| y as u32),
195- genre: tag.genre().map(|s| s.to_string()),
196 }50 }
197 }51 }
198 }52 }
199 53
54+/// Lire les tags ID3 d'un fichier MP3
55+pub fn read_id3_tags(path: &Path) -> Result<Id3Tags, MetadataError> {
56+ if !path.exists() {
57+ return Err(MetadataError::FileNotFound(path.display().to_string()));
58+ }
59+
60+ let file = File::open(path)
61+ .map_err(MetadataError::IoError)?;
62+
63+ let tag = Tag::read_from2(file)
64+ .map_err(MetadataError::from)?;
65+
66+ Ok(Id3Tags::from(tag))
67+}
68+
200 /// Écrire les tags ID3 sur un fichier MP369 /// Écrire les tags ID3 sur un fichier MP3
201 pub fn write_id3_tags(70 pub fn write_id3_tags(
202 path: &Path,71 path: &Path,
@@ -210,11 +79,11 @@ pub fn write_id3_tags(
210 79
211 // Lire le tag existant80 // Lire le tag existant
212 let file_read = File::open(path)81 let file_read = File::open(path)
213- .map_err(|e| MetadataError::IoError(e))?;82+ .map_err(MetadataError::IoError)?;
214- 83+
215 let mut tag = Tag::read_from2(file_read)84 let mut tag = Tag::read_from2(file_read)
216 .unwrap_or_default();85 .unwrap_or_default();
217- 86+
218 // Mettre à jour les champs87 // Mettre à jour les champs
219 if let Some(title) = title {88 if let Some(title) = title {
220 tag.set_title(title);89 tag.set_title(title);
@@ -230,8 +99,8 @@ pub fn write_id3_tags(
230 let mut file_write = std::fs::OpenOptions::new()99 let mut file_write = std::fs::OpenOptions::new()
231 .write(true)100 .write(true)
232 .open(path)101 .open(path)
233- .map_err(|e| MetadataError::IoError(e))?;102+ .map_err(MetadataError::IoError)?;
234- 103+
235 tag.write_to(&mut file_write, id3::Version::Id3v24)104 tag.write_to(&mut file_write, id3::Version::Id3v24)
236 .map_err(|e| MetadataError::Id3WriteError(e.to_string()))?;105 .map_err(|e| MetadataError::Id3WriteError(e.to_string()))?;
237 106
@@ -240,13 +109,13 @@ pub fn write_id3_tags(
240 109
241 /// Assurer que les tags ID3 (titre, artiste, album) sont présents sur le fichier110 /// Assurer que les tags ID3 (titre, artiste, album) sont présents sur le fichier
242 /// Si des tags sont manquants, les remplir avec les informations fournies111 /// Si des tags sont manquants, les remplir avec les informations fournies
243-/// 112+///
244 /// # Arguments113 /// # Arguments
245 /// * `file_path` - Chemin vers le fichier audio114 /// * `file_path` - Chemin vers le fichier audio
246 /// * `episode_title` - Titre de l'épisode (pour le tag titre)115 /// * `episode_title` - Titre de l'épisode (pour le tag titre)
247 /// * `episode_artist` - Artiste de l'épisode (pour le tag artiste)116 /// * `episode_artist` - Artiste de l'épisode (pour le tag artiste)
248 /// * `episode_album` - Album de l'épisode (pour le tag album)117 /// * `episode_album` - Album de l'épisode (pour le tag album)
249-/// 118+///
250 /// # Retourne119 /// # Retourne
251 /// Ok(()) si les tags ont été vérifiés/écrits avec succès120 /// Ok(()) si les tags ont été vérifiés/écrits avec succès
252 /// Erreur si le fichier n'existe pas ou si l'écriture échoue121 /// Erreur si le fichier n'existe pas ou si l'écriture échoue
@@ -257,34 +126,34 @@ pub fn ensure_id3_tags(
257 episode_album: Option<&str>,126 episode_album: Option<&str>,
258 ) -> Result<(), MetadataError> {127 ) -> Result<(), MetadataError> {
259 log::info!("Checking ID3 tags for file: {}", file_path.display());128 log::info!("Checking ID3 tags for file: {}", file_path.display());
260- 129+
261 // Lire les tags existants130 // Lire les tags existants
262 let tags = read_id3_tags(file_path)?;131 let tags = read_id3_tags(file_path)?;
263- 132+
264 let mut needs_write = false;133 let mut needs_write = false;
265 let mut title_to_write = None;134 let mut title_to_write = None;
266 let mut artist_to_write = None;135 let mut artist_to_write = None;
267 let mut album_to_write = None;136 let mut album_to_write = None;
268 137
269 // Vérifier et préparer les valeurs à écrire138 // Vérifier et préparer les valeurs à écrire
270- if tags.title.is_none() || tags.title.as_ref().map_or(true, |t| t.is_empty()) {139+ if tags.title.as_ref().is_none_or(|t| t.is_empty()) {
271 title_to_write = Some(episode_title);140 title_to_write = Some(episode_title);
272 needs_write = true;141 needs_write = true;
273 log::info!(" Missing or empty title tag, will set to: {}", episode_title);142 log::info!(" Missing or empty title tag, will set to: {}", episode_title);
274 }143 }
275- 144+
276 // Utiliser l'artiste de l'épisode si disponible, sinon ne pas écrire145 // Utiliser l'artiste de l'épisode si disponible, sinon ne pas écrire
277 if let Some(artist) = episode_artist {146 if let Some(artist) = episode_artist {
278- if tags.artist.is_none() || tags.artist.as_ref().map_or(true, |a| a.is_empty()) {147+ if tags.artist.as_ref().is_none_or(|a| a.is_empty()) {
279 artist_to_write = Some(artist);148 artist_to_write = Some(artist);
280 needs_write = true;149 needs_write = true;
281 log::info!(" Missing or empty artist tag, will set to: {}", artist);150 log::info!(" Missing or empty artist tag, will set to: {}", artist);
282 }151 }
283 }152 }
284- 153+
285 // Utiliser l'album de l'épisode si disponible, sinon ne pas écrire154 // Utiliser l'album de l'épisode si disponible, sinon ne pas écrire
286 if let Some(album) = episode_album {155 if let Some(album) = episode_album {
287- if tags.album.is_none() || tags.album.as_ref().map_or(true, |a| a.is_empty()) {156+ if tags.album.as_ref().is_none_or(|a| a.is_empty()) {
288 album_to_write = Some(album);157 album_to_write = Some(album);
289 needs_write = true;158 needs_write = true;
290 log::info!(" Missing or empty album tag, will set to: {}", album);159 log::info!(" Missing or empty album tag, will set to: {}", album);
@@ -295,9 +164,9 @@ pub fn ensure_id3_tags(
295 if needs_write {164 if needs_write {
296 write_id3_tags(165 write_id3_tags(
297 file_path,166 file_path,
298- title_to_write.as_deref(),167+ title_to_write,
299- artist_to_write.as_deref(),168+ artist_to_write,
300- album_to_write.as_deref(),169+ album_to_write,
301 )?;170 )?;
302 log::info!(" ID3 tags updated successfully for: {}", file_path.display());171 log::info!(" ID3 tags updated successfully for: {}", file_path.display());
303 } else {172 } else {
@@ -307,136 +176,56 @@ pub fn ensure_id3_tags(
307 Ok(())176 Ok(())
308 }177 }
309 178
310-/// Lire uniquement la durée d'un fichier audio
311-pub fn get_audio_duration(path: &Path) -> Result<u64, MetadataError> {
312- let metadata = read_audio_metadata(path)?;
313- metadata.duration_seconds.ok_or(MetadataError::ParseError("Durée non disponible".to_string()))
314-}
315-
316-/// Estimer la durée d'un fichier audio à partir de sa taille et du bitrate moyen
317-pub fn estimate_duration_from_size(file_size: u64, bitrate_kbps: u32) -> u64 {
318- if file_size == 0 || bitrate_kbps == 0 {
319- return 0;
320- }
321-
322- // Bitrate en bps
323- let bitrate_bps = bitrate_kbps as f64 * 1000.0;
324-
325- // Durée en secondes = (taille en octets * 8) / bitrate en bps
326- let duration_seconds = (file_size as f64 * 8.0) / bitrate_bps;
327-
328- duration_seconds as u64
329-}
330-
331-/// Lire la durée de plusieurs fichiers audio
332-#[allow(dead_code)]
333-pub fn get_multiple_audio_durations(paths: &[impl AsRef<Path>]) -> Vec<(PathBuf, Option<u64>)> {
334- paths.iter()
335- .map(|path| {
336- let path_ref = path.as_ref();
337- let path_buf = path_ref.to_path_buf();
338- match get_audio_duration(path_ref) {
339- Ok(duration) => (path_buf, Some(duration)),
340- Err(_) => (path_buf, None),
341- }
342- })
343- .collect()
344-}
345-
346-/// Vérifier si un fichier est un fichier audio supporté
347-pub fn is_supported_audio_file(path: &Path) -> bool {
348- let ext = path.extension()
349- .and_then(|s| s.to_str())
350- .map(|s| s.to_lowercase());
351-
352- matches!(
353- ext.as_deref(),
354- Some("mp3") | Some("m4a") | Some("wav") | Some("flac") | Some("ogg") | Some("aac") | Some("mp4")
355- )
356-}
357-
358-/// Formater une durée en secondes en une chaîne lisible
359-pub fn format_duration(seconds: u64) -> String {
360- if seconds < 60 {
361- format!("{}s", seconds)
362- } else if seconds < 3600 {
363- let minutes = seconds / 60;
364- let secs = seconds % 60;
365- format!("{}m {}s", minutes, secs)
366- } else {
367- let hours = seconds / 3600;
368- let minutes = (seconds % 3600) / 60;
369- let secs = seconds % 60;
370- format!("{}h {}m {}s", hours, minutes, secs)
371- }
372-}
373-
374 #[cfg(test)]179 #[cfg(test)]
375 mod tests {180 mod tests {
376 use super::*;181 use super::*;
182+ use tempfile::tempdir;
377 183
378 #[test]184 #[test]
379- fn test_is_supported_audio_file() {185+ fn test_read_id3_tags_file_not_found() {
380- let mp3_path = Path::new("test.mp3");186+ let result = read_id3_tags(Path::new("nonexistent/file.mp3"));
381- let wav_path = Path::new("test.wav");187+ assert!(result.is_err());
382- let txt_path = Path::new("test.txt");
383-
384- assert!(is_supported_audio_file(mp3_path));
385- assert!(is_supported_audio_file(wav_path));
386- assert!(!is_supported_audio_file(txt_path));
387 }188 }
388 189
389 #[test]190 #[test]
390- fn test_estimate_duration() {191+ fn test_write_id3_tags_file_not_found() {
391- // Un fichier de 5 Mo à 128 kbps devrait faire environ 320 secondes192+ let result = write_id3_tags(Path::new("nonexistent/file.mp3"), Some("Title"), None, None);
392- let file_size = 5 * 1024 * 1024; // 5 Mo193+ assert!(result.is_err());
393- let bitrate = 128; // 128 kbps
394-
395- let duration = estimate_duration_from_size(file_size, bitrate);
396- assert!(duration > 320 && duration < 335);
397 }194 }
398 195
399 #[test]196 #[test]
400- fn test_format_duration() {197+ fn test_id3_tags_default() {
401- assert_eq!(format_duration(0), "0s");198+ let tags = Id3Tags::default();
402- assert_eq!(format_duration(30), "30s");199+ assert!(tags.title.is_none());
403- assert_eq!(format_duration(90), "1m 30s");200+ assert!(tags.artist.is_none());
404- assert_eq!(format_duration(3661), "1h 1m 1s");201+ assert!(tags.album.is_none());
405 }202 }
406- 203+
407 #[test]204 #[test]
408- fn test_id3_tags_has_all_required_tags() {205+ fn test_ensure_id3_tags_file_not_found() {
409- let tags = Id3Tags {206+ let result = ensure_id3_tags(Path::new("nonexistent/file.mp3"), "Title", None, None);
410- title: Some("Test".to_string()),207+ assert!(result.is_err());
411- artist: Some("Artist".to_string()),
412- album: Some("Album".to_string()),
413- year: None,
414- genre: None,
415- };
416- assert!(tags.has_all_required_tags());
417-
418- let tags_missing = Id3Tags {
419- title: Some("Test".to_string()),
420- artist: None,
421- album: Some("Album".to_string()),
422- year: None,
423- genre: None,
424- };
425- assert!(!tags_missing.has_all_required_tags());
426 }208 }
427- 209+
428 #[test]210 #[test]
429- fn test_id3_tags_missing_tags() {211+ fn test_write_and_read_id3_tags_roundtrip() {
430- let tags = Id3Tags {212+ let temp_dir = tempdir().unwrap();
431- title: None,213+ let file_path = temp_dir.path().join("test.mp3");
432- artist: Some("Artist".to_string()),214+
433- album: None,215+ // Créer un fichier MP3 valide (en-tête ID3 vide + quelques octets)
434- year: None,216+ let mut tag = Tag::new();
435- genre: None,217+ tag.set_title("Initial Title");
436- };218+ let mut file = std::fs::File::create(&file_path).unwrap();
437- let missing = tags.missing_tags();219+ tag.write_to(&mut file, id3::Version::Id3v24).unwrap();
438- assert!(missing.contains(&"title"));220+ drop(file);
439- assert!(missing.contains(&"album"));221+
440- assert!(!missing.contains(&"artist"));222+ // Écrire de nouveaux tags
223+ write_id3_tags(&file_path, Some("New Title"), Some("Artist"), Some("Album")).unwrap();
224+
225+ // Relire et vérifier
226+ let tags = read_id3_tags(&file_path).unwrap();
227+ assert_eq!(tags.title.as_deref(), Some("New Title"));
228+ assert_eq!(tags.artist.as_deref(), Some("Artist"));
229+ assert_eq!(tags.album.as_deref(), Some("Album"));
441 }230 }
442 }231 }
modified src/rss/downloader.rs +0 -60
@@ -26,12 +26,6 @@ pub enum DownloadError {
2626 FileCreationError(String),
2727 #[error("Failed to write to file: {0}")]
2828 WriteError(String),
29- #[error("File already exists: {0}")]
30- #[allow(dead_code)]
31- FileExists(String),
32- #[error("Insufficient disk space")]
33- #[allow(dead_code)]
34- DiskFull,
3529 #[error("Content length mismatch")]
3630 ContentLengthMismatch,
3731 #[error("Network error: {0}")]
@@ -41,15 +35,6 @@ pub enum DownloadError {
4135 /// Type alias for progress callback
4236 pub type ProgressCallback = Box<dyn Fn(f32) + Send + Sync>;
4337
44-/// HTTP client for synchronous downloads
45-#[allow(dead_code)]
46-pub struct DownloadClient;
47-
48-#[allow(dead_code)]
49-impl DownloadClient {
50- pub fn new() -> Self { Self }
51-}
52-
5338 /// Creates parent directories for a file path
5439 fn create_parent_dirs(path: &Path) -> Result<(), DownloadError> {
5540 if let Some(parent) = path.parent() {
@@ -61,12 +46,6 @@ fn create_parent_dirs(path: &Path) -> Result<(), DownloadError> {
6146 Ok(())
6247 }
6348
64-/// Gets the download directory for a feed
65-#[allow(dead_code)]
66-pub fn get_download_dir(feed_download_path: &Path) -> PathBuf {
67- feed_download_path.to_path_buf()
68-}
69-
7049 /// Downloads a file from a URL to a destination path
7150 pub fn download_file(
7251 url: &str,
@@ -243,23 +222,6 @@ pub fn download_episode(
243222 Ok(save_path)
244223 }
245224
246-/// Downloads multiple episodes
247-#[allow(dead_code)]
248-pub fn download_episodes(
249- episodes: &[Episode],
250- download_path: &Path,
251-) -> Result<Vec<PathBuf>, DownloadError> {
252- let mut downloaded_paths = Vec::new();
253-
254- for episode in episodes {
255- // Use a no-op callback for now
256- let path = download_episode(episode, download_path, Box::new(|_| {}))?;
257- downloaded_paths.push(path);
258- }
259-
260- Ok(downloaded_paths)
261-}
262-
263225 /// Checks if a file needs to be downloaded based on its existence and size
264226 pub fn needs_download(episode: &Episode, download_path: &Path) -> bool {
265227 let save_path = episode.get_save_path(download_path);
@@ -279,26 +241,11 @@ pub fn needs_download(episode: &Episode, download_path: &Path) -> bool {
279241 false
280242 }
281243
282-/// Removes a downloaded episode file
283-#[allow(dead_code)]
284-pub fn remove_downloaded_episode(episode: &Episode, download_path: &Path) -> Result<(), DownloadError> {
285- let save_path = episode.get_save_path(download_path);
286-
287- if save_path.exists() {
288- fs::remove_file(&save_path)
289- .map_err(|e| DownloadError::WriteError(e.to_string()))?;
290- info!("Removed downloaded episode: {}", episode.title);
291- }
292-
293- Ok(())
294-}
295-
296244 #[cfg(test)]
297245 mod tests {
298246 use super::*;
299247 use crate::config::episode::Episode;
300248 use chrono::Utc;
301- use std::path::PathBuf;
302249 use tempfile::tempdir;
303250
304251 #[test]
@@ -361,13 +308,6 @@ mod tests {
361308 assert_eq!(part.file_name().unwrap().to_string_lossy(), "episode.mp3.part");
362309 }
363310
364- #[test]
365- fn test_get_download_dir() {
366- let path = PathBuf::from("/downloads/podcasts");
367- let result = get_download_dir(&path);
368- assert_eq!(result, path);
369- }
370-
371311 #[test]
372312 fn test_create_parent_dirs() {
373313 let temp_dir = tempdir().unwrap();
@@ -26,12 +26,6 @@ pub enum DownloadError {
26 FileCreationError(String),26 FileCreationError(String),
27 #[error("Failed to write to file: {0}")]27 #[error("Failed to write to file: {0}")]
28 WriteError(String),28 WriteError(String),
29- #[error("File already exists: {0}")]
30- #[allow(dead_code)]
31- FileExists(String),
32- #[error("Insufficient disk space")]
33- #[allow(dead_code)]
34- DiskFull,
35 #[error("Content length mismatch")]29 #[error("Content length mismatch")]
36 ContentLengthMismatch,30 ContentLengthMismatch,
37 #[error("Network error: {0}")]31 #[error("Network error: {0}")]
@@ -41,15 +35,6 @@ pub enum DownloadError {
41 /// Type alias for progress callback35 /// Type alias for progress callback
42 pub type ProgressCallback = Box<dyn Fn(f32) + Send + Sync>;36 pub type ProgressCallback = Box<dyn Fn(f32) + Send + Sync>;
43 37
44-/// HTTP client for synchronous downloads
45-#[allow(dead_code)]
46-pub struct DownloadClient;
47-
48-#[allow(dead_code)]
49-impl DownloadClient {
50- pub fn new() -> Self { Self }
51-}
52-
53 /// Creates parent directories for a file path38 /// Creates parent directories for a file path
54 fn create_parent_dirs(path: &Path) -> Result<(), DownloadError> {39 fn create_parent_dirs(path: &Path) -> Result<(), DownloadError> {
55 if let Some(parent) = path.parent() {40 if let Some(parent) = path.parent() {
@@ -61,12 +46,6 @@ fn create_parent_dirs(path: &Path) -> Result<(), DownloadError> {
61 Ok(())46 Ok(())
62 }47 }
63 48
64-/// Gets the download directory for a feed
65-#[allow(dead_code)]
66-pub fn get_download_dir(feed_download_path: &Path) -> PathBuf {
67- feed_download_path.to_path_buf()
68-}
69-
70 /// Downloads a file from a URL to a destination path49 /// Downloads a file from a URL to a destination path
71 pub fn download_file(50 pub fn download_file(
72 url: &str,51 url: &str,
@@ -243,23 +222,6 @@ pub fn download_episode(
243 Ok(save_path)222 Ok(save_path)
244 }223 }
245 224
246-/// Downloads multiple episodes
247-#[allow(dead_code)]
248-pub fn download_episodes(
249- episodes: &[Episode],
250- download_path: &Path,
251-) -> Result<Vec<PathBuf>, DownloadError> {
252- let mut downloaded_paths = Vec::new();
253-
254- for episode in episodes {
255- // Use a no-op callback for now
256- let path = download_episode(episode, download_path, Box::new(|_| {}))?;
257- downloaded_paths.push(path);
258- }
259-
260- Ok(downloaded_paths)
261-}
262-
263 /// Checks if a file needs to be downloaded based on its existence and size225 /// Checks if a file needs to be downloaded based on its existence and size
264 pub fn needs_download(episode: &Episode, download_path: &Path) -> bool {226 pub fn needs_download(episode: &Episode, download_path: &Path) -> bool {
265 let save_path = episode.get_save_path(download_path);227 let save_path = episode.get_save_path(download_path);
@@ -279,26 +241,11 @@ pub fn needs_download(episode: &Episode, download_path: &Path) -> bool {
279 false241 false
280 }242 }
281 243
282-/// Removes a downloaded episode file
283-#[allow(dead_code)]
284-pub fn remove_downloaded_episode(episode: &Episode, download_path: &Path) -> Result<(), DownloadError> {
285- let save_path = episode.get_save_path(download_path);
286-
287- if save_path.exists() {
288- fs::remove_file(&save_path)
289- .map_err(|e| DownloadError::WriteError(e.to_string()))?;
290- info!("Removed downloaded episode: {}", episode.title);
291- }
292-
293- Ok(())
294-}
295-
296 #[cfg(test)]244 #[cfg(test)]
297 mod tests {245 mod tests {
298 use super::*;246 use super::*;
299 use crate::config::episode::Episode;247 use crate::config::episode::Episode;
300 use chrono::Utc;248 use chrono::Utc;
301- use std::path::PathBuf;
302 use tempfile::tempdir;249 use tempfile::tempdir;
303 250
304 #[test]251 #[test]
@@ -361,13 +308,6 @@ mod tests {
361 assert_eq!(part.file_name().unwrap().to_string_lossy(), "episode.mp3.part");308 assert_eq!(part.file_name().unwrap().to_string_lossy(), "episode.mp3.part");
362 }309 }
363 310
364- #[test]
365- fn test_get_download_dir() {
366- let path = PathBuf::from("/downloads/podcasts");
367- let result = get_download_dir(&path);
368- assert_eq!(result, path);
369- }
370-
371 #[test]311 #[test]
372 fn test_create_parent_dirs() {312 fn test_create_parent_dirs() {
373 let temp_dir = tempdir().unwrap();313 let temp_dir = tempdir().unwrap();
modified src/rss/episode_extractor.rs +34 -64
@@ -6,7 +6,6 @@ use crate::config::episode::Episode;
66 use chrono::{DateTime, FixedOffset, Utc};
77 use log::{debug, info};
88 use rss::Channel;
9-use std::collections::HashSet;
109 use thiserror::Error;
1110 use uuid::Uuid;
1211
@@ -17,12 +16,6 @@ pub enum ExtractionError {
1716 NoItemsFound,
1817 #[error("No valid audio episodes found in the feed")]
1918 NoAudioEpisodes,
20- #[error("Invalid date format in feed item: {0}")]
21- #[allow(dead_code)]
22- InvalidDateFormat(String),
23- #[error("Missing required field in feed item: {0}")]
24- #[allow(dead_code)]
25- MissingField(String),
2619 }
2720
2821 /// Audio MIME types to look for in enclosures
@@ -195,63 +188,35 @@ pub fn extract_episodes(channel: &Channel) -> Result<Vec<Episode>, ExtractionErr
195188 Ok(episodes)
196189 }
197190
198-#[allow(dead_code)]
199-pub fn extract_episodes_since(
200- channel: &Channel,
201- since_date: DateTime<Utc>,
202-) -> Result<Vec<Episode>, ExtractionError> {
203- let all_episodes = extract_episodes(channel)?;
204- let filtered = all_episodes
205- .into_iter()
206- .filter(|ep| ep.pub_date >= since_date)
207- .collect();
208- Ok(filtered)
209-}
210-
211-#[allow(dead_code)]
212-pub fn extract_new_episodes(
213- channel: &Channel,
214- downloaded_ids: &HashSet<String>,
215-) -> Result<Vec<Episode>, ExtractionError> {
216- let all_episodes = extract_episodes(channel)?;
217- let filtered = all_episodes
218- .into_iter()
219- .filter(|ep| !downloaded_ids.contains(&ep.audio_url))
220- .collect();
221- Ok(filtered)
222-}
223-
224191 #[cfg(test)]
225192 mod tests {
226193 use super::*;
227194 use rss::Channel;
228195
229196 fn create_test_channel() -> Channel {
230- let mut channel = Channel::default();
231- channel.title = "Test Podcast".to_string();
232- channel.link = "https://example.com".to_string();
233- channel.description = "Test Description".to_string();
234- channel
197+ Channel {
198+ title: "Test Podcast".to_string(),
199+ link: "https://example.com".to_string(),
200+ description: "Test Description".to_string(),
201+ ..Default::default()
202+ }
235203 }
236204
237205 fn add_test_item(channel: &mut Channel, title: &str, url: &str, pub_date: Option<&str>) {
238206 use rss::{Enclosure, Item};
239-
240- let mut item = Item::default();
241- item.title = Some(title.to_string());
242- item.link = Some("https://example.com/episode".to_string());
243-
244- let mut enclosure = Enclosure::default();
245- enclosure.url = url.to_string();
246- enclosure.mime_type = "audio/mpeg".to_string();
247- enclosure.length = "1234".to_string();
248-
249- item.enclosure = Some(enclosure);
250-
251- if let Some(date) = pub_date {
252- item.pub_date = Some(date.to_string());
253- }
254-
207+
208+ let item = Item {
209+ title: Some(title.to_string()),
210+ link: Some("https://example.com/episode".to_string()),
211+ enclosure: Some(Enclosure {
212+ url: url.to_string(),
213+ mime_type: "audio/mpeg".to_string(),
214+ length: "1234".to_string(),
215+ }),
216+ pub_date: pub_date.map(|date| date.to_string()),
217+ ..Default::default()
218+ };
219+
255220 channel.items.push(item);
256221 }
257222
@@ -283,20 +248,25 @@ mod tests {
283248 #[test]
284249 fn test_extract_episodes_no_audio_enclosures() {
285250 let mut channel = create_test_channel();
286-
251+
287252 // Add item without enclosure
288253 use rss::Item;
289- let mut item = Item::default();
290- item.title = Some("Non-audio item".to_string());
254+ let item = Item {
255+ title: Some("Non-audio item".to_string()),
256+ ..Default::default()
257+ };
291258 channel.items.push(item);
292-
259+
293260 // Add item with non-audio enclosure (using .avi which is not in AUDIO_EXTENSIONS)
294- let mut item2 = Item::default();
295- item2.title = Some("Video item".to_string());
296- let mut enclosure = rss::Enclosure::default();
297- enclosure.url = "https://example.com/video.avi".to_string();
298- enclosure.mime_type = "video/x-msvideo".to_string();
299- item2.enclosure = Some(enclosure);
261+ let item2 = Item {
262+ title: Some("Video item".to_string()),
263+ enclosure: Some(rss::Enclosure {
264+ url: "https://example.com/video.avi".to_string(),
265+ mime_type: "video/x-msvideo".to_string(),
266+ ..Default::default()
267+ }),
268+ ..Default::default()
269+ };
300270 channel.items.push(item2);
301271
302272 let result = extract_episodes(&channel);
@@ -6,7 +6,6 @@ use crate::config::episode::Episode;
6 use chrono::{DateTime, FixedOffset, Utc};6 use chrono::{DateTime, FixedOffset, Utc};
7 use log::{debug, info};7 use log::{debug, info};
8 use rss::Channel;8 use rss::Channel;
9-use std::collections::HashSet;
10 use thiserror::Error;9 use thiserror::Error;
11 use uuid::Uuid;10 use uuid::Uuid;
12 11
@@ -17,12 +16,6 @@ pub enum ExtractionError {
17 NoItemsFound,16 NoItemsFound,
18 #[error("No valid audio episodes found in the feed")]17 #[error("No valid audio episodes found in the feed")]
19 NoAudioEpisodes,18 NoAudioEpisodes,
20- #[error("Invalid date format in feed item: {0}")]
21- #[allow(dead_code)]
22- InvalidDateFormat(String),
23- #[error("Missing required field in feed item: {0}")]
24- #[allow(dead_code)]
25- MissingField(String),
26 }19 }
27 20
28 /// Audio MIME types to look for in enclosures21 /// Audio MIME types to look for in enclosures
@@ -195,63 +188,35 @@ pub fn extract_episodes(channel: &Channel) -> Result<Vec<Episode>, ExtractionErr
195 Ok(episodes)188 Ok(episodes)
196 }189 }
197 190
198-#[allow(dead_code)]
199-pub fn extract_episodes_since(
200- channel: &Channel,
201- since_date: DateTime<Utc>,
202-) -> Result<Vec<Episode>, ExtractionError> {
203- let all_episodes = extract_episodes(channel)?;
204- let filtered = all_episodes
205- .into_iter()
206- .filter(|ep| ep.pub_date >= since_date)
207- .collect();
208- Ok(filtered)
209-}
210-
211-#[allow(dead_code)]
212-pub fn extract_new_episodes(
213- channel: &Channel,
214- downloaded_ids: &HashSet<String>,
215-) -> Result<Vec<Episode>, ExtractionError> {
216- let all_episodes = extract_episodes(channel)?;
217- let filtered = all_episodes
218- .into_iter()
219- .filter(|ep| !downloaded_ids.contains(&ep.audio_url))
220- .collect();
221- Ok(filtered)
222-}
223-
224 #[cfg(test)]191 #[cfg(test)]
225 mod tests {192 mod tests {
226 use super::*;193 use super::*;
227 use rss::Channel;194 use rss::Channel;
228 195
229 fn create_test_channel() -> Channel {196 fn create_test_channel() -> Channel {
230- let mut channel = Channel::default();197+ Channel {
231- channel.title = "Test Podcast".to_string();198+ title: "Test Podcast".to_string(),
232- channel.link = "https://example.com".to_string();199+ link: "https://example.com".to_string(),
233- channel.description = "Test Description".to_string();200+ description: "Test Description".to_string(),
234- channel201+ ..Default::default()
202+ }
235 }203 }
236 204
237 fn add_test_item(channel: &mut Channel, title: &str, url: &str, pub_date: Option<&str>) {205 fn add_test_item(channel: &mut Channel, title: &str, url: &str, pub_date: Option<&str>) {
238 use rss::{Enclosure, Item};206 use rss::{Enclosure, Item};
239- 207+
240- let mut item = Item::default();208+ let item = Item {
241- item.title = Some(title.to_string());209+ title: Some(title.to_string()),
242- item.link = Some("https://example.com/episode".to_string());210+ link: Some("https://example.com/episode".to_string()),
243- 211+ enclosure: Some(Enclosure {
244- let mut enclosure = Enclosure::default();212+ url: url.to_string(),
245- enclosure.url = url.to_string();213+ mime_type: "audio/mpeg".to_string(),
246- enclosure.mime_type = "audio/mpeg".to_string();214+ length: "1234".to_string(),
247- enclosure.length = "1234".to_string();215+ }),
248- 216+ pub_date: pub_date.map(|date| date.to_string()),
249- item.enclosure = Some(enclosure);217+ ..Default::default()
250- 218+ };
251- if let Some(date) = pub_date {219+
252- item.pub_date = Some(date.to_string());
253- }
254-
255 channel.items.push(item);220 channel.items.push(item);
256 }221 }
257 222
@@ -283,20 +248,25 @@ mod tests {
283 #[test]248 #[test]
284 fn test_extract_episodes_no_audio_enclosures() {249 fn test_extract_episodes_no_audio_enclosures() {
285 let mut channel = create_test_channel();250 let mut channel = create_test_channel();
286- 251+
287 // Add item without enclosure252 // Add item without enclosure
288 use rss::Item;253 use rss::Item;
289- let mut item = Item::default();254+ let item = Item {
290- item.title = Some("Non-audio item".to_string());255+ title: Some("Non-audio item".to_string()),
256+ ..Default::default()
257+ };
291 channel.items.push(item);258 channel.items.push(item);
292- 259+
293 // Add item with non-audio enclosure (using .avi which is not in AUDIO_EXTENSIONS)260 // Add item with non-audio enclosure (using .avi which is not in AUDIO_EXTENSIONS)
294- let mut item2 = Item::default();261+ let item2 = Item {
295- item2.title = Some("Video item".to_string());262+ title: Some("Video item".to_string()),
296- let mut enclosure = rss::Enclosure::default();263+ enclosure: Some(rss::Enclosure {
297- enclosure.url = "https://example.com/video.avi".to_string();264+ url: "https://example.com/video.avi".to_string(),
298- enclosure.mime_type = "video/x-msvideo".to_string();265+ mime_type: "video/x-msvideo".to_string(),
299- item2.enclosure = Some(enclosure);266+ ..Default::default()
267+ }),
268+ ..Default::default()
269+ };
300 channel.items.push(item2);270 channel.items.push(item2);
301 271
302 let result = extract_episodes(&channel);272 let result = extract_episodes(&channel);
modified src/rss/mod.rs +0 -19
@@ -7,22 +7,3 @@ pub mod audio_metadata;
77 pub mod downloader;
88 pub mod episode_extractor;
99 pub mod parser;
10-
11-// Re-export main types for convenience
12-// Note: Some exports may not be directly used in the current codebase but are
13-// kept for future extensibility and API completeness
14-#[allow(dead_code)]
15-#[allow(unused_imports)]
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-#[allow(dead_code)]
18-#[allow(unused_imports)]
19-pub use async_sync::{sync_all_feeds_async, sync_feed_async};
20-#[allow(dead_code)]
21-#[allow(unused_imports)]
22-pub use downloader::{DownloadClient, DownloadError, ProgressCallback, download_episode, download_episodes, download_file, get_download_dir, needs_download, remove_downloaded_episode};
23-#[allow(dead_code)]
24-#[allow(unused_imports)]
25-pub use episode_extractor::{ExtractionError, extract_episodes, extract_episodes_since, extract_new_episodes};
26-#[allow(dead_code)]
27-#[allow(unused_imports)]
28-pub use parser::{FeedClient, ParserError, RssFeed, fetch_feed, parse_rss_content};
@@ -7,22 +7,3 @@ pub mod audio_metadata;
7 pub mod downloader;7 pub mod downloader;
8 pub mod episode_extractor;8 pub mod episode_extractor;
9 pub mod parser;9 pub mod parser;
10-
11-// Re-export main types for convenience
12-// Note: Some exports may not be directly used in the current codebase but are
13-// kept for future extensibility and API completeness
14-#[allow(dead_code)]
15-#[allow(unused_imports)]
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-#[allow(dead_code)]
18-#[allow(unused_imports)]
19-pub use async_sync::{sync_all_feeds_async, sync_feed_async};
20-#[allow(dead_code)]
21-#[allow(unused_imports)]
22-pub use downloader::{DownloadClient, DownloadError, ProgressCallback, download_episode, download_episodes, download_file, get_download_dir, needs_download, remove_downloaded_episode};
23-#[allow(dead_code)]
24-#[allow(unused_imports)]
25-pub use episode_extractor::{ExtractionError, extract_episodes, extract_episodes_since, extract_new_episodes};
26-#[allow(dead_code)]
27-#[allow(unused_imports)]
28-pub use parser::{FeedClient, ParserError, RssFeed, fetch_feed, parse_rss_content};
modified src/rss/parser.rs +0 -12
@@ -17,9 +17,6 @@ pub enum ParserError {
1717 ParseError(String),
1818 #[error("No valid feed data found")]
1919 NoFeedData,
20- #[error("SSL/TLS error: {0}")]
21- #[allow(dead_code)]
22- SslError(String),
2320 }
2421
2522 /// RSS Channel type for compatibility
@@ -79,15 +76,6 @@ pub fn parse_rss_content(content: &str) -> Result<RssFeed, ParserError> {
7976 Ok(channel)
8077 }
8178
82-/// Placeholder for actual implementation
83-#[allow(dead_code)]
84-pub struct FeedClient;
85-
86-#[allow(dead_code)]
87-impl FeedClient {
88- pub fn new() -> Self { Self }
89-}
90-
9179 #[cfg(test)]
9280 mod tests {
9381 use super::*;
@@ -17,9 +17,6 @@ pub enum ParserError {
17 ParseError(String),17 ParseError(String),
18 #[error("No valid feed data found")]18 #[error("No valid feed data found")]
19 NoFeedData,19 NoFeedData,
20- #[error("SSL/TLS error: {0}")]
21- #[allow(dead_code)]
22- SslError(String),
23 }20 }
24 21
25 /// RSS Channel type for compatibility22 /// RSS Channel type for compatibility
@@ -79,15 +76,6 @@ pub fn parse_rss_content(content: &str) -> Result<RssFeed, ParserError> {
79 Ok(channel)76 Ok(channel)
80 }77 }
81 78
82-/// Placeholder for actual implementation
83-#[allow(dead_code)]
84-pub struct FeedClient;
85-
86-#[allow(dead_code)]
87-impl FeedClient {
88- pub fn new() -> Self { Self }
89-}
90-
91 #[cfg(test)]79 #[cfg(test)]
92 mod tests {80 mod tests {
93 use super::*;81 use super::*;
modified src/ui/filter_panel.rs +0 -48
@@ -189,51 +189,3 @@ pub fn filter_feeds(feeds: &[crate::config::feed::Feed], state: &AppState) -> Ve
189189 .map(|(i, _)| i)
190190 .collect()
191191 }
192-
193-/// Filters episodes based on current filter criteria
194-#[allow(dead_code)]
195-pub fn filter_episodes(episodes: &[crate::config::episode::Episode], state: &AppState) -> Vec<usize> {
196- episodes.iter()
197- .enumerate()
198- .filter(|(_, episode)| {
199- // Check date filter
200- if let Some(date_from) = state.filter_date_from {
201- if episode.pub_date < date_from {
202- return false;
203- }
204- }
205-
206- if let Some(date_to) = state.filter_date_to {
207- if episode.pub_date > date_to {
208- return false;
209- }
210- }
211-
212- // Check episode number filter
213- if !state.filter_episode_number.is_empty() {
214- if let Ok(ep_num) = state.filter_episode_number.parse::<u32>() {
215- // Try to extract episode number from title (e.g., "Episode 10" or "#10")
216- let title_lower = episode.title.to_lowercase();
217- if title_lower.contains("episode") || title_lower.contains("ep") || title_lower.contains("#") {
218- // Simple extraction - look for numbers in title
219- let found_number = episode.title
220- .chars()
221- .filter(|c| c.is_ascii_digit())
222- .collect::<String>()
223- .parse::<u32>()
224- .ok();
225-
226- if let Some(found_num) = found_number {
227- if found_num != ep_num {
228- return false;
229- }
230- }
231- }
232- }
233- }
234-
235- true
236- })
237- .map(|(i, _)| i)
238- .collect()
239-}
@@ -189,51 +189,3 @@ pub fn filter_feeds(feeds: &[crate::config::feed::Feed], state: &AppState) -> Ve
189 .map(|(i, _)| i)189 .map(|(i, _)| i)
190 .collect()190 .collect()
191 }191 }
192-
193-/// Filters episodes based on current filter criteria
194-#[allow(dead_code)]
195-pub fn filter_episodes(episodes: &[crate::config::episode::Episode], state: &AppState) -> Vec<usize> {
196- episodes.iter()
197- .enumerate()
198- .filter(|(_, episode)| {
199- // Check date filter
200- if let Some(date_from) = state.filter_date_from {
201- if episode.pub_date < date_from {
202- return false;
203- }
204- }
205-
206- if let Some(date_to) = state.filter_date_to {
207- if episode.pub_date > date_to {
208- return false;
209- }
210- }
211-
212- // Check episode number filter
213- if !state.filter_episode_number.is_empty() {
214- if let Ok(ep_num) = state.filter_episode_number.parse::<u32>() {
215- // Try to extract episode number from title (e.g., "Episode 10" or "#10")
216- let title_lower = episode.title.to_lowercase();
217- if title_lower.contains("episode") || title_lower.contains("ep") || title_lower.contains("#") {
218- // Simple extraction - look for numbers in title
219- let found_number = episode.title
220- .chars()
221- .filter(|c| c.is_ascii_digit())
222- .collect::<String>()
223- .parse::<u32>()
224- .ok();
225-
226- if let Some(found_num) = found_number {
227- if found_num != ep_num {
228- return false;
229- }
230- }
231- }
232- }
233- }
234-
235- true
236- })
237- .map(|(i, _)| i)
238- .collect()
239-}
modified src/ui/history_panel.rs +3 -4
@@ -1,4 +1,4 @@
1-#![allow(dead_code)]
1+#![allow(dead_code)] // Fonctionnalité prévue dans la spec (Phase 9+), pas encore branchée dans l'UI
22 use crate::config::history::{HistoryEntry, HistoryManager, OperationStatus, OperationType};
33 use eframe::egui;
44
@@ -117,12 +117,11 @@ impl HistoryPanel {
117117 });
118118
119119 // Bouton pour effacer les filtres
120- if self.filter_type.is_some() || self.filter_status.is_some() {
121- if ui.button("Effacer filtres").clicked() {
120+ if (self.filter_type.is_some() || self.filter_status.is_some())
121+ && ui.button("Effacer filtres").clicked() {
122122 self.filter_type = None;
123123 self.filter_status = None;
124124 }
125- }
126125 });
127126 }
128127
@@ -1,4 +1,4 @@
1-#![allow(dead_code)]1+#![allow(dead_code)] // Fonctionnalité prévue dans la spec (Phase 9+), pas encore branchée dans l'UI
2 use crate::config::history::{HistoryEntry, HistoryManager, OperationStatus, OperationType};2 use crate::config::history::{HistoryEntry, HistoryManager, OperationStatus, OperationType};
3 use eframe::egui;3 use eframe::egui;
4 4
@@ -117,12 +117,11 @@ impl HistoryPanel {
117 });117 });
118 118
119 // Bouton pour effacer les filtres119 // Bouton pour effacer les filtres
120- if self.filter_type.is_some() || self.filter_status.is_some() {120+ if (self.filter_type.is_some() || self.filter_status.is_some())
121- if ui.button("Effacer filtres").clicked() {121+ && ui.button("Effacer filtres").clicked() {
122 self.filter_type = None;122 self.filter_type = None;
123 self.filter_status = None;123 self.filter_status = None;
124 }124 }
125- }
126 });125 });
127 }126 }
128 127
modified src/ui/home.rs +34 -15
@@ -12,22 +12,41 @@ pub fn draw_home(state: &mut AppState, ui: &mut Ui) {
1212 let messages = state.sync_task_manager.process_messages();
1313 let mut needs_save = false;
1414 for message in messages {
15- if let crate::config::sync_task::SyncMessage::SyncCompleted {
16- feed_id,
17- downloaded_episodes_count,
18- last_downloaded_episode_name,
19- last_downloaded_episode_date,
20- last_checked,
21- ..
22- } = message {
23- // Find the feed and update its statistics
24- if let Some(feed) = state.feeds.iter_mut().find(|f| f.id == feed_id) {
25- feed.downloaded_episodes_count = downloaded_episodes_count;
26- feed.last_downloaded_episode_name = last_downloaded_episode_name;
27- feed.last_downloaded_episode_date = last_downloaded_episode_date;
28- feed.last_checked = last_checked;
29- needs_save = true;
15+ match message {
16+ crate::config::sync_task::SyncMessage::SyncCompleted {
17+ feed_id,
18+ downloaded_episodes_count,
19+ last_downloaded_episode_name,
20+ last_downloaded_episode_date,
21+ last_checked,
22+ ..
23+ } => {
24+ // Find the feed and update its statistics
25+ if let Some(feed) = state.feeds.iter_mut().find(|f| f.id == feed_id) {
26+ feed.downloaded_episodes_count = downloaded_episodes_count;
27+ feed.last_downloaded_episode_name = last_downloaded_episode_name;
28+ feed.last_downloaded_episode_date = last_downloaded_episode_date;
29+ feed.last_checked = last_checked;
30+ needs_save = true;
31+ }
3032 }
33+ crate::config::sync_task::SyncMessage::EpisodeDownloadFailed {
34+ feed_id,
35+ episode_title,
36+ error,
37+ } => {
38+ let feed_name = state
39+ .feeds
40+ .iter()
41+ .find(|f| f.id == feed_id)
42+ .map(|f| f.name.clone())
43+ .unwrap_or_else(|| feed_id.clone());
44+ state.notification_manager.error(format!(
45+ "Échec du téléchargement de \"{}\" ({}) : {}",
46+ episode_title, feed_name, error
47+ ));
48+ }
49+ _ => {}
3150 }
3251 }
3352
@@ -12,22 +12,41 @@ pub fn draw_home(state: &mut AppState, ui: &mut Ui) {
12 let messages = state.sync_task_manager.process_messages();12 let messages = state.sync_task_manager.process_messages();
13 let mut needs_save = false;13 let mut needs_save = false;
14 for message in messages {14 for message in messages {
15- if let crate::config::sync_task::SyncMessage::SyncCompleted {15+ match message {
16- feed_id,16+ crate::config::sync_task::SyncMessage::SyncCompleted {
17- downloaded_episodes_count,17+ feed_id,
18- last_downloaded_episode_name,18+ downloaded_episodes_count,
19- last_downloaded_episode_date,19+ last_downloaded_episode_name,
20- last_checked,20+ last_downloaded_episode_date,
21- ..21+ last_checked,
22- } = message {22+ ..
23- // Find the feed and update its statistics23+ } => {
24- if let Some(feed) = state.feeds.iter_mut().find(|f| f.id == feed_id) {24+ // Find the feed and update its statistics
25- feed.downloaded_episodes_count = downloaded_episodes_count;25+ if let Some(feed) = state.feeds.iter_mut().find(|f| f.id == feed_id) {
26- feed.last_downloaded_episode_name = last_downloaded_episode_name;26+ feed.downloaded_episodes_count = downloaded_episodes_count;
27- feed.last_downloaded_episode_date = last_downloaded_episode_date;27+ feed.last_downloaded_episode_name = last_downloaded_episode_name;
28- feed.last_checked = last_checked;28+ feed.last_downloaded_episode_date = last_downloaded_episode_date;
29- needs_save = true;29+ feed.last_checked = last_checked;
30+ needs_save = true;
31+ }
30 }32 }
33+ crate::config::sync_task::SyncMessage::EpisodeDownloadFailed {
34+ feed_id,
35+ episode_title,
36+ error,
37+ } => {
38+ let feed_name = state
39+ .feeds
40+ .iter()
41+ .find(|f| f.id == feed_id)
42+ .map(|f| f.name.clone())
43+ .unwrap_or_else(|| feed_id.clone());
44+ state.notification_manager.error(format!(
45+ "Échec du téléchargement de \"{}\" ({}) : {}",
46+ episode_title, feed_name, error
47+ ));
48+ }
49+ _ => {}
31 }50 }
32 }51 }
33 52
modified src/ui/notifications.rs +0 -29
@@ -26,10 +26,6 @@ pub enum NotificationType {
2626 Info,
2727 /// Success message (green)
2828 Success,
29- /// Warning message (yellow/orange)
30- #[allow(dead_code)]
31- Warning,
32- /// Error message (red)
3329 Error,
3430 }
3531
@@ -54,17 +50,6 @@ impl Notification {
5450 }
5551 }
5652
57- /// Creates a new warning notification
58- #[allow(dead_code)]
59- pub fn warning(message: impl Into<String>) -> Self {
60- Self {
61- message: message.into(),
62- notification_type: NotificationType::Warning,
63- created_at: Instant::now(),
64- duration: Duration::from_secs(5),
65- }
66- }
67-
6853 /// Creates a new error notification
6954 pub fn error(message: impl Into<String>) -> Self {
7055 Self {
@@ -85,7 +70,6 @@ impl Notification {
8570 match self.notification_type {
8671 NotificationType::Info => Color32::from_rgb(70, 130, 180), // Steel blue
8772 NotificationType::Success => Color32::from_rgb(34, 139, 34), // Forest green
88- NotificationType::Warning => Color32::from_rgb(255, 165, 0), // Orange
8973 NotificationType::Error => Color32::from_rgb(220, 53, 69), // Red
9074 }
9175 }
@@ -95,7 +79,6 @@ impl Notification {
9579 match self.notification_type {
9680 NotificationType::Info => "ℹ️",
9781 NotificationType::Success => "✅",
98- NotificationType::Warning => "⚠️",
9982 NotificationType::Error => "❌",
10083 }
10184 }
@@ -130,12 +113,6 @@ impl NotificationManager {
130113 self.add_notification(Notification::success(message));
131114 }
132115
133- /// Adds a warning notification
134- #[allow(dead_code)]
135- pub fn warning(&mut self, message: impl Into<String>) {
136- self.add_notification(Notification::warning(message));
137- }
138-
139116 /// Adds an error notification
140117 pub fn error(&mut self, message: impl Into<String>) {
141118 self.add_notification(Notification::error(message));
@@ -151,12 +128,6 @@ impl NotificationManager {
151128 self.notifications.iter().filter(|n| n.is_visible()).collect()
152129 }
153130
154- /// Returns the number of active notifications
155- #[allow(dead_code)]
156- pub fn active_count(&self) -> usize {
157- self.notifications.iter().filter(|n| n.is_visible()).count()
158- }
159-
160131 /// Draws all active notifications in the UI
161132 pub fn draw(&mut self, ui: &mut Ui) {
162133 self.cleanup();
@@ -26,10 +26,6 @@ pub enum NotificationType {
26 Info,26 Info,
27 /// Success message (green)27 /// Success message (green)
28 Success,28 Success,
29- /// Warning message (yellow/orange)
30- #[allow(dead_code)]
31- Warning,
32- /// Error message (red)
33 Error,29 Error,
34 }30 }
35 31
@@ -54,17 +50,6 @@ impl Notification {
54 }50 }
55 }51 }
56 52
57- /// Creates a new warning notification
58- #[allow(dead_code)]
59- pub fn warning(message: impl Into<String>) -> Self {
60- Self {
61- message: message.into(),
62- notification_type: NotificationType::Warning,
63- created_at: Instant::now(),
64- duration: Duration::from_secs(5),
65- }
66- }
67-
68 /// Creates a new error notification53 /// Creates a new error notification
69 pub fn error(message: impl Into<String>) -> Self {54 pub fn error(message: impl Into<String>) -> Self {
70 Self {55 Self {
@@ -85,7 +70,6 @@ impl Notification {
85 match self.notification_type {70 match self.notification_type {
86 NotificationType::Info => Color32::from_rgb(70, 130, 180), // Steel blue71 NotificationType::Info => Color32::from_rgb(70, 130, 180), // Steel blue
87 NotificationType::Success => Color32::from_rgb(34, 139, 34), // Forest green72 NotificationType::Success => Color32::from_rgb(34, 139, 34), // Forest green
88- NotificationType::Warning => Color32::from_rgb(255, 165, 0), // Orange
89 NotificationType::Error => Color32::from_rgb(220, 53, 69), // Red73 NotificationType::Error => Color32::from_rgb(220, 53, 69), // Red
90 }74 }
91 }75 }
@@ -95,7 +79,6 @@ impl Notification {
95 match self.notification_type {79 match self.notification_type {
96 NotificationType::Info => "ℹ️",80 NotificationType::Info => "ℹ️",
97 NotificationType::Success => "✅",81 NotificationType::Success => "✅",
98- NotificationType::Warning => "⚠️",
99 NotificationType::Error => "❌",82 NotificationType::Error => "❌",
100 }83 }
101 }84 }
@@ -130,12 +113,6 @@ impl NotificationManager {
130 self.add_notification(Notification::success(message));113 self.add_notification(Notification::success(message));
131 }114 }
132 115
133- /// Adds a warning notification
134- #[allow(dead_code)]
135- pub fn warning(&mut self, message: impl Into<String>) {
136- self.add_notification(Notification::warning(message));
137- }
138-
139 /// Adds an error notification116 /// Adds an error notification
140 pub fn error(&mut self, message: impl Into<String>) {117 pub fn error(&mut self, message: impl Into<String>) {
141 self.add_notification(Notification::error(message));118 self.add_notification(Notification::error(message));
@@ -151,12 +128,6 @@ impl NotificationManager {
151 self.notifications.iter().filter(|n| n.is_visible()).collect()128 self.notifications.iter().filter(|n| n.is_visible()).collect()
152 }129 }
153 130
154- /// Returns the number of active notifications
155- #[allow(dead_code)]
156- pub fn active_count(&self) -> usize {
157- self.notifications.iter().filter(|n| n.is_visible()).count()
158- }
159-
160 /// Draws all active notifications in the UI131 /// Draws all active notifications in the UI
161 pub fn draw(&mut self, ui: &mut Ui) {132 pub fn draw(&mut self, ui: &mut Ui) {
162 self.cleanup();133 self.cleanup();
modified src/ui/search_bar.rs +2 -9
@@ -1,8 +1,9 @@
1-#![allow(dead_code)]
1+#![allow(dead_code)] // Fonctionnalité prévue dans la spec (Phase 9+), pas encore branchée dans l'UI
22 use crate::config::search::{SearchQuery, SearchType};
33 use eframe::egui;
44
55 /// Barre de recherche pour l'application
6+#[derive(Default)]
67 pub struct SearchBar {
78 /// Requête de recherche active
89 pub query: SearchQuery,
@@ -10,14 +11,6 @@ pub struct SearchBar {
1011 focus: bool,
1112 }
1213
13-impl Default for SearchBar {
14- fn default() -> Self {
15- Self {
16- query: SearchQuery::default(),
17- focus: false,
18- }
19- }
20-}
2114
2215 impl SearchBar {
2316 /// Créer une nouvelle barre de recherche
@@ -1,8 +1,9 @@
1-#![allow(dead_code)]1+#![allow(dead_code)] // Fonctionnalité prévue dans la spec (Phase 9+), pas encore branchée dans l'UI
2 use crate::config::search::{SearchQuery, SearchType};2 use crate::config::search::{SearchQuery, SearchType};
3 use eframe::egui;3 use eframe::egui;
4 4
5 /// Barre de recherche pour l'application5 /// Barre de recherche pour l'application
6+#[derive(Default)]
6 pub struct SearchBar {7 pub struct SearchBar {
7 /// Requête de recherche active8 /// Requête de recherche active
8 pub query: SearchQuery,9 pub query: SearchQuery,
@@ -10,14 +11,6 @@ pub struct SearchBar {
10 focus: bool,11 focus: bool,
11 }12 }
12 13
13-impl Default for SearchBar {
14- fn default() -> Self {
15- Self {
16- query: SearchQuery::default(),
17- focus: false,
18- }
19- }
20-}
21 14
22 impl SearchBar {15 impl SearchBar {
23 /// Créer une nouvelle barre de recherche16 /// Créer une nouvelle barre de recherche
modified src/ui/stats_panel.rs +6 -10
@@ -1,18 +1,14 @@
1-#![allow(dead_code)]
1+#![allow(dead_code)] // Fonctionnalité prévue dans la spec (Phase 9+), pas encore branchée dans l'UI
22 use crate::config::statistics::{AppStatistics, format_bytes};
33 use eframe::egui;
44
55 /// Panneau d'affichage des statistiques
6+#[derive(Default)]
67 pub struct StatsPanel {
78 /// Visibilité du panneau
89 visible: bool,
910 }
1011
11-impl Default for StatsPanel {
12- fn default() -> Self {
13- Self { visible: false }
14- }
15-}
1612
1713 impl StatsPanel {
1814 /// Créer un nouveau panneau de statistiques
@@ -88,7 +84,7 @@ impl StatsPanel {
8884 ui.end_row();
8985
9086 ui.label("Taille totale des téléchargements:");
91- ui.label(format!("{}", stats.get_formatted_total_size()));
87+ ui.label(stats.get_formatted_total_size().to_string());
9288 ui.end_row();
9389
9490 ui.label("Pourcentage téléchargé:");
@@ -96,7 +92,7 @@ impl StatsPanel {
9692 ui.end_row();
9793
9894 ui.label("Taille moyenne par fichier:");
99- ui.label(format!("{}", format_bytes(stats.get_average_file_size())));
95+ ui.label(format_bytes(stats.get_average_file_size()).to_string());
10096 ui.end_row();
10197
10298 ui.label("Nombre de synchronisations:");
@@ -150,13 +146,13 @@ impl StatsPanel {
150146
151147 // Lignes de données
152148 let mut feed_stats: Vec<_> = stats.feed_statistics.values().collect();
153- feed_stats.sort_by(|a, b| b.total_episodes.cmp(&a.total_episodes));
149+ feed_stats.sort_by_key(|s| std::cmp::Reverse(s.total_episodes));
154150
155151 for feed_stat in feed_stats {
156152 ui.label(&feed_stat.feed_name);
157153 ui.label(format!("{}", feed_stat.total_episodes));
158154 ui.label(format!("{}", feed_stat.downloaded_episodes));
159- ui.label(format!("{}", feed_stat.get_formatted_size()));
155+ ui.label(feed_stat.get_formatted_size().to_string());
160156 ui.label(format!("{:.1}%", feed_stat.get_download_percentage()));
161157 ui.end_row();
162158 }
@@ -1,18 +1,14 @@
1-#![allow(dead_code)]1+#![allow(dead_code)] // Fonctionnalité prévue dans la spec (Phase 9+), pas encore branchée dans l'UI
2 use crate::config::statistics::{AppStatistics, format_bytes};2 use crate::config::statistics::{AppStatistics, format_bytes};
3 use eframe::egui;3 use eframe::egui;
4 4
5 /// Panneau d'affichage des statistiques5 /// Panneau d'affichage des statistiques
6+#[derive(Default)]
6 pub struct StatsPanel {7 pub struct StatsPanel {
7 /// Visibilité du panneau8 /// Visibilité du panneau
8 visible: bool,9 visible: bool,
9 }10 }
10 11
11-impl Default for StatsPanel {
12- fn default() -> Self {
13- Self { visible: false }
14- }
15-}
16 12
17 impl StatsPanel {13 impl StatsPanel {
18 /// Créer un nouveau panneau de statistiques14 /// Créer un nouveau panneau de statistiques
@@ -88,7 +84,7 @@ impl StatsPanel {
88 ui.end_row();84 ui.end_row();
89 85
90 ui.label("Taille totale des téléchargements:");86 ui.label("Taille totale des téléchargements:");
91- ui.label(format!("{}", stats.get_formatted_total_size()));87+ ui.label(stats.get_formatted_total_size().to_string());
92 ui.end_row();88 ui.end_row();
93 89
94 ui.label("Pourcentage téléchargé:");90 ui.label("Pourcentage téléchargé:");
@@ -96,7 +92,7 @@ impl StatsPanel {
96 ui.end_row();92 ui.end_row();
97 93
98 ui.label("Taille moyenne par fichier:");94 ui.label("Taille moyenne par fichier:");
99- ui.label(format!("{}", format_bytes(stats.get_average_file_size())));95+ ui.label(format_bytes(stats.get_average_file_size()).to_string());
100 ui.end_row();96 ui.end_row();
101 97
102 ui.label("Nombre de synchronisations:");98 ui.label("Nombre de synchronisations:");
@@ -150,13 +146,13 @@ impl StatsPanel {
150 146
151 // Lignes de données147 // Lignes de données
152 let mut feed_stats: Vec<_> = stats.feed_statistics.values().collect();148 let mut feed_stats: Vec<_> = stats.feed_statistics.values().collect();
153- feed_stats.sort_by(|a, b| b.total_episodes.cmp(&a.total_episodes));149+ feed_stats.sort_by_key(|s| std::cmp::Reverse(s.total_episodes));
154 150
155 for feed_stat in feed_stats {151 for feed_stat in feed_stats {
156 ui.label(&feed_stat.feed_name);152 ui.label(&feed_stat.feed_name);
157 ui.label(format!("{}", feed_stat.total_episodes));153 ui.label(format!("{}", feed_stat.total_episodes));
158 ui.label(format!("{}", feed_stat.downloaded_episodes));154 ui.label(format!("{}", feed_stat.downloaded_episodes));
159- ui.label(format!("{}", feed_stat.get_formatted_size()));155+ ui.label(feed_stat.get_formatted_size().to_string());
160 ui.label(format!("{:.1}%", feed_stat.get_download_percentage()));156 ui.label(format!("{:.1}%", feed_stat.get_download_percentage()));
161 ui.end_row();157 ui.end_row();
162 }158 }
modified src/ui/status_bar.rs +0 -26
@@ -31,32 +31,6 @@ pub fn draw_status_bar(state: &mut AppState, ui: &mut Ui) {
3131 .show(ui, |ui| {
3232 ui.spacing_mut().item_spacing.x = 16.0;
3333
34- // Error message (highest priority) avec icône
35- if let Some(ref error) = state.error_message {
36- let error_clone = error.clone();
37- ui.horizontal(|ui| {
38- ui.label(egui::RichText::new("❌ ").color(egui::Color32::RED));
39- ui.colored_label(egui::Color32::from_rgb(240, 100, 100), &error_clone);
40- if ui.button(egui::RichText::new("✕").size(14.0)).clicked() {
41- state.clear_error();
42- }
43- });
44- return;
45- }
46-
47- // Info message avec icône
48- if let Some(ref info) = state.info_message {
49- let info_clone = info.clone();
50- ui.horizontal(|ui| {
51- ui.label(egui::RichText::new("ℹ️ ").color(egui::Color32::from_rgb(100, 200, 100)));
52- ui.colored_label(egui::Color32::from_rgb(100, 220, 100), &info_clone);
53- if ui.button(egui::RichText::new("✕").size(14.0)).clicked() {
54- state.clear_info();
55- }
56- });
57- return;
58- }
59-
6034 // Sync status avec icône
6135 // Note: Sync messages are processed in home.rs before drawing
6236 let feed_count = state.feed_count();
@@ -31,32 +31,6 @@ pub fn draw_status_bar(state: &mut AppState, ui: &mut Ui) {
31 .show(ui, |ui| {31 .show(ui, |ui| {
32 ui.spacing_mut().item_spacing.x = 16.0;32 ui.spacing_mut().item_spacing.x = 16.0;
33 33
34- // Error message (highest priority) avec icône
35- if let Some(ref error) = state.error_message {
36- let error_clone = error.clone();
37- ui.horizontal(|ui| {
38- ui.label(egui::RichText::new("❌ ").color(egui::Color32::RED));
39- ui.colored_label(egui::Color32::from_rgb(240, 100, 100), &error_clone);
40- if ui.button(egui::RichText::new("✕").size(14.0)).clicked() {
41- state.clear_error();
42- }
43- });
44- return;
45- }
46-
47- // Info message avec icône
48- if let Some(ref info) = state.info_message {
49- let info_clone = info.clone();
50- ui.horizontal(|ui| {
51- ui.label(egui::RichText::new("ℹ️ ").color(egui::Color32::from_rgb(100, 200, 100)));
52- ui.colored_label(egui::Color32::from_rgb(100, 220, 100), &info_clone);
53- if ui.button(egui::RichText::new("✕").size(14.0)).clicked() {
54- state.clear_info();
55- }
56- });
57- return;
58- }
59-
60 // Sync status avec icône34 // Sync status avec icône
61 // Note: Sync messages are processed in home.rs before drawing35 // Note: Sync messages are processed in home.rs before drawing
62 let feed_count = state.feed_count();36 let feed_count = state.feed_count();
modified src/ui/sync_status.rs +14 -5
@@ -59,11 +59,15 @@ pub fn draw_sync_indicator(state: &mut AppState, ui: &mut Ui) {
5959 let global_sync_in_progress = status.global_sync_in_progress;
6060 let syncing_count = status.syncing_feed_count();
6161 let completed_count = status.feed_sync_status.values()
62- .filter(|s| matches!(s, crate::config::sync_task::FeedSyncStatus::Completed { .. }))
63- .count();
64- let failed_count = status.feed_sync_status.values()
65- .filter(|s| matches!(s, crate::config::sync_task::FeedSyncStatus::Failed { .. }))
62+ .filter(|s| matches!(s, crate::config::sync_task::FeedSyncStatus::Completed))
6663 .count();
64+ let failed_errors: Vec<String> = status.feed_sync_status.values()
65+ .filter_map(|s| match s {
66+ crate::config::sync_task::FeedSyncStatus::Failed { error } => Some(error.clone()),
67+ _ => None,
68+ })
69+ .collect();
70+ let failed_count = failed_errors.len();
6771 let total_feeds = status.total_feeds;
6872 let completed_feeds = status.completed_feeds;
6973 let overall_progress = status.get_overall_progress();
@@ -92,11 +96,16 @@ pub fn draw_sync_indicator(state: &mut AppState, ui: &mut Ui) {
9296 // Show completion status briefly
9397 ui.label(RichText::new("✅").size(16.0).color(Color32::from_rgb(80, 220, 100)));
9498 let total_completed = completed_count + failed_count;
95- ui.label(RichText::new(format!("Synchro terminée: {} flux ({} OK, {} erreur)",
99+ let completion_label = ui.label(RichText::new(format!("Synchro terminée: {} flux ({} OK, {} erreur)",
96100 total_completed, completed_count, failed_count))
97101 .size(13.0)
98102 .color(Color32::from_rgb(150, 220, 150)));
99103
104+ // Show the failure details on hover
105+ if !failed_errors.is_empty() {
106+ completion_label.on_hover_text(failed_errors.join("\n"));
107+ }
108+
100109 // Add a button to clear completed sync status
101110 if ui.button(RichText::new("✕").size(12.0)).clicked() {
102111 state.sync_task_manager.clear_completed_sync_status();
@@ -59,11 +59,15 @@ pub fn draw_sync_indicator(state: &mut AppState, ui: &mut Ui) {
59 let global_sync_in_progress = status.global_sync_in_progress;59 let global_sync_in_progress = status.global_sync_in_progress;
60 let syncing_count = status.syncing_feed_count();60 let syncing_count = status.syncing_feed_count();
61 let completed_count = status.feed_sync_status.values()61 let completed_count = status.feed_sync_status.values()
62- .filter(|s| matches!(s, crate::config::sync_task::FeedSyncStatus::Completed { .. }))62+ .filter(|s| matches!(s, crate::config::sync_task::FeedSyncStatus::Completed))
63- .count();
64- let failed_count = status.feed_sync_status.values()
65- .filter(|s| matches!(s, crate::config::sync_task::FeedSyncStatus::Failed { .. }))
66 .count();63 .count();
64+ let failed_errors: Vec<String> = status.feed_sync_status.values()
65+ .filter_map(|s| match s {
66+ crate::config::sync_task::FeedSyncStatus::Failed { error } => Some(error.clone()),
67+ _ => None,
68+ })
69+ .collect();
70+ let failed_count = failed_errors.len();
67 let total_feeds = status.total_feeds;71 let total_feeds = status.total_feeds;
68 let completed_feeds = status.completed_feeds;72 let completed_feeds = status.completed_feeds;
69 let overall_progress = status.get_overall_progress();73 let overall_progress = status.get_overall_progress();
@@ -92,11 +96,16 @@ pub fn draw_sync_indicator(state: &mut AppState, ui: &mut Ui) {
92 // Show completion status briefly96 // Show completion status briefly
93 ui.label(RichText::new("✅").size(16.0).color(Color32::from_rgb(80, 220, 100)));97 ui.label(RichText::new("✅").size(16.0).color(Color32::from_rgb(80, 220, 100)));
94 let total_completed = completed_count + failed_count;98 let total_completed = completed_count + failed_count;
95- ui.label(RichText::new(format!("Synchro terminée: {} flux ({} OK, {} erreur)", 99+ let completion_label = ui.label(RichText::new(format!("Synchro terminée: {} flux ({} OK, {} erreur)",
96 total_completed, completed_count, failed_count))100 total_completed, completed_count, failed_count))
97 .size(13.0)101 .size(13.0)
98 .color(Color32::from_rgb(150, 220, 150)));102 .color(Color32::from_rgb(150, 220, 150)));
99 103
104+ // Show the failure details on hover
105+ if !failed_errors.is_empty() {
106+ completion_label.on_hover_text(failed_errors.join("\n"));
107+ }
108+
100 // Add a button to clear completed sync status109 // Add a button to clear completed sync status
101 if ui.button(RichText::new("✕").size(12.0)).clicked() {110 if ui.button(RichText::new("✕").size(12.0)).clicked() {
102 state.sync_task_manager.clear_completed_sync_status();111 state.sync_task_manager.clear_completed_sync_status();