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)
2e074e1 parent: 90dae95 modified
src/config/app_state.rs +14 -309 | @@ -11,23 +11,18 @@ use crate::config::sync_task::SyncTaskManager; | ||
| 11 | 11 | use crate::ui::notifications::NotificationManager; |
| 12 | 12 | use chrono::{DateTime, Utc}; |
| 13 | 13 | use serde::{Deserialize, Serialize}; |
| 14 | -use std::collections::HashMap; | |
| 15 | -use std::path::PathBuf; | |
| 16 | 14 | |
| 17 | 15 | /// Theme options for the application |
| 18 | 16 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 17 | +#[derive(Default)] | |
| 19 | 18 | pub enum Theme { |
| 20 | 19 | /// Dark theme (default) |
| 20 | + #[default] | |
| 21 | 21 | Dark, |
| 22 | 22 | /// Light theme |
| 23 | 23 | Light, |
| 24 | 24 | } |
| 25 | 25 | |
| 26 | -impl Default for Theme { | |
| 27 | - fn default() -> Self { | |
| 28 | - Theme::Dark | |
| 29 | - } | |
| 30 | -} | |
| 31 | 26 | |
| 32 | 27 | impl Theme { |
| 33 | 28 | /// Toggles between light and dark theme |
| @@ -37,15 +32,6 @@ impl Theme { | ||
| 37 | 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 | 37 | /// Main application state |
| @@ -80,41 +66,19 @@ pub struct AppState { | ||
| 80 | 66 | /// Index of the feed being edited (if any) |
| 81 | 67 | pub editing_feed_index: Option<usize>, |
| 82 | 68 | /// Whether the add playlist dialog is visible |
| 83 | - #[allow(dead_code)] | |
| 84 | 69 | pub show_add_playlist_dialog: bool, |
| 85 | 70 | /// Temporary storage for add playlist name |
| 86 | - #[allow(dead_code)] | |
| 87 | 71 | pub add_playlist_name: String, |
| 88 | 72 | /// Temporary storage for add playlist description |
| 89 | - #[allow(dead_code)] | |
| 90 | 73 | pub add_playlist_description: String, |
| 91 | 74 | /// Whether the edit playlist dialog is visible |
| 92 | - #[allow(dead_code)] | |
| 93 | 75 | pub show_edit_playlist_dialog: bool, |
| 94 | 76 | /// Temporary storage for edit playlist name |
| 95 | - #[allow(dead_code)] | |
| 96 | 77 | pub edit_playlist_name: String, |
| 97 | 78 | /// Temporary storage for edit playlist description |
| 98 | - #[allow(dead_code)] | |
| 99 | 79 | pub edit_playlist_description: String, |
| 100 | 80 | /// Index of the playlist being edited (if any) |
| 101 | - #[allow(dead_code)] | |
| 102 | 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 | 82 | /// Manager for displaying notifications to the user |
| 119 | 83 | pub notification_manager: NotificationManager, |
| 120 | 84 | /// List of playlists for grouping feeds |
| @@ -123,49 +87,37 @@ pub struct AppState { | ||
| 123 | 87 | pub theme: Theme, |
| 124 | 88 | // Filter state |
| 125 | 89 | /// Filter: date from (inclusive) |
| 126 | - #[allow(dead_code)] | |
| 127 | 90 | pub filter_date_from: Option<DateTime<Utc>>, |
| 128 | 91 | /// Filter: date to (inclusive) |
| 129 | - #[allow(dead_code)] | |
| 130 | 92 | pub filter_date_to: Option<DateTime<Utc>>, |
| 131 | 93 | /// Filter: episode number |
| 132 | - #[allow(dead_code)] | |
| 133 | 94 | pub filter_episode_number: String, |
| 134 | 95 | /// Whether to show the date picker for 'from' date |
| 135 | - #[allow(dead_code)] | |
| 136 | 96 | pub show_date_picker_from: bool, |
| 137 | 97 | /// Whether to show the date picker for 'to' date |
| 138 | - #[allow(dead_code)] | |
| 139 | 98 | pub show_date_picker_to: bool, |
| 140 | 99 | /// Whether the export dialog is visible |
| 141 | - #[allow(dead_code)] | |
| 142 | 100 | pub show_export_dialog: bool, |
| 143 | 101 | /// Whether the import dialog is visible |
| 144 | - #[allow(dead_code)] | |
| 145 | 102 | pub show_import_dialog: bool, |
| 146 | 103 | /// Last export/import error message |
| 147 | - #[allow(dead_code)] | |
| 148 | 104 | pub export_import_error: Option<String>, |
| 149 | 105 | // Phase 9: Search functionality |
| 150 | 106 | /// Current search query |
| 107 | + /// Not yet wired into the UI (see ui/search_bar.rs) | |
| 151 | 108 | #[allow(dead_code)] |
| 152 | 109 | pub search_query: SearchQuery, |
| 153 | 110 | /// Whether search panel is visible |
| 154 | 111 | #[allow(dead_code)] |
| 155 | 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 | 113 | // Phase 9: Statistics |
| 164 | 114 | /// Application statistics |
| 115 | + /// Not yet wired into the UI (see ui/stats_panel.rs) | |
| 165 | 116 | #[allow(dead_code)] |
| 166 | 117 | pub statistics: AppStatistics, |
| 167 | 118 | // Phase 9: History |
| 168 | 119 | /// History manager for tracking operations |
| 120 | + /// Not yet wired into the UI (see ui/history_panel.rs) | |
| 169 | 121 | #[allow(dead_code)] |
| 170 | 122 | pub history_manager: HistoryManager, |
| 171 | 123 | /// Manager for async synchronization tasks |
| @@ -173,8 +125,8 @@ pub struct AppState { | ||
| 173 | 125 | } |
| 174 | 126 | |
| 175 | 127 | 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 { | |
| 178 | 130 | Self { |
| 179 | 131 | feeds: Vec::new(), |
| 180 | 132 | current_feed: None, |
| @@ -196,12 +148,6 @@ impl AppState { | ||
| 196 | 148 | edit_playlist_name: String::new(), |
| 197 | 149 | edit_playlist_description: String::new(), |
| 198 | 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 | 151 | notification_manager: NotificationManager::new(), |
| 206 | 152 | playlists: Vec::new(), |
| 207 | 153 | filter_date_from: None, |
| @@ -215,8 +161,6 @@ impl AppState { | ||
| 215 | 161 | // Phase 9 fields |
| 216 | 162 | search_query: SearchQuery::default(), |
| 217 | 163 | show_search: false, |
| 218 | - sort_field: "name".to_string(), | |
| 219 | - sort_direction: "asc".to_string(), | |
| 220 | 164 | statistics: AppStatistics::new(), |
| 221 | 165 | history_manager: HistoryManager::new(), |
| 222 | 166 | theme: Theme::default(), |
| @@ -226,26 +170,7 @@ impl AppState { | ||
| 226 | 170 | |
| 227 | 171 | /// Creates a new AppState with default configuration path |
| 228 | 172 | 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() | |
| 249 | 174 | } |
| 250 | 175 | |
| 251 | 176 | /// Adds a new feed to the application state |
| @@ -254,79 +179,17 @@ impl AppState { | ||
| 254 | 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 | 182 | /// Selects a feed by index |
| 290 | 183 | pub fn select_feed(&mut self, index: Option<usize>) { |
| 291 | 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 | 187 | /// Returns the number of feeds |
| 324 | 188 | pub fn feed_count(&self) -> usize { |
| 325 | 189 | self.feeds.len() |
| 326 | 190 | } |
| 327 | 191 | |
| 328 | 192 | /// Returns the number of enabled feeds |
| 329 | - #[allow(dead_code)] | |
| 330 | 193 | pub fn enabled_feed_count(&self) -> usize { |
| 331 | 194 | self.feeds.iter().filter(|f| f.enabled).count() |
| 332 | 195 | } |
| @@ -336,30 +199,6 @@ impl AppState { | ||
| 336 | 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 | 202 | /// Clears the new feed form fields |
| 364 | 203 | pub fn clear_new_feed_form(&mut self) { |
| 365 | 204 | self.new_feed_name.clear(); |
| @@ -391,14 +230,12 @@ impl AppState { | ||
| 391 | 230 | // Playlist methods |
| 392 | 231 | |
| 393 | 232 | /// Adds a new playlist |
| 394 | - #[allow(dead_code)] | |
| 395 | 233 | pub fn add_playlist(&mut self, playlist: Playlist) { |
| 396 | 234 | self.playlists.push(playlist); |
| 397 | 235 | self.notification_manager.success("Playlist ajoutée"); |
| 398 | 236 | } |
| 399 | 237 | |
| 400 | 238 | /// Removes a playlist by index |
| 401 | - #[allow(dead_code)] | |
| 402 | 239 | pub fn remove_playlist(&mut self, index: usize) -> Option<Playlist> { |
| 403 | 240 | if index < self.playlists.len() { |
| 404 | 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 | 249 | /// Clears the add playlist form fields |
| 464 | - #[allow(dead_code)] | |
| 465 | 250 | pub fn clear_add_playlist_form(&mut self) { |
| 466 | 251 | self.add_playlist_name.clear(); |
| 467 | 252 | self.add_playlist_description.clear(); |
| 468 | 253 | } |
| 469 | 254 | |
| 470 | 255 | /// Clears the edit playlist form fields |
| 471 | - #[allow(dead_code)] | |
| 472 | 256 | pub fn clear_edit_playlist_form(&mut self) { |
| 473 | 257 | self.edit_playlist_name.clear(); |
| 474 | 258 | self.edit_playlist_description.clear(); |
| 475 | 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 | 262 | /// Clears all filter criteria |
| 488 | - #[allow(dead_code)] | |
| 489 | 263 | pub fn clear_filters(&mut self) { |
| 490 | 264 | self.filter_date_from = None; |
| 491 | 265 | self.filter_date_to = None; |
| @@ -494,7 +268,6 @@ impl AppState { | ||
| 494 | 268 | } |
| 495 | 269 | |
| 496 | 270 | /// Sets an export/import error message |
| 497 | - #[allow(dead_code)] | |
| 498 | 271 | pub fn set_export_import_error(&mut self, message: impl Into<String>) { |
| 499 | 272 | let msg: String = message.into(); |
| 500 | 273 | self.export_import_error = Some(msg.clone()); |
| @@ -502,7 +275,6 @@ impl AppState { | ||
| 502 | 275 | } |
| 503 | 276 | |
| 504 | 277 | /// Clears the export/import error message |
| 505 | - #[allow(dead_code)] | |
| 506 | 278 | pub fn clear_export_import_error(&mut self) { |
| 507 | 279 | self.export_import_error = None; |
| 508 | 280 | } |
| @@ -517,12 +289,6 @@ impl AppState { | ||
| 517 | 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 | 292 | /// Gets the current theme |
| 527 | 293 | pub fn get_theme(&self) -> Theme { |
| 528 | 294 | self.theme |
| @@ -536,22 +302,14 @@ mod tests { | ||
| 536 | 302 | |
| 537 | 303 | #[test] |
| 538 | 304 | fn test_app_state_new() { |
| 539 | - let app_state = AppState::new("test/config.json"); | |
| 305 | + let app_state = AppState::new(); | |
| 540 | 306 | assert!(app_state.feeds.is_empty()); |
| 541 | 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 | 310 | #[test] |
| 553 | 311 | fn test_add_feed() { |
| 554 | - let mut app_state = AppState::new("test/config.json"); | |
| 312 | + let mut app_state = AppState::new(); | |
| 555 | 313 | let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads"); |
| 556 | 314 | |
| 557 | 315 | app_state.add_feed(feed); |
| @@ -560,56 +318,21 @@ mod tests { | ||
| 560 | 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 | 321 | #[test] |
| 580 | 322 | fn test_select_feed() { |
| 581 | - let mut app_state = AppState::new("test/config.json"); | |
| 323 | + let mut app_state = AppState::new(); | |
| 582 | 324 | let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads"); |
| 583 | 325 | |
| 584 | 326 | app_state.add_feed(feed); |
| 585 | 327 | app_state.select_feed(Some(0)); |
| 586 | 328 | |
| 587 | 329 | 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"); | |
| 608 | 331 | } |
| 609 | 332 | |
| 610 | 333 | #[test] |
| 611 | 334 | fn test_feed_count() { |
| 612 | - let mut app_state = AppState::new("test/config.json"); | |
| 335 | + let mut app_state = AppState::new(); | |
| 613 | 336 | assert_eq!(app_state.feed_count(), 0); |
| 614 | 337 | |
| 615 | 338 | app_state.add_feed(Feed::new("url1", "Podcast 1", "path1")); |
| @@ -621,7 +344,7 @@ mod tests { | ||
| 621 | 344 | |
| 622 | 345 | #[test] |
| 623 | 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 | 349 | app_state.add_feed(Feed::new("url1", "Podcast 1", "path1")); // enabled |
| 627 | 350 | app_state.add_feed(Feed::new("url2", "Podcast 2", "path2")); // enabled |
| @@ -631,22 +354,4 @@ mod tests { | ||
| 631 | 354 | |
| 632 | 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 | } |
| @@ -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 application | 15 | /// 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 theme | 22 | /// 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 theme | 28 | /// 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 state | 37 | /// 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 visible | 68 | /// 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 name | 70 | /// 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 description | 72 | /// 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 visible | 74 | /// 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 name | 76 | /// 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 description | 78 | /// 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 user | 82 | /// Manager for displaying notifications to the user |
| 119 | pub notification_manager: NotificationManager, | 83 | pub notification_manager: NotificationManager, |
| 120 | /// List of playlists for grouping feeds | 84 | /// List of playlists for grouping feeds |
| @@ -123,49 +87,37 @@ pub struct AppState { | |||
| 123 | pub theme: Theme, | 87 | pub theme: Theme, |
| 124 | // Filter state | 88 | // 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 number | 93 | /// 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' date | 95 | /// 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' date | 97 | /// 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 visible | 99 | /// 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 visible | 101 | /// 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 message | 103 | /// 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 functionality | 105 | // Phase 9: Search functionality |
| 150 | /// Current search query | 106 | /// 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 visible | 110 | /// 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: Statistics | 113 | // Phase 9: Statistics |
| 164 | /// Application statistics | 114 | /// 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: History | 118 | // Phase 9: History |
| 168 | /// History manager for tracking operations | 119 | /// 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 tasks | 123 | /// 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 path | 128 | + /// 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 fields | 161 | // 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 path | 171 | /// 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 state | 176 | /// 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 index | 182 | /// 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 feeds | 187 | /// 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 feeds | 192 | /// 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 fields | 202 | /// 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 methods | 230 | // Playlist methods |
| 392 | 231 | ||
| 393 | /// Adds a new playlist | 232 | /// 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 index | 238 | /// 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 fields | 249 | /// 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 fields | 255 | /// 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 criteria | 262 | /// 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 message | 270 | /// 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 message | 277 | /// 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 theme | 292 | /// Gets the current theme |
| 527 | pub fn get_theme(&self) -> Theme { | 293 | pub fn get_theme(&self) -> Theme { |
| 528 | self.theme | 294 | 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")); // enabled | 349 | app_state.add_feed(Feed::new("url1", "Podcast 1", "path1")); // enabled |
| 627 | app_state.add_feed(Feed::new("url2", "Podcast 2", "path2")); // enabled | 350 | 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 | |
| 2 | 2 | use crate::config::{feed::Feed, storage::{self, StorageError}}; |
| 3 | 3 | use chrono::{DateTime, Utc}; |
| 4 | 4 | use serde::{Deserialize, Serialize}; |
| @@ -186,7 +186,7 @@ impl BackupManager { | ||
| 186 | 186 | } |
| 187 | 187 | |
| 188 | 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 | 191 | Ok(backups) |
| 192 | 192 | } |
| @@ -272,7 +272,7 @@ mod tests { | ||
| 272 | 272 | #[test] |
| 273 | 273 | fn test_backup_manager_creation() { |
| 274 | 274 | 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 | |
| 276 | 276 | } |
| 277 | 277 | |
| 278 | 278 | #[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 pas | 275 | + 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 { | ||
| 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 | 90 | /// Returns the full path where the episode should be saved |
| 116 | 91 | pub fn get_save_path(&self, download_dir: &Path) -> PathBuf { |
| 117 | 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 | 96 | #[cfg(test)] |
| @@ -241,54 +148,4 @@ mod tests { | ||
| 241 | 148 | let save_path = episode.get_save_path(&download_dir); |
| 242 | 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 | } |
| @@ -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 saved | 90 | /// 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 { | ||
| 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 | 73 | /// Returns the tags as a comma-separated string |
| 134 | - #[allow(dead_code)] | |
| 135 | 74 | pub fn tags_string(&self) -> String { |
| 136 | 75 | self.tags.join(", ") |
| 137 | 76 | } |
| @@ -158,37 +97,4 @@ mod tests { | ||
| 158 | 97 | assert_eq!(feed.download_path, PathBuf::from("/downloads")); |
| 159 | 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 | } |
| @@ -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 string | 73 | /// 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 | |
| 2 | 2 | use chrono::{DateTime, Utc}; |
| 3 | 3 | use serde::{Deserialize, Serialize}; |
| 4 | 4 | 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; | ||
| 13 | 13 | pub mod storage; |
| 14 | 14 | pub mod sync_task; |
| 15 | 15 | |
| 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 | |
| 21 | 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 | 18 | pub use episode::Episode; |
| 28 | -#[allow(dead_code)] | |
| 29 | -#[allow(unused_imports)] | |
| 30 | 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}; | |
| @@ -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 convenience | 16 | +// 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 { | ||
| 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 | 43 | /// Returns the number of feeds in the playlist |
| 73 | 44 | pub fn feed_count(&self) -> usize { |
| 74 | 45 | self.feed_ids.len() |
| @@ -105,39 +76,10 @@ mod tests { | ||
| 105 | 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 | 79 | #[test] |
| 139 | 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 | 83 | assert_eq!(playlist.feed_count(), 3); |
| 142 | 84 | } |
| 143 | 85 | |
| @@ -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 playlist | 43 | /// 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 | |
| 2 | 2 | use crate::config::{episode::Episode, feed::Feed}; |
| 3 | 3 | |
| 4 | 4 | /// Type de recherche possible |
| 5 | 5 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 6 | +#[derive(Default)] | |
| 6 | 7 | pub enum SearchType { |
| 7 | 8 | /// Recherche dans les noms de flux |
| 8 | 9 | FeedName, |
| @@ -15,14 +16,10 @@ pub enum SearchType { | ||
| 15 | 16 | /// Recherche dans les descriptions d'épisodes |
| 16 | 17 | EpisodeDescription, |
| 17 | 18 | /// Recherche dans tous les champs |
| 19 | + #[default] | |
| 18 | 20 | All, |
| 19 | 21 | } |
| 20 | 22 | |
| 21 | -impl Default for SearchType { | |
| 22 | - fn default() -> Self { | |
| 23 | - SearchType::All | |
| 24 | - } | |
| 25 | -} | |
| 26 | 23 | |
| 27 | 24 | /// Requête de recherche |
| 28 | 25 | #[derive(Debug, Clone, Default)] |
| @@ -238,8 +235,8 @@ pub fn search_feeds( | ||
| 238 | 235 | } |
| 239 | 236 | |
| 240 | 237 | // 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)); | |
| 243 | 240 | |
| 244 | 241 | result |
| 245 | 242 | } |
| @@ -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 possible | 4 | /// 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 flux | 8 | /// Recherche dans les noms de flux |
| 8 | FeedName, | 9 | FeedName, |
| @@ -15,14 +16,10 @@ pub enum SearchType { | |||
| 15 | /// Recherche dans les descriptions d'épisodes | 16 | /// Recherche dans les descriptions d'épisodes |
| 16 | EpisodeDescription, | 17 | EpisodeDescription, |
| 17 | /// Recherche dans tous les champs | 18 | /// 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 recherche | 24 | /// 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 | result | 241 | 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 | |
| 2 | 2 | use crate::config::{episode::Episode, feed::Feed}; |
| 3 | 3 | use chrono::{DateTime, Utc}; |
| 4 | 4 | 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 @@ | ||
| 5 | 5 | use crate::config::feed::Feed; |
| 6 | 6 | use crate::config::playlist::Playlist; |
| 7 | 7 | use log::{info, warn}; |
| 8 | -#[allow(unused_imports)] | |
| 9 | -use serde::{Deserialize, Serialize}; | |
| 10 | 8 | use std::fs; |
| 11 | 9 | use std::path::Path; |
| 12 | 10 | use thiserror::Error; |
| @@ -30,11 +28,6 @@ pub enum StorageError { | ||
| 30 | 28 | #[error("Failed to serialize configuration: {0}")] |
| 31 | 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 | 31 | /// Invalid configuration directory |
| 39 | 32 | #[error("Invalid configuration directory: {0}")] |
| 40 | 33 | InvalidDirectory(String), |
| @@ -210,33 +203,22 @@ pub fn load_playlists(path: &Path) -> Result<Vec<Playlist>, StorageError> { | ||
| 210 | 203 | } |
| 211 | 204 | |
| 212 | 205 | /// Saves playlists to the default configuration path |
| 213 | -#[allow(dead_code)] | |
| 214 | 206 | pub fn save_playlists_default(playlists: &[Playlist]) -> Result<(), StorageError> { |
| 215 | 207 | save_playlists(playlists, &get_playlists_path()) |
| 216 | 208 | } |
| 217 | 209 | |
| 218 | 210 | /// Loads playlists from the default configuration path |
| 219 | -#[allow(dead_code)] | |
| 220 | 211 | pub fn load_playlists_default() -> Result<Vec<Playlist>, StorageError> { |
| 221 | 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 | 215 | /// Imports feeds from a custom JSON file (user-selected path) |
| 232 | 216 | /// This is the same as load_feeds but with import semantics |
| 233 | -#[allow(dead_code)] | |
| 234 | 217 | pub fn import_feeds(path: &Path) -> Result<Vec<Feed>, StorageError> { |
| 235 | 218 | load_feeds(path) |
| 236 | 219 | } |
| 237 | 220 | |
| 238 | 221 | /// Exports feeds to a custom JSON file with a pretty format suitable for sharing |
| 239 | -#[allow(dead_code)] | |
| 240 | 222 | pub fn export_feeds_pretty(feeds: &[Feed], path: &Path) -> Result<(), StorageError> { |
| 241 | 223 | info!("Exporting {} feeds to {}", feeds.len(), path.display()); |
| 242 | 224 | |
| @@ -331,6 +313,6 @@ mod tests { | ||
| 331 | 313 | fn test_get_config_path() { |
| 332 | 314 | let path = get_config_path(); |
| 333 | 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 | } |
| @@ -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 directory | 31 | /// 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 path | 205 | /// 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 path | 210 | /// 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 semantics | 216 | /// 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 sharing | 221 | /// 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 { | ||
| 13 | 13 | /// Sync started for a feed |
| 14 | 14 | SyncStarted { |
| 15 | 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 | 17 | /// Sync completed successfully |
| 25 | 18 | SyncCompleted { |
| 26 | 19 | feed_id: String, |
| 27 | - feed_name: String, | |
| 28 | - episodes_found: usize, | |
| 29 | - episodes_downloaded: usize, | |
| 30 | 20 | /// Updated download statistics for the feed |
| 31 | 21 | downloaded_episodes_count: usize, |
| 32 | 22 | last_downloaded_episode_name: Option<String>, |
| @@ -37,25 +27,8 @@ pub enum SyncMessage { | ||
| 37 | 27 | /// Sync failed with an error |
| 38 | 28 | SyncFailed { |
| 39 | 29 | feed_id: String, |
| 40 | - feed_name: String, | |
| 41 | 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 | 32 | /// Individual episode download failed |
| 60 | 33 | EpisodeDownloadFailed { |
| 61 | 34 | feed_id: String, |
| @@ -65,57 +38,21 @@ pub enum SyncMessage { | ||
| 65 | 38 | } |
| 66 | 39 | |
| 67 | 40 | /// Status of a feed synchronization |
| 68 | -#[derive(Debug, Clone)] | |
| 41 | +#[derive(Debug, Clone, Default)] | |
| 69 | 42 | pub enum FeedSyncStatus { |
| 70 | 43 | /// Sync not started or completed |
| 44 | + #[default] | |
| 71 | 45 | Idle, |
| 72 | 46 | /// Sync in progress |
| 73 | - InProgress { | |
| 74 | - progress: f32, | |
| 75 | - message: String, | |
| 76 | - }, | |
| 47 | + InProgress, | |
| 77 | 48 | /// Sync completed successfully |
| 78 | - Completed { | |
| 79 | - episodes_found: usize, | |
| 80 | - episodes_downloaded: usize, | |
| 81 | - }, | |
| 49 | + Completed, | |
| 82 | 50 | /// Sync failed |
| 83 | 51 | Failed { |
| 84 | 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 | 56 | /// Status of all synchronization tasks |
| 120 | 57 | #[derive(Debug, Default)] |
| 121 | 58 | pub struct SyncStatus { |
| @@ -123,8 +60,6 @@ pub struct SyncStatus { | ||
| 123 | 60 | pub global_sync_in_progress: bool, |
| 124 | 61 | /// Individual feed sync status (feed_id -> status) |
| 125 | 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 | 63 | /// Total feeds to sync |
| 129 | 64 | pub total_feeds: usize, |
| 130 | 65 | /// Feeds completed |
| @@ -161,7 +96,7 @@ impl SyncStatus { | ||
| 161 | 96 | // Update overall progress |
| 162 | 97 | if self.global_sync_in_progress && self.total_feeds > 0 { |
| 163 | 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 | 100 | .count(); |
| 166 | 101 | self.completed_feeds = completed; |
| 167 | 102 | self.overall_progress = (completed as f32 / self.total_feeds as f32).min(1.0); |
| @@ -172,12 +107,6 @@ impl SyncStatus { | ||
| 172 | 107 | pub fn get_overall_progress(&self) -> f32 { |
| 173 | 108 | 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 | 111 | /// Check if there are any sync activities (in progress or recently completed) |
| 183 | 112 | /// Used to determine if we should show sync-related UI elements |
| @@ -189,7 +118,7 @@ impl SyncStatus { | ||
| 189 | 118 | /// Get syncing feed count |
| 190 | 119 | pub fn syncing_feed_count(&self) -> usize { |
| 191 | 120 | self.feed_sync_status.values() |
| 192 | - .filter(|s| matches!(s, FeedSyncStatus::InProgress { .. })) | |
| 121 | + .filter(|s| matches!(s, FeedSyncStatus::InProgress)) | |
| 193 | 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 | 148 | /// Process incoming messages and update status |
| 225 | 149 | pub fn process_messages(&mut self) -> Vec<SyncMessage> { |
| 226 | 150 | let mut messages = Vec::new(); |
| @@ -239,44 +163,29 @@ impl SyncTaskManager { | ||
| 239 | 163 | /// Handle a single sync message |
| 240 | 164 | fn handle_message(&mut self, message: &SyncMessage) { |
| 241 | 165 | match message { |
| 242 | - SyncMessage::SyncStarted { feed_id, .. } => { | |
| 166 | + SyncMessage::SyncStarted { feed_id } => { | |
| 243 | 167 | self.status.update_feed_status( |
| 244 | 168 | 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, | |
| 258 | 170 | ); |
| 259 | 171 | } |
| 260 | - SyncMessage::SyncCompleted { feed_id, feed_name: _, episodes_found, episodes_downloaded, .. } => { | |
| 172 | + SyncMessage::SyncCompleted { feed_id, .. } => { | |
| 261 | 173 | self.status.update_feed_status( |
| 262 | 174 | feed_id.clone(), |
| 263 | - FeedSyncStatus::Completed { | |
| 264 | - episodes_found: *episodes_found, | |
| 265 | - episodes_downloaded: *episodes_downloaded, | |
| 266 | - } | |
| 175 | + FeedSyncStatus::Completed, | |
| 267 | 176 | ); |
| 268 | 177 | |
| 269 | 178 | // Check if all feeds are completed |
| 270 | 179 | if self.status.global_sync_in_progress { |
| 271 | 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 | 183 | if all_completed { |
| 275 | 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 | 189 | self.status.update_feed_status( |
| 281 | 190 | feed_id.clone(), |
| 282 | 191 | FeedSyncStatus::Failed { |
| @@ -287,28 +196,15 @@ impl SyncTaskManager { | ||
| 287 | 196 | // Check if all feeds are completed (including failed ones) |
| 288 | 197 | if self.status.global_sync_in_progress { |
| 289 | 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 | 201 | if all_completed { |
| 293 | 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 | 206 | SyncMessage::EpisodeDownloadFailed { .. } => { |
| 311 | - // Episode download failed | |
| 207 | + // Displayed by the caller (home.rs), no status update needed | |
| 312 | 208 | } |
| 313 | 209 | } |
| 314 | 210 | } |
| @@ -330,13 +226,7 @@ impl SyncTaskManager { | ||
| 330 | 226 | let feed_id = feed.id.clone(); |
| 331 | 227 | |
| 332 | 228 | // 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); | |
| 340 | 230 | |
| 341 | 231 | thread::spawn(move || { |
| 342 | 232 | crate::rss::async_sync::sync_feed_async(feed, sender); |
| @@ -346,7 +236,7 @@ impl SyncTaskManager { | ||
| 346 | 236 | /// Clears completed and failed sync statuses |
| 347 | 237 | pub fn clear_completed_sync_status(&mut self) { |
| 348 | 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 | 257 | assert!(!status.global_sync_in_progress); |
| 368 | 258 | assert!(status.feed_sync_status.is_empty()); |
| 369 | 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 | 263 | #[test] |
| @@ -387,8 +277,8 @@ mod tests { | ||
| 387 | 277 | status.start_global_sync(3); |
| 388 | 278 | |
| 389 | 279 | // 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); | |
| 392 | 282 | |
| 393 | 283 | assert_eq!(status.completed_feeds, 2); |
| 394 | 284 | assert!((status.get_overall_progress() - 0.666).abs() < 0.01); |
| @@ -13,20 +13,10 @@ pub enum SyncMessage { | |||
| 13 | /// Sync started for a feed | 13 | /// 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 successfully | 17 | /// 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 feed | 20 | /// 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 error | 27 | /// 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 failed | 32 | /// 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 synchronization | 40 | /// 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 completed | 43 | /// Sync not started or completed |
| 44 | + #[default] | ||
| 71 | Idle, | 45 | Idle, |
| 72 | /// Sync in progress | 46 | /// Sync in progress |
| 73 | - InProgress { | 47 | + InProgress, |
| 74 | - progress: f32, | ||
| 75 | - message: String, | ||
| 76 | - }, | ||
| 77 | /// Sync completed successfully | 48 | /// Sync completed successfully |
| 78 | - Completed { | 49 | + Completed, |
| 79 | - episodes_found: usize, | ||
| 80 | - episodes_downloaded: usize, | ||
| 81 | - }, | ||
| 82 | /// Sync failed | 50 | /// 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 tasks | 56 | /// 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 sync | 63 | /// Total feeds to sync |
| 129 | pub total_feeds: usize, | 64 | pub total_feeds: usize, |
| 130 | /// Feeds completed | 65 | /// Feeds completed |
| @@ -161,7 +96,7 @@ impl SyncStatus { | |||
| 161 | // Update overall progress | 96 | // 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_progress | 108 | 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 elements | 112 | /// Used to determine if we should show sync-related UI elements |
| @@ -189,7 +118,7 @@ impl SyncStatus { | |||
| 189 | /// Get syncing feed count | 118 | /// 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 status | 148 | /// 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 message | 163 | /// 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 completed | 178 | // 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 failed | 207 | + // 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 status | 228 | // 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 statuses | 236 | /// 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 feeds | 279 | // 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 @@ | ||
| 1 | 1 | //! Async Synchronization module for RSS Podcast Downloader |
| 2 | 2 | //! |
| 3 | 3 | //! 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 | 6 | use crate::config::{Episode, Feed}; |
| 7 | 7 | use crate::config::sync_task::SyncMessage; |
| @@ -11,191 +11,92 @@ use crate::rss::parser::{fetch_feed, parse_rss_content}; | ||
| 11 | 11 | use chrono::Utc; |
| 12 | 12 | use log::{error, info, warn}; |
| 13 | 13 | use std::sync::mpsc::Sender; |
| 14 | -use std::sync::Arc; | |
| 15 | -use std::sync::atomic::{AtomicUsize, Ordering}; | |
| 16 | 14 | |
| 17 | 15 | /// 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. | |
| 20 | 18 | pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) { |
| 21 | 19 | let feed_id = feed.id.clone(); |
| 22 | - let feed_name = feed.name.clone(); | |
| 23 | - | |
| 20 | + | |
| 24 | 21 | // 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 { | |
| 37 | 24 | 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 | |
| 42 | 29 | } |
| 43 | 30 | |
| 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); | |
| 53 | 32 | |
| 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); | |
| 60 | 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 | 39 | // Fetch the feed content |
| 71 | 40 | let content = match fetch_feed(&feed.url) { |
| 72 | 41 | Ok(content) => content, |
| 73 | 42 | Err(e) => { |
| 74 | 43 | 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)); | |
| 80 | 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 | 49 | // Parse the RSS content |
| 91 | 50 | let channel = match parse_rss_content(&content) { |
| 92 | 51 | Ok(channel) => channel, |
| 93 | 52 | Err(e) => { |
| 94 | 53 | 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)); | |
| 100 | 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 | 59 | // Extract episodes from the channel |
| 111 | 60 | let episodes = match extract_episodes(&channel) { |
| 112 | 61 | Ok(episodes) => episodes, |
| 113 | 62 | Err(e) => { |
| 114 | 63 | 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 | + ); | |
| 120 | 69 | return; |
| 121 | 70 | } |
| 122 | 71 | }; |
| 123 | 72 | |
| 124 | 73 | if episodes.is_empty() { |
| 125 | 74 | 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()); | |
| 131 | 76 | return; |
| 132 | 77 | } |
| 133 | - | |
| 78 | + | |
| 134 | 79 | 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; | |
| 145 | 82 | let mut downloaded_count = 0; |
| 146 | - let mut current_episode = 0; | |
| 147 | - | |
| 83 | + | |
| 148 | 84 | for episode in &episodes { |
| 149 | - current_episode += 1; | |
| 150 | - | |
| 151 | 85 | // Skip episodes that are already downloaded |
| 152 | 86 | if is_episode_downloaded(&feed_mut, episode) { |
| 153 | 87 | info!("Episode already downloaded: {}", episode.title); |
| 154 | 88 | continue; |
| 155 | 89 | } |
| 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(|_| {})) { | |
| 186 | 92 | Ok(_) => { |
| 187 | 93 | info!("Successfully downloaded episode: {}", episode.title); |
| 188 | 94 | downloaded_count += 1; |
| 189 | - | |
| 95 | + | |
| 190 | 96 | // Update feed download statistics |
| 191 | 97 | feed_mut.downloaded_episodes_count += 1; |
| 192 | 98 | feed_mut.last_downloaded_episode_name = Some(episode.title.clone()); |
| 193 | 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 | 101 | Err(e) => { |
| 201 | 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 | 112 | // Update feed metadata |
| 212 | 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 | 115 | feed_mut.last_episode_date = Some(latest.pub_date); |
| 215 | 116 | } |
| 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) | |
| 221 | 119 | 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, | |
| 226 | 121 | downloaded_episodes_count: feed_mut.downloaded_episodes_count, |
| 227 | 122 | last_downloaded_episode_name: feed_mut.last_downloaded_episode_name.clone(), |
| 228 | 123 | last_downloaded_episode_date: feed_mut.last_downloaded_episode_date, |
| 229 | 124 | last_checked: feed_mut.last_checked, |
| 230 | 125 | }); |
| 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 | + }); | |
| 234 | 155 | } |
| 235 | 156 | |
| 236 | 157 | /// Synchronizes all feeds asynchronously |
| 237 | 158 | pub fn sync_all_feeds_async(feeds: Vec<Feed>, sender: Sender<SyncMessage>) { |
| 238 | 159 | 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 { | |
| 246 | 162 | let sender_clone = sender.clone(); |
| 247 | - let completed_count_clone = completed_count.clone(); | |
| 248 | - | |
| 163 | + | |
| 249 | 164 | // Spawn a separate thread for each feed sync |
| 250 | 165 | 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); | |
| 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 | 171 | /// Checks if an episode has already been downloaded |
| @@ -265,14 +176,10 @@ fn is_episode_downloaded(feed: &Feed, episode: &Episode) -> bool { | ||
| 265 | 176 | #[cfg(test)] |
| 266 | 177 | mod tests { |
| 267 | 178 | use super::*; |
| 268 | - use std::sync::mpsc::channel; | |
| 179 | + use chrono::Utc; | |
| 269 | 180 | |
| 270 | 181 | #[test] |
| 271 | 182 | fn test_is_episode_downloaded() { |
| 272 | - use std::path::PathBuf; | |
| 273 | - use crate::config::episode::Episode; | |
| 274 | - use chrono::Utc; | |
| 275 | - | |
| 276 | 183 | let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads"); |
| 277 | 184 | let episode = Episode::new( |
| 278 | 185 | "ep-123", |
| @@ -280,8 +187,23 @@ mod tests { | ||
| 280 | 187 | "https://example.com/episode.mp3", |
| 281 | 188 | Utc::now(), |
| 282 | 189 | ); |
| 283 | - | |
| 190 | + | |
| 284 | 191 | // Episode not downloaded (file doesn't exist) |
| 285 | 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 | } |
| @@ -1,7 +1,7 @@ | |||
| 1 | //! Async Synchronization module for RSS Podcast Downloader | 1 | //! Async Synchronization module for RSS Podcast Downloader |
| 2 | //! | 2 | //! |
| 3 | //! This module provides asynchronous versions of the synchronization functions | 3 | //! 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 asynchronously | 15 | /// 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 message | 21 | // 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 path | 31 | + 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 content | 39 | // 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 content | 49 | // 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 channel | 59 | // 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 downloaded | 85 | // 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 episode | 91 | + 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 statistics | 96 | // 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 metadata | 112 | // 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 feed | 118 | + // 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 asynchronously | 157 | /// 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 sync | 164 | // 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 downloaded | 171 | /// 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 @@ | ||
| 1 | 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, | |
| 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. | |
| 5 | 5 | |
| 6 | 6 | use id3::{Tag, TagLike, Error as Id3Error}; |
| 7 | 7 | 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; | |
| 46 | 9 | |
| 47 | 10 | /// Erreur de lecture des métadonnées |
| 48 | 11 | #[derive(Debug, thiserror::Error)] |
| @@ -50,24 +13,15 @@ pub enum MetadataError { | ||
| 50 | 13 | /// Fichier introuvable |
| 51 | 14 | #[error("Fichier introuvable: {0}")] |
| 52 | 15 | FileNotFound(String), |
| 53 | - | |
| 16 | + | |
| 54 | 17 | /// Erreur de lecture du fichier |
| 55 | 18 | #[error("Erreur de lecture du fichier: {0}")] |
| 56 | 19 | 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 | + | |
| 67 | 21 | /// Erreur d'écriture des tags ID3 |
| 68 | 22 | #[error("Erreur d'écriture des tags ID3: {0}")] |
| 69 | 23 | Id3WriteError(String), |
| 70 | - | |
| 24 | + | |
| 71 | 25 | /// Erreur de lecture des tags ID3 |
| 72 | 26 | #[error("Erreur de lecture des tags ID3: {0}")] |
| 73 | 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 | 36 | /// Structure pour stocker les tags ID3 extraits |
| 155 | 37 | #[derive(Debug, Clone, Default)] |
| 156 | 38 | pub struct Id3Tags { |
| 157 | 39 | pub title: Option<String>, |
| 158 | 40 | pub artist: Option<String>, |
| 159 | 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 | 44 | impl From<Tag> for Id3Tags { |
| @@ -191,12 +47,25 @@ impl From<Tag> for Id3Tags { | ||
| 191 | 47 | title: tag.title().map(|s| s.to_string()), |
| 192 | 48 | artist: tag.artist().map(|s| s.to_string()), |
| 193 | 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 | 69 | /// Écrire les tags ID3 sur un fichier MP3 |
| 201 | 70 | pub fn write_id3_tags( |
| 202 | 71 | path: &Path, |
| @@ -210,11 +79,11 @@ pub fn write_id3_tags( | ||
| 210 | 79 | |
| 211 | 80 | // Lire le tag existant |
| 212 | 81 | let file_read = File::open(path) |
| 213 | - .map_err(|e| MetadataError::IoError(e))?; | |
| 214 | - | |
| 82 | + .map_err(MetadataError::IoError)?; | |
| 83 | + | |
| 215 | 84 | let mut tag = Tag::read_from2(file_read) |
| 216 | 85 | .unwrap_or_default(); |
| 217 | - | |
| 86 | + | |
| 218 | 87 | // Mettre à jour les champs |
| 219 | 88 | if let Some(title) = title { |
| 220 | 89 | tag.set_title(title); |
| @@ -230,8 +99,8 @@ pub fn write_id3_tags( | ||
| 230 | 99 | let mut file_write = std::fs::OpenOptions::new() |
| 231 | 100 | .write(true) |
| 232 | 101 | .open(path) |
| 233 | - .map_err(|e| MetadataError::IoError(e))?; | |
| 234 | - | |
| 102 | + .map_err(MetadataError::IoError)?; | |
| 103 | + | |
| 235 | 104 | tag.write_to(&mut file_write, id3::Version::Id3v24) |
| 236 | 105 | .map_err(|e| MetadataError::Id3WriteError(e.to_string()))?; |
| 237 | 106 | |
| @@ -240,13 +109,13 @@ pub fn write_id3_tags( | ||
| 240 | 109 | |
| 241 | 110 | /// Assurer que les tags ID3 (titre, artiste, album) sont présents sur le fichier |
| 242 | 111 | /// Si des tags sont manquants, les remplir avec les informations fournies |
| 243 | -/// | |
| 112 | +/// | |
| 244 | 113 | /// # Arguments |
| 245 | 114 | /// * `file_path` - Chemin vers le fichier audio |
| 246 | 115 | /// * `episode_title` - Titre de l'épisode (pour le tag titre) |
| 247 | 116 | /// * `episode_artist` - Artiste de l'épisode (pour le tag artiste) |
| 248 | 117 | /// * `episode_album` - Album de l'épisode (pour le tag album) |
| 249 | -/// | |
| 118 | +/// | |
| 250 | 119 | /// # Retourne |
| 251 | 120 | /// Ok(()) si les tags ont été vérifiés/écrits avec succès |
| 252 | 121 | /// Erreur si le fichier n'existe pas ou si l'écriture échoue |
| @@ -257,34 +126,34 @@ pub fn ensure_id3_tags( | ||
| 257 | 126 | episode_album: Option<&str>, |
| 258 | 127 | ) -> Result<(), MetadataError> { |
| 259 | 128 | log::info!("Checking ID3 tags for file: {}", file_path.display()); |
| 260 | - | |
| 129 | + | |
| 261 | 130 | // Lire les tags existants |
| 262 | 131 | let tags = read_id3_tags(file_path)?; |
| 263 | - | |
| 132 | + | |
| 264 | 133 | let mut needs_write = false; |
| 265 | 134 | let mut title_to_write = None; |
| 266 | 135 | let mut artist_to_write = None; |
| 267 | 136 | let mut album_to_write = None; |
| 268 | 137 | |
| 269 | 138 | // 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 | 140 | title_to_write = Some(episode_title); |
| 272 | 141 | needs_write = true; |
| 273 | 142 | log::info!(" Missing or empty title tag, will set to: {}", episode_title); |
| 274 | 143 | } |
| 275 | - | |
| 144 | + | |
| 276 | 145 | // Utiliser l'artiste de l'épisode si disponible, sinon ne pas écrire |
| 277 | 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 | 148 | artist_to_write = Some(artist); |
| 280 | 149 | needs_write = true; |
| 281 | 150 | log::info!(" Missing or empty artist tag, will set to: {}", artist); |
| 282 | 151 | } |
| 283 | 152 | } |
| 284 | - | |
| 153 | + | |
| 285 | 154 | // Utiliser l'album de l'épisode si disponible, sinon ne pas écrire |
| 286 | 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 | 157 | album_to_write = Some(album); |
| 289 | 158 | needs_write = true; |
| 290 | 159 | log::info!(" Missing or empty album tag, will set to: {}", album); |
| @@ -295,9 +164,9 @@ pub fn ensure_id3_tags( | ||
| 295 | 164 | if needs_write { |
| 296 | 165 | write_id3_tags( |
| 297 | 166 | 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, | |
| 301 | 170 | )?; |
| 302 | 171 | log::info!(" ID3 tags updated successfully for: {}", file_path.display()); |
| 303 | 172 | } else { |
| @@ -307,136 +176,56 @@ pub fn ensure_id3_tags( | ||
| 307 | 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 | 179 | #[cfg(test)] |
| 375 | 180 | mod tests { |
| 376 | 181 | use super::*; |
| 182 | + use tempfile::tempdir; | |
| 377 | 183 | |
| 378 | 184 | #[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()); | |
| 387 | 188 | } |
| 388 | 189 | |
| 389 | 190 | #[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()); | |
| 397 | 194 | } |
| 398 | 195 | |
| 399 | 196 | #[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()); | |
| 405 | 202 | } |
| 406 | - | |
| 203 | + | |
| 407 | 204 | #[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()); | |
| 426 | 208 | } |
| 427 | - | |
| 209 | + | |
| 428 | 210 | #[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")); | |
| 441 | 230 | } |
| 442 | 231 | } |
| @@ -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ées | 10 | /// 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 introuvable | 13 | /// 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 fichier | 17 | /// 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 ID3 | 21 | /// 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 ID3 | 25 | /// 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 extraits | 36 | /// 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 MP3 | 69 | /// É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 existant | 80 | // 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 champs | 87 | // 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 fichier | 110 | /// 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 fournies | 111 | /// Si des tags sont manquants, les remplir avec les informations fournies |
| 243 | -/// | 112 | +/// |
| 244 | /// # Arguments | 113 | /// # Arguments |
| 245 | /// * `file_path` - Chemin vers le fichier audio | 114 | /// * `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 | /// # Retourne | 119 | /// # Retourne |
| 251 | /// Ok(()) si les tags ont été vérifiés/écrits avec succès | 120 | /// 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 échoue | 121 | /// 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 existants | 130 | // 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 à écrire | 138 | // 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 écrire | 145 | // 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 écrire | 154 | // 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 secondes | 192 | + let result = write_id3_tags(Path::new("nonexistent/file.mp3"), Some("Title"), None, None); |
| 392 | - let file_size = 5 * 1024 * 1024; // 5 Mo | 193 | + 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 { | ||
| 26 | 26 | FileCreationError(String), |
| 27 | 27 | #[error("Failed to write to file: {0}")] |
| 28 | 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 | 29 | #[error("Content length mismatch")] |
| 36 | 30 | ContentLengthMismatch, |
| 37 | 31 | #[error("Network error: {0}")] |
| @@ -41,15 +35,6 @@ pub enum DownloadError { | ||
| 41 | 35 | /// Type alias for progress callback |
| 42 | 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 | 38 | /// Creates parent directories for a file path |
| 54 | 39 | fn create_parent_dirs(path: &Path) -> Result<(), DownloadError> { |
| 55 | 40 | if let Some(parent) = path.parent() { |
| @@ -61,12 +46,6 @@ fn create_parent_dirs(path: &Path) -> Result<(), DownloadError> { | ||
| 61 | 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 | 49 | /// Downloads a file from a URL to a destination path |
| 71 | 50 | pub fn download_file( |
| 72 | 51 | url: &str, |
| @@ -243,23 +222,6 @@ pub fn download_episode( | ||
| 243 | 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 | 225 | /// Checks if a file needs to be downloaded based on its existence and size |
| 264 | 226 | pub fn needs_download(episode: &Episode, download_path: &Path) -> bool { |
| 265 | 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 | 241 | 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 | 244 | #[cfg(test)] |
| 297 | 245 | mod tests { |
| 298 | 246 | use super::*; |
| 299 | 247 | use crate::config::episode::Episode; |
| 300 | 248 | use chrono::Utc; |
| 301 | - use std::path::PathBuf; | |
| 302 | 249 | use tempfile::tempdir; |
| 303 | 250 | |
| 304 | 251 | #[test] |
| @@ -361,13 +308,6 @@ mod tests { | ||
| 361 | 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 | 311 | #[test] |
| 372 | 312 | fn test_create_parent_dirs() { |
| 373 | 313 | 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 callback | 35 | /// 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 path | 38 | /// 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 path | 49 | /// 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 size | 225 | /// 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 | false | 241 | 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; | ||
| 6 | 6 | use chrono::{DateTime, FixedOffset, Utc}; |
| 7 | 7 | use log::{debug, info}; |
| 8 | 8 | use rss::Channel; |
| 9 | -use std::collections::HashSet; | |
| 10 | 9 | use thiserror::Error; |
| 11 | 10 | use uuid::Uuid; |
| 12 | 11 | |
| @@ -17,12 +16,6 @@ pub enum ExtractionError { | ||
| 17 | 16 | NoItemsFound, |
| 18 | 17 | #[error("No valid audio episodes found in the feed")] |
| 19 | 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 | 21 | /// Audio MIME types to look for in enclosures |
| @@ -195,63 +188,35 @@ pub fn extract_episodes(channel: &Channel) -> Result<Vec<Episode>, ExtractionErr | ||
| 195 | 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 | 191 | #[cfg(test)] |
| 225 | 192 | mod tests { |
| 226 | 193 | use super::*; |
| 227 | 194 | use rss::Channel; |
| 228 | 195 | |
| 229 | 196 | 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 | + } | |
| 235 | 203 | } |
| 236 | 204 | |
| 237 | 205 | fn add_test_item(channel: &mut Channel, title: &str, url: &str, pub_date: Option<&str>) { |
| 238 | 206 | 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 | + | |
| 255 | 220 | channel.items.push(item); |
| 256 | 221 | } |
| 257 | 222 | |
| @@ -283,20 +248,25 @@ mod tests { | ||
| 283 | 248 | #[test] |
| 284 | 249 | fn test_extract_episodes_no_audio_enclosures() { |
| 285 | 250 | let mut channel = create_test_channel(); |
| 286 | - | |
| 251 | + | |
| 287 | 252 | // Add item without enclosure |
| 288 | 253 | 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 | + }; | |
| 291 | 258 | channel.items.push(item); |
| 292 | - | |
| 259 | + | |
| 293 | 260 | // 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 | + }; | |
| 300 | 270 | channel.items.push(item2); |
| 301 | 271 | |
| 302 | 272 | 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 enclosures | 21 | /// 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 | - channel | 201 | + ..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 enclosure | 252 | // 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; | ||
| 7 | 7 | pub mod downloader; |
| 8 | 8 | pub mod episode_extractor; |
| 9 | 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}; | |
| @@ -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 { | ||
| 17 | 17 | ParseError(String), |
| 18 | 18 | #[error("No valid feed data found")] |
| 19 | 19 | NoFeedData, |
| 20 | - #[error("SSL/TLS error: {0}")] | |
| 21 | - #[allow(dead_code)] | |
| 22 | - SslError(String), | |
| 23 | 20 | } |
| 24 | 21 | |
| 25 | 22 | /// RSS Channel type for compatibility |
| @@ -79,15 +76,6 @@ pub fn parse_rss_content(content: &str) -> Result<RssFeed, ParserError> { | ||
| 79 | 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 | 79 | #[cfg(test)] |
| 92 | 80 | mod tests { |
| 93 | 81 | 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 compatibility | 22 | /// 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 | ||
| 189 | 189 | .map(|(i, _)| i) |
| 190 | 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 | -} | |
| @@ -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 | |
| 2 | 2 | use crate::config::history::{HistoryEntry, HistoryManager, OperationStatus, OperationType}; |
| 3 | 3 | use eframe::egui; |
| 4 | 4 | |
| @@ -117,12 +117,11 @@ impl HistoryPanel { | ||
| 117 | 117 | }); |
| 118 | 118 | |
| 119 | 119 | // 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() { | |
| 122 | 122 | self.filter_type = None; |
| 123 | 123 | self.filter_status = None; |
| 124 | 124 | } |
| 125 | - } | |
| 126 | 125 | }); |
| 127 | 126 | } |
| 128 | 127 | |
| @@ -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 filtres | 119 | // 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) { | ||
| 12 | 12 | let messages = state.sync_task_manager.process_messages(); |
| 13 | 13 | let mut needs_save = false; |
| 14 | 14 | 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 | + } | |
| 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 | |
| @@ -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 statistics | 23 | + } => { |
| 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 { | ||
| 26 | 26 | Info, |
| 27 | 27 | /// Success message (green) |
| 28 | 28 | Success, |
| 29 | - /// Warning message (yellow/orange) | |
| 30 | - #[allow(dead_code)] | |
| 31 | - Warning, | |
| 32 | - /// Error message (red) | |
| 33 | 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 | 53 | /// Creates a new error notification |
| 69 | 54 | pub fn error(message: impl Into<String>) -> Self { |
| 70 | 55 | Self { |
| @@ -85,7 +70,6 @@ impl Notification { | ||
| 85 | 70 | match self.notification_type { |
| 86 | 71 | NotificationType::Info => Color32::from_rgb(70, 130, 180), // Steel blue |
| 87 | 72 | NotificationType::Success => Color32::from_rgb(34, 139, 34), // Forest green |
| 88 | - NotificationType::Warning => Color32::from_rgb(255, 165, 0), // Orange | |
| 89 | 73 | NotificationType::Error => Color32::from_rgb(220, 53, 69), // Red |
| 90 | 74 | } |
| 91 | 75 | } |
| @@ -95,7 +79,6 @@ impl Notification { | ||
| 95 | 79 | match self.notification_type { |
| 96 | 80 | NotificationType::Info => "ℹ️", |
| 97 | 81 | NotificationType::Success => "✅", |
| 98 | - NotificationType::Warning => "⚠️", | |
| 99 | 82 | NotificationType::Error => "❌", |
| 100 | 83 | } |
| 101 | 84 | } |
| @@ -130,12 +113,6 @@ impl NotificationManager { | ||
| 130 | 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 | 116 | /// Adds an error notification |
| 140 | 117 | pub fn error(&mut self, message: impl Into<String>) { |
| 141 | 118 | self.add_notification(Notification::error(message)); |
| @@ -151,12 +128,6 @@ impl NotificationManager { | ||
| 151 | 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 | 131 | /// Draws all active notifications in the UI |
| 161 | 132 | pub fn draw(&mut self, ui: &mut Ui) { |
| 162 | 133 | 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 notification | 53 | /// 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 blue | 71 | NotificationType::Info => Color32::from_rgb(70, 130, 180), // Steel blue |
| 87 | NotificationType::Success => Color32::from_rgb(34, 139, 34), // Forest green | 72 | 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), // Red | 73 | 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 notification | 116 | /// 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 UI | 131 | /// 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 | |
| 2 | 2 | use crate::config::search::{SearchQuery, SearchType}; |
| 3 | 3 | use eframe::egui; |
| 4 | 4 | |
| 5 | 5 | /// Barre de recherche pour l'application |
| 6 | +#[derive(Default)] | |
| 6 | 7 | pub struct SearchBar { |
| 7 | 8 | /// Requête de recherche active |
| 8 | 9 | pub query: SearchQuery, |
| @@ -10,14 +11,6 @@ pub struct SearchBar { | ||
| 10 | 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 | 15 | impl SearchBar { |
| 23 | 16 | /// 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'application | 5 | /// Barre de recherche pour l'application |
| 6 | +#[derive(Default)] | ||
| 6 | pub struct SearchBar { | 7 | pub struct SearchBar { |
| 7 | /// Requête de recherche active | 8 | /// 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 recherche | 16 | /// 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 | |
| 2 | 2 | use crate::config::statistics::{AppStatistics, format_bytes}; |
| 3 | 3 | use eframe::egui; |
| 4 | 4 | |
| 5 | 5 | /// Panneau d'affichage des statistiques |
| 6 | +#[derive(Default)] | |
| 6 | 7 | pub struct StatsPanel { |
| 7 | 8 | /// Visibilité du panneau |
| 8 | 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 | 13 | impl StatsPanel { |
| 18 | 14 | /// Créer un nouveau panneau de statistiques |
| @@ -88,7 +84,7 @@ impl StatsPanel { | ||
| 88 | 84 | ui.end_row(); |
| 89 | 85 | |
| 90 | 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 | 88 | ui.end_row(); |
| 93 | 89 | |
| 94 | 90 | ui.label("Pourcentage téléchargé:"); |
| @@ -96,7 +92,7 @@ impl StatsPanel { | ||
| 96 | 92 | ui.end_row(); |
| 97 | 93 | |
| 98 | 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 | 96 | ui.end_row(); |
| 101 | 97 | |
| 102 | 98 | ui.label("Nombre de synchronisations:"); |
| @@ -150,13 +146,13 @@ impl StatsPanel { | ||
| 150 | 146 | |
| 151 | 147 | // Lignes de données |
| 152 | 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 | 151 | for feed_stat in feed_stats { |
| 156 | 152 | ui.label(&feed_stat.feed_name); |
| 157 | 153 | ui.label(format!("{}", feed_stat.total_episodes)); |
| 158 | 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 | 156 | ui.label(format!("{:.1}%", feed_stat.get_download_percentage())); |
| 161 | 157 | ui.end_row(); |
| 162 | 158 | } |
| @@ -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 statistiques | 5 | /// Panneau d'affichage des statistiques |
| 6 | +#[derive(Default)] | ||
| 6 | pub struct StatsPanel { | 7 | pub struct StatsPanel { |
| 7 | /// Visibilité du panneau | 8 | /// 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 statistiques | 14 | /// 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ées | 147 | // 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) { | ||
| 31 | 31 | .show(ui, |ui| { |
| 32 | 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 | 34 | // Sync status avec icône |
| 61 | 35 | // Note: Sync messages are processed in home.rs before drawing |
| 62 | 36 | 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ône | 34 | // Sync status avec icône |
| 61 | // Note: Sync messages are processed in home.rs before drawing | 35 | // 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) { | ||
| 59 | 59 | let global_sync_in_progress = status.global_sync_in_progress; |
| 60 | 60 | let syncing_count = status.syncing_feed_count(); |
| 61 | 61 | 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)) | |
| 66 | 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 | 71 | let total_feeds = status.total_feeds; |
| 68 | 72 | let completed_feeds = status.completed_feeds; |
| 69 | 73 | let overall_progress = status.get_overall_progress(); |
| @@ -92,11 +96,16 @@ pub fn draw_sync_indicator(state: &mut AppState, ui: &mut Ui) { | ||
| 92 | 96 | // Show completion status briefly |
| 93 | 97 | ui.label(RichText::new("✅").size(16.0).color(Color32::from_rgb(80, 220, 100))); |
| 94 | 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 | 100 | total_completed, completed_count, failed_count)) |
| 97 | 101 | .size(13.0) |
| 98 | 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 | 109 | // Add a button to clear completed sync status |
| 101 | 110 | if ui.button(RichText::new("✕").size(12.0)).clicked() { |
| 102 | 111 | 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 briefly | 96 | // 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 status | 109 | // 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(); |