UX and transcode updates
b0e5d07 parent: 5951266 modified
AGENTS.md +7 -2 | @@ -7,7 +7,7 @@ Application desktop Rust/egui (egui + eframe 0.35) pour télécharger des podcas | ||
| 7 | 7 | ```bash |
| 8 | 8 | cargo build # build debug |
| 9 | 9 | cargo run # lance l'app |
| 10 | -cargo test # 69 tests (modèles, storage, parsing, downloader, extraction, UI) | |
| 10 | +cargo test # 98 tests (modèles, storage, parsing, downloader, extraction, UI) | |
| 11 | 11 | cargo clippy --all-targets # doit passer sans aucun avertissement |
| 12 | 12 | ``` |
| 13 | 13 | |
| @@ -26,18 +26,23 @@ src/ | ||
| 26 | 26 | │ ├── storage.rs # save/load JSON dans %APPDATA%/rss-podcast-dl (unix : ~/.config/rss-podcast-dl) |
| 27 | 27 | │ ├── settings.rs # settings.json (thème), history.json |
| 28 | 28 | │ ├── history.rs # historique des opérations, auto-persisté |
| 29 | -│ └── sync_task.rs # SyncTaskManager : threads + std::sync::mpsc | |
| 29 | +│ └── sync_task.rs # SyncTaskManager : threads + mpsc, statut + progression par flux | |
| 30 | 30 | ├── rss/ # I/O réseau et fichiers |
| 31 | 31 | │ ├── parser.rs / episode_extractor.rs # récupération + parsing du flux (crate rss) |
| 32 | +│ ├── async_sync.rs # synchro asynchrone : émet SyncProgress épisode par épisode | |
| 32 | 33 | │ ├── downloader.rs # téléchargement atomique : .part puis rename, via reqwest blocking |
| 34 | +│ ├── transcode.rs # réencodage .m4a -> .mp3 via ffmpeg (fallback : original conservé) | |
| 33 | 35 | │ └── audio_metadata.rs # tags ID3 (crate id3) |
| 34 | 36 | └── ui/ # rendu pur : fonctions draw_*(state: &mut AppState, ui) |
| 37 | + ├── feed_list.rs # liste des flux ; barre de progression de synchro par ligne | |
| 38 | + └── sync_status.rs # indicateur global + draw_progress_bar (réutilisé par feed_list) | |
| 35 | 39 | ``` |
| 36 | 40 | |
| 37 | 41 | ## Patterns à respecter |
| 38 | 42 | |
| 39 | 43 | - **State centralisé** : tout état mutable va dans `AppState` (struct plat, champs `pub`). Les composants UI sont des fonctions libres `draw_...(&mut AppState, &mut egui::Ui)` — pas de widgets à état interne. |
| 40 | 44 | - **Jamais de blocage du thread UI** : les opérations réseau/downloads tournent dans des threads détachés et communiquent via `mpsc` (`SyncMessage` dans `sync_task.rs`). Les messages sont drainés par `try_recv` au fil des frames ; on ne fait pas de `recv()` bloquant dans la boucle egui. |
| 45 | +- **Progression de synchro par flux** : `async_sync.rs` émet `SyncMessage::SyncProgress` (0.0 → 1.0) au fil des épisodes ; `SyncStatus.feed_sync_progress` stocke la progression courante. `feed_list.rs` affiche une barre sur la ligne de chaque flux concerné. En synchro globale, `start_global_sync` marque tous les flux `Pending` (barre visible sur toutes les lignes) ; `complete_global_sync` vide `feed_sync_progress` pour faire disparaître les barres simultanément. | |
| 41 | 46 | - **Téléchargements atomiques** : toujours écrire via un fichier `.part` puis renommer. Ne jamais écrire directement le fichier final. |
| 42 | 47 | - **Persistance** : sauvegarder le JSON après chaque mutation de feeds/playlists (`storage::save_*_default`). Historique auto-persisté via `HistoryManager::set_save_path`. |
| 43 | 48 | - **Couleurs** : uniquement via la palette `ui::theme::ThemeColors` (`apply_custom_theme`). Pas de couleur en dur dans les composants — les deux thèmes (sombre/clair) doivent rester cohérents. |
| @@ -7,7 +7,7 @@ Application desktop Rust/egui (egui + eframe 0.35) pour télécharger des podcas | |||
| 7 | ```bash | 7 | ```bash |
| 8 | cargo build # build debug | 8 | cargo build # build debug |
| 9 | cargo run # lance l'app | 9 | cargo run # lance l'app |
| 10 | -cargo test # 69 tests (modèles, storage, parsing, downloader, extraction, UI) | 10 | +cargo test # 98 tests (modèles, storage, parsing, downloader, extraction, UI) |
| 11 | cargo clippy --all-targets # doit passer sans aucun avertissement | 11 | cargo clippy --all-targets # doit passer sans aucun avertissement |
| 12 | ``` | 12 | ``` |
| 13 | 13 | ||
| @@ -26,18 +26,23 @@ src/ | |||
| 26 | │ ├── storage.rs # save/load JSON dans %APPDATA%/rss-podcast-dl (unix : ~/.config/rss-podcast-dl) | 26 | │ ├── storage.rs # save/load JSON dans %APPDATA%/rss-podcast-dl (unix : ~/.config/rss-podcast-dl) |
| 27 | │ ├── settings.rs # settings.json (thème), history.json | 27 | │ ├── settings.rs # settings.json (thème), history.json |
| 28 | │ ├── history.rs # historique des opérations, auto-persisté | 28 | │ ├── history.rs # historique des opérations, auto-persisté |
| 29 | -│ └── sync_task.rs # SyncTaskManager : threads + std::sync::mpsc | 29 | +│ └── sync_task.rs # SyncTaskManager : threads + mpsc, statut + progression par flux |
| 30 | ├── rss/ # I/O réseau et fichiers | 30 | ├── rss/ # I/O réseau et fichiers |
| 31 | │ ├── parser.rs / episode_extractor.rs # récupération + parsing du flux (crate rss) | 31 | │ ├── parser.rs / episode_extractor.rs # récupération + parsing du flux (crate rss) |
| 32 | +│ ├── async_sync.rs # synchro asynchrone : émet SyncProgress épisode par épisode | ||
| 32 | │ ├── downloader.rs # téléchargement atomique : .part puis rename, via reqwest blocking | 33 | │ ├── downloader.rs # téléchargement atomique : .part puis rename, via reqwest blocking |
| 34 | +│ ├── transcode.rs # réencodage .m4a -> .mp3 via ffmpeg (fallback : original conservé) | ||
| 33 | │ └── audio_metadata.rs # tags ID3 (crate id3) | 35 | │ └── audio_metadata.rs # tags ID3 (crate id3) |
| 34 | └── ui/ # rendu pur : fonctions draw_*(state: &mut AppState, ui) | 36 | └── ui/ # rendu pur : fonctions draw_*(state: &mut AppState, ui) |
| 37 | + ├── feed_list.rs # liste des flux ; barre de progression de synchro par ligne | ||
| 38 | + └── sync_status.rs # indicateur global + draw_progress_bar (réutilisé par feed_list) | ||
| 35 | ``` | 39 | ``` |
| 36 | 40 | ||
| 37 | ## Patterns à respecter | 41 | ## Patterns à respecter |
| 38 | 42 | ||
| 39 | - **State centralisé** : tout état mutable va dans `AppState` (struct plat, champs `pub`). Les composants UI sont des fonctions libres `draw_...(&mut AppState, &mut egui::Ui)` — pas de widgets à état interne. | 43 | - **State centralisé** : tout état mutable va dans `AppState` (struct plat, champs `pub`). Les composants UI sont des fonctions libres `draw_...(&mut AppState, &mut egui::Ui)` — pas de widgets à état interne. |
| 40 | - **Jamais de blocage du thread UI** : les opérations réseau/downloads tournent dans des threads détachés et communiquent via `mpsc` (`SyncMessage` dans `sync_task.rs`). Les messages sont drainés par `try_recv` au fil des frames ; on ne fait pas de `recv()` bloquant dans la boucle egui. | 44 | - **Jamais de blocage du thread UI** : les opérations réseau/downloads tournent dans des threads détachés et communiquent via `mpsc` (`SyncMessage` dans `sync_task.rs`). Les messages sont drainés par `try_recv` au fil des frames ; on ne fait pas de `recv()` bloquant dans la boucle egui. |
| 45 | +- **Progression de synchro par flux** : `async_sync.rs` émet `SyncMessage::SyncProgress` (0.0 → 1.0) au fil des épisodes ; `SyncStatus.feed_sync_progress` stocke la progression courante. `feed_list.rs` affiche une barre sur la ligne de chaque flux concerné. En synchro globale, `start_global_sync` marque tous les flux `Pending` (barre visible sur toutes les lignes) ; `complete_global_sync` vide `feed_sync_progress` pour faire disparaître les barres simultanément. | ||
| 41 | - **Téléchargements atomiques** : toujours écrire via un fichier `.part` puis renommer. Ne jamais écrire directement le fichier final. | 46 | - **Téléchargements atomiques** : toujours écrire via un fichier `.part` puis renommer. Ne jamais écrire directement le fichier final. |
| 42 | - **Persistance** : sauvegarder le JSON après chaque mutation de feeds/playlists (`storage::save_*_default`). Historique auto-persisté via `HistoryManager::set_save_path`. | 47 | - **Persistance** : sauvegarder le JSON après chaque mutation de feeds/playlists (`storage::save_*_default`). Historique auto-persisté via `HistoryManager::set_save_path`. |
| 43 | - **Couleurs** : uniquement via la palette `ui::theme::ThemeColors` (`apply_custom_theme`). Pas de couleur en dur dans les composants — les deux thèmes (sombre/clair) doivent rester cohérents. | 48 | - **Couleurs** : uniquement via la palette `ui::theme::ThemeColors` (`apply_custom_theme`). Pas de couleur en dur dans les composants — les deux thèmes (sombre/clair) doivent rester cohérents. |
modified
README.md +2 -1 | @@ -8,7 +8,8 @@ Un outil en Rust avec interface egui pour télécharger automatiquement des podc | ||
| 8 | 8 | - **Synchronisation asynchrone** : synchronisation globale ou flux par flux, sans bloquer l'interface, avec indicateur de progression |
| 9 | 9 | - **Vue détaillée par flux** : liste des épisodes avec dates, tailles et durées, téléchargement individuel, filtre par titre, actualisation |
| 10 | 10 | - **Téléchargement atomique** : écriture via un fichier `.part` puis renommage — un téléchargement interrompu n'est jamais confondu avec un épisode complet |
| 11 | -- **Tags ID3** : titre, artiste et album écrits dans les fichiers téléchargés | |
| 11 | +- **Tags ID3** : titre, artiste et album écrits dans les fichiers téléchargés, puis renommage en `[artiste] - [titre]` (les fichiers déjà existants sont rattrapés à la synchronisation) | |
| 12 | +- **Transcodage .m4a → .mp3** (optionnel, menu Paramètres) : les téléchargements `.m4a` sont réencodés en `.mp3` via `ffmpeg`, détecté au démarrage ; sans `ffmpeg` ou si l'option est désactivée, le fichier original est conservé tel quel | |
| 12 | 13 | - **Recherche** : filtrage instantané des flux par nom, URL ou tags, avec compteur de résultats |
| 13 | 14 | - **Filtres par date** : périodes personnalisées ou prédéfinies (7/30 derniers jours) |
| 14 | 15 | - **Playlists** : regroupement des flux avec description |
| @@ -8,7 +8,8 @@ Un outil en Rust avec interface egui pour télécharger automatiquement des podc | |||
| 8 | - **Synchronisation asynchrone** : synchronisation globale ou flux par flux, sans bloquer l'interface, avec indicateur de progression | 8 | - **Synchronisation asynchrone** : synchronisation globale ou flux par flux, sans bloquer l'interface, avec indicateur de progression |
| 9 | - **Vue détaillée par flux** : liste des épisodes avec dates, tailles et durées, téléchargement individuel, filtre par titre, actualisation | 9 | - **Vue détaillée par flux** : liste des épisodes avec dates, tailles et durées, téléchargement individuel, filtre par titre, actualisation |
| 10 | - **Téléchargement atomique** : écriture via un fichier `.part` puis renommage — un téléchargement interrompu n'est jamais confondu avec un épisode complet | 10 | - **Téléchargement atomique** : écriture via un fichier `.part` puis renommage — un téléchargement interrompu n'est jamais confondu avec un épisode complet |
| 11 | -- **Tags ID3** : titre, artiste et album écrits dans les fichiers téléchargés | 11 | +- **Tags ID3** : titre, artiste et album écrits dans les fichiers téléchargés, puis renommage en `[artiste] - [titre]` (les fichiers déjà existants sont rattrapés à la synchronisation) |
| 12 | +- **Transcodage .m4a → .mp3** (optionnel, menu Paramètres) : les téléchargements `.m4a` sont réencodés en `.mp3` via `ffmpeg`, détecté au démarrage ; sans `ffmpeg` ou si l'option est désactivée, le fichier original est conservé tel quel | ||
| 12 | - **Recherche** : filtrage instantané des flux par nom, URL ou tags, avec compteur de résultats | 13 | - **Recherche** : filtrage instantané des flux par nom, URL ou tags, avec compteur de résultats |
| 13 | - **Filtres par date** : périodes personnalisées ou prédéfinies (7/30 derniers jours) | 14 | - **Filtres par date** : périodes personnalisées ou prédéfinies (7/30 derniers jours) |
| 14 | - **Playlists** : regroupement des flux avec description | 15 | - **Playlists** : regroupement des flux avec description |
modified
src/app.rs +11 -5 | @@ -38,6 +38,9 @@ impl eframe::App for RssPodcastApp { | ||
| 38 | 38 | // Appliquer le thème centralisé et récupérer la palette active |
| 39 | 39 | let colors = crate::ui::theme::apply_custom_theme(&ctx, self.state.get_theme()); |
| 40 | 40 | |
| 41 | + // Récupérer le résultat de la détection ffmpeg lancée au démarrage | |
| 42 | + self.state.poll_ffmpeg_detection(); | |
| 43 | + | |
| 41 | 44 | // Raccourcis clavier globaux (Ctrl+N, F5, Ctrl+F, Échap) |
| 42 | 45 | crate::ui::menu_bar::handle_shortcuts(&mut self.state, &ctx); |
| 43 | 46 | |
| @@ -106,11 +109,14 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> { | ||
| 106 | 109 | } |
| 107 | 110 | } |
| 108 | 111 | |
| 109 | - // Restore the persisted theme (settings.json) | |
| 110 | - if let Some(theme) = crate::config::settings::load_theme() { | |
| 111 | - state.theme = theme; | |
| 112 | - info!("Restored persisted theme"); | |
| 113 | - } | |
| 112 | + // Restore the persisted settings (theme, transcoding option) | |
| 113 | + let settings = crate::config::settings::load_settings(); | |
| 114 | + state.theme = settings.theme; | |
| 115 | + state.transcode_m4a_to_mp3 = settings.transcode_m4a_to_mp3; | |
| 116 | + info!("Restored persisted settings"); | |
| 117 | + | |
| 118 | + // Detect ffmpeg in the background (polled each frame, never blocking) | |
| 119 | + state.start_ffmpeg_detection(); | |
| 114 | 120 | |
| 115 | 121 | // Load the operation history and persist new entries automatically |
| 116 | 122 | let history_path = crate::config::settings::get_history_path(); |
| @@ -38,6 +38,9 @@ impl eframe::App for RssPodcastApp { | |||
| 38 | // Appliquer le thème centralisé et récupérer la palette active | 38 | // Appliquer le thème centralisé et récupérer la palette active |
| 39 | let colors = crate::ui::theme::apply_custom_theme(&ctx, self.state.get_theme()); | 39 | let colors = crate::ui::theme::apply_custom_theme(&ctx, self.state.get_theme()); |
| 40 | 40 | ||
| 41 | + // Récupérer le résultat de la détection ffmpeg lancée au démarrage | ||
| 42 | + self.state.poll_ffmpeg_detection(); | ||
| 43 | + | ||
| 41 | // Raccourcis clavier globaux (Ctrl+N, F5, Ctrl+F, Échap) | 44 | // Raccourcis clavier globaux (Ctrl+N, F5, Ctrl+F, Échap) |
| 42 | crate::ui::menu_bar::handle_shortcuts(&mut self.state, &ctx); | 45 | crate::ui::menu_bar::handle_shortcuts(&mut self.state, &ctx); |
| 43 | 46 | ||
| @@ -106,11 +109,14 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> { | |||
| 106 | } | 109 | } |
| 107 | } | 110 | } |
| 108 | 111 | ||
| 109 | - // Restore the persisted theme (settings.json) | 112 | + // Restore the persisted settings (theme, transcoding option) |
| 110 | - if let Some(theme) = crate::config::settings::load_theme() { | 113 | + let settings = crate::config::settings::load_settings(); |
| 111 | - state.theme = theme; | 114 | + state.theme = settings.theme; |
| 112 | - info!("Restored persisted theme"); | 115 | + state.transcode_m4a_to_mp3 = settings.transcode_m4a_to_mp3; |
| 113 | - } | 116 | + info!("Restored persisted settings"); |
| 117 | + | ||
| 118 | + // Detect ffmpeg in the background (polled each frame, never blocking) | ||
| 119 | + state.start_ffmpeg_detection(); | ||
| 114 | 120 | ||
| 115 | // Load the operation history and persist new entries automatically | 121 | // Load the operation history and persist new entries automatically |
| 116 | let history_path = crate::config::settings::get_history_path(); | 122 | let history_path = crate::config::settings::get_history_path(); |
modified
src/config/app_state.rs +84 -2 | @@ -8,10 +8,14 @@ use crate::config::history::{HistoryEntry, HistoryManager, OperationType}; | ||
| 8 | 8 | use crate::config::playlist::Playlist; |
| 9 | 9 | use crate::config::search::SearchQuery; |
| 10 | 10 | use crate::config::sync_task::SyncTaskManager; |
| 11 | +use crate::config::settings::Settings; | |
| 11 | 12 | use crate::ui::notifications::NotificationManager; |
| 12 | 13 | use chrono::{DateTime, Utc}; |
| 13 | 14 | use serde::{Deserialize, Serialize}; |
| 14 | 15 | use std::collections::HashSet; |
| 16 | +use std::sync::atomic::{AtomicU8, Ordering}; | |
| 17 | +use std::sync::Arc; | |
| 18 | +use std::thread; | |
| 15 | 19 | |
| 16 | 20 | /// Theme options for the application |
| 17 | 21 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| @@ -149,6 +153,15 @@ pub struct AppState { | ||
| 149 | 153 | pub detail_episode_filter: String, |
| 150 | 154 | /// IDs of episodes currently being downloaded individually |
| 151 | 155 | pub downloading_episode_ids: HashSet<String>, |
| 156 | + /// Whether .m4a downloads are re-encoded to .mp3 (persisted setting) | |
| 157 | + pub transcode_m4a_to_mp3: bool, | |
| 158 | + /// Result of the ffmpeg detection (None while the detection is running) | |
| 159 | + pub ffmpeg_available: Option<bool>, | |
| 160 | + /// Shared flag written by the detection thread: 0 = pending, 1 = found, | |
| 161 | + /// 2 = not found. Polled each frame, never blocking the UI thread. | |
| 162 | + pub ffmpeg_detection: Arc<AtomicU8>, | |
| 163 | + /// Whether the user was already notified that ffmpeg is missing | |
| 164 | + pub ffmpeg_missing_notified: bool, | |
| 152 | 165 | /// Manager for async synchronization tasks |
| 153 | 166 | pub sync_task_manager: SyncTaskManager, |
| 154 | 167 | } |
| @@ -203,6 +216,10 @@ impl AppState { | ||
| 203 | 216 | detail_episode_filter: String::new(), |
| 204 | 217 | downloading_episode_ids: HashSet::new(), |
| 205 | 218 | theme: Theme::default(), |
| 219 | + transcode_m4a_to_mp3: Settings::default().transcode_m4a_to_mp3, | |
| 220 | + ffmpeg_available: None, | |
| 221 | + ffmpeg_detection: Arc::new(AtomicU8::new(0)), | |
| 222 | + ffmpeg_missing_notified: false, | |
| 206 | 223 | sync_task_manager: SyncTaskManager::new(), |
| 207 | 224 | } |
| 208 | 225 | } |
| @@ -269,7 +286,9 @@ impl AppState { | ||
| 269 | 286 | log::info!("Starting asynchronous synchronization of all feeds"); |
| 270 | 287 | |
| 271 | 288 | let feeds_to_sync = self.feeds.clone(); |
| 272 | - self.sync_task_manager.start_sync_all_feeds(feeds_to_sync); | |
| 289 | + let transcode_m4a = self.transcode_m4a_to_mp3; | |
| 290 | + self.sync_task_manager | |
| 291 | + .start_sync_all_feeds(feeds_to_sync, transcode_m4a); | |
| 273 | 292 | self.record_history( |
| 274 | 293 | OperationType::SyncAll, |
| 275 | 294 | "Synchronisation de tous les flux lancée".to_string(), |
| @@ -429,13 +448,76 @@ impl AppState { | ||
| 429 | 448 | Theme::Dark => "sombre", |
| 430 | 449 | Theme::Light => "clair", |
| 431 | 450 | }; |
| 432 | - if let Err(e) = crate::config::settings::save_theme(self.theme) { | |
| 451 | + if let Err(e) = self.save_settings() { | |
| 433 | 452 | log::error!("Failed to save theme: {}", e); |
| 434 | 453 | } |
| 435 | 454 | self.notification_manager |
| 436 | 455 | .info(format!("Thème basculé vers: {}", theme_name)); |
| 437 | 456 | } |
| 438 | 457 | |
| 458 | + /// Enables or disables the .m4a re-encoding and persists the choice | |
| 459 | + pub fn set_transcode_m4a_to_mp3(&mut self, enabled: bool) { | |
| 460 | + self.transcode_m4a_to_mp3 = enabled; | |
| 461 | + if let Err(e) = self.save_settings() { | |
| 462 | + log::error!("Failed to save the transcoding setting: {}", e); | |
| 463 | + self.notification_manager | |
| 464 | + .error("Échec de la sauvegarde du réglage de transcodage"); | |
| 465 | + return; | |
| 466 | + } | |
| 467 | + self.notification_manager.info(if enabled { | |
| 468 | + "Réencodage des .m4a en .mp3 activé" | |
| 469 | + } else { | |
| 470 | + "Réencodage des .m4a en .mp3 désactivé" | |
| 471 | + }); | |
| 472 | + } | |
| 473 | + | |
| 474 | + /// Persists the current settings (theme, transcoding option) | |
| 475 | + fn save_settings(&self) -> Result<(), crate::config::settings::SettingsError> { | |
| 476 | + crate::config::settings::save_settings(&Settings { | |
| 477 | + theme: self.theme, | |
| 478 | + transcode_m4a_to_mp3: self.transcode_m4a_to_mp3, | |
| 479 | + }) | |
| 480 | + } | |
| 481 | + | |
| 482 | + /// Starts the ffmpeg detection in a detached thread; the result is | |
| 483 | + /// published through the shared flag and polled each frame | |
| 484 | + pub fn start_ffmpeg_detection(&mut self) { | |
| 485 | + self.ffmpeg_available = None; | |
| 486 | + self.ffmpeg_missing_notified = false; | |
| 487 | + self.ffmpeg_detection.store(0, Ordering::Relaxed); | |
| 488 | + | |
| 489 | + let flag = Arc::clone(&self.ffmpeg_detection); | |
| 490 | + thread::spawn(move || { | |
| 491 | + let detected = crate::rss::transcode::ffmpeg_available(); | |
| 492 | + flag.store(if detected { 1 } else { 2 }, Ordering::Relaxed); | |
| 493 | + }); | |
| 494 | + } | |
| 495 | + | |
| 496 | + /// Polls the ffmpeg detection started by `start_ffmpeg_detection`. | |
| 497 | + /// Cheap and non-blocking: safe to call on every frame. | |
| 498 | + pub fn poll_ffmpeg_detection(&mut self) { | |
| 499 | + if self.ffmpeg_available.is_some() { | |
| 500 | + return; | |
| 501 | + } | |
| 502 | + let detected = match self.ffmpeg_detection.load(Ordering::Relaxed) { | |
| 503 | + 1 => Some(true), | |
| 504 | + 2 => Some(false), | |
| 505 | + _ => None, | |
| 506 | + }; | |
| 507 | + let Some(detected) = detected else { | |
| 508 | + return; | |
| 509 | + }; | |
| 510 | + self.ffmpeg_available = Some(detected); | |
| 511 | + | |
| 512 | + // Warn once when the re-encoding cannot actually happen: the user | |
| 513 | + // would otherwise see no transcoded file without knowing why | |
| 514 | + if !detected && self.transcode_m4a_to_mp3 && !self.ffmpeg_missing_notified { | |
| 515 | + self.ffmpeg_missing_notified = true; | |
| 516 | + self.notification_manager | |
| 517 | + .info("ffmpeg non détecté : les fichiers .m4a seront conservés tels quels"); | |
| 518 | + } | |
| 519 | + } | |
| 520 | + | |
| 439 | 521 | /// Gets the current theme |
| 440 | 522 | pub fn get_theme(&self) -> Theme { |
| 441 | 523 | self.theme |
| @@ -8,10 +8,14 @@ use crate::config::history::{HistoryEntry, HistoryManager, OperationType}; | |||
| 8 | use crate::config::playlist::Playlist; | 8 | use crate::config::playlist::Playlist; |
| 9 | use crate::config::search::SearchQuery; | 9 | use crate::config::search::SearchQuery; |
| 10 | use crate::config::sync_task::SyncTaskManager; | 10 | use crate::config::sync_task::SyncTaskManager; |
| 11 | +use crate::config::settings::Settings; | ||
| 11 | use crate::ui::notifications::NotificationManager; | 12 | use crate::ui::notifications::NotificationManager; |
| 12 | use chrono::{DateTime, Utc}; | 13 | use chrono::{DateTime, Utc}; |
| 13 | use serde::{Deserialize, Serialize}; | 14 | use serde::{Deserialize, Serialize}; |
| 14 | use std::collections::HashSet; | 15 | use std::collections::HashSet; |
| 16 | +use std::sync::atomic::{AtomicU8, Ordering}; | ||
| 17 | +use std::sync::Arc; | ||
| 18 | +use std::thread; | ||
| 15 | 19 | ||
| 16 | /// Theme options for the application | 20 | /// Theme options for the application |
| 17 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | 21 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| @@ -149,6 +153,15 @@ pub struct AppState { | |||
| 149 | pub detail_episode_filter: String, | 153 | pub detail_episode_filter: String, |
| 150 | /// IDs of episodes currently being downloaded individually | 154 | /// IDs of episodes currently being downloaded individually |
| 151 | pub downloading_episode_ids: HashSet<String>, | 155 | pub downloading_episode_ids: HashSet<String>, |
| 156 | + /// Whether .m4a downloads are re-encoded to .mp3 (persisted setting) | ||
| 157 | + pub transcode_m4a_to_mp3: bool, | ||
| 158 | + /// Result of the ffmpeg detection (None while the detection is running) | ||
| 159 | + pub ffmpeg_available: Option<bool>, | ||
| 160 | + /// Shared flag written by the detection thread: 0 = pending, 1 = found, | ||
| 161 | + /// 2 = not found. Polled each frame, never blocking the UI thread. | ||
| 162 | + pub ffmpeg_detection: Arc<AtomicU8>, | ||
| 163 | + /// Whether the user was already notified that ffmpeg is missing | ||
| 164 | + pub ffmpeg_missing_notified: bool, | ||
| 152 | /// Manager for async synchronization tasks | 165 | /// Manager for async synchronization tasks |
| 153 | pub sync_task_manager: SyncTaskManager, | 166 | pub sync_task_manager: SyncTaskManager, |
| 154 | } | 167 | } |
| @@ -203,6 +216,10 @@ impl AppState { | |||
| 203 | detail_episode_filter: String::new(), | 216 | detail_episode_filter: String::new(), |
| 204 | downloading_episode_ids: HashSet::new(), | 217 | downloading_episode_ids: HashSet::new(), |
| 205 | theme: Theme::default(), | 218 | theme: Theme::default(), |
| 219 | + transcode_m4a_to_mp3: Settings::default().transcode_m4a_to_mp3, | ||
| 220 | + ffmpeg_available: None, | ||
| 221 | + ffmpeg_detection: Arc::new(AtomicU8::new(0)), | ||
| 222 | + ffmpeg_missing_notified: false, | ||
| 206 | sync_task_manager: SyncTaskManager::new(), | 223 | sync_task_manager: SyncTaskManager::new(), |
| 207 | } | 224 | } |
| 208 | } | 225 | } |
| @@ -269,7 +286,9 @@ impl AppState { | |||
| 269 | log::info!("Starting asynchronous synchronization of all feeds"); | 286 | log::info!("Starting asynchronous synchronization of all feeds"); |
| 270 | 287 | ||
| 271 | let feeds_to_sync = self.feeds.clone(); | 288 | let feeds_to_sync = self.feeds.clone(); |
| 272 | - self.sync_task_manager.start_sync_all_feeds(feeds_to_sync); | 289 | + let transcode_m4a = self.transcode_m4a_to_mp3; |
| 290 | + self.sync_task_manager | ||
| 291 | + .start_sync_all_feeds(feeds_to_sync, transcode_m4a); | ||
| 273 | self.record_history( | 292 | self.record_history( |
| 274 | OperationType::SyncAll, | 293 | OperationType::SyncAll, |
| 275 | "Synchronisation de tous les flux lancée".to_string(), | 294 | "Synchronisation de tous les flux lancée".to_string(), |
| @@ -429,13 +448,76 @@ impl AppState { | |||
| 429 | Theme::Dark => "sombre", | 448 | Theme::Dark => "sombre", |
| 430 | Theme::Light => "clair", | 449 | Theme::Light => "clair", |
| 431 | }; | 450 | }; |
| 432 | - if let Err(e) = crate::config::settings::save_theme(self.theme) { | 451 | + if let Err(e) = self.save_settings() { |
| 433 | log::error!("Failed to save theme: {}", e); | 452 | log::error!("Failed to save theme: {}", e); |
| 434 | } | 453 | } |
| 435 | self.notification_manager | 454 | self.notification_manager |
| 436 | .info(format!("Thème basculé vers: {}", theme_name)); | 455 | .info(format!("Thème basculé vers: {}", theme_name)); |
| 437 | } | 456 | } |
| 438 | 457 | ||
| 458 | + /// Enables or disables the .m4a re-encoding and persists the choice | ||
| 459 | + pub fn set_transcode_m4a_to_mp3(&mut self, enabled: bool) { | ||
| 460 | + self.transcode_m4a_to_mp3 = enabled; | ||
| 461 | + if let Err(e) = self.save_settings() { | ||
| 462 | + log::error!("Failed to save the transcoding setting: {}", e); | ||
| 463 | + self.notification_manager | ||
| 464 | + .error("Échec de la sauvegarde du réglage de transcodage"); | ||
| 465 | + return; | ||
| 466 | + } | ||
| 467 | + self.notification_manager.info(if enabled { | ||
| 468 | + "Réencodage des .m4a en .mp3 activé" | ||
| 469 | + } else { | ||
| 470 | + "Réencodage des .m4a en .mp3 désactivé" | ||
| 471 | + }); | ||
| 472 | + } | ||
| 473 | + | ||
| 474 | + /// Persists the current settings (theme, transcoding option) | ||
| 475 | + fn save_settings(&self) -> Result<(), crate::config::settings::SettingsError> { | ||
| 476 | + crate::config::settings::save_settings(&Settings { | ||
| 477 | + theme: self.theme, | ||
| 478 | + transcode_m4a_to_mp3: self.transcode_m4a_to_mp3, | ||
| 479 | + }) | ||
| 480 | + } | ||
| 481 | + | ||
| 482 | + /// Starts the ffmpeg detection in a detached thread; the result is | ||
| 483 | + /// published through the shared flag and polled each frame | ||
| 484 | + pub fn start_ffmpeg_detection(&mut self) { | ||
| 485 | + self.ffmpeg_available = None; | ||
| 486 | + self.ffmpeg_missing_notified = false; | ||
| 487 | + self.ffmpeg_detection.store(0, Ordering::Relaxed); | ||
| 488 | + | ||
| 489 | + let flag = Arc::clone(&self.ffmpeg_detection); | ||
| 490 | + thread::spawn(move || { | ||
| 491 | + let detected = crate::rss::transcode::ffmpeg_available(); | ||
| 492 | + flag.store(if detected { 1 } else { 2 }, Ordering::Relaxed); | ||
| 493 | + }); | ||
| 494 | + } | ||
| 495 | + | ||
| 496 | + /// Polls the ffmpeg detection started by `start_ffmpeg_detection`. | ||
| 497 | + /// Cheap and non-blocking: safe to call on every frame. | ||
| 498 | + pub fn poll_ffmpeg_detection(&mut self) { | ||
| 499 | + if self.ffmpeg_available.is_some() { | ||
| 500 | + return; | ||
| 501 | + } | ||
| 502 | + let detected = match self.ffmpeg_detection.load(Ordering::Relaxed) { | ||
| 503 | + 1 => Some(true), | ||
| 504 | + 2 => Some(false), | ||
| 505 | + _ => None, | ||
| 506 | + }; | ||
| 507 | + let Some(detected) = detected else { | ||
| 508 | + return; | ||
| 509 | + }; | ||
| 510 | + self.ffmpeg_available = Some(detected); | ||
| 511 | + | ||
| 512 | + // Warn once when the re-encoding cannot actually happen: the user | ||
| 513 | + // would otherwise see no transcoded file without knowing why | ||
| 514 | + if !detected && self.transcode_m4a_to_mp3 && !self.ffmpeg_missing_notified { | ||
| 515 | + self.ffmpeg_missing_notified = true; | ||
| 516 | + self.notification_manager | ||
| 517 | + .info("ffmpeg non détecté : les fichiers .m4a seront conservés tels quels"); | ||
| 518 | + } | ||
| 519 | + } | ||
| 520 | + | ||
| 439 | /// Gets the current theme | 521 | /// Gets the current theme |
| 440 | pub fn get_theme(&self) -> Theme { | 522 | pub fn get_theme(&self) -> Theme { |
| 441 | self.theme | 523 | self.theme |
modified
src/config/settings.rs +42 -12 | @@ -18,11 +18,29 @@ pub enum SettingsError { | ||
| 18 | 18 | SerializeError(String), |
| 19 | 19 | } |
| 20 | 20 | |
| 21 | +/// Default value of the m4a transcoding setting | |
| 22 | +fn default_transcode_m4a_to_mp3() -> bool { | |
| 23 | + true | |
| 24 | +} | |
| 25 | + | |
| 21 | 26 | /// Application settings persisted across sessions |
| 22 | -#[derive(Debug, Default, Serialize, Deserialize)] | |
| 27 | +#[derive(Debug, Serialize, Deserialize)] | |
| 23 | 28 | pub struct Settings { |
| 24 | 29 | /// UI theme (dark or light) |
| 30 | + #[serde(default)] | |
| 25 | 31 | pub theme: Theme, |
| 32 | + /// Whether .m4a downloads are re-encoded to .mp3 (requires ffmpeg) | |
| 33 | + #[serde(default = "default_transcode_m4a_to_mp3")] | |
| 34 | + pub transcode_m4a_to_mp3: bool, | |
| 35 | +} | |
| 36 | + | |
| 37 | +impl Default for Settings { | |
| 38 | + fn default() -> Self { | |
| 39 | + Self { | |
| 40 | + theme: Theme::default(), | |
| 41 | + transcode_m4a_to_mp3: default_transcode_m4a_to_mp3(), | |
| 42 | + } | |
| 43 | + } | |
| 26 | 44 | } |
| 27 | 45 | |
| 28 | 46 | /// Returns the path of the settings file in the configuration directory |
| @@ -30,9 +48,8 @@ pub fn get_settings_path() -> PathBuf { | ||
| 30 | 48 | crate::config::storage::get_config_dir().join("settings.json") |
| 31 | 49 | } |
| 32 | 50 | |
| 33 | -/// Saves the current settings (theme) to the default settings file | |
| 34 | -pub fn save_theme(theme: Theme) -> Result<(), SettingsError> { | |
| 35 | - let settings = Settings { theme }; | |
| 51 | +/// Saves the settings to the default settings file | |
| 52 | +pub fn save_settings(settings: &Settings) -> Result<(), SettingsError> { | |
| 36 | 53 | let path = get_settings_path(); |
| 37 | 54 | |
| 38 | 55 | if let Some(parent) = path.parent() { |
| @@ -43,34 +60,36 @@ pub fn save_theme(theme: Theme) -> Result<(), SettingsError> { | ||
| 43 | 60 | } |
| 44 | 61 | } |
| 45 | 62 | |
| 46 | - let json = serde_json::to_string_pretty(&settings) | |
| 63 | + let json = serde_json::to_string_pretty(settings) | |
| 47 | 64 | .map_err(|e| SettingsError::SerializeError(e.to_string()))?; |
| 48 | 65 | fs::write(&path, json).map_err(|e| SettingsError::WriteError(e.to_string()))?; |
| 49 | 66 | |
| 50 | - info!("Saved theme settings to {}", path.display()); | |
| 67 | + info!("Saved settings to {}", path.display()); | |
| 51 | 68 | Ok(()) |
| 52 | 69 | } |
| 53 | 70 | |
| 54 | -/// Loads the persisted theme, if a settings file exists and is valid | |
| 55 | -pub fn load_theme() -> Option<Theme> { | |
| 71 | +/// Loads the persisted settings, or the defaults when no valid settings | |
| 72 | +/// file exists. A settings file written before an option existed keeps its | |
| 73 | +/// values and fills the missing options with their defaults. | |
| 74 | +pub fn load_settings() -> Settings { | |
| 56 | 75 | let path = get_settings_path(); |
| 57 | 76 | if !path.exists() { |
| 58 | - return None; | |
| 77 | + return Settings::default(); | |
| 59 | 78 | } |
| 60 | 79 | |
| 61 | 80 | let content = match fs::read_to_string(&path) { |
| 62 | 81 | Ok(content) => content, |
| 63 | 82 | Err(e) => { |
| 64 | 83 | warn!("Failed to read settings file {}: {}", path.display(), e); |
| 65 | - return None; | |
| 84 | + return Settings::default(); | |
| 66 | 85 | } |
| 67 | 86 | }; |
| 68 | 87 | |
| 69 | 88 | match serde_json::from_str::<Settings>(&content) { |
| 70 | - Ok(settings) => Some(settings.theme), | |
| 89 | + Ok(settings) => settings, | |
| 71 | 90 | Err(e) => { |
| 72 | 91 | warn!("Failed to parse settings file {}: {}", path.display(), e); |
| 73 | - None | |
| 92 | + Settings::default() | |
| 74 | 93 | } |
| 75 | 94 | } |
| 76 | 95 | } |
| @@ -88,15 +107,26 @@ mod tests { | ||
| 88 | 107 | fn test_settings_default() { |
| 89 | 108 | let settings = Settings::default(); |
| 90 | 109 | assert_eq!(settings.theme, Theme::Dark); |
| 110 | + assert!(settings.transcode_m4a_to_mp3); | |
| 91 | 111 | } |
| 92 | 112 | |
| 93 | 113 | #[test] |
| 94 | 114 | fn test_settings_roundtrip() { |
| 95 | 115 | let settings = Settings { |
| 96 | 116 | theme: Theme::Light, |
| 117 | + transcode_m4a_to_mp3: false, | |
| 97 | 118 | }; |
| 98 | 119 | let json = serde_json::to_string(&settings).unwrap(); |
| 99 | 120 | let parsed: Settings = serde_json::from_str(&json).unwrap(); |
| 100 | 121 | assert_eq!(parsed.theme, Theme::Light); |
| 122 | + assert!(!parsed.transcode_m4a_to_mp3); | |
| 123 | + } | |
| 124 | + | |
| 125 | + #[test] | |
| 126 | + fn test_settings_legacy_json() { | |
| 127 | + // A settings file written before the transcoding option existed | |
| 128 | + let parsed: Settings = serde_json::from_str(r#"{"theme":"Light"}"#).unwrap(); | |
| 129 | + assert_eq!(parsed.theme, Theme::Light); | |
| 130 | + assert!(parsed.transcode_m4a_to_mp3); | |
| 101 | 131 | } |
| 102 | 132 | } |
| @@ -18,11 +18,29 @@ pub enum SettingsError { | |||
| 18 | SerializeError(String), | 18 | SerializeError(String), |
| 19 | } | 19 | } |
| 20 | 20 | ||
| 21 | +/// Default value of the m4a transcoding setting | ||
| 22 | +fn default_transcode_m4a_to_mp3() -> bool { | ||
| 23 | + true | ||
| 24 | +} | ||
| 25 | + | ||
| 21 | /// Application settings persisted across sessions | 26 | /// Application settings persisted across sessions |
| 22 | -#[derive(Debug, Default, Serialize, Deserialize)] | 27 | +#[derive(Debug, Serialize, Deserialize)] |
| 23 | pub struct Settings { | 28 | pub struct Settings { |
| 24 | /// UI theme (dark or light) | 29 | /// UI theme (dark or light) |
| 30 | + #[serde(default)] | ||
| 25 | pub theme: Theme, | 31 | pub theme: Theme, |
| 32 | + /// Whether .m4a downloads are re-encoded to .mp3 (requires ffmpeg) | ||
| 33 | + #[serde(default = "default_transcode_m4a_to_mp3")] | ||
| 34 | + pub transcode_m4a_to_mp3: bool, | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +impl Default for Settings { | ||
| 38 | + fn default() -> Self { | ||
| 39 | + Self { | ||
| 40 | + theme: Theme::default(), | ||
| 41 | + transcode_m4a_to_mp3: default_transcode_m4a_to_mp3(), | ||
| 42 | + } | ||
| 43 | + } | ||
| 26 | } | 44 | } |
| 27 | 45 | ||
| 28 | /// Returns the path of the settings file in the configuration directory | 46 | /// Returns the path of the settings file in the configuration directory |
| @@ -30,9 +48,8 @@ pub fn get_settings_path() -> PathBuf { | |||
| 30 | crate::config::storage::get_config_dir().join("settings.json") | 48 | crate::config::storage::get_config_dir().join("settings.json") |
| 31 | } | 49 | } |
| 32 | 50 | ||
| 33 | -/// Saves the current settings (theme) to the default settings file | 51 | +/// Saves the settings to the default settings file |
| 34 | -pub fn save_theme(theme: Theme) -> Result<(), SettingsError> { | 52 | +pub fn save_settings(settings: &Settings) -> Result<(), SettingsError> { |
| 35 | - let settings = Settings { theme }; | ||
| 36 | let path = get_settings_path(); | 53 | let path = get_settings_path(); |
| 37 | 54 | ||
| 38 | if let Some(parent) = path.parent() { | 55 | if let Some(parent) = path.parent() { |
| @@ -43,34 +60,36 @@ pub fn save_theme(theme: Theme) -> Result<(), SettingsError> { | |||
| 43 | } | 60 | } |
| 44 | } | 61 | } |
| 45 | 62 | ||
| 46 | - let json = serde_json::to_string_pretty(&settings) | 63 | + let json = serde_json::to_string_pretty(settings) |
| 47 | .map_err(|e| SettingsError::SerializeError(e.to_string()))?; | 64 | .map_err(|e| SettingsError::SerializeError(e.to_string()))?; |
| 48 | fs::write(&path, json).map_err(|e| SettingsError::WriteError(e.to_string()))?; | 65 | fs::write(&path, json).map_err(|e| SettingsError::WriteError(e.to_string()))?; |
| 49 | 66 | ||
| 50 | - info!("Saved theme settings to {}", path.display()); | 67 | + info!("Saved settings to {}", path.display()); |
| 51 | Ok(()) | 68 | Ok(()) |
| 52 | } | 69 | } |
| 53 | 70 | ||
| 54 | -/// Loads the persisted theme, if a settings file exists and is valid | 71 | +/// Loads the persisted settings, or the defaults when no valid settings |
| 55 | -pub fn load_theme() -> Option<Theme> { | 72 | +/// file exists. A settings file written before an option existed keeps its |
| 73 | +/// values and fills the missing options with their defaults. | ||
| 74 | +pub fn load_settings() -> Settings { | ||
| 56 | let path = get_settings_path(); | 75 | let path = get_settings_path(); |
| 57 | if !path.exists() { | 76 | if !path.exists() { |
| 58 | - return None; | 77 | + return Settings::default(); |
| 59 | } | 78 | } |
| 60 | 79 | ||
| 61 | let content = match fs::read_to_string(&path) { | 80 | let content = match fs::read_to_string(&path) { |
| 62 | Ok(content) => content, | 81 | Ok(content) => content, |
| 63 | Err(e) => { | 82 | Err(e) => { |
| 64 | warn!("Failed to read settings file {}: {}", path.display(), e); | 83 | warn!("Failed to read settings file {}: {}", path.display(), e); |
| 65 | - return None; | 84 | + return Settings::default(); |
| 66 | } | 85 | } |
| 67 | }; | 86 | }; |
| 68 | 87 | ||
| 69 | match serde_json::from_str::<Settings>(&content) { | 88 | match serde_json::from_str::<Settings>(&content) { |
| 70 | - Ok(settings) => Some(settings.theme), | 89 | + Ok(settings) => settings, |
| 71 | Err(e) => { | 90 | Err(e) => { |
| 72 | warn!("Failed to parse settings file {}: {}", path.display(), e); | 91 | warn!("Failed to parse settings file {}: {}", path.display(), e); |
| 73 | - None | 92 | + Settings::default() |
| 74 | } | 93 | } |
| 75 | } | 94 | } |
| 76 | } | 95 | } |
| @@ -88,15 +107,26 @@ mod tests { | |||
| 88 | fn test_settings_default() { | 107 | fn test_settings_default() { |
| 89 | let settings = Settings::default(); | 108 | let settings = Settings::default(); |
| 90 | assert_eq!(settings.theme, Theme::Dark); | 109 | assert_eq!(settings.theme, Theme::Dark); |
| 110 | + assert!(settings.transcode_m4a_to_mp3); | ||
| 91 | } | 111 | } |
| 92 | 112 | ||
| 93 | #[test] | 113 | #[test] |
| 94 | fn test_settings_roundtrip() { | 114 | fn test_settings_roundtrip() { |
| 95 | let settings = Settings { | 115 | let settings = Settings { |
| 96 | theme: Theme::Light, | 116 | theme: Theme::Light, |
| 117 | + transcode_m4a_to_mp3: false, | ||
| 97 | }; | 118 | }; |
| 98 | let json = serde_json::to_string(&settings).unwrap(); | 119 | let json = serde_json::to_string(&settings).unwrap(); |
| 99 | let parsed: Settings = serde_json::from_str(&json).unwrap(); | 120 | let parsed: Settings = serde_json::from_str(&json).unwrap(); |
| 100 | assert_eq!(parsed.theme, Theme::Light); | 121 | assert_eq!(parsed.theme, Theme::Light); |
| 122 | + assert!(!parsed.transcode_m4a_to_mp3); | ||
| 123 | + } | ||
| 124 | + | ||
| 125 | + #[test] | ||
| 126 | + fn test_settings_legacy_json() { | ||
| 127 | + // A settings file written before the transcoding option existed | ||
| 128 | + let parsed: Settings = serde_json::from_str(r#"{"theme":"Light"}"#).unwrap(); | ||
| 129 | + assert_eq!(parsed.theme, Theme::Light); | ||
| 130 | + assert!(parsed.transcode_m4a_to_mp3); | ||
| 101 | } | 131 | } |
| 102 | } | 132 | } |
modified
src/config/sync_task.rs +195 -13 | @@ -52,6 +52,12 @@ pub enum SyncMessage { | ||
| 52 | 52 | episode_title: String, |
| 53 | 53 | size: Option<u64>, |
| 54 | 54 | }, |
| 55 | + /// Progression de la synchronisation d'un flux (0.0 à 1.0) | |
| 56 | + SyncProgress { | |
| 57 | + feed_id: String, | |
| 58 | + progress: f32, | |
| 59 | + status_text: String, | |
| 60 | + }, | |
| 55 | 61 | } |
| 56 | 62 | |
| 57 | 63 | /// Status of a feed synchronization |
| @@ -60,6 +66,8 @@ pub enum FeedSyncStatus { | ||
| 60 | 66 | /// Sync not started or completed |
| 61 | 67 | #[default] |
| 62 | 68 | Idle, |
| 69 | + /// En attente de début dans une synchronisation globale | |
| 70 | + Pending, | |
| 63 | 71 | /// Sync in progress |
| 64 | 72 | InProgress, |
| 65 | 73 | /// Sync completed successfully |
| @@ -70,6 +78,15 @@ pub enum FeedSyncStatus { | ||
| 70 | 78 | }, |
| 71 | 79 | } |
| 72 | 80 | |
| 81 | +/// Progression détaillée de la synchronisation d'un flux | |
| 82 | +#[derive(Debug, Clone)] | |
| 83 | +pub struct FeedSyncProgress { | |
| 84 | + /// Avancement entre 0.0 et 1.0 | |
| 85 | + pub progress: f32, | |
| 86 | + /// Texte de statut (ex. épisode en cours) | |
| 87 | + pub status_text: String, | |
| 88 | +} | |
| 89 | + | |
| 73 | 90 | /// Status of all synchronization tasks |
| 74 | 91 | #[derive(Debug, Default)] |
| 75 | 92 | pub struct SyncStatus { |
| @@ -77,6 +94,8 @@ pub struct SyncStatus { | ||
| 77 | 94 | pub global_sync_in_progress: bool, |
| 78 | 95 | /// Individual feed sync status (feed_id -> status) |
| 79 | 96 | pub feed_sync_status: HashMap<String, FeedSyncStatus>, |
| 97 | + /// Progression détaillée par flux (feed_id -> progression) | |
| 98 | + pub feed_sync_progress: HashMap<String, FeedSyncProgress>, | |
| 80 | 99 | /// Total feeds to sync |
| 81 | 100 | pub total_feeds: usize, |
| 82 | 101 | /// Feeds completed |
| @@ -91,23 +110,36 @@ impl SyncStatus { | ||
| 91 | 110 | Self::default() |
| 92 | 111 | } |
| 93 | 112 | |
| 94 | - /// Start global sync | |
| 95 | - pub fn start_global_sync(&mut self, feed_count: usize) { | |
| 113 | + /// Start global sync — every feed is marked `Pending` so that the UI | |
| 114 | + /// shows a progress bar on every row from the start. | |
| 115 | + pub fn start_global_sync(&mut self, feed_ids: &[String]) { | |
| 96 | 116 | self.global_sync_in_progress = true; |
| 97 | - self.total_feeds = feed_count; | |
| 117 | + self.total_feeds = feed_ids.len(); | |
| 98 | 118 | self.completed_feeds = 0; |
| 99 | 119 | self.overall_progress = 0.0; |
| 100 | 120 | self.feed_sync_status.clear(); |
| 121 | + self.feed_sync_progress.clear(); | |
| 122 | + for id in feed_ids { | |
| 123 | + self.feed_sync_status.insert(id.clone(), FeedSyncStatus::Pending); | |
| 124 | + } | |
| 101 | 125 | } |
| 102 | 126 | |
| 103 | - /// Complete global sync | |
| 127 | + /// Complete global sync — clears per-feed progress so that the progress | |
| 128 | + /// bars disappear from every row at the same time. | |
| 104 | 129 | pub fn complete_global_sync(&mut self) { |
| 105 | 130 | self.global_sync_in_progress = false; |
| 106 | 131 | self.overall_progress = 1.0; |
| 132 | + self.feed_sync_progress.clear(); | |
| 107 | 133 | } |
| 108 | 134 | |
| 109 | 135 | /// Update feed sync status |
| 110 | 136 | pub fn update_feed_status(&mut self, feed_id: String, status: FeedSyncStatus) { |
| 137 | + // Once a feed reaches a terminal state, its progress bar is no longer | |
| 138 | + // relevant — remove the detailed progress entry. | |
| 139 | + if matches!(status, FeedSyncStatus::Completed | FeedSyncStatus::Failed { .. }) { | |
| 140 | + self.feed_sync_progress.remove(&feed_id); | |
| 141 | + } | |
| 142 | + | |
| 111 | 143 | self.feed_sync_status.insert(feed_id, status); |
| 112 | 144 | |
| 113 | 145 | // Update overall progress |
| @@ -133,13 +165,38 @@ impl SyncStatus { | ||
| 133 | 165 | self.global_sync_in_progress || !self.feed_sync_status.is_empty() |
| 134 | 166 | } |
| 135 | 167 | |
| 136 | - /// Get syncing feed count | |
| 168 | + /// Get syncing feed count (actively downloading, not pending) | |
| 137 | 169 | pub fn syncing_feed_count(&self) -> usize { |
| 138 | 170 | self.feed_sync_status |
| 139 | 171 | .values() |
| 140 | 172 | .filter(|s| matches!(s, FeedSyncStatus::InProgress)) |
| 141 | 173 | .count() |
| 142 | 174 | } |
| 175 | + | |
| 176 | + /// Update the detailed progress of a feed and ensure its status is | |
| 177 | + /// `InProgress`. | |
| 178 | + pub fn update_feed_progress(&mut self, feed_id: String, progress: f32, status_text: String) { | |
| 179 | + self.feed_sync_progress.insert( | |
| 180 | + feed_id.clone(), | |
| 181 | + FeedSyncProgress { | |
| 182 | + progress: progress.clamp(0.0, 1.0), | |
| 183 | + status_text, | |
| 184 | + }, | |
| 185 | + ); | |
| 186 | + // A feed that reports progress is actively syncing | |
| 187 | + if matches!( | |
| 188 | + self.feed_sync_status.get(&feed_id), | |
| 189 | + Some(FeedSyncStatus::Pending) | None | |
| 190 | + ) { | |
| 191 | + self.feed_sync_status | |
| 192 | + .insert(feed_id, FeedSyncStatus::InProgress); | |
| 193 | + } | |
| 194 | + } | |
| 195 | + | |
| 196 | + /// Get the detailed progress of a feed, if any | |
| 197 | + pub fn feed_progress(&self, feed_id: &str) -> Option<&FeedSyncProgress> { | |
| 198 | + self.feed_sync_progress.get(feed_id) | |
| 199 | + } | |
| 143 | 200 | } |
| 144 | 201 | |
| 145 | 202 | /// Manager for async sync tasks |
| @@ -218,6 +275,14 @@ impl SyncTaskManager { | ||
| 218 | 275 | } |
| 219 | 276 | } |
| 220 | 277 | } |
| 278 | + SyncMessage::SyncProgress { | |
| 279 | + feed_id, | |
| 280 | + progress, | |
| 281 | + status_text, | |
| 282 | + } => { | |
| 283 | + self.status | |
| 284 | + .update_feed_progress(feed_id.clone(), *progress, status_text.clone()); | |
| 285 | + } | |
| 221 | 286 | SyncMessage::EpisodeDownloadFailed { .. } |
| 222 | 287 | | SyncMessage::EpisodesLoaded { .. } |
| 223 | 288 | | SyncMessage::EpisodesLoadFailed { .. } |
| @@ -228,18 +293,19 @@ impl SyncTaskManager { | ||
| 228 | 293 | } |
| 229 | 294 | |
| 230 | 295 | /// Start syncing all feeds asynchronously |
| 231 | - pub fn start_sync_all_feeds(&mut self, feeds: Vec<crate::config::feed::Feed>) { | |
| 232 | - self.status.start_global_sync(feeds.len()); | |
| 296 | + pub fn start_sync_all_feeds(&mut self, feeds: Vec<crate::config::feed::Feed>, transcode_m4a: bool) { | |
| 297 | + let feed_ids: Vec<String> = feeds.iter().map(|f| f.id.clone()).collect(); | |
| 298 | + self.status.start_global_sync(&feed_ids); | |
| 233 | 299 | |
| 234 | 300 | let sender = self.sender.clone(); |
| 235 | 301 | |
| 236 | 302 | thread::spawn(move || { |
| 237 | - crate::rss::async_sync::sync_all_feeds_async(feeds, sender); | |
| 303 | + crate::rss::async_sync::sync_all_feeds_async(feeds, sender, transcode_m4a); | |
| 238 | 304 | }); |
| 239 | 305 | } |
| 240 | 306 | |
| 241 | 307 | /// Start syncing a single feed asynchronously |
| 242 | - pub fn start_sync_feed(&mut self, feed: crate::config::feed::Feed) { | |
| 308 | + pub fn start_sync_feed(&mut self, feed: crate::config::feed::Feed, transcode_m4a: bool) { | |
| 243 | 309 | let sender = self.sender.clone(); |
| 244 | 310 | let feed_id = feed.id.clone(); |
| 245 | 311 | |
| @@ -248,7 +314,7 @@ impl SyncTaskManager { | ||
| 248 | 314 | .update_feed_status(feed_id, FeedSyncStatus::InProgress); |
| 249 | 315 | |
| 250 | 316 | thread::spawn(move || { |
| 251 | - crate::rss::async_sync::sync_feed_async(feed, sender); | |
| 317 | + crate::rss::async_sync::sync_feed_async(feed, sender, transcode_m4a); | |
| 252 | 318 | }); |
| 253 | 319 | } |
| 254 | 320 | |
| @@ -285,6 +351,7 @@ impl SyncTaskManager { | ||
| 285 | 351 | &mut self, |
| 286 | 352 | feed: crate::config::feed::Feed, |
| 287 | 353 | episode: crate::config::episode::Episode, |
| 354 | + transcode_m4a: bool, | |
| 288 | 355 | ) { |
| 289 | 356 | let sender = self.sender.clone(); |
| 290 | 357 | let feed_id = feed.id.clone(); |
| @@ -294,6 +361,7 @@ impl SyncTaskManager { | ||
| 294 | 361 | &episode, |
| 295 | 362 | &feed.download_path, |
| 296 | 363 | Box::new(|_| {}), |
| 364 | + transcode_m4a, | |
| 297 | 365 | ) { |
| 298 | 366 | Ok(path) => { |
| 299 | 367 | let size = std::fs::metadata(&path).map(|m| m.len()).ok(); |
| @@ -318,8 +386,9 @@ impl SyncTaskManager { | ||
| 318 | 386 | /// Clears completed and failed sync statuses |
| 319 | 387 | pub fn clear_completed_sync_status(&mut self) { |
| 320 | 388 | self.status.feed_sync_status.retain(|_, status| { |
| 321 | - matches!(status, FeedSyncStatus::InProgress) | |
| 389 | + matches!(status, FeedSyncStatus::InProgress | FeedSyncStatus::Pending) | |
| 322 | 390 | }); |
| 391 | + self.status.feed_sync_progress.clear(); | |
| 323 | 392 | } |
| 324 | 393 | } |
| 325 | 394 | |
| @@ -345,18 +414,25 @@ mod tests { | ||
| 345 | 414 | #[test] |
| 346 | 415 | fn test_sync_status_start_global_sync() { |
| 347 | 416 | let mut status = SyncStatus::new(); |
| 348 | - status.start_global_sync(5); | |
| 417 | + let ids: Vec<String> = (0..5).map(|i| format!("feed-{}", i)).collect(); | |
| 418 | + status.start_global_sync(&ids); | |
| 349 | 419 | |
| 350 | 420 | assert!(status.global_sync_in_progress); |
| 351 | 421 | assert_eq!(status.total_feeds, 5); |
| 352 | 422 | assert_eq!(status.completed_feeds, 0); |
| 353 | 423 | assert_eq!(status.get_overall_progress(), 0.0); |
| 424 | + // Every feed is marked Pending so the UI shows a bar on every row | |
| 425 | + assert_eq!(status.feed_sync_status.len(), 5); | |
| 426 | + assert!(status | |
| 427 | + .feed_sync_status | |
| 428 | + .values() | |
| 429 | + .all(|s| matches!(s, FeedSyncStatus::Pending))); | |
| 354 | 430 | } |
| 355 | 431 | |
| 356 | 432 | #[test] |
| 357 | 433 | fn test_sync_status_overall_progress() { |
| 358 | 434 | let mut status = SyncStatus::new(); |
| 359 | - status.start_global_sync(3); | |
| 435 | + status.start_global_sync(&["feed-1".to_string(), "feed-2".to_string(), "feed-3".to_string()]); | |
| 360 | 436 | |
| 361 | 437 | // Add completed feeds |
| 362 | 438 | status.update_feed_status("feed-1".to_string(), FeedSyncStatus::Completed); |
| @@ -388,4 +464,110 @@ mod tests { | ||
| 388 | 464 | let manager = SyncTaskManager::new(); |
| 389 | 465 | assert!(!manager.status.global_sync_in_progress); |
| 390 | 466 | } |
| 467 | + | |
| 468 | + #[test] | |
| 469 | + fn test_update_feed_progress_marks_in_progress() { | |
| 470 | + let mut status = SyncStatus::new(); | |
| 471 | + status.start_global_sync(&["feed-1".to_string()]); | |
| 472 | + assert!(matches!( | |
| 473 | + status.feed_sync_status.get("feed-1"), | |
| 474 | + Some(FeedSyncStatus::Pending) | |
| 475 | + )); | |
| 476 | + | |
| 477 | + status.update_feed_progress("feed-1".to_string(), 0.5, "Épisode 1/2".to_string()); | |
| 478 | + | |
| 479 | + assert!(matches!( | |
| 480 | + status.feed_sync_status.get("feed-1"), | |
| 481 | + Some(FeedSyncStatus::InProgress) | |
| 482 | + )); | |
| 483 | + let progress = status.feed_progress("feed-1").unwrap(); | |
| 484 | + assert!((progress.progress - 0.5).abs() < 0.001); | |
| 485 | + assert_eq!(progress.status_text, "Épisode 1/2"); | |
| 486 | + } | |
| 487 | + | |
| 488 | + #[test] | |
| 489 | + fn test_update_feed_progress_clamps() { | |
| 490 | + let mut status = SyncStatus::new(); | |
| 491 | + status.update_feed_progress("feed-1".to_string(), 1.5, "trop haut".to_string()); | |
| 492 | + assert_eq!(status.feed_progress("feed-1").unwrap().progress, 1.0); | |
| 493 | + | |
| 494 | + status.update_feed_progress("feed-1".to_string(), -0.5, "trop bas".to_string()); | |
| 495 | + assert_eq!(status.feed_progress("feed-1").unwrap().progress, 0.0); | |
| 496 | + } | |
| 497 | + | |
| 498 | + #[test] | |
| 499 | + fn test_complete_global_sync_clears_progress() { | |
| 500 | + let mut status = SyncStatus::new(); | |
| 501 | + status.start_global_sync(&["feed-1".to_string(), "feed-2".to_string()]); | |
| 502 | + status.update_feed_progress("feed-1".to_string(), 0.5, "encours".to_string()); | |
| 503 | + assert!(!status.feed_sync_progress.is_empty()); | |
| 504 | + | |
| 505 | + // Simuler la fin : tous les flux sont terminés | |
| 506 | + status.update_feed_status("feed-1".to_string(), FeedSyncStatus::Completed); | |
| 507 | + status.update_feed_status("feed-2".to_string(), FeedSyncStatus::Completed); | |
| 508 | + status.complete_global_sync(); | |
| 509 | + | |
| 510 | + assert!(!status.global_sync_in_progress); | |
| 511 | + assert_eq!(status.get_overall_progress(), 1.0); | |
| 512 | + assert!(status.feed_sync_progress.is_empty()); | |
| 513 | + // Statuts terminaux conservés pour le résumé de la barre de statut | |
| 514 | + assert!(status | |
| 515 | + .feed_sync_status | |
| 516 | + .values() | |
| 517 | + .all(|s| matches!(s, FeedSyncStatus::Completed))); | |
| 518 | + } | |
| 519 | + | |
| 520 | + #[test] | |
| 521 | + fn test_update_feed_status_removes_progress_on_terminal() { | |
| 522 | + let mut status = SyncStatus::new(); | |
| 523 | + status.update_feed_progress("feed-1".to_string(), 0.5, "encours".to_string()); | |
| 524 | + assert!(status.feed_progress("feed-1").is_some()); | |
| 525 | + | |
| 526 | + status.update_feed_status("feed-1".to_string(), FeedSyncStatus::Completed); | |
| 527 | + assert!(status.feed_progress("feed-1").is_none()); | |
| 528 | + | |
| 529 | + status.update_feed_progress("feed-1".to_string(), 0.5, "encours".to_string()); | |
| 530 | + status.update_feed_status( | |
| 531 | + "feed-1".to_string(), | |
| 532 | + FeedSyncStatus::Failed { | |
| 533 | + error: "err".to_string(), | |
| 534 | + }, | |
| 535 | + ); | |
| 536 | + assert!(status.feed_progress("feed-1").is_none()); | |
| 537 | + } | |
| 538 | + | |
| 539 | + #[test] | |
| 540 | + fn test_handle_sync_progress_message() { | |
| 541 | + let mut manager = SyncTaskManager::new(); | |
| 542 | + manager.status.start_global_sync(&["feed-1".to_string()]); | |
| 543 | + | |
| 544 | + manager.handle_message(&SyncMessage::SyncProgress { | |
| 545 | + feed_id: "feed-1".to_string(), | |
| 546 | + progress: 0.3, | |
| 547 | + status_text: "Épisode 1/3".to_string(), | |
| 548 | + }); | |
| 549 | + | |
| 550 | + assert!(matches!( | |
| 551 | + manager.status.feed_sync_status.get("feed-1"), | |
| 552 | + Some(FeedSyncStatus::InProgress) | |
| 553 | + )); | |
| 554 | + assert!((manager.status.feed_progress("feed-1").unwrap().progress - 0.3).abs() < 0.001); | |
| 555 | + } | |
| 556 | + | |
| 557 | + #[test] | |
| 558 | + fn test_clear_completed_sync_status_keeps_pending_and_in_progress() { | |
| 559 | + let mut manager = SyncTaskManager::new(); | |
| 560 | + manager.status.start_global_sync(&["a".to_string(), "b".to_string(), "c".to_string()]); | |
| 561 | + manager | |
| 562 | + .status | |
| 563 | + .update_feed_status("c".to_string(), FeedSyncStatus::Completed); | |
| 564 | + | |
| 565 | + manager.clear_completed_sync_status(); | |
| 566 | + | |
| 567 | + assert_eq!(manager.status.feed_sync_status.len(), 2); | |
| 568 | + assert!(manager.status.feed_sync_status.contains_key("a")); | |
| 569 | + assert!(manager.status.feed_sync_status.contains_key("b")); | |
| 570 | + assert!(!manager.status.feed_sync_status.contains_key("c")); | |
| 571 | + assert!(manager.status.feed_sync_progress.is_empty()); | |
| 572 | + } | |
| 391 | 573 | } |
| @@ -52,6 +52,12 @@ pub enum SyncMessage { | |||
| 52 | episode_title: String, | 52 | episode_title: String, |
| 53 | size: Option<u64>, | 53 | size: Option<u64>, |
| 54 | }, | 54 | }, |
| 55 | + /// Progression de la synchronisation d'un flux (0.0 à 1.0) | ||
| 56 | + SyncProgress { | ||
| 57 | + feed_id: String, | ||
| 58 | + progress: f32, | ||
| 59 | + status_text: String, | ||
| 60 | + }, | ||
| 55 | } | 61 | } |
| 56 | 62 | ||
| 57 | /// Status of a feed synchronization | 63 | /// Status of a feed synchronization |
| @@ -60,6 +66,8 @@ pub enum FeedSyncStatus { | |||
| 60 | /// Sync not started or completed | 66 | /// Sync not started or completed |
| 61 | #[default] | 67 | #[default] |
| 62 | Idle, | 68 | Idle, |
| 69 | + /// En attente de début dans une synchronisation globale | ||
| 70 | + Pending, | ||
| 63 | /// Sync in progress | 71 | /// Sync in progress |
| 64 | InProgress, | 72 | InProgress, |
| 65 | /// Sync completed successfully | 73 | /// Sync completed successfully |
| @@ -70,6 +78,15 @@ pub enum FeedSyncStatus { | |||
| 70 | }, | 78 | }, |
| 71 | } | 79 | } |
| 72 | 80 | ||
| 81 | +/// Progression détaillée de la synchronisation d'un flux | ||
| 82 | +#[derive(Debug, Clone)] | ||
| 83 | +pub struct FeedSyncProgress { | ||
| 84 | + /// Avancement entre 0.0 et 1.0 | ||
| 85 | + pub progress: f32, | ||
| 86 | + /// Texte de statut (ex. épisode en cours) | ||
| 87 | + pub status_text: String, | ||
| 88 | +} | ||
| 89 | + | ||
| 73 | /// Status of all synchronization tasks | 90 | /// Status of all synchronization tasks |
| 74 | #[derive(Debug, Default)] | 91 | #[derive(Debug, Default)] |
| 75 | pub struct SyncStatus { | 92 | pub struct SyncStatus { |
| @@ -77,6 +94,8 @@ pub struct SyncStatus { | |||
| 77 | pub global_sync_in_progress: bool, | 94 | pub global_sync_in_progress: bool, |
| 78 | /// Individual feed sync status (feed_id -> status) | 95 | /// Individual feed sync status (feed_id -> status) |
| 79 | pub feed_sync_status: HashMap<String, FeedSyncStatus>, | 96 | pub feed_sync_status: HashMap<String, FeedSyncStatus>, |
| 97 | + /// Progression détaillée par flux (feed_id -> progression) | ||
| 98 | + pub feed_sync_progress: HashMap<String, FeedSyncProgress>, | ||
| 80 | /// Total feeds to sync | 99 | /// Total feeds to sync |
| 81 | pub total_feeds: usize, | 100 | pub total_feeds: usize, |
| 82 | /// Feeds completed | 101 | /// Feeds completed |
| @@ -91,23 +110,36 @@ impl SyncStatus { | |||
| 91 | Self::default() | 110 | Self::default() |
| 92 | } | 111 | } |
| 93 | 112 | ||
| 94 | - /// Start global sync | 113 | + /// Start global sync — every feed is marked `Pending` so that the UI |
| 95 | - pub fn start_global_sync(&mut self, feed_count: usize) { | 114 | + /// shows a progress bar on every row from the start. |
| 115 | + pub fn start_global_sync(&mut self, feed_ids: &[String]) { | ||
| 96 | self.global_sync_in_progress = true; | 116 | self.global_sync_in_progress = true; |
| 97 | - self.total_feeds = feed_count; | 117 | + self.total_feeds = feed_ids.len(); |
| 98 | self.completed_feeds = 0; | 118 | self.completed_feeds = 0; |
| 99 | self.overall_progress = 0.0; | 119 | self.overall_progress = 0.0; |
| 100 | self.feed_sync_status.clear(); | 120 | self.feed_sync_status.clear(); |
| 121 | + self.feed_sync_progress.clear(); | ||
| 122 | + for id in feed_ids { | ||
| 123 | + self.feed_sync_status.insert(id.clone(), FeedSyncStatus::Pending); | ||
| 124 | + } | ||
| 101 | } | 125 | } |
| 102 | 126 | ||
| 103 | - /// Complete global sync | 127 | + /// Complete global sync — clears per-feed progress so that the progress |
| 128 | + /// bars disappear from every row at the same time. | ||
| 104 | pub fn complete_global_sync(&mut self) { | 129 | pub fn complete_global_sync(&mut self) { |
| 105 | self.global_sync_in_progress = false; | 130 | self.global_sync_in_progress = false; |
| 106 | self.overall_progress = 1.0; | 131 | self.overall_progress = 1.0; |
| 132 | + self.feed_sync_progress.clear(); | ||
| 107 | } | 133 | } |
| 108 | 134 | ||
| 109 | /// Update feed sync status | 135 | /// Update feed sync status |
| 110 | pub fn update_feed_status(&mut self, feed_id: String, status: FeedSyncStatus) { | 136 | pub fn update_feed_status(&mut self, feed_id: String, status: FeedSyncStatus) { |
| 137 | + // Once a feed reaches a terminal state, its progress bar is no longer | ||
| 138 | + // relevant — remove the detailed progress entry. | ||
| 139 | + if matches!(status, FeedSyncStatus::Completed | FeedSyncStatus::Failed { .. }) { | ||
| 140 | + self.feed_sync_progress.remove(&feed_id); | ||
| 141 | + } | ||
| 142 | + | ||
| 111 | self.feed_sync_status.insert(feed_id, status); | 143 | self.feed_sync_status.insert(feed_id, status); |
| 112 | 144 | ||
| 113 | // Update overall progress | 145 | // Update overall progress |
| @@ -133,13 +165,38 @@ impl SyncStatus { | |||
| 133 | self.global_sync_in_progress || !self.feed_sync_status.is_empty() | 165 | self.global_sync_in_progress || !self.feed_sync_status.is_empty() |
| 134 | } | 166 | } |
| 135 | 167 | ||
| 136 | - /// Get syncing feed count | 168 | + /// Get syncing feed count (actively downloading, not pending) |
| 137 | pub fn syncing_feed_count(&self) -> usize { | 169 | pub fn syncing_feed_count(&self) -> usize { |
| 138 | self.feed_sync_status | 170 | self.feed_sync_status |
| 139 | .values() | 171 | .values() |
| 140 | .filter(|s| matches!(s, FeedSyncStatus::InProgress)) | 172 | .filter(|s| matches!(s, FeedSyncStatus::InProgress)) |
| 141 | .count() | 173 | .count() |
| 142 | } | 174 | } |
| 175 | + | ||
| 176 | + /// Update the detailed progress of a feed and ensure its status is | ||
| 177 | + /// `InProgress`. | ||
| 178 | + pub fn update_feed_progress(&mut self, feed_id: String, progress: f32, status_text: String) { | ||
| 179 | + self.feed_sync_progress.insert( | ||
| 180 | + feed_id.clone(), | ||
| 181 | + FeedSyncProgress { | ||
| 182 | + progress: progress.clamp(0.0, 1.0), | ||
| 183 | + status_text, | ||
| 184 | + }, | ||
| 185 | + ); | ||
| 186 | + // A feed that reports progress is actively syncing | ||
| 187 | + if matches!( | ||
| 188 | + self.feed_sync_status.get(&feed_id), | ||
| 189 | + Some(FeedSyncStatus::Pending) | None | ||
| 190 | + ) { | ||
| 191 | + self.feed_sync_status | ||
| 192 | + .insert(feed_id, FeedSyncStatus::InProgress); | ||
| 193 | + } | ||
| 194 | + } | ||
| 195 | + | ||
| 196 | + /// Get the detailed progress of a feed, if any | ||
| 197 | + pub fn feed_progress(&self, feed_id: &str) -> Option<&FeedSyncProgress> { | ||
| 198 | + self.feed_sync_progress.get(feed_id) | ||
| 199 | + } | ||
| 143 | } | 200 | } |
| 144 | 201 | ||
| 145 | /// Manager for async sync tasks | 202 | /// Manager for async sync tasks |
| @@ -218,6 +275,14 @@ impl SyncTaskManager { | |||
| 218 | } | 275 | } |
| 219 | } | 276 | } |
| 220 | } | 277 | } |
| 278 | + SyncMessage::SyncProgress { | ||
| 279 | + feed_id, | ||
| 280 | + progress, | ||
| 281 | + status_text, | ||
| 282 | + } => { | ||
| 283 | + self.status | ||
| 284 | + .update_feed_progress(feed_id.clone(), *progress, status_text.clone()); | ||
| 285 | + } | ||
| 221 | SyncMessage::EpisodeDownloadFailed { .. } | 286 | SyncMessage::EpisodeDownloadFailed { .. } |
| 222 | | SyncMessage::EpisodesLoaded { .. } | 287 | | SyncMessage::EpisodesLoaded { .. } |
| 223 | | SyncMessage::EpisodesLoadFailed { .. } | 288 | | SyncMessage::EpisodesLoadFailed { .. } |
| @@ -228,18 +293,19 @@ impl SyncTaskManager { | |||
| 228 | } | 293 | } |
| 229 | 294 | ||
| 230 | /// Start syncing all feeds asynchronously | 295 | /// Start syncing all feeds asynchronously |
| 231 | - pub fn start_sync_all_feeds(&mut self, feeds: Vec<crate::config::feed::Feed>) { | 296 | + pub fn start_sync_all_feeds(&mut self, feeds: Vec<crate::config::feed::Feed>, transcode_m4a: bool) { |
| 232 | - self.status.start_global_sync(feeds.len()); | 297 | + let feed_ids: Vec<String> = feeds.iter().map(|f| f.id.clone()).collect(); |
| 298 | + self.status.start_global_sync(&feed_ids); | ||
| 233 | 299 | ||
| 234 | let sender = self.sender.clone(); | 300 | let sender = self.sender.clone(); |
| 235 | 301 | ||
| 236 | thread::spawn(move || { | 302 | thread::spawn(move || { |
| 237 | - crate::rss::async_sync::sync_all_feeds_async(feeds, sender); | 303 | + crate::rss::async_sync::sync_all_feeds_async(feeds, sender, transcode_m4a); |
| 238 | }); | 304 | }); |
| 239 | } | 305 | } |
| 240 | 306 | ||
| 241 | /// Start syncing a single feed asynchronously | 307 | /// Start syncing a single feed asynchronously |
| 242 | - pub fn start_sync_feed(&mut self, feed: crate::config::feed::Feed) { | 308 | + pub fn start_sync_feed(&mut self, feed: crate::config::feed::Feed, transcode_m4a: bool) { |
| 243 | let sender = self.sender.clone(); | 309 | let sender = self.sender.clone(); |
| 244 | let feed_id = feed.id.clone(); | 310 | let feed_id = feed.id.clone(); |
| 245 | 311 | ||
| @@ -248,7 +314,7 @@ impl SyncTaskManager { | |||
| 248 | .update_feed_status(feed_id, FeedSyncStatus::InProgress); | 314 | .update_feed_status(feed_id, FeedSyncStatus::InProgress); |
| 249 | 315 | ||
| 250 | thread::spawn(move || { | 316 | thread::spawn(move || { |
| 251 | - crate::rss::async_sync::sync_feed_async(feed, sender); | 317 | + crate::rss::async_sync::sync_feed_async(feed, sender, transcode_m4a); |
| 252 | }); | 318 | }); |
| 253 | } | 319 | } |
| 254 | 320 | ||
| @@ -285,6 +351,7 @@ impl SyncTaskManager { | |||
| 285 | &mut self, | 351 | &mut self, |
| 286 | feed: crate::config::feed::Feed, | 352 | feed: crate::config::feed::Feed, |
| 287 | episode: crate::config::episode::Episode, | 353 | episode: crate::config::episode::Episode, |
| 354 | + transcode_m4a: bool, | ||
| 288 | ) { | 355 | ) { |
| 289 | let sender = self.sender.clone(); | 356 | let sender = self.sender.clone(); |
| 290 | let feed_id = feed.id.clone(); | 357 | let feed_id = feed.id.clone(); |
| @@ -294,6 +361,7 @@ impl SyncTaskManager { | |||
| 294 | &episode, | 361 | &episode, |
| 295 | &feed.download_path, | 362 | &feed.download_path, |
| 296 | Box::new(|_| {}), | 363 | Box::new(|_| {}), |
| 364 | + transcode_m4a, | ||
| 297 | ) { | 365 | ) { |
| 298 | Ok(path) => { | 366 | Ok(path) => { |
| 299 | let size = std::fs::metadata(&path).map(|m| m.len()).ok(); | 367 | let size = std::fs::metadata(&path).map(|m| m.len()).ok(); |
| @@ -318,8 +386,9 @@ impl SyncTaskManager { | |||
| 318 | /// Clears completed and failed sync statuses | 386 | /// Clears completed and failed sync statuses |
| 319 | pub fn clear_completed_sync_status(&mut self) { | 387 | pub fn clear_completed_sync_status(&mut self) { |
| 320 | self.status.feed_sync_status.retain(|_, status| { | 388 | self.status.feed_sync_status.retain(|_, status| { |
| 321 | - matches!(status, FeedSyncStatus::InProgress) | 389 | + matches!(status, FeedSyncStatus::InProgress | FeedSyncStatus::Pending) |
| 322 | }); | 390 | }); |
| 391 | + self.status.feed_sync_progress.clear(); | ||
| 323 | } | 392 | } |
| 324 | } | 393 | } |
| 325 | 394 | ||
| @@ -345,18 +414,25 @@ mod tests { | |||
| 345 | #[test] | 414 | #[test] |
| 346 | fn test_sync_status_start_global_sync() { | 415 | fn test_sync_status_start_global_sync() { |
| 347 | let mut status = SyncStatus::new(); | 416 | let mut status = SyncStatus::new(); |
| 348 | - status.start_global_sync(5); | 417 | + let ids: Vec<String> = (0..5).map(|i| format!("feed-{}", i)).collect(); |
| 418 | + status.start_global_sync(&ids); | ||
| 349 | 419 | ||
| 350 | assert!(status.global_sync_in_progress); | 420 | assert!(status.global_sync_in_progress); |
| 351 | assert_eq!(status.total_feeds, 5); | 421 | assert_eq!(status.total_feeds, 5); |
| 352 | assert_eq!(status.completed_feeds, 0); | 422 | assert_eq!(status.completed_feeds, 0); |
| 353 | assert_eq!(status.get_overall_progress(), 0.0); | 423 | assert_eq!(status.get_overall_progress(), 0.0); |
| 424 | + // Every feed is marked Pending so the UI shows a bar on every row | ||
| 425 | + assert_eq!(status.feed_sync_status.len(), 5); | ||
| 426 | + assert!(status | ||
| 427 | + .feed_sync_status | ||
| 428 | + .values() | ||
| 429 | + .all(|s| matches!(s, FeedSyncStatus::Pending))); | ||
| 354 | } | 430 | } |
| 355 | 431 | ||
| 356 | #[test] | 432 | #[test] |
| 357 | fn test_sync_status_overall_progress() { | 433 | fn test_sync_status_overall_progress() { |
| 358 | let mut status = SyncStatus::new(); | 434 | let mut status = SyncStatus::new(); |
| 359 | - status.start_global_sync(3); | 435 | + status.start_global_sync(&["feed-1".to_string(), "feed-2".to_string(), "feed-3".to_string()]); |
| 360 | 436 | ||
| 361 | // Add completed feeds | 437 | // Add completed feeds |
| 362 | status.update_feed_status("feed-1".to_string(), FeedSyncStatus::Completed); | 438 | status.update_feed_status("feed-1".to_string(), FeedSyncStatus::Completed); |
| @@ -388,4 +464,110 @@ mod tests { | |||
| 388 | let manager = SyncTaskManager::new(); | 464 | let manager = SyncTaskManager::new(); |
| 389 | assert!(!manager.status.global_sync_in_progress); | 465 | assert!(!manager.status.global_sync_in_progress); |
| 390 | } | 466 | } |
| 467 | + | ||
| 468 | + #[test] | ||
| 469 | + fn test_update_feed_progress_marks_in_progress() { | ||
| 470 | + let mut status = SyncStatus::new(); | ||
| 471 | + status.start_global_sync(&["feed-1".to_string()]); | ||
| 472 | + assert!(matches!( | ||
| 473 | + status.feed_sync_status.get("feed-1"), | ||
| 474 | + Some(FeedSyncStatus::Pending) | ||
| 475 | + )); | ||
| 476 | + | ||
| 477 | + status.update_feed_progress("feed-1".to_string(), 0.5, "Épisode 1/2".to_string()); | ||
| 478 | + | ||
| 479 | + assert!(matches!( | ||
| 480 | + status.feed_sync_status.get("feed-1"), | ||
| 481 | + Some(FeedSyncStatus::InProgress) | ||
| 482 | + )); | ||
| 483 | + let progress = status.feed_progress("feed-1").unwrap(); | ||
| 484 | + assert!((progress.progress - 0.5).abs() < 0.001); | ||
| 485 | + assert_eq!(progress.status_text, "Épisode 1/2"); | ||
| 486 | + } | ||
| 487 | + | ||
| 488 | + #[test] | ||
| 489 | + fn test_update_feed_progress_clamps() { | ||
| 490 | + let mut status = SyncStatus::new(); | ||
| 491 | + status.update_feed_progress("feed-1".to_string(), 1.5, "trop haut".to_string()); | ||
| 492 | + assert_eq!(status.feed_progress("feed-1").unwrap().progress, 1.0); | ||
| 493 | + | ||
| 494 | + status.update_feed_progress("feed-1".to_string(), -0.5, "trop bas".to_string()); | ||
| 495 | + assert_eq!(status.feed_progress("feed-1").unwrap().progress, 0.0); | ||
| 496 | + } | ||
| 497 | + | ||
| 498 | + #[test] | ||
| 499 | + fn test_complete_global_sync_clears_progress() { | ||
| 500 | + let mut status = SyncStatus::new(); | ||
| 501 | + status.start_global_sync(&["feed-1".to_string(), "feed-2".to_string()]); | ||
| 502 | + status.update_feed_progress("feed-1".to_string(), 0.5, "encours".to_string()); | ||
| 503 | + assert!(!status.feed_sync_progress.is_empty()); | ||
| 504 | + | ||
| 505 | + // Simuler la fin : tous les flux sont terminés | ||
| 506 | + status.update_feed_status("feed-1".to_string(), FeedSyncStatus::Completed); | ||
| 507 | + status.update_feed_status("feed-2".to_string(), FeedSyncStatus::Completed); | ||
| 508 | + status.complete_global_sync(); | ||
| 509 | + | ||
| 510 | + assert!(!status.global_sync_in_progress); | ||
| 511 | + assert_eq!(status.get_overall_progress(), 1.0); | ||
| 512 | + assert!(status.feed_sync_progress.is_empty()); | ||
| 513 | + // Statuts terminaux conservés pour le résumé de la barre de statut | ||
| 514 | + assert!(status | ||
| 515 | + .feed_sync_status | ||
| 516 | + .values() | ||
| 517 | + .all(|s| matches!(s, FeedSyncStatus::Completed))); | ||
| 518 | + } | ||
| 519 | + | ||
| 520 | + #[test] | ||
| 521 | + fn test_update_feed_status_removes_progress_on_terminal() { | ||
| 522 | + let mut status = SyncStatus::new(); | ||
| 523 | + status.update_feed_progress("feed-1".to_string(), 0.5, "encours".to_string()); | ||
| 524 | + assert!(status.feed_progress("feed-1").is_some()); | ||
| 525 | + | ||
| 526 | + status.update_feed_status("feed-1".to_string(), FeedSyncStatus::Completed); | ||
| 527 | + assert!(status.feed_progress("feed-1").is_none()); | ||
| 528 | + | ||
| 529 | + status.update_feed_progress("feed-1".to_string(), 0.5, "encours".to_string()); | ||
| 530 | + status.update_feed_status( | ||
| 531 | + "feed-1".to_string(), | ||
| 532 | + FeedSyncStatus::Failed { | ||
| 533 | + error: "err".to_string(), | ||
| 534 | + }, | ||
| 535 | + ); | ||
| 536 | + assert!(status.feed_progress("feed-1").is_none()); | ||
| 537 | + } | ||
| 538 | + | ||
| 539 | + #[test] | ||
| 540 | + fn test_handle_sync_progress_message() { | ||
| 541 | + let mut manager = SyncTaskManager::new(); | ||
| 542 | + manager.status.start_global_sync(&["feed-1".to_string()]); | ||
| 543 | + | ||
| 544 | + manager.handle_message(&SyncMessage::SyncProgress { | ||
| 545 | + feed_id: "feed-1".to_string(), | ||
| 546 | + progress: 0.3, | ||
| 547 | + status_text: "Épisode 1/3".to_string(), | ||
| 548 | + }); | ||
| 549 | + | ||
| 550 | + assert!(matches!( | ||
| 551 | + manager.status.feed_sync_status.get("feed-1"), | ||
| 552 | + Some(FeedSyncStatus::InProgress) | ||
| 553 | + )); | ||
| 554 | + assert!((manager.status.feed_progress("feed-1").unwrap().progress - 0.3).abs() < 0.001); | ||
| 555 | + } | ||
| 556 | + | ||
| 557 | + #[test] | ||
| 558 | + fn test_clear_completed_sync_status_keeps_pending_and_in_progress() { | ||
| 559 | + let mut manager = SyncTaskManager::new(); | ||
| 560 | + manager.status.start_global_sync(&["a".to_string(), "b".to_string(), "c".to_string()]); | ||
| 561 | + manager | ||
| 562 | + .status | ||
| 563 | + .update_feed_status("c".to_string(), FeedSyncStatus::Completed); | ||
| 564 | + | ||
| 565 | + manager.clear_completed_sync_status(); | ||
| 566 | + | ||
| 567 | + assert_eq!(manager.status.feed_sync_status.len(), 2); | ||
| 568 | + assert!(manager.status.feed_sync_status.contains_key("a")); | ||
| 569 | + assert!(manager.status.feed_sync_status.contains_key("b")); | ||
| 570 | + assert!(!manager.status.feed_sync_status.contains_key("c")); | ||
| 571 | + assert!(manager.status.feed_sync_progress.is_empty()); | ||
| 572 | + } | ||
| 391 | } | 573 | } |
modified
src/rss/async_sync.rs +92 -7 | @@ -5,9 +5,14 @@ | ||
| 5 | 5 | |
| 6 | 6 | use crate::config::{Episode, Feed}; |
| 7 | 7 | use crate::config::sync_task::SyncMessage; |
| 8 | -use crate::rss::downloader::download_episode; | |
| 8 | +use crate::rss::downloader::{ | |
| 9 | + download_episode, refresh_downloaded_file, rename_existing_files_to_tag_filenames, | |
| 10 | +}; | |
| 9 | 11 | use crate::rss::episode_extractor::extract_episodes; |
| 10 | 12 | use crate::rss::parser::{fetch_feed, parse_rss_content}; |
| 13 | +use crate::rss::status_file::{ | |
| 14 | + compute_new_episodes, list_downloaded_audio_files, read_status_files, write_status_files, | |
| 15 | +}; | |
| 11 | 16 | use chrono::Utc; |
| 12 | 17 | use log::{error, info, warn}; |
| 13 | 18 | use std::sync::mpsc::Sender; |
| @@ -15,7 +20,9 @@ use std::sync::mpsc::Sender; | ||
| 15 | 20 | /// Synchronizes a single feed asynchronously |
| 16 | 21 | /// |
| 17 | 22 | /// This function runs in a separate thread and sends updates via the sender. |
| 18 | -pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) { | |
| 23 | +/// `transcode_m4a` is the persisted user setting: when false, .m4a downloads | |
| 24 | +/// are kept as is instead of being re-encoded to .mp3. | |
| 25 | +pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>, transcode_m4a: bool) { | |
| 19 | 26 | let feed_id = feed.id.clone(); |
| 20 | 27 | |
| 21 | 28 | // Send sync started message |
| @@ -30,6 +37,25 @@ pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) { | ||
| 30 | 37 | |
| 31 | 38 | info!("Starting async synchronization for feed: {}", feed_id); |
| 32 | 39 | |
| 40 | + // Report initial progress so the UI shows a bar immediately | |
| 41 | + let _ = sender.send(SyncMessage::SyncProgress { | |
| 42 | + feed_id: feed_id.clone(), | |
| 43 | + progress: 0.0, | |
| 44 | + status_text: "Récupération du flux…".to_string(), | |
| 45 | + }); | |
| 46 | + | |
| 47 | + // Catch-up pass: apply the tag-based renaming rule to the files already | |
| 48 | + // present in the download directory (e.g. downloaded before the rule | |
| 49 | + // existed, or files whose episode is no longer in the feed) | |
| 50 | + let renamed = rename_existing_files_to_tag_filenames(std::path::Path::new(&feed.download_path)); | |
| 51 | + if renamed > 0 { | |
| 52 | + info!( | |
| 53 | + "Renamed {} existing files to their tag-based name in {}", | |
| 54 | + renamed, | |
| 55 | + feed.download_path.display() | |
| 56 | + ); | |
| 57 | + } | |
| 58 | + | |
| 33 | 59 | // Validate the feed configuration |
| 34 | 60 | if let Err(err) = validate_feed(&feed) { |
| 35 | 61 | send_failed(&sender, &feed_id, err); |
| @@ -80,15 +106,53 @@ pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) { | ||
| 80 | 106 | |
| 81 | 107 | let mut feed_mut = feed; |
| 82 | 108 | let mut downloaded_count = 0; |
| 109 | + let total_episodes = episodes.len(); | |
| 110 | + | |
| 111 | + // Baseline of the previous sync: the files listed in status.txt. The | |
| 112 | + // delta (episodes whose file is not listed) identifies the new | |
| 113 | + // episodes; status.txt is rewritten at the end of this sync. | |
| 114 | + let previous_files = read_status_files(std::path::Path::new(&feed_mut.download_path)); | |
| 115 | + let new_episodes = compute_new_episodes(&episodes, &previous_files); | |
| 116 | + info!( | |
| 117 | + "Delta for feed {}: {} new episodes out of {} ({} files recorded in status.txt)", | |
| 118 | + feed_mut.name, | |
| 119 | + new_episodes.len(), | |
| 120 | + total_episodes, | |
| 121 | + previous_files.len() | |
| 122 | + ); | |
| 123 | + | |
| 124 | + for (idx, episode) in episodes.iter().enumerate() { | |
| 125 | + // Report per-episode progress so the UI bar advances step by step | |
| 126 | + let episode_progress = (idx + 1) as f32 / total_episodes as f32; | |
| 127 | + let _ = sender.send(SyncMessage::SyncProgress { | |
| 128 | + feed_id: feed_id.clone(), | |
| 129 | + progress: episode_progress, | |
| 130 | + status_text: format!("Épisode {}/{}: {}", idx + 1, total_episodes, episode.title), | |
| 131 | + }); | |
| 83 | 132 | |
| 84 | - for episode in &episodes { | |
| 85 | - // Skip episodes that are already downloaded | |
| 86 | 133 | if is_episode_downloaded(&feed_mut, episode) { |
| 87 | 134 | info!("Episode already downloaded: {}", episode.title); |
| 135 | + | |
| 136 | + // Catch-up: ensure the tags and the tag-based filename of files | |
| 137 | + // downloaded before those rules existed (no download happens) | |
| 138 | + refresh_downloaded_file( | |
| 139 | + episode, | |
| 140 | + std::path::Path::new(&feed_mut.download_path), | |
| 141 | + transcode_m4a, | |
| 142 | + ); | |
| 88 | 143 | continue; |
| 89 | 144 | } |
| 90 | 145 | |
| 91 | - match download_episode(episode, &feed_mut.download_path, Box::new(|_| {})) { | |
| 146 | + if new_episodes.iter().any(|e| e.id == episode.id) { | |
| 147 | + info!("New episode detected by delta: {}", episode.title); | |
| 148 | + } | |
| 149 | + | |
| 150 | + match download_episode( | |
| 151 | + episode, | |
| 152 | + &feed_mut.download_path, | |
| 153 | + Box::new(|_| {}), | |
| 154 | + transcode_m4a, | |
| 155 | + ) { | |
| 92 | 156 | Ok(_) => { |
| 93 | 157 | info!("Successfully downloaded episode: {}", episode.title); |
| 94 | 158 | downloaded_count += 1; |
| @@ -109,6 +173,13 @@ pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) { | ||
| 109 | 173 | } |
| 110 | 174 | } |
| 111 | 175 | |
| 176 | + // Final progress tick before the completion message | |
| 177 | + let _ = sender.send(SyncMessage::SyncProgress { | |
| 178 | + feed_id: feed_id.clone(), | |
| 179 | + progress: 1.0, | |
| 180 | + status_text: "Finalisation…".to_string(), | |
| 181 | + }); | |
| 182 | + | |
| 112 | 183 | // Recompute the downloaded episode count from the files actually |
| 113 | 184 | // present on disk: the displayed counter must reflect the current |
| 114 | 185 | // state, not a cumulative total that drifts over time. |
| @@ -123,6 +194,20 @@ pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) { | ||
| 123 | 194 | feed_mut.last_episode_date = Some(latest.pub_date); |
| 124 | 195 | } |
| 125 | 196 | |
| 197 | + // Persist the list of downloaded files in status.txt: it is the | |
| 198 | + // baseline for the delta computation of the next sync | |
| 199 | + let downloaded_files = | |
| 200 | + list_downloaded_audio_files(std::path::Path::new(&feed_mut.download_path)); | |
| 201 | + if let Err(e) = write_status_files( | |
| 202 | + std::path::Path::new(&feed_mut.download_path), | |
| 203 | + &downloaded_files, | |
| 204 | + ) { | |
| 205 | + warn!( | |
| 206 | + "Failed to write status.txt for feed {}: {}", | |
| 207 | + feed_mut.name, e | |
| 208 | + ); | |
| 209 | + } | |
| 210 | + | |
| 126 | 211 | // The caller saves the feeds once sync completes (see ui/home.rs) |
| 127 | 212 | let _ = sender.send(SyncMessage::SyncCompleted { |
| 128 | 213 | feed_id, |
| @@ -163,7 +248,7 @@ fn send_failed(sender: &Sender<SyncMessage>, feed_id: &str, error: String) { | ||
| 163 | 248 | } |
| 164 | 249 | |
| 165 | 250 | /// Synchronizes all feeds asynchronously |
| 166 | -pub fn sync_all_feeds_async(feeds: Vec<Feed>, sender: Sender<SyncMessage>) { | |
| 251 | +pub fn sync_all_feeds_async(feeds: Vec<Feed>, sender: Sender<SyncMessage>, transcode_m4a: bool) { | |
| 167 | 252 | info!("Starting async synchronization of all {} feeds", feeds.len()); |
| 168 | 253 | |
| 169 | 254 | for feed in feeds { |
| @@ -171,7 +256,7 @@ pub fn sync_all_feeds_async(feeds: Vec<Feed>, sender: Sender<SyncMessage>) { | ||
| 171 | 256 | |
| 172 | 257 | // Spawn a separate thread for each feed sync |
| 173 | 258 | std::thread::spawn(move || { |
| 174 | - sync_feed_async(feed, sender_clone); | |
| 259 | + sync_feed_async(feed, sender_clone, transcode_m4a); | |
| 175 | 260 | }); |
| 176 | 261 | } |
| 177 | 262 | } |
| @@ -5,9 +5,14 @@ | |||
| 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; |
| 8 | -use crate::rss::downloader::download_episode; | 8 | +use crate::rss::downloader::{ |
| 9 | + download_episode, refresh_downloaded_file, rename_existing_files_to_tag_filenames, | ||
| 10 | +}; | ||
| 9 | use crate::rss::episode_extractor::extract_episodes; | 11 | use crate::rss::episode_extractor::extract_episodes; |
| 10 | use crate::rss::parser::{fetch_feed, parse_rss_content}; | 12 | use crate::rss::parser::{fetch_feed, parse_rss_content}; |
| 13 | +use crate::rss::status_file::{ | ||
| 14 | + compute_new_episodes, list_downloaded_audio_files, read_status_files, write_status_files, | ||
| 15 | +}; | ||
| 11 | use chrono::Utc; | 16 | use chrono::Utc; |
| 12 | use log::{error, info, warn}; | 17 | use log::{error, info, warn}; |
| 13 | use std::sync::mpsc::Sender; | 18 | use std::sync::mpsc::Sender; |
| @@ -15,7 +20,9 @@ use std::sync::mpsc::Sender; | |||
| 15 | /// Synchronizes a single feed asynchronously | 20 | /// Synchronizes a single feed asynchronously |
| 16 | /// | 21 | /// |
| 17 | /// This function runs in a separate thread and sends updates via the sender. | 22 | /// This function runs in a separate thread and sends updates via the sender. |
| 18 | -pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) { | 23 | +/// `transcode_m4a` is the persisted user setting: when false, .m4a downloads |
| 24 | +/// are kept as is instead of being re-encoded to .mp3. | ||
| 25 | +pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>, transcode_m4a: bool) { | ||
| 19 | let feed_id = feed.id.clone(); | 26 | let feed_id = feed.id.clone(); |
| 20 | 27 | ||
| 21 | // Send sync started message | 28 | // Send sync started message |
| @@ -30,6 +37,25 @@ pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) { | |||
| 30 | 37 | ||
| 31 | info!("Starting async synchronization for feed: {}", feed_id); | 38 | info!("Starting async synchronization for feed: {}", feed_id); |
| 32 | 39 | ||
| 40 | + // Report initial progress so the UI shows a bar immediately | ||
| 41 | + let _ = sender.send(SyncMessage::SyncProgress { | ||
| 42 | + feed_id: feed_id.clone(), | ||
| 43 | + progress: 0.0, | ||
| 44 | + status_text: "Récupération du flux…".to_string(), | ||
| 45 | + }); | ||
| 46 | + | ||
| 47 | + // Catch-up pass: apply the tag-based renaming rule to the files already | ||
| 48 | + // present in the download directory (e.g. downloaded before the rule | ||
| 49 | + // existed, or files whose episode is no longer in the feed) | ||
| 50 | + let renamed = rename_existing_files_to_tag_filenames(std::path::Path::new(&feed.download_path)); | ||
| 51 | + if renamed > 0 { | ||
| 52 | + info!( | ||
| 53 | + "Renamed {} existing files to their tag-based name in {}", | ||
| 54 | + renamed, | ||
| 55 | + feed.download_path.display() | ||
| 56 | + ); | ||
| 57 | + } | ||
| 58 | + | ||
| 33 | // Validate the feed configuration | 59 | // Validate the feed configuration |
| 34 | if let Err(err) = validate_feed(&feed) { | 60 | if let Err(err) = validate_feed(&feed) { |
| 35 | send_failed(&sender, &feed_id, err); | 61 | send_failed(&sender, &feed_id, err); |
| @@ -80,15 +106,53 @@ pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) { | |||
| 80 | 106 | ||
| 81 | let mut feed_mut = feed; | 107 | let mut feed_mut = feed; |
| 82 | let mut downloaded_count = 0; | 108 | let mut downloaded_count = 0; |
| 109 | + let total_episodes = episodes.len(); | ||
| 110 | + | ||
| 111 | + // Baseline of the previous sync: the files listed in status.txt. The | ||
| 112 | + // delta (episodes whose file is not listed) identifies the new | ||
| 113 | + // episodes; status.txt is rewritten at the end of this sync. | ||
| 114 | + let previous_files = read_status_files(std::path::Path::new(&feed_mut.download_path)); | ||
| 115 | + let new_episodes = compute_new_episodes(&episodes, &previous_files); | ||
| 116 | + info!( | ||
| 117 | + "Delta for feed {}: {} new episodes out of {} ({} files recorded in status.txt)", | ||
| 118 | + feed_mut.name, | ||
| 119 | + new_episodes.len(), | ||
| 120 | + total_episodes, | ||
| 121 | + previous_files.len() | ||
| 122 | + ); | ||
| 123 | + | ||
| 124 | + for (idx, episode) in episodes.iter().enumerate() { | ||
| 125 | + // Report per-episode progress so the UI bar advances step by step | ||
| 126 | + let episode_progress = (idx + 1) as f32 / total_episodes as f32; | ||
| 127 | + let _ = sender.send(SyncMessage::SyncProgress { | ||
| 128 | + feed_id: feed_id.clone(), | ||
| 129 | + progress: episode_progress, | ||
| 130 | + status_text: format!("Épisode {}/{}: {}", idx + 1, total_episodes, episode.title), | ||
| 131 | + }); | ||
| 83 | 132 | ||
| 84 | - for episode in &episodes { | ||
| 85 | - // Skip episodes that are already downloaded | ||
| 86 | if is_episode_downloaded(&feed_mut, episode) { | 133 | if is_episode_downloaded(&feed_mut, episode) { |
| 87 | info!("Episode already downloaded: {}", episode.title); | 134 | info!("Episode already downloaded: {}", episode.title); |
| 135 | + | ||
| 136 | + // Catch-up: ensure the tags and the tag-based filename of files | ||
| 137 | + // downloaded before those rules existed (no download happens) | ||
| 138 | + refresh_downloaded_file( | ||
| 139 | + episode, | ||
| 140 | + std::path::Path::new(&feed_mut.download_path), | ||
| 141 | + transcode_m4a, | ||
| 142 | + ); | ||
| 88 | continue; | 143 | continue; |
| 89 | } | 144 | } |
| 90 | 145 | ||
| 91 | - match download_episode(episode, &feed_mut.download_path, Box::new(|_| {})) { | 146 | + if new_episodes.iter().any(|e| e.id == episode.id) { |
| 147 | + info!("New episode detected by delta: {}", episode.title); | ||
| 148 | + } | ||
| 149 | + | ||
| 150 | + match download_episode( | ||
| 151 | + episode, | ||
| 152 | + &feed_mut.download_path, | ||
| 153 | + Box::new(|_| {}), | ||
| 154 | + transcode_m4a, | ||
| 155 | + ) { | ||
| 92 | Ok(_) => { | 156 | Ok(_) => { |
| 93 | info!("Successfully downloaded episode: {}", episode.title); | 157 | info!("Successfully downloaded episode: {}", episode.title); |
| 94 | downloaded_count += 1; | 158 | downloaded_count += 1; |
| @@ -109,6 +173,13 @@ pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) { | |||
| 109 | } | 173 | } |
| 110 | } | 174 | } |
| 111 | 175 | ||
| 176 | + // Final progress tick before the completion message | ||
| 177 | + let _ = sender.send(SyncMessage::SyncProgress { | ||
| 178 | + feed_id: feed_id.clone(), | ||
| 179 | + progress: 1.0, | ||
| 180 | + status_text: "Finalisation…".to_string(), | ||
| 181 | + }); | ||
| 182 | + | ||
| 112 | // Recompute the downloaded episode count from the files actually | 183 | // Recompute the downloaded episode count from the files actually |
| 113 | // present on disk: the displayed counter must reflect the current | 184 | // present on disk: the displayed counter must reflect the current |
| 114 | // state, not a cumulative total that drifts over time. | 185 | // state, not a cumulative total that drifts over time. |
| @@ -123,6 +194,20 @@ pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) { | |||
| 123 | feed_mut.last_episode_date = Some(latest.pub_date); | 194 | feed_mut.last_episode_date = Some(latest.pub_date); |
| 124 | } | 195 | } |
| 125 | 196 | ||
| 197 | + // Persist the list of downloaded files in status.txt: it is the | ||
| 198 | + // baseline for the delta computation of the next sync | ||
| 199 | + let downloaded_files = | ||
| 200 | + list_downloaded_audio_files(std::path::Path::new(&feed_mut.download_path)); | ||
| 201 | + if let Err(e) = write_status_files( | ||
| 202 | + std::path::Path::new(&feed_mut.download_path), | ||
| 203 | + &downloaded_files, | ||
| 204 | + ) { | ||
| 205 | + warn!( | ||
| 206 | + "Failed to write status.txt for feed {}: {}", | ||
| 207 | + feed_mut.name, e | ||
| 208 | + ); | ||
| 209 | + } | ||
| 210 | + | ||
| 126 | // The caller saves the feeds once sync completes (see ui/home.rs) | 211 | // The caller saves the feeds once sync completes (see ui/home.rs) |
| 127 | let _ = sender.send(SyncMessage::SyncCompleted { | 212 | let _ = sender.send(SyncMessage::SyncCompleted { |
| 128 | feed_id, | 213 | feed_id, |
| @@ -163,7 +248,7 @@ fn send_failed(sender: &Sender<SyncMessage>, feed_id: &str, error: String) { | |||
| 163 | } | 248 | } |
| 164 | 249 | ||
| 165 | /// Synchronizes all feeds asynchronously | 250 | /// Synchronizes all feeds asynchronously |
| 166 | -pub fn sync_all_feeds_async(feeds: Vec<Feed>, sender: Sender<SyncMessage>) { | 251 | +pub fn sync_all_feeds_async(feeds: Vec<Feed>, sender: Sender<SyncMessage>, transcode_m4a: bool) { |
| 167 | info!("Starting async synchronization of all {} feeds", feeds.len()); | 252 | info!("Starting async synchronization of all {} feeds", feeds.len()); |
| 168 | 253 | ||
| 169 | for feed in feeds { | 254 | for feed in feeds { |
| @@ -171,7 +256,7 @@ pub fn sync_all_feeds_async(feeds: Vec<Feed>, sender: Sender<SyncMessage>) { | |||
| 171 | 256 | ||
| 172 | // Spawn a separate thread for each feed sync | 257 | // Spawn a separate thread for each feed sync |
| 173 | std::thread::spawn(move || { | 258 | std::thread::spawn(move || { |
| 174 | - sync_feed_async(feed, sender_clone); | 259 | + sync_feed_async(feed, sender_clone, transcode_m4a); |
| 175 | }); | 260 | }); |
| 176 | } | 261 | } |
| 177 | } | 262 | } |
modified
src/rss/audio_metadata.rs +10 -4 | @@ -95,13 +95,16 @@ pub fn write_id3_tags( | ||
| 95 | 95 | tag.set_album(album); |
| 96 | 96 | } |
| 97 | 97 | |
| 98 | - // Écrire le tag modifié | |
| 98 | + // Écrire le tag modifié. `write_to_file` gère le décalage des données | |
| 99 | + // audio (contrairement à `write_to` qui écrase le début du fichier | |
| 100 | + // quand il ne contient pas déjà un tag de taille suffisante). | |
| 99 | 101 | let mut file_write = std::fs::OpenOptions::new() |
| 102 | + .read(true) | |
| 100 | 103 | .write(true) |
| 101 | 104 | .open(path) |
| 102 | 105 | .map_err(MetadataError::IoError)?; |
| 103 | 106 | |
| 104 | - tag.write_to(&mut file_write, id3::Version::Id3v24) | |
| 107 | + tag.write_to_file(&mut file_write, id3::Version::Id3v24) | |
| 105 | 108 | .map_err(|e| MetadataError::Id3WriteError(e.to_string()))?; |
| 106 | 109 | |
| 107 | 110 | Ok(()) |
| @@ -127,8 +130,11 @@ pub fn ensure_id3_tags( | ||
| 127 | 130 | ) -> Result<(), MetadataError> { |
| 128 | 131 | log::info!("Checking ID3 tags for file: {}", file_path.display()); |
| 129 | 132 | |
| 130 | - // Lire les tags existants | |
| 131 | - let tags = read_id3_tags(file_path)?; | |
| 133 | + // Lire les tags existants. Un fichier audio sans tags ID3 est valide: le | |
| 134 | + // traiter comme ayant des tags vides pour que les valeurs manquantes | |
| 135 | + // soient écrites ci-dessous (sinon un fichier fraîchement téléchargé | |
| 136 | + // ne serait jamais taggé ni renommé selon ses tags) | |
| 137 | + let tags = read_id3_tags(file_path).unwrap_or_default(); | |
| 132 | 138 | |
| 133 | 139 | let mut needs_write = false; |
| 134 | 140 | let mut title_to_write = None; |
| @@ -95,13 +95,16 @@ pub fn write_id3_tags( | |||
| 95 | tag.set_album(album); | 95 | tag.set_album(album); |
| 96 | } | 96 | } |
| 97 | 97 | ||
| 98 | - // Écrire le tag modifié | 98 | + // Écrire le tag modifié. `write_to_file` gère le décalage des données |
| 99 | + // audio (contrairement à `write_to` qui écrase le début du fichier | ||
| 100 | + // quand il ne contient pas déjà un tag de taille suffisante). | ||
| 99 | let mut file_write = std::fs::OpenOptions::new() | 101 | let mut file_write = std::fs::OpenOptions::new() |
| 102 | + .read(true) | ||
| 100 | .write(true) | 103 | .write(true) |
| 101 | .open(path) | 104 | .open(path) |
| 102 | .map_err(MetadataError::IoError)?; | 105 | .map_err(MetadataError::IoError)?; |
| 103 | 106 | ||
| 104 | - tag.write_to(&mut file_write, id3::Version::Id3v24) | 107 | + tag.write_to_file(&mut file_write, id3::Version::Id3v24) |
| 105 | .map_err(|e| MetadataError::Id3WriteError(e.to_string()))?; | 108 | .map_err(|e| MetadataError::Id3WriteError(e.to_string()))?; |
| 106 | 109 | ||
| 107 | Ok(()) | 110 | Ok(()) |
| @@ -127,8 +130,11 @@ pub fn ensure_id3_tags( | |||
| 127 | ) -> Result<(), MetadataError> { | 130 | ) -> Result<(), MetadataError> { |
| 128 | log::info!("Checking ID3 tags for file: {}", file_path.display()); | 131 | log::info!("Checking ID3 tags for file: {}", file_path.display()); |
| 129 | 132 | ||
| 130 | - // Lire les tags existants | 133 | + // Lire les tags existants. Un fichier audio sans tags ID3 est valide: le |
| 131 | - let tags = read_id3_tags(file_path)?; | 134 | + // traiter comme ayant des tags vides pour que les valeurs manquantes |
| 135 | + // soient écrites ci-dessous (sinon un fichier fraîchement téléchargé | ||
| 136 | + // ne serait jamais taggé ni renommé selon ses tags) | ||
| 137 | + let tags = read_id3_tags(file_path).unwrap_or_default(); | ||
| 132 | 138 | ||
| 133 | let mut needs_write = false; | 139 | let mut needs_write = false; |
| 134 | let mut title_to_write = None; | 140 | let mut title_to_write = None; |
modified
src/rss/downloader.rs +780 -31 | @@ -3,7 +3,11 @@ | ||
| 3 | 3 | //! This module handles downloading podcast episodes from URLs. |
| 4 | 4 | |
| 5 | 5 | use crate::config::episode::Episode; |
| 6 | -use crate::rss::audio_metadata::ensure_id3_tags; | |
| 6 | +use crate::rss::audio_metadata::{ensure_id3_tags, read_id3_tags}; | |
| 7 | +use crate::rss::episode_extractor::AUDIO_EXTENSIONS; | |
| 8 | +use crate::rss::transcode::{ | |
| 9 | + is_transcode_source, mp3_counterpart, remove_transcode_source_next_to, transcode_to_mp3, | |
| 10 | +}; | |
| 7 | 11 | use log::{info, warn}; |
| 8 | 12 | use std::fs; |
| 9 | 13 | use std::io::{Read, Write}; |
| @@ -181,32 +185,41 @@ fn stream_to_file( | ||
| 181 | 185 | Ok(downloaded) |
| 182 | 186 | } |
| 183 | 187 | |
| 188 | +/// Maximum length of a filename component built from tag values, keeping | |
| 189 | +/// full paths well under common filesystem limits | |
| 190 | +const MAX_FILENAME_COMPONENT_LEN: usize = 100; | |
| 191 | + | |
| 184 | 192 | /// Downloads an episode to its designated location |
| 185 | -/// After downloading, ensures ID3 tags (title, artist, album) are present | |
| 193 | +/// After downloading, ensures ID3 tags (title, artist, album) are present, | |
| 194 | +/// then renames the file to "[artist] - [title]" based on its ID3 tags. | |
| 195 | +/// A .m4a download is re-encoded to .mp3 first when `transcode_m4a` is set | |
| 196 | +/// (the persisted user setting); it is kept as is otherwise. | |
| 186 | 197 | pub fn download_episode( |
| 187 | 198 | episode: &Episode, |
| 188 | 199 | download_path: &Path, |
| 189 | 200 | progress_callback: ProgressCallback, |
| 201 | + transcode_m4a: bool, | |
| 190 | 202 | ) -> Result<PathBuf, DownloadError> { |
| 191 | - let save_path = episode.get_save_path(download_path); | |
| 192 | - | |
| 193 | 203 | // Check if already exists and has correct size (via needs_download) |
| 194 | - if !needs_download(episode, download_path) { | |
| 204 | + if let Some(existing_path) = | |
| 205 | + refresh_downloaded_file(episode, download_path, transcode_m4a) | |
| 206 | + { | |
| 195 | 207 | info!("Episode already downloaded: {}", episode.title); |
| 196 | - // Still ensure ID3 tags are present even if file already exists | |
| 197 | - if let Err(e) = ensure_id3_tags( | |
| 198 | - &save_path, | |
| 199 | - &episode.title, | |
| 200 | - episode.artist.as_deref(), | |
| 201 | - episode.album.as_deref(), | |
| 202 | - ) { | |
| 203 | - warn!("Failed to ensure ID3 tags for existing file {}: {}", save_path.display(), e); | |
| 204 | - } | |
| 205 | - return Ok(save_path); | |
| 208 | + return Ok(existing_path); | |
| 206 | 209 | } |
| 207 | 210 | |
| 211 | + let mut save_path = episode.get_save_path(download_path); | |
| 208 | 212 | download_file(&episode.audio_url, &save_path, Some(progress_callback))?; |
| 209 | - | |
| 213 | + | |
| 214 | + // Re-encode non-ID3 formats (e.g. .m4a) to .mp3 so a single tagging and | |
| 215 | + // renaming pipeline applies. When disabled or without ffmpeg the original | |
| 216 | + // is kept as is: it must never receive an ID3 tag, which would corrupt it | |
| 217 | + if is_transcode_source(&save_path) | |
| 218 | + && (!transcode_m4a || !transcode_to_mp3(&mut save_path)) | |
| 219 | + { | |
| 220 | + return Ok(save_path); | |
| 221 | + } | |
| 222 | + | |
| 210 | 223 | // Ensure ID3 tags are present after downloading. A tag failure must not |
| 211 | 224 | // fail the whole download: the audio file is already complete on disk |
| 212 | 225 | // and would otherwise never be counted as downloaded. |
| @@ -218,27 +231,279 @@ pub fn download_episode( | ||
| 218 | 231 | ) { |
| 219 | 232 | warn!("Failed to write ID3 tags for {}: {}", save_path.display(), e); |
| 220 | 233 | } |
| 221 | - | |
| 234 | + | |
| 235 | + rename_to_tag_filename(&mut save_path); | |
| 236 | + | |
| 222 | 237 | Ok(save_path) |
| 223 | 238 | } |
| 224 | 239 | |
| 225 | -/// Checks if a file needs to be downloaded based on its existence and size | |
| 240 | +/// Checks if a file needs to be downloaded based on its existence and size. | |
| 241 | +/// An episode is considered downloaded when a complete file exists under | |
| 242 | +/// either its URL-derived name or its tag-derived "[artist] - [title]" name | |
| 243 | +/// (the file is renamed to the tag-based name after download). | |
| 226 | 244 | pub fn needs_download(episode: &Episode, download_path: &Path) -> bool { |
| 227 | - let save_path = episode.get_save_path(download_path); | |
| 228 | - | |
| 229 | - // If file doesn't exist, we need to download | |
| 230 | - if !save_path.exists() { | |
| 231 | - return true; | |
| 232 | - } | |
| 233 | - | |
| 234 | - // If file exists but is empty, we need to download | |
| 235 | - if let Ok(metadata) = fs::metadata(&save_path) { | |
| 236 | - if metadata.len() == 0 { | |
| 237 | - return true; | |
| 245 | + find_downloaded_path(episode, download_path).is_none() | |
| 246 | +} | |
| 247 | + | |
| 248 | +/// Ensures the ID3 tags and the tag-based filename of an episode's already | |
| 249 | +/// downloaded file: the tags are filled from the episode metadata when the | |
| 250 | +/// file predates the tagging rule, then the file is renamed to | |
| 251 | +/// "[artist] - [title]". This is the catch-up path for files downloaded | |
| 252 | +/// before those rules existed. A .m4a file is re-encoded to .mp3 first when | |
| 253 | +/// `transcode_m4a` is set. Returns the (possibly renamed) path of the file, | |
| 254 | +/// or None when the episode is not on disk. | |
| 255 | +pub fn refresh_downloaded_file( | |
| 256 | + episode: &Episode, | |
| 257 | + download_path: &Path, | |
| 258 | + transcode_m4a: bool, | |
| 259 | +) -> Option<PathBuf> { | |
| 260 | + let mut existing_path = find_downloaded_path(episode, download_path)?; | |
| 261 | + | |
| 262 | + // A non-ID3 format (e.g. .m4a) is re-encoded to .mp3 first: it must | |
| 263 | + // never receive an ID3 tag, which would corrupt it. When the option is | |
| 264 | + // disabled or without ffmpeg the file is kept as is (no tags, no rename) | |
| 265 | + if is_transcode_source(&existing_path) | |
| 266 | + && (!transcode_m4a || !transcode_to_mp3(&mut existing_path)) | |
| 267 | + { | |
| 268 | + return Some(existing_path); | |
| 269 | + } | |
| 270 | + | |
| 271 | + // The canonical file is the transcoded .mp3: remove a leftover original | |
| 272 | + // .m4a next to it (e.g. its removal failed during a previous pass). The | |
| 273 | + // episode stays downloaded — the .mp3 is preferred by the download | |
| 274 | + // detection — so it is never re-downloaded. | |
| 275 | + if !is_transcode_source(&existing_path) { | |
| 276 | + remove_transcode_source_next_to(&existing_path); | |
| 277 | + } | |
| 278 | + | |
| 279 | + // Still ensure ID3 tags are present even if the file already exists | |
| 280 | + if let Err(e) = ensure_id3_tags( | |
| 281 | + &existing_path, | |
| 282 | + &episode.title, | |
| 283 | + episode.artist.as_deref(), | |
| 284 | + episode.album.as_deref(), | |
| 285 | + ) { | |
| 286 | + warn!( | |
| 287 | + "Failed to ensure ID3 tags for existing file {}: {}", | |
| 288 | + existing_path.display(), | |
| 289 | + e | |
| 290 | + ); | |
| 291 | + } | |
| 292 | + rename_to_tag_filename(&mut existing_path); | |
| 293 | + | |
| 294 | + Some(existing_path) | |
| 295 | +} | |
| 296 | + | |
| 297 | +/// Returns the path of the episode's complete file on disk, if any. | |
| 298 | +/// Checks the transcoded .mp3 first (the canonical file for a re-encoded | |
| 299 | +/// download), then the URL-derived filename, then the tag-based filename. | |
| 300 | +pub fn find_downloaded_path(episode: &Episode, download_path: &Path) -> Option<PathBuf> { | |
| 301 | + let url_path = episode.get_save_path(download_path); | |
| 302 | + | |
| 303 | + if let Some(mp3_path) = mp3_counterpart(&url_path) { | |
| 304 | + if is_complete_file(&mp3_path) { | |
| 305 | + return Some(mp3_path); | |
| 306 | + } | |
| 307 | + } | |
| 308 | + | |
| 309 | + if is_complete_file(&url_path) { | |
| 310 | + return Some(url_path); | |
| 311 | + } | |
| 312 | + | |
| 313 | + if let Some(tag_name) = episode_tag_filename(episode) { | |
| 314 | + let tag_path = download_path.join(tag_name); | |
| 315 | + if is_complete_file(&tag_path) { | |
| 316 | + return Some(tag_path); | |
| 238 | 317 | } |
| 239 | 318 | } |
| 240 | - | |
| 241 | - false | |
| 319 | + | |
| 320 | + None | |
| 321 | +} | |
| 322 | + | |
| 323 | +/// Returns every filename an episode's downloaded file can have on disk: | |
| 324 | +/// the transcoded .mp3 counterpart (canonical for a re-encoded download), | |
| 325 | +/// the URL-derived filename, and the tag-based "[artist] - [title]" name. | |
| 326 | +/// Used to match an episode against the file list recorded in status.txt. | |
| 327 | +pub fn possible_filenames(episode: &Episode) -> Vec<String> { | |
| 328 | + let url_path = episode.get_save_path(Path::new("")); | |
| 329 | + let mut names = Vec::new(); | |
| 330 | + | |
| 331 | + if let Some(mp3_path) = mp3_counterpart(&url_path) { | |
| 332 | + if let Some(name) = mp3_path.file_name() { | |
| 333 | + names.push(name.to_string_lossy().into_owned()); | |
| 334 | + } | |
| 335 | + } | |
| 336 | + | |
| 337 | + if let Some(name) = url_path.file_name() { | |
| 338 | + names.push(name.to_string_lossy().into_owned()); | |
| 339 | + } | |
| 340 | + | |
| 341 | + if let Some(tag_name) = episode_tag_filename(episode) { | |
| 342 | + names.push(tag_name); | |
| 343 | + } | |
| 344 | + | |
| 345 | + names | |
| 346 | +} | |
| 347 | + | |
| 348 | +/// Returns true when the file exists and is not empty | |
| 349 | +/// (an empty file is a failed or incomplete download) | |
| 350 | +fn is_complete_file(path: &Path) -> bool { | |
| 351 | + fs::metadata(path).map(|m| m.len() > 0).unwrap_or(false) | |
| 352 | +} | |
| 353 | + | |
| 354 | +/// Builds the "[artist] - [title].[ext]" filename derived from the episode | |
| 355 | +/// metadata (the same values written into the ID3 tags), or None when the | |
| 356 | +/// artist or title is missing | |
| 357 | +fn episode_tag_filename(episode: &Episode) -> Option<String> { | |
| 358 | + let artist = non_empty(&episode.artist)?; | |
| 359 | + let title = episode.title.trim(); | |
| 360 | + if title.is_empty() { | |
| 361 | + return None; | |
| 362 | + } | |
| 363 | + let source = Path::new(&episode.audio_filename); | |
| 364 | + // A re-encoded download (e.g. .m4a) ends up as .mp3 | |
| 365 | + let extension = if is_transcode_source(source) { | |
| 366 | + "mp3" | |
| 367 | + } else { | |
| 368 | + source.extension().and_then(|e| e.to_str()).unwrap_or("mp3") | |
| 369 | + }; | |
| 370 | + tag_based_filename(artist, title, extension) | |
| 371 | +} | |
| 372 | + | |
| 373 | +/// Builds a "[artist] - [title].[extension]" filename from ID3 tag values. | |
| 374 | +/// Returns None when nothing usable remains after sanitization. | |
| 375 | +fn tag_based_filename(artist: &str, title: &str, extension: &str) -> Option<String> { | |
| 376 | + let artist = sanitize_for_filename(artist); | |
| 377 | + let title = sanitize_for_filename(title); | |
| 378 | + if artist.is_empty() || title.is_empty() { | |
| 379 | + return None; | |
| 380 | + } | |
| 381 | + Some(format!("{} - {}.{}", artist, title, extension)) | |
| 382 | +} | |
| 383 | + | |
| 384 | +/// Renames a downloaded file to "[artist] - [title].[ext]" based on its ID3 | |
| 385 | +/// tags. The file keeps its current name when the tags are missing or | |
| 386 | +/// incomplete, when the name already matches, or when the target name is | |
| 387 | +/// already taken by another file. | |
| 388 | +fn rename_to_tag_filename(file_path: &mut PathBuf) { | |
| 389 | + let extension = file_path | |
| 390 | + .extension() | |
| 391 | + .and_then(|e| e.to_str()) | |
| 392 | + .unwrap_or("mp3") | |
| 393 | + .to_string(); | |
| 394 | + | |
| 395 | + // No readable tags: keep the current name | |
| 396 | + let tags = match read_id3_tags(file_path) { | |
| 397 | + Ok(tags) => tags, | |
| 398 | + Err(_) => return, | |
| 399 | + }; | |
| 400 | + | |
| 401 | + let Some(artist) = non_empty(&tags.artist) else { | |
| 402 | + return; | |
| 403 | + }; | |
| 404 | + let Some(title) = non_empty(&tags.title) else { | |
| 405 | + return; | |
| 406 | + }; | |
| 407 | + | |
| 408 | + let Some(desired_name) = tag_based_filename(artist, title, &extension) else { | |
| 409 | + return; | |
| 410 | + }; | |
| 411 | + | |
| 412 | + let current_name = file_path | |
| 413 | + .file_name() | |
| 414 | + .map(|n| n.to_string_lossy()) | |
| 415 | + .unwrap_or_default(); | |
| 416 | + if current_name == desired_name { | |
| 417 | + return; | |
| 418 | + } | |
| 419 | + | |
| 420 | + let desired_path = file_path.with_file_name(&desired_name); | |
| 421 | + if desired_path.exists() { | |
| 422 | + warn!( | |
| 423 | + "Skipping rename of {}: target filename already exists: {}", | |
| 424 | + file_path.display(), | |
| 425 | + desired_name | |
| 426 | + ); | |
| 427 | + return; | |
| 428 | + } | |
| 429 | + | |
| 430 | + let current_path = file_path.clone(); | |
| 431 | + match fs::rename(¤t_path, &desired_path) { | |
| 432 | + Ok(()) => { | |
| 433 | + info!("Renamed {} to {}", current_path.display(), desired_name); | |
| 434 | + *file_path = desired_path; | |
| 435 | + } | |
| 436 | + Err(e) => warn!( | |
| 437 | + "Failed to rename {} to {}: {}", | |
| 438 | + current_path.display(), | |
| 439 | + desired_name, | |
| 440 | + e | |
| 441 | + ), | |
| 442 | + } | |
| 443 | +} | |
| 444 | + | |
| 445 | +/// Returns the string trimmed, or None when it is empty | |
| 446 | +fn non_empty(value: &Option<String>) -> Option<&str> { | |
| 447 | + value.as_deref().map(str::trim).filter(|s| !s.is_empty()) | |
| 448 | +} | |
| 449 | + | |
| 450 | +/// Applies the "[artist] - [title]" renaming rule to the audio files already | |
| 451 | +/// present in a download directory (e.g. files downloaded before the rule | |
| 452 | +/// existed). Returns the number of files renamed. Files without usable ID3 | |
| 453 | +/// tags, and files whose name already matches, are left untouched. | |
| 454 | +pub fn rename_existing_files_to_tag_filenames(download_path: &Path) -> usize { | |
| 455 | + let entries = match fs::read_dir(download_path) { | |
| 456 | + Ok(entries) => entries, | |
| 457 | + // The directory does not exist: nothing downloaded yet | |
| 458 | + Err(_) => return 0, | |
| 459 | + }; | |
| 460 | + | |
| 461 | + let mut renamed = 0; | |
| 462 | + for entry in entries.flatten() { | |
| 463 | + let mut file_path = entry.path(); | |
| 464 | + if !file_path.is_file() { | |
| 465 | + continue; | |
| 466 | + } | |
| 467 | + | |
| 468 | + let Some(extension) = file_path | |
| 469 | + .extension() | |
| 470 | + .and_then(|e| e.to_str()) | |
| 471 | + .map(|e| e.to_lowercase()) | |
| 472 | + else { | |
| 473 | + continue; | |
| 474 | + }; | |
| 475 | + if !AUDIO_EXTENSIONS.contains(&extension.as_str()) { | |
| 476 | + continue; | |
| 477 | + } | |
| 478 | + | |
| 479 | + let original_path = file_path.clone(); | |
| 480 | + rename_to_tag_filename(&mut file_path); | |
| 481 | + if file_path != original_path { | |
| 482 | + renamed += 1; | |
| 483 | + } | |
| 484 | + } | |
| 485 | + renamed | |
| 486 | +} | |
| 487 | + | |
| 488 | +/// Sanitizes a tag value for use in a filename: replaces characters that are | |
| 489 | +/// invalid on common filesystems, trims whitespace and trailing dots, and | |
| 490 | +/// caps the length. Returns an empty string when nothing usable remains. | |
| 491 | +fn sanitize_for_filename(value: &str) -> String { | |
| 492 | + let replaced: String = value | |
| 493 | + .chars() | |
| 494 | + .map(|c| match c { | |
| 495 | + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-', | |
| 496 | + c if c.is_control() => '-', | |
| 497 | + c => c, | |
| 498 | + }) | |
| 499 | + .collect(); | |
| 500 | + replaced | |
| 501 | + .chars() | |
| 502 | + .take(MAX_FILENAME_COMPONENT_LEN) | |
| 503 | + .collect::<String>() | |
| 504 | + .trim() | |
| 505 | + .trim_matches(['-', '.', ' ']) | |
| 506 | + .to_string() | |
| 242 | 507 | } |
| 243 | 508 | |
| 244 | 509 | #[cfg(test)] |
| @@ -317,6 +582,490 @@ mod tests { | ||
| 317 | 582 | assert!(nested_path.parent().unwrap().exists()); |
| 318 | 583 | } |
| 319 | 584 | |
| 585 | + #[test] | |
| 586 | + fn test_sanitize_for_filename() { | |
| 587 | + // Invalid characters are replaced | |
| 588 | + assert_eq!(sanitize_for_filename("AC/DC: Best?"), "AC-DC- Best"); | |
| 589 | + // Leading/trailing whitespace and trailing dots are stripped | |
| 590 | + assert_eq!(sanitize_for_filename(" Episode 42... "), "Episode 42"); | |
| 591 | + // Nothing usable remains | |
| 592 | + assert_eq!(sanitize_for_filename(" /? "), ""); | |
| 593 | + // Length is capped | |
| 594 | + let long = "a".repeat(MAX_FILENAME_COMPONENT_LEN + 50); | |
| 595 | + assert_eq!(sanitize_for_filename(&long).chars().count(), MAX_FILENAME_COMPONENT_LEN); | |
| 596 | + } | |
| 597 | + | |
| 598 | + #[test] | |
| 599 | + fn test_tag_based_filename() { | |
| 600 | + assert_eq!( | |
| 601 | + tag_based_filename("Artist", "Title", "mp3").as_deref(), | |
| 602 | + Some("Artist - Title.mp3") | |
| 603 | + ); | |
| 604 | + // Missing values or nothing usable after sanitization | |
| 605 | + assert_eq!(tag_based_filename("Artist", "", "mp3"), None); | |
| 606 | + assert_eq!(tag_based_filename("///", "Title", "mp3"), None); | |
| 607 | + } | |
| 608 | + | |
| 609 | + #[test] | |
| 610 | + fn test_episode_tag_filename() { | |
| 611 | + let mut episode = Episode::new( | |
| 612 | + "ep-123", | |
| 613 | + "Episode Title", | |
| 614 | + "https://example.com/episode.mp3", | |
| 615 | + Utc::now(), | |
| 616 | + ); | |
| 617 | + assert_eq!(episode_tag_filename(&episode), None, "no artist"); | |
| 618 | + | |
| 619 | + episode.artist = Some("Artist Name".to_string()); | |
| 620 | + assert_eq!( | |
| 621 | + episode_tag_filename(&episode).as_deref(), | |
| 622 | + Some("Artist Name - Episode Title.mp3") | |
| 623 | + ); | |
| 624 | + | |
| 625 | + // Falls back to .mp3 when the URL has no extension | |
| 626 | + let mut no_ext = Episode::new("ep-456", "Title", "https://example.com/audio", Utc::now()); | |
| 627 | + no_ext.artist = Some("Artist".to_string()); | |
| 628 | + assert_eq!( | |
| 629 | + episode_tag_filename(&no_ext).as_deref(), | |
| 630 | + Some("Artist - Title.mp3") | |
| 631 | + ); | |
| 632 | + } | |
| 633 | + | |
| 634 | + #[test] | |
| 635 | + fn test_possible_filenames() { | |
| 636 | + let mut episode = Episode::new( | |
| 637 | + "ep-123", | |
| 638 | + "Episode Title", | |
| 639 | + "https://example.com/episode.mp3", | |
| 640 | + Utc::now(), | |
| 641 | + ); | |
| 642 | + // No artist: only the URL-derived name | |
| 643 | + assert_eq!(possible_filenames(&episode), vec!["episode.mp3".to_string()]); | |
| 644 | + | |
| 645 | + // With an artist, the tag-based name is also possible | |
| 646 | + episode.artist = Some("Artist Name".to_string()); | |
| 647 | + assert_eq!( | |
| 648 | + possible_filenames(&episode), | |
| 649 | + vec![ | |
| 650 | + "episode.mp3".to_string(), | |
| 651 | + "Artist Name - Episode Title.mp3".to_string() | |
| 652 | + ] | |
| 653 | + ); | |
| 654 | + | |
| 655 | + // A re-encoded download: the .mp3 counterpart comes first | |
| 656 | + let m4a = Episode::new("ep-456", "Title", "https://example.com/ep.m4a", Utc::now()); | |
| 657 | + assert_eq!( | |
| 658 | + possible_filenames(&m4a), | |
| 659 | + vec!["ep.mp3".to_string(), "ep.m4a".to_string()] | |
| 660 | + ); | |
| 661 | + } | |
| 662 | + | |
| 663 | + #[test] | |
| 664 | + fn test_needs_download_tag_named_file() { | |
| 665 | + let mut episode = Episode::new( | |
| 666 | + "ep-123", | |
| 667 | + "Episode Title", | |
| 668 | + "https://example.com/episode.mp3", | |
| 669 | + Utc::now(), | |
| 670 | + ); | |
| 671 | + episode.artist = Some("Artist Name".to_string()); | |
| 672 | + let temp_dir = tempdir().unwrap(); | |
| 673 | + let download_path = temp_dir.path(); | |
| 674 | + | |
| 675 | + // Nothing on disk | |
| 676 | + assert!(needs_download(&episode, download_path)); | |
| 677 | + | |
| 678 | + // File renamed to its tag-based name: detected as downloaded | |
| 679 | + fs::write(download_path.join("Artist Name - Episode Title.mp3"), "audio").unwrap(); | |
| 680 | + assert!(!needs_download(&episode, download_path)); | |
| 681 | + assert_eq!( | |
| 682 | + find_downloaded_path(&episode, download_path), | |
| 683 | + Some(download_path.join("Artist Name - Episode Title.mp3")) | |
| 684 | + ); | |
| 685 | + | |
| 686 | + // An empty tag-named file is incomplete | |
| 687 | + fs::write(download_path.join("Artist Name - Episode Title.mp3"), "").unwrap(); | |
| 688 | + assert!(needs_download(&episode, download_path)); | |
| 689 | + } | |
| 690 | + | |
| 691 | + #[test] | |
| 692 | + fn test_rename_to_tag_filename() { | |
| 693 | + use id3::{Tag, TagLike}; | |
| 694 | + | |
| 695 | + let temp_dir = tempdir().unwrap(); | |
| 696 | + let file_path = temp_dir.path().join("episode.mp3"); | |
| 697 | + | |
| 698 | + // Create a valid MP3 file with ID3 tags | |
| 699 | + let mut tag = Tag::new(); | |
| 700 | + tag.set_title("Episode Title"); | |
| 701 | + tag.set_artist("Artist Name"); | |
| 702 | + let mut file = fs::File::create(&file_path).unwrap(); | |
| 703 | + tag.write_to(&mut file, id3::Version::Id3v24).unwrap(); | |
| 704 | + drop(file); | |
| 705 | + | |
| 706 | + let mut path = file_path.clone(); | |
| 707 | + rename_to_tag_filename(&mut path); | |
| 708 | + let expected = temp_dir.path().join("Artist Name - Episode Title.mp3"); | |
| 709 | + assert_eq!(path, expected); | |
| 710 | + assert!(expected.exists()); | |
| 711 | + assert!(!file_path.exists()); | |
| 712 | + | |
| 713 | + // Renaming again is a no-op: the name already matches | |
| 714 | + rename_to_tag_filename(&mut path); | |
| 715 | + assert_eq!(path, expected); | |
| 716 | + } | |
| 717 | + | |
| 718 | + #[test] | |
| 719 | + fn test_rename_to_tag_filename_without_tags() { | |
| 720 | + let temp_dir = tempdir().unwrap(); | |
| 721 | + let file_path = temp_dir.path().join("episode.mp3"); | |
| 722 | + fs::write(&file_path, "not an audio file").unwrap(); | |
| 723 | + | |
| 724 | + // No readable ID3 tags: the current name is kept | |
| 725 | + let mut path = file_path.clone(); | |
| 726 | + rename_to_tag_filename(&mut path); | |
| 727 | + assert_eq!(path, file_path); | |
| 728 | + assert!(file_path.exists()); | |
| 729 | + } | |
| 730 | + | |
| 731 | + #[test] | |
| 732 | + fn test_rename_to_tag_filename_target_exists() { | |
| 733 | + use id3::{Tag, TagLike}; | |
| 734 | + | |
| 735 | + let temp_dir = tempdir().unwrap(); | |
| 736 | + let file_path = temp_dir.path().join("episode.mp3"); | |
| 737 | + | |
| 738 | + let mut tag = Tag::new(); | |
| 739 | + tag.set_title("Episode Title"); | |
| 740 | + tag.set_artist("Artist Name"); | |
| 741 | + let mut file = fs::File::create(&file_path).unwrap(); | |
| 742 | + tag.write_to(&mut file, id3::Version::Id3v24).unwrap(); | |
| 743 | + drop(file); | |
| 744 | + | |
| 745 | + // The target name is already taken by another file: skip the rename | |
| 746 | + fs::write(temp_dir.path().join("Artist Name - Episode Title.mp3"), "other").unwrap(); | |
| 747 | + | |
| 748 | + let mut path = file_path.clone(); | |
| 749 | + rename_to_tag_filename(&mut path); | |
| 750 | + assert_eq!(path, file_path); | |
| 751 | + assert!(file_path.exists()); | |
| 752 | + } | |
| 753 | + | |
| 754 | + #[test] | |
| 755 | + fn test_rename_existing_files_to_tag_filenames() { | |
| 756 | + use id3::{Tag, TagLike}; | |
| 757 | + | |
| 758 | + let temp_dir = tempdir().unwrap(); | |
| 759 | + let download_path = temp_dir.path(); | |
| 760 | + | |
| 761 | + // An old file with tags: must be renamed | |
| 762 | + let old_file = download_path.join("old_episode.mp3"); | |
| 763 | + let mut tag = Tag::new(); | |
| 764 | + tag.set_title("Episode Title"); | |
| 765 | + tag.set_artist("Artist Name"); | |
| 766 | + let mut file = fs::File::create(&old_file).unwrap(); | |
| 767 | + tag.write_to(&mut file, id3::Version::Id3v24).unwrap(); | |
| 768 | + drop(file); | |
| 769 | + | |
| 770 | + // A file already matching the rule: unchanged | |
| 771 | + let matching_file = download_path.join("Artist Name - Episode Title.mp3"); | |
| 772 | + fs::copy(&old_file, &matching_file).unwrap(); | |
| 773 | + | |
| 774 | + // A file without tags: unchanged | |
| 775 | + let untagged_file = download_path.join("untagged.mp3"); | |
| 776 | + fs::write(&untagged_file, "no tags here").unwrap(); | |
| 777 | + | |
| 778 | + // A non-audio file: unchanged | |
| 779 | + let text_file = download_path.join("notes.txt"); | |
| 780 | + fs::write(&text_file, "text").unwrap(); | |
| 781 | + | |
| 782 | + let renamed = rename_existing_files_to_tag_filenames(download_path); | |
| 783 | + // old_episode.mp3 collides with the already-matching file, so only | |
| 784 | + // the collision-free renames are counted; here none can happen | |
| 785 | + assert_eq!(renamed, 0); | |
| 786 | + assert!(old_file.exists()); | |
| 787 | + | |
| 788 | + // Without the collision, the old file is renamed | |
| 789 | + fs::remove_file(&matching_file).unwrap(); | |
| 790 | + let renamed = rename_existing_files_to_tag_filenames(download_path); | |
| 791 | + assert_eq!(renamed, 1); | |
| 792 | + assert!(matching_file.exists()); | |
| 793 | + assert!(!old_file.exists()); | |
| 794 | + assert!(untagged_file.exists()); | |
| 795 | + assert!(text_file.exists()); | |
| 796 | + | |
| 797 | + // A second pass finds nothing left to rename | |
| 798 | + assert_eq!(rename_existing_files_to_tag_filenames(download_path), 0); | |
| 799 | + } | |
| 800 | + | |
| 801 | + #[test] | |
| 802 | + fn test_find_downloaded_path_transcoded_m4a() { | |
| 803 | + let mut episode = Episode::new( | |
| 804 | + "ep-123", | |
| 805 | + "Episode Title", | |
| 806 | + "https://example.com/episode.m4a", | |
| 807 | + Utc::now(), | |
| 808 | + ); | |
| 809 | + episode.artist = Some("Artist Name".to_string()); | |
| 810 | + let temp_dir = tempdir().unwrap(); | |
| 811 | + let download_path = temp_dir.path(); | |
| 812 | + | |
| 813 | + // A .m4a left as is (no ffmpeg): detected under its original name | |
| 814 | + let m4a_path = download_path.join("episode.m4a"); | |
| 815 | + fs::write(&m4a_path, "audio").unwrap(); | |
| 816 | + assert_eq!( | |
| 817 | + find_downloaded_path(&episode, download_path), | |
| 818 | + Some(m4a_path.clone()) | |
| 819 | + ); | |
| 820 | + assert!(!needs_download(&episode, download_path)); | |
| 821 | + | |
| 822 | + // The transcoded .mp3 is the canonical file: preferred over the | |
| 823 | + // original when both exist | |
| 824 | + let mp3_path = download_path.join("episode.mp3"); | |
| 825 | + fs::write(&mp3_path, "audio").unwrap(); | |
| 826 | + assert_eq!( | |
| 827 | + find_downloaded_path(&episode, download_path), | |
| 828 | + Some(mp3_path.clone()) | |
| 829 | + ); | |
| 830 | + | |
| 831 | + // And detected on its own | |
| 832 | + fs::remove_file(&m4a_path).unwrap(); | |
| 833 | + assert_eq!(find_downloaded_path(&episode, download_path), Some(mp3_path)); | |
| 834 | + | |
| 835 | + // The tag-based name of a re-encoded download carries the .mp3 | |
| 836 | + // extension | |
| 837 | + assert_eq!( | |
| 838 | + episode_tag_filename(&episode).as_deref(), | |
| 839 | + Some("Artist Name - Episode Title.mp3") | |
| 840 | + ); | |
| 841 | + } | |
| 842 | + | |
| 843 | + #[test] | |
| 844 | + fn test_refresh_downloaded_file() { | |
| 845 | + let mut episode = Episode::new( | |
| 846 | + "ep-123", | |
| 847 | + "Episode Title", | |
| 848 | + "https://example.com/episode.mp3", | |
| 849 | + Utc::now(), | |
| 850 | + ); | |
| 851 | + episode.artist = Some("Artist Name".to_string()); | |
| 852 | + let temp_dir = tempdir().unwrap(); | |
| 853 | + let download_path = temp_dir.path(); | |
| 854 | + | |
| 855 | + // An old download without tags, under its URL-derived name: the | |
| 856 | + // catch-up must write the tags from the episode metadata and rename | |
| 857 | + let old_path = download_path.join(&episode.audio_filename); | |
| 858 | + fs::write(&old_path, "audio payload").unwrap(); | |
| 859 | + | |
| 860 | + let path = refresh_downloaded_file(&episode, download_path, true).unwrap(); | |
| 861 | + let expected = download_path.join("Artist Name - Episode Title.mp3"); | |
| 862 | + assert_eq!(path, expected); | |
| 863 | + assert!(expected.exists()); | |
| 864 | + assert!(!old_path.exists()); | |
| 865 | + | |
| 866 | + let tags = read_id3_tags(&expected).unwrap(); | |
| 867 | + assert_eq!(tags.title.as_deref(), Some("Episode Title")); | |
| 868 | + assert_eq!(tags.artist.as_deref(), Some("Artist Name")); | |
| 869 | + } | |
| 870 | + | |
| 871 | + /// Non-régression : quand le .mp3 transcodé est le fichier canonique et | |
| 872 | + /// qu'un .m4a original traîne à côté (suppression échouée lors d'une | |
| 873 | + /// passe précédente), le nettoyage doit supprimer le .m4a sans que | |
| 874 | + /// l'épisode soit de nouveau considéré comme à télécharger. | |
| 875 | + #[test] | |
| 876 | + fn test_refresh_downloaded_file_removes_leftover_m4a() { | |
| 877 | + let episode = Episode::new( | |
| 878 | + "ep-123", | |
| 879 | + "Episode Title", | |
| 880 | + "https://example.com/episode.m4a", | |
| 881 | + Utc::now(), | |
| 882 | + ); | |
| 883 | + let temp_dir = tempdir().unwrap(); | |
| 884 | + let download_path = temp_dir.path(); | |
| 885 | + | |
| 886 | + // The .mp3 is canonical (already transcoded) and a leftover .m4a | |
| 887 | + // sits next to it | |
| 888 | + let mp3_path = download_path.join("episode.mp3"); | |
| 889 | + let m4a_path = download_path.join("episode.m4a"); | |
| 890 | + fs::write(&mp3_path, "transcoded audio").unwrap(); | |
| 891 | + fs::write(&m4a_path, "original audio").unwrap(); | |
| 892 | + | |
| 893 | + let path = refresh_downloaded_file(&episode, download_path, true).unwrap(); | |
| 894 | + assert_eq!(path, mp3_path); | |
| 895 | + assert!(mp3_path.exists()); | |
| 896 | + assert!(!m4a_path.exists(), "the leftover .m4a must be removed"); | |
| 897 | + | |
| 898 | + // The episode is still detected as downloaded: no re-download | |
| 899 | + assert!(!needs_download(&episode, download_path)); | |
| 900 | + assert_eq!( | |
| 901 | + find_downloaded_path(&episode, download_path), | |
| 902 | + Some(mp3_path) | |
| 903 | + ); | |
| 904 | + } | |
| 905 | + | |
| 906 | + /// Non-régression : un .m4a téléchargé puis converti en .mp3 doit voir | |
| 907 | + /// son original supprimé, et l'épisode rester marqué comme téléchargé | |
| 908 | + /// (pas de re-téléchargement à la synchro suivante). | |
| 909 | + #[test] | |
| 910 | + fn test_download_episode_transcode_success_removes_m4a() { | |
| 911 | + if !crate::rss::transcode::ffmpeg_available() { | |
| 912 | + return; // ffmpeg not installed: the transcode is skipped at runtime | |
| 913 | + } | |
| 914 | + | |
| 915 | + let episode = Episode::new( | |
| 916 | + "ep-123", | |
| 917 | + "Episode Title", | |
| 918 | + "https://example.com/episode.m4a", | |
| 919 | + Utc::now(), | |
| 920 | + ); | |
| 921 | + let temp_dir = tempdir().unwrap(); | |
| 922 | + let download_path = temp_dir.path(); | |
| 923 | + | |
| 924 | + // Generate a real .m4a file (as if it had just been downloaded) | |
| 925 | + let m4a_path = download_path.join("episode.m4a"); | |
| 926 | + let status = std::process::Command::new("ffmpeg") | |
| 927 | + .args([ | |
| 928 | + "-y", | |
| 929 | + "-hide_banner", | |
| 930 | + "-loglevel", | |
| 931 | + "error", | |
| 932 | + "-f", | |
| 933 | + "lavfi", | |
| 934 | + "-i", | |
| 935 | + "sine=frequency=440:duration=1", | |
| 936 | + "-codec:a", | |
| 937 | + "aac", | |
| 938 | + ]) | |
| 939 | + .arg(&m4a_path) | |
| 940 | + .stdout(std::process::Stdio::null()) | |
| 941 | + .stderr(std::process::Stdio::null()) | |
| 942 | + .status() | |
| 943 | + .unwrap(); | |
| 944 | + assert!(status.success(), "failed to generate the test .m4a"); | |
| 945 | + | |
| 946 | + // The file already exists: no network call, the transcode happens | |
| 947 | + // through the refresh path | |
| 948 | + let result = download_episode(&episode, download_path, Box::new(|_| {}), true); | |
| 949 | + assert!(result.is_ok()); | |
| 950 | + let path = result.unwrap(); | |
| 951 | + assert_eq!(path, download_path.join("episode.mp3")); | |
| 952 | + assert!(path.exists()); | |
| 953 | + assert!(!m4a_path.exists(), "the original .m4a must be removed"); | |
| 954 | + | |
| 955 | + // The episode stays downloaded: no re-download on the next sync | |
| 956 | + assert!(!needs_download(&episode, download_path)); | |
| 957 | + } | |
| 958 | + | |
| 959 | + /// Non-régression : un échec de conversion .m4a → .mp3 ne doit pas faire | |
| 960 | + /// échouer le téléchargement de l'épisode. Le fichier original est | |
| 961 | + /// conservé et `download_episode` retourne `Ok` — la boucle de synchro | |
| 962 | + /// (`async_sync.rs`) peut ainsi passer à l'épisode suivant. | |
| 963 | + #[test] | |
| 964 | + fn test_download_episode_transcode_failure_keeps_original() { | |
| 965 | + let mut episode = Episode::new( | |
| 966 | + "ep-123", | |
| 967 | + "Episode Title", | |
| 968 | + "https://example.com/episode.m4a", | |
| 969 | + Utc::now(), | |
| 970 | + ); | |
| 971 | + episode.artist = Some("Artist Name".to_string()); | |
| 972 | + let temp_dir = tempdir().unwrap(); | |
| 973 | + let download_path = temp_dir.path(); | |
| 974 | + | |
| 975 | + // Fichier .m4a invalide déjà présent : simule un téléchargement | |
| 976 | + // terminé dont la conversion va échouer (contenu non audio) | |
| 977 | + let m4a_path = download_path.join("episode.m4a"); | |
| 978 | + fs::write(&m4a_path, "not a real audio file").unwrap(); | |
| 979 | + | |
| 980 | + // La conversion est activée ; le fichier existe déjà donc aucun | |
| 981 | + // appel réseau n'est fait (chemin refresh_downloaded_file) | |
| 982 | + let result = download_episode(&episode, download_path, Box::new(|_| {}), true); | |
| 983 | + assert!( | |
| 984 | + result.is_ok(), | |
| 985 | + "un échec de conversion ne doit pas faire échouer le téléchargement" | |
| 986 | + ); | |
| 987 | + let path = result.unwrap(); | |
| 988 | + assert_eq!(path, m4a_path); | |
| 989 | + assert!(m4a_path.exists(), "le fichier original doit être conservé"); | |
| 990 | + assert!( | |
| 991 | + !download_path.join("episode.mp3").exists(), | |
| 992 | + "aucun .mp3 partiel ne doit être créé" | |
| 993 | + ); | |
| 994 | + } | |
| 995 | + | |
| 996 | + #[test] | |
| 997 | + fn test_refresh_downloaded_file_not_downloaded() { | |
| 998 | + let episode = Episode::new( | |
| 999 | + "ep-123", | |
| 1000 | + "Test Episode", | |
| 1001 | + "https://example.com/episode.mp3", | |
| 1002 | + Utc::now(), | |
| 1003 | + ); | |
| 1004 | + let temp_dir = tempdir().unwrap(); | |
| 1005 | + | |
| 1006 | + assert_eq!(refresh_downloaded_file(&episode, temp_dir.path(), true), None); | |
| 1007 | + } | |
| 1008 | + | |
| 1009 | + #[test] | |
| 1010 | + fn test_refresh_downloaded_file_transcode_disabled() { | |
| 1011 | + let mut episode = Episode::new( | |
| 1012 | + "ep-123", | |
| 1013 | + "Episode Title", | |
| 1014 | + "https://example.com/episode.m4a", | |
| 1015 | + Utc::now(), | |
| 1016 | + ); | |
| 1017 | + episode.artist = Some("Artist Name".to_string()); | |
| 1018 | + let temp_dir = tempdir().unwrap(); | |
| 1019 | + let download_path = temp_dir.path(); | |
| 1020 | + | |
| 1021 | + let m4a_path = download_path.join("episode.m4a"); | |
| 1022 | + fs::write(&m4a_path, "audio payload").unwrap(); | |
| 1023 | + | |
| 1024 | + // The option is disabled: the .m4a is returned untouched, without | |
| 1025 | + // any re-encoding, tagging or renaming attempt | |
| 1026 | + let path = refresh_downloaded_file(&episode, download_path, false).unwrap(); | |
| 1027 | + assert_eq!(path, m4a_path); | |
| 1028 | + assert_eq!(fs::read(&path).unwrap(), b"audio payload".to_vec()); | |
| 1029 | + assert!(!download_path.join("episode.mp3").exists()); | |
| 1030 | + assert_eq!( | |
| 1031 | + fs::read_dir(download_path).unwrap().count(), | |
| 1032 | + 1, | |
| 1033 | + "no extra file must be created" | |
| 1034 | + ); | |
| 1035 | + } | |
| 1036 | + | |
| 1037 | + #[test] | |
| 1038 | + fn test_rename_existing_files_missing_directory() { | |
| 1039 | + let temp_dir = tempdir().unwrap(); | |
| 1040 | + let missing = temp_dir.path().join("does_not_exist"); | |
| 1041 | + assert_eq!(rename_existing_files_to_tag_filenames(&missing), 0); | |
| 1042 | + } | |
| 1043 | + | |
| 1044 | + #[test] | |
| 1045 | + fn test_ensure_id3_tags_on_untagged_file() { | |
| 1046 | + use crate::rss::audio_metadata::{ensure_id3_tags, read_id3_tags}; | |
| 1047 | + | |
| 1048 | + let temp_dir = tempdir().unwrap(); | |
| 1049 | + let file_path = temp_dir.path().join("episode.mp3"); | |
| 1050 | + fs::write(&file_path, "fake audio payload").unwrap(); | |
| 1051 | + | |
| 1052 | + // A file without any ID3 tags must still receive the episode tags | |
| 1053 | + ensure_id3_tags(&file_path, "Episode Title", Some("Artist Name"), Some("Album")).unwrap(); | |
| 1054 | + | |
| 1055 | + let tags = read_id3_tags(&file_path).unwrap(); | |
| 1056 | + assert_eq!(tags.title.as_deref(), Some("Episode Title")); | |
| 1057 | + assert_eq!(tags.artist.as_deref(), Some("Artist Name")); | |
| 1058 | + assert_eq!(tags.album.as_deref(), Some("Album")); | |
| 1059 | + | |
| 1060 | + // Writing the tags must not destroy the audio content | |
| 1061 | + let tagged = fs::read(&file_path).unwrap(); | |
| 1062 | + let payload = b"fake audio payload"; | |
| 1063 | + assert!( | |
| 1064 | + tagged.windows(payload.len()).any(|w| w == payload), | |
| 1065 | + "audio payload lost after tagging" | |
| 1066 | + ); | |
| 1067 | + } | |
| 1068 | + | |
| 320 | 1069 | #[test] |
| 321 | 1070 | fn test_download_error_display() { |
| 322 | 1071 | let error = DownloadError::InvalidUrl("test".to_string()); |
| @@ -3,7 +3,11 @@ | |||
| 3 | //! This module handles downloading podcast episodes from URLs. | 3 | //! This module handles downloading podcast episodes from URLs. |
| 4 | 4 | ||
| 5 | use crate::config::episode::Episode; | 5 | use crate::config::episode::Episode; |
| 6 | -use crate::rss::audio_metadata::ensure_id3_tags; | 6 | +use crate::rss::audio_metadata::{ensure_id3_tags, read_id3_tags}; |
| 7 | +use crate::rss::episode_extractor::AUDIO_EXTENSIONS; | ||
| 8 | +use crate::rss::transcode::{ | ||
| 9 | + is_transcode_source, mp3_counterpart, remove_transcode_source_next_to, transcode_to_mp3, | ||
| 10 | +}; | ||
| 7 | use log::{info, warn}; | 11 | use log::{info, warn}; |
| 8 | use std::fs; | 12 | use std::fs; |
| 9 | use std::io::{Read, Write}; | 13 | use std::io::{Read, Write}; |
| @@ -181,32 +185,41 @@ fn stream_to_file( | |||
| 181 | Ok(downloaded) | 185 | Ok(downloaded) |
| 182 | } | 186 | } |
| 183 | 187 | ||
| 188 | +/// Maximum length of a filename component built from tag values, keeping | ||
| 189 | +/// full paths well under common filesystem limits | ||
| 190 | +const MAX_FILENAME_COMPONENT_LEN: usize = 100; | ||
| 191 | + | ||
| 184 | /// Downloads an episode to its designated location | 192 | /// Downloads an episode to its designated location |
| 185 | -/// After downloading, ensures ID3 tags (title, artist, album) are present | 193 | +/// After downloading, ensures ID3 tags (title, artist, album) are present, |
| 194 | +/// then renames the file to "[artist] - [title]" based on its ID3 tags. | ||
| 195 | +/// A .m4a download is re-encoded to .mp3 first when `transcode_m4a` is set | ||
| 196 | +/// (the persisted user setting); it is kept as is otherwise. | ||
| 186 | pub fn download_episode( | 197 | pub fn download_episode( |
| 187 | episode: &Episode, | 198 | episode: &Episode, |
| 188 | download_path: &Path, | 199 | download_path: &Path, |
| 189 | progress_callback: ProgressCallback, | 200 | progress_callback: ProgressCallback, |
| 201 | + transcode_m4a: bool, | ||
| 190 | ) -> Result<PathBuf, DownloadError> { | 202 | ) -> Result<PathBuf, DownloadError> { |
| 191 | - let save_path = episode.get_save_path(download_path); | ||
| 192 | - | ||
| 193 | // Check if already exists and has correct size (via needs_download) | 203 | // Check if already exists and has correct size (via needs_download) |
| 194 | - if !needs_download(episode, download_path) { | 204 | + if let Some(existing_path) = |
| 205 | + refresh_downloaded_file(episode, download_path, transcode_m4a) | ||
| 206 | + { | ||
| 195 | info!("Episode already downloaded: {}", episode.title); | 207 | info!("Episode already downloaded: {}", episode.title); |
| 196 | - // Still ensure ID3 tags are present even if file already exists | 208 | + return Ok(existing_path); |
| 197 | - if let Err(e) = ensure_id3_tags( | ||
| 198 | - &save_path, | ||
| 199 | - &episode.title, | ||
| 200 | - episode.artist.as_deref(), | ||
| 201 | - episode.album.as_deref(), | ||
| 202 | - ) { | ||
| 203 | - warn!("Failed to ensure ID3 tags for existing file {}: {}", save_path.display(), e); | ||
| 204 | - } | ||
| 205 | - return Ok(save_path); | ||
| 206 | } | 209 | } |
| 207 | 210 | ||
| 211 | + let mut save_path = episode.get_save_path(download_path); | ||
| 208 | download_file(&episode.audio_url, &save_path, Some(progress_callback))?; | 212 | download_file(&episode.audio_url, &save_path, Some(progress_callback))?; |
| 209 | - | 213 | + |
| 214 | + // Re-encode non-ID3 formats (e.g. .m4a) to .mp3 so a single tagging and | ||
| 215 | + // renaming pipeline applies. When disabled or without ffmpeg the original | ||
| 216 | + // is kept as is: it must never receive an ID3 tag, which would corrupt it | ||
| 217 | + if is_transcode_source(&save_path) | ||
| 218 | + && (!transcode_m4a || !transcode_to_mp3(&mut save_path)) | ||
| 219 | + { | ||
| 220 | + return Ok(save_path); | ||
| 221 | + } | ||
| 222 | + | ||
| 210 | // Ensure ID3 tags are present after downloading. A tag failure must not | 223 | // Ensure ID3 tags are present after downloading. A tag failure must not |
| 211 | // fail the whole download: the audio file is already complete on disk | 224 | // fail the whole download: the audio file is already complete on disk |
| 212 | // and would otherwise never be counted as downloaded. | 225 | // and would otherwise never be counted as downloaded. |
| @@ -218,27 +231,279 @@ pub fn download_episode( | |||
| 218 | ) { | 231 | ) { |
| 219 | warn!("Failed to write ID3 tags for {}: {}", save_path.display(), e); | 232 | warn!("Failed to write ID3 tags for {}: {}", save_path.display(), e); |
| 220 | } | 233 | } |
| 221 | - | 234 | + |
| 235 | + rename_to_tag_filename(&mut save_path); | ||
| 236 | + | ||
| 222 | Ok(save_path) | 237 | Ok(save_path) |
| 223 | } | 238 | } |
| 224 | 239 | ||
| 225 | -/// Checks if a file needs to be downloaded based on its existence and size | 240 | +/// Checks if a file needs to be downloaded based on its existence and size. |
| 241 | +/// An episode is considered downloaded when a complete file exists under | ||
| 242 | +/// either its URL-derived name or its tag-derived "[artist] - [title]" name | ||
| 243 | +/// (the file is renamed to the tag-based name after download). | ||
| 226 | pub fn needs_download(episode: &Episode, download_path: &Path) -> bool { | 244 | pub fn needs_download(episode: &Episode, download_path: &Path) -> bool { |
| 227 | - let save_path = episode.get_save_path(download_path); | 245 | + find_downloaded_path(episode, download_path).is_none() |
| 228 | - | 246 | +} |
| 229 | - // If file doesn't exist, we need to download | 247 | + |
| 230 | - if !save_path.exists() { | 248 | +/// Ensures the ID3 tags and the tag-based filename of an episode's already |
| 231 | - return true; | 249 | +/// downloaded file: the tags are filled from the episode metadata when the |
| 232 | - } | 250 | +/// file predates the tagging rule, then the file is renamed to |
| 233 | - | 251 | +/// "[artist] - [title]". This is the catch-up path for files downloaded |
| 234 | - // If file exists but is empty, we need to download | 252 | +/// before those rules existed. A .m4a file is re-encoded to .mp3 first when |
| 235 | - if let Ok(metadata) = fs::metadata(&save_path) { | 253 | +/// `transcode_m4a` is set. Returns the (possibly renamed) path of the file, |
| 236 | - if metadata.len() == 0 { | 254 | +/// or None when the episode is not on disk. |
| 237 | - return true; | 255 | +pub fn refresh_downloaded_file( |
| 256 | + episode: &Episode, | ||
| 257 | + download_path: &Path, | ||
| 258 | + transcode_m4a: bool, | ||
| 259 | +) -> Option<PathBuf> { | ||
| 260 | + let mut existing_path = find_downloaded_path(episode, download_path)?; | ||
| 261 | + | ||
| 262 | + // A non-ID3 format (e.g. .m4a) is re-encoded to .mp3 first: it must | ||
| 263 | + // never receive an ID3 tag, which would corrupt it. When the option is | ||
| 264 | + // disabled or without ffmpeg the file is kept as is (no tags, no rename) | ||
| 265 | + if is_transcode_source(&existing_path) | ||
| 266 | + && (!transcode_m4a || !transcode_to_mp3(&mut existing_path)) | ||
| 267 | + { | ||
| 268 | + return Some(existing_path); | ||
| 269 | + } | ||
| 270 | + | ||
| 271 | + // The canonical file is the transcoded .mp3: remove a leftover original | ||
| 272 | + // .m4a next to it (e.g. its removal failed during a previous pass). The | ||
| 273 | + // episode stays downloaded — the .mp3 is preferred by the download | ||
| 274 | + // detection — so it is never re-downloaded. | ||
| 275 | + if !is_transcode_source(&existing_path) { | ||
| 276 | + remove_transcode_source_next_to(&existing_path); | ||
| 277 | + } | ||
| 278 | + | ||
| 279 | + // Still ensure ID3 tags are present even if the file already exists | ||
| 280 | + if let Err(e) = ensure_id3_tags( | ||
| 281 | + &existing_path, | ||
| 282 | + &episode.title, | ||
| 283 | + episode.artist.as_deref(), | ||
| 284 | + episode.album.as_deref(), | ||
| 285 | + ) { | ||
| 286 | + warn!( | ||
| 287 | + "Failed to ensure ID3 tags for existing file {}: {}", | ||
| 288 | + existing_path.display(), | ||
| 289 | + e | ||
| 290 | + ); | ||
| 291 | + } | ||
| 292 | + rename_to_tag_filename(&mut existing_path); | ||
| 293 | + | ||
| 294 | + Some(existing_path) | ||
| 295 | +} | ||
| 296 | + | ||
| 297 | +/// Returns the path of the episode's complete file on disk, if any. | ||
| 298 | +/// Checks the transcoded .mp3 first (the canonical file for a re-encoded | ||
| 299 | +/// download), then the URL-derived filename, then the tag-based filename. | ||
| 300 | +pub fn find_downloaded_path(episode: &Episode, download_path: &Path) -> Option<PathBuf> { | ||
| 301 | + let url_path = episode.get_save_path(download_path); | ||
| 302 | + | ||
| 303 | + if let Some(mp3_path) = mp3_counterpart(&url_path) { | ||
| 304 | + if is_complete_file(&mp3_path) { | ||
| 305 | + return Some(mp3_path); | ||
| 306 | + } | ||
| 307 | + } | ||
| 308 | + | ||
| 309 | + if is_complete_file(&url_path) { | ||
| 310 | + return Some(url_path); | ||
| 311 | + } | ||
| 312 | + | ||
| 313 | + if let Some(tag_name) = episode_tag_filename(episode) { | ||
| 314 | + let tag_path = download_path.join(tag_name); | ||
| 315 | + if is_complete_file(&tag_path) { | ||
| 316 | + return Some(tag_path); | ||
| 238 | } | 317 | } |
| 239 | } | 318 | } |
| 240 | - | 319 | + |
| 241 | - false | 320 | + None |
| 321 | +} | ||
| 322 | + | ||
| 323 | +/// Returns every filename an episode's downloaded file can have on disk: | ||
| 324 | +/// the transcoded .mp3 counterpart (canonical for a re-encoded download), | ||
| 325 | +/// the URL-derived filename, and the tag-based "[artist] - [title]" name. | ||
| 326 | +/// Used to match an episode against the file list recorded in status.txt. | ||
| 327 | +pub fn possible_filenames(episode: &Episode) -> Vec<String> { | ||
| 328 | + let url_path = episode.get_save_path(Path::new("")); | ||
| 329 | + let mut names = Vec::new(); | ||
| 330 | + | ||
| 331 | + if let Some(mp3_path) = mp3_counterpart(&url_path) { | ||
| 332 | + if let Some(name) = mp3_path.file_name() { | ||
| 333 | + names.push(name.to_string_lossy().into_owned()); | ||
| 334 | + } | ||
| 335 | + } | ||
| 336 | + | ||
| 337 | + if let Some(name) = url_path.file_name() { | ||
| 338 | + names.push(name.to_string_lossy().into_owned()); | ||
| 339 | + } | ||
| 340 | + | ||
| 341 | + if let Some(tag_name) = episode_tag_filename(episode) { | ||
| 342 | + names.push(tag_name); | ||
| 343 | + } | ||
| 344 | + | ||
| 345 | + names | ||
| 346 | +} | ||
| 347 | + | ||
| 348 | +/// Returns true when the file exists and is not empty | ||
| 349 | +/// (an empty file is a failed or incomplete download) | ||
| 350 | +fn is_complete_file(path: &Path) -> bool { | ||
| 351 | + fs::metadata(path).map(|m| m.len() > 0).unwrap_or(false) | ||
| 352 | +} | ||
| 353 | + | ||
| 354 | +/// Builds the "[artist] - [title].[ext]" filename derived from the episode | ||
| 355 | +/// metadata (the same values written into the ID3 tags), or None when the | ||
| 356 | +/// artist or title is missing | ||
| 357 | +fn episode_tag_filename(episode: &Episode) -> Option<String> { | ||
| 358 | + let artist = non_empty(&episode.artist)?; | ||
| 359 | + let title = episode.title.trim(); | ||
| 360 | + if title.is_empty() { | ||
| 361 | + return None; | ||
| 362 | + } | ||
| 363 | + let source = Path::new(&episode.audio_filename); | ||
| 364 | + // A re-encoded download (e.g. .m4a) ends up as .mp3 | ||
| 365 | + let extension = if is_transcode_source(source) { | ||
| 366 | + "mp3" | ||
| 367 | + } else { | ||
| 368 | + source.extension().and_then(|e| e.to_str()).unwrap_or("mp3") | ||
| 369 | + }; | ||
| 370 | + tag_based_filename(artist, title, extension) | ||
| 371 | +} | ||
| 372 | + | ||
| 373 | +/// Builds a "[artist] - [title].[extension]" filename from ID3 tag values. | ||
| 374 | +/// Returns None when nothing usable remains after sanitization. | ||
| 375 | +fn tag_based_filename(artist: &str, title: &str, extension: &str) -> Option<String> { | ||
| 376 | + let artist = sanitize_for_filename(artist); | ||
| 377 | + let title = sanitize_for_filename(title); | ||
| 378 | + if artist.is_empty() || title.is_empty() { | ||
| 379 | + return None; | ||
| 380 | + } | ||
| 381 | + Some(format!("{} - {}.{}", artist, title, extension)) | ||
| 382 | +} | ||
| 383 | + | ||
| 384 | +/// Renames a downloaded file to "[artist] - [title].[ext]" based on its ID3 | ||
| 385 | +/// tags. The file keeps its current name when the tags are missing or | ||
| 386 | +/// incomplete, when the name already matches, or when the target name is | ||
| 387 | +/// already taken by another file. | ||
| 388 | +fn rename_to_tag_filename(file_path: &mut PathBuf) { | ||
| 389 | + let extension = file_path | ||
| 390 | + .extension() | ||
| 391 | + .and_then(|e| e.to_str()) | ||
| 392 | + .unwrap_or("mp3") | ||
| 393 | + .to_string(); | ||
| 394 | + | ||
| 395 | + // No readable tags: keep the current name | ||
| 396 | + let tags = match read_id3_tags(file_path) { | ||
| 397 | + Ok(tags) => tags, | ||
| 398 | + Err(_) => return, | ||
| 399 | + }; | ||
| 400 | + | ||
| 401 | + let Some(artist) = non_empty(&tags.artist) else { | ||
| 402 | + return; | ||
| 403 | + }; | ||
| 404 | + let Some(title) = non_empty(&tags.title) else { | ||
| 405 | + return; | ||
| 406 | + }; | ||
| 407 | + | ||
| 408 | + let Some(desired_name) = tag_based_filename(artist, title, &extension) else { | ||
| 409 | + return; | ||
| 410 | + }; | ||
| 411 | + | ||
| 412 | + let current_name = file_path | ||
| 413 | + .file_name() | ||
| 414 | + .map(|n| n.to_string_lossy()) | ||
| 415 | + .unwrap_or_default(); | ||
| 416 | + if current_name == desired_name { | ||
| 417 | + return; | ||
| 418 | + } | ||
| 419 | + | ||
| 420 | + let desired_path = file_path.with_file_name(&desired_name); | ||
| 421 | + if desired_path.exists() { | ||
| 422 | + warn!( | ||
| 423 | + "Skipping rename of {}: target filename already exists: {}", | ||
| 424 | + file_path.display(), | ||
| 425 | + desired_name | ||
| 426 | + ); | ||
| 427 | + return; | ||
| 428 | + } | ||
| 429 | + | ||
| 430 | + let current_path = file_path.clone(); | ||
| 431 | + match fs::rename(¤t_path, &desired_path) { | ||
| 432 | + Ok(()) => { | ||
| 433 | + info!("Renamed {} to {}", current_path.display(), desired_name); | ||
| 434 | + *file_path = desired_path; | ||
| 435 | + } | ||
| 436 | + Err(e) => warn!( | ||
| 437 | + "Failed to rename {} to {}: {}", | ||
| 438 | + current_path.display(), | ||
| 439 | + desired_name, | ||
| 440 | + e | ||
| 441 | + ), | ||
| 442 | + } | ||
| 443 | +} | ||
| 444 | + | ||
| 445 | +/// Returns the string trimmed, or None when it is empty | ||
| 446 | +fn non_empty(value: &Option<String>) -> Option<&str> { | ||
| 447 | + value.as_deref().map(str::trim).filter(|s| !s.is_empty()) | ||
| 448 | +} | ||
| 449 | + | ||
| 450 | +/// Applies the "[artist] - [title]" renaming rule to the audio files already | ||
| 451 | +/// present in a download directory (e.g. files downloaded before the rule | ||
| 452 | +/// existed). Returns the number of files renamed. Files without usable ID3 | ||
| 453 | +/// tags, and files whose name already matches, are left untouched. | ||
| 454 | +pub fn rename_existing_files_to_tag_filenames(download_path: &Path) -> usize { | ||
| 455 | + let entries = match fs::read_dir(download_path) { | ||
| 456 | + Ok(entries) => entries, | ||
| 457 | + // The directory does not exist: nothing downloaded yet | ||
| 458 | + Err(_) => return 0, | ||
| 459 | + }; | ||
| 460 | + | ||
| 461 | + let mut renamed = 0; | ||
| 462 | + for entry in entries.flatten() { | ||
| 463 | + let mut file_path = entry.path(); | ||
| 464 | + if !file_path.is_file() { | ||
| 465 | + continue; | ||
| 466 | + } | ||
| 467 | + | ||
| 468 | + let Some(extension) = file_path | ||
| 469 | + .extension() | ||
| 470 | + .and_then(|e| e.to_str()) | ||
| 471 | + .map(|e| e.to_lowercase()) | ||
| 472 | + else { | ||
| 473 | + continue; | ||
| 474 | + }; | ||
| 475 | + if !AUDIO_EXTENSIONS.contains(&extension.as_str()) { | ||
| 476 | + continue; | ||
| 477 | + } | ||
| 478 | + | ||
| 479 | + let original_path = file_path.clone(); | ||
| 480 | + rename_to_tag_filename(&mut file_path); | ||
| 481 | + if file_path != original_path { | ||
| 482 | + renamed += 1; | ||
| 483 | + } | ||
| 484 | + } | ||
| 485 | + renamed | ||
| 486 | +} | ||
| 487 | + | ||
| 488 | +/// Sanitizes a tag value for use in a filename: replaces characters that are | ||
| 489 | +/// invalid on common filesystems, trims whitespace and trailing dots, and | ||
| 490 | +/// caps the length. Returns an empty string when nothing usable remains. | ||
| 491 | +fn sanitize_for_filename(value: &str) -> String { | ||
| 492 | + let replaced: String = value | ||
| 493 | + .chars() | ||
| 494 | + .map(|c| match c { | ||
| 495 | + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-', | ||
| 496 | + c if c.is_control() => '-', | ||
| 497 | + c => c, | ||
| 498 | + }) | ||
| 499 | + .collect(); | ||
| 500 | + replaced | ||
| 501 | + .chars() | ||
| 502 | + .take(MAX_FILENAME_COMPONENT_LEN) | ||
| 503 | + .collect::<String>() | ||
| 504 | + .trim() | ||
| 505 | + .trim_matches(['-', '.', ' ']) | ||
| 506 | + .to_string() | ||
| 242 | } | 507 | } |
| 243 | 508 | ||
| 244 | #[cfg(test)] | 509 | #[cfg(test)] |
| @@ -317,6 +582,490 @@ mod tests { | |||
| 317 | assert!(nested_path.parent().unwrap().exists()); | 582 | assert!(nested_path.parent().unwrap().exists()); |
| 318 | } | 583 | } |
| 319 | 584 | ||
| 585 | + #[test] | ||
| 586 | + fn test_sanitize_for_filename() { | ||
| 587 | + // Invalid characters are replaced | ||
| 588 | + assert_eq!(sanitize_for_filename("AC/DC: Best?"), "AC-DC- Best"); | ||
| 589 | + // Leading/trailing whitespace and trailing dots are stripped | ||
| 590 | + assert_eq!(sanitize_for_filename(" Episode 42... "), "Episode 42"); | ||
| 591 | + // Nothing usable remains | ||
| 592 | + assert_eq!(sanitize_for_filename(" /? "), ""); | ||
| 593 | + // Length is capped | ||
| 594 | + let long = "a".repeat(MAX_FILENAME_COMPONENT_LEN + 50); | ||
| 595 | + assert_eq!(sanitize_for_filename(&long).chars().count(), MAX_FILENAME_COMPONENT_LEN); | ||
| 596 | + } | ||
| 597 | + | ||
| 598 | + #[test] | ||
| 599 | + fn test_tag_based_filename() { | ||
| 600 | + assert_eq!( | ||
| 601 | + tag_based_filename("Artist", "Title", "mp3").as_deref(), | ||
| 602 | + Some("Artist - Title.mp3") | ||
| 603 | + ); | ||
| 604 | + // Missing values or nothing usable after sanitization | ||
| 605 | + assert_eq!(tag_based_filename("Artist", "", "mp3"), None); | ||
| 606 | + assert_eq!(tag_based_filename("///", "Title", "mp3"), None); | ||
| 607 | + } | ||
| 608 | + | ||
| 609 | + #[test] | ||
| 610 | + fn test_episode_tag_filename() { | ||
| 611 | + let mut episode = Episode::new( | ||
| 612 | + "ep-123", | ||
| 613 | + "Episode Title", | ||
| 614 | + "https://example.com/episode.mp3", | ||
| 615 | + Utc::now(), | ||
| 616 | + ); | ||
| 617 | + assert_eq!(episode_tag_filename(&episode), None, "no artist"); | ||
| 618 | + | ||
| 619 | + episode.artist = Some("Artist Name".to_string()); | ||
| 620 | + assert_eq!( | ||
| 621 | + episode_tag_filename(&episode).as_deref(), | ||
| 622 | + Some("Artist Name - Episode Title.mp3") | ||
| 623 | + ); | ||
| 624 | + | ||
| 625 | + // Falls back to .mp3 when the URL has no extension | ||
| 626 | + let mut no_ext = Episode::new("ep-456", "Title", "https://example.com/audio", Utc::now()); | ||
| 627 | + no_ext.artist = Some("Artist".to_string()); | ||
| 628 | + assert_eq!( | ||
| 629 | + episode_tag_filename(&no_ext).as_deref(), | ||
| 630 | + Some("Artist - Title.mp3") | ||
| 631 | + ); | ||
| 632 | + } | ||
| 633 | + | ||
| 634 | + #[test] | ||
| 635 | + fn test_possible_filenames() { | ||
| 636 | + let mut episode = Episode::new( | ||
| 637 | + "ep-123", | ||
| 638 | + "Episode Title", | ||
| 639 | + "https://example.com/episode.mp3", | ||
| 640 | + Utc::now(), | ||
| 641 | + ); | ||
| 642 | + // No artist: only the URL-derived name | ||
| 643 | + assert_eq!(possible_filenames(&episode), vec!["episode.mp3".to_string()]); | ||
| 644 | + | ||
| 645 | + // With an artist, the tag-based name is also possible | ||
| 646 | + episode.artist = Some("Artist Name".to_string()); | ||
| 647 | + assert_eq!( | ||
| 648 | + possible_filenames(&episode), | ||
| 649 | + vec![ | ||
| 650 | + "episode.mp3".to_string(), | ||
| 651 | + "Artist Name - Episode Title.mp3".to_string() | ||
| 652 | + ] | ||
| 653 | + ); | ||
| 654 | + | ||
| 655 | + // A re-encoded download: the .mp3 counterpart comes first | ||
| 656 | + let m4a = Episode::new("ep-456", "Title", "https://example.com/ep.m4a", Utc::now()); | ||
| 657 | + assert_eq!( | ||
| 658 | + possible_filenames(&m4a), | ||
| 659 | + vec!["ep.mp3".to_string(), "ep.m4a".to_string()] | ||
| 660 | + ); | ||
| 661 | + } | ||
| 662 | + | ||
| 663 | + #[test] | ||
| 664 | + fn test_needs_download_tag_named_file() { | ||
| 665 | + let mut episode = Episode::new( | ||
| 666 | + "ep-123", | ||
| 667 | + "Episode Title", | ||
| 668 | + "https://example.com/episode.mp3", | ||
| 669 | + Utc::now(), | ||
| 670 | + ); | ||
| 671 | + episode.artist = Some("Artist Name".to_string()); | ||
| 672 | + let temp_dir = tempdir().unwrap(); | ||
| 673 | + let download_path = temp_dir.path(); | ||
| 674 | + | ||
| 675 | + // Nothing on disk | ||
| 676 | + assert!(needs_download(&episode, download_path)); | ||
| 677 | + | ||
| 678 | + // File renamed to its tag-based name: detected as downloaded | ||
| 679 | + fs::write(download_path.join("Artist Name - Episode Title.mp3"), "audio").unwrap(); | ||
| 680 | + assert!(!needs_download(&episode, download_path)); | ||
| 681 | + assert_eq!( | ||
| 682 | + find_downloaded_path(&episode, download_path), | ||
| 683 | + Some(download_path.join("Artist Name - Episode Title.mp3")) | ||
| 684 | + ); | ||
| 685 | + | ||
| 686 | + // An empty tag-named file is incomplete | ||
| 687 | + fs::write(download_path.join("Artist Name - Episode Title.mp3"), "").unwrap(); | ||
| 688 | + assert!(needs_download(&episode, download_path)); | ||
| 689 | + } | ||
| 690 | + | ||
| 691 | + #[test] | ||
| 692 | + fn test_rename_to_tag_filename() { | ||
| 693 | + use id3::{Tag, TagLike}; | ||
| 694 | + | ||
| 695 | + let temp_dir = tempdir().unwrap(); | ||
| 696 | + let file_path = temp_dir.path().join("episode.mp3"); | ||
| 697 | + | ||
| 698 | + // Create a valid MP3 file with ID3 tags | ||
| 699 | + let mut tag = Tag::new(); | ||
| 700 | + tag.set_title("Episode Title"); | ||
| 701 | + tag.set_artist("Artist Name"); | ||
| 702 | + let mut file = fs::File::create(&file_path).unwrap(); | ||
| 703 | + tag.write_to(&mut file, id3::Version::Id3v24).unwrap(); | ||
| 704 | + drop(file); | ||
| 705 | + | ||
| 706 | + let mut path = file_path.clone(); | ||
| 707 | + rename_to_tag_filename(&mut path); | ||
| 708 | + let expected = temp_dir.path().join("Artist Name - Episode Title.mp3"); | ||
| 709 | + assert_eq!(path, expected); | ||
| 710 | + assert!(expected.exists()); | ||
| 711 | + assert!(!file_path.exists()); | ||
| 712 | + | ||
| 713 | + // Renaming again is a no-op: the name already matches | ||
| 714 | + rename_to_tag_filename(&mut path); | ||
| 715 | + assert_eq!(path, expected); | ||
| 716 | + } | ||
| 717 | + | ||
| 718 | + #[test] | ||
| 719 | + fn test_rename_to_tag_filename_without_tags() { | ||
| 720 | + let temp_dir = tempdir().unwrap(); | ||
| 721 | + let file_path = temp_dir.path().join("episode.mp3"); | ||
| 722 | + fs::write(&file_path, "not an audio file").unwrap(); | ||
| 723 | + | ||
| 724 | + // No readable ID3 tags: the current name is kept | ||
| 725 | + let mut path = file_path.clone(); | ||
| 726 | + rename_to_tag_filename(&mut path); | ||
| 727 | + assert_eq!(path, file_path); | ||
| 728 | + assert!(file_path.exists()); | ||
| 729 | + } | ||
| 730 | + | ||
| 731 | + #[test] | ||
| 732 | + fn test_rename_to_tag_filename_target_exists() { | ||
| 733 | + use id3::{Tag, TagLike}; | ||
| 734 | + | ||
| 735 | + let temp_dir = tempdir().unwrap(); | ||
| 736 | + let file_path = temp_dir.path().join("episode.mp3"); | ||
| 737 | + | ||
| 738 | + let mut tag = Tag::new(); | ||
| 739 | + tag.set_title("Episode Title"); | ||
| 740 | + tag.set_artist("Artist Name"); | ||
| 741 | + let mut file = fs::File::create(&file_path).unwrap(); | ||
| 742 | + tag.write_to(&mut file, id3::Version::Id3v24).unwrap(); | ||
| 743 | + drop(file); | ||
| 744 | + | ||
| 745 | + // The target name is already taken by another file: skip the rename | ||
| 746 | + fs::write(temp_dir.path().join("Artist Name - Episode Title.mp3"), "other").unwrap(); | ||
| 747 | + | ||
| 748 | + let mut path = file_path.clone(); | ||
| 749 | + rename_to_tag_filename(&mut path); | ||
| 750 | + assert_eq!(path, file_path); | ||
| 751 | + assert!(file_path.exists()); | ||
| 752 | + } | ||
| 753 | + | ||
| 754 | + #[test] | ||
| 755 | + fn test_rename_existing_files_to_tag_filenames() { | ||
| 756 | + use id3::{Tag, TagLike}; | ||
| 757 | + | ||
| 758 | + let temp_dir = tempdir().unwrap(); | ||
| 759 | + let download_path = temp_dir.path(); | ||
| 760 | + | ||
| 761 | + // An old file with tags: must be renamed | ||
| 762 | + let old_file = download_path.join("old_episode.mp3"); | ||
| 763 | + let mut tag = Tag::new(); | ||
| 764 | + tag.set_title("Episode Title"); | ||
| 765 | + tag.set_artist("Artist Name"); | ||
| 766 | + let mut file = fs::File::create(&old_file).unwrap(); | ||
| 767 | + tag.write_to(&mut file, id3::Version::Id3v24).unwrap(); | ||
| 768 | + drop(file); | ||
| 769 | + | ||
| 770 | + // A file already matching the rule: unchanged | ||
| 771 | + let matching_file = download_path.join("Artist Name - Episode Title.mp3"); | ||
| 772 | + fs::copy(&old_file, &matching_file).unwrap(); | ||
| 773 | + | ||
| 774 | + // A file without tags: unchanged | ||
| 775 | + let untagged_file = download_path.join("untagged.mp3"); | ||
| 776 | + fs::write(&untagged_file, "no tags here").unwrap(); | ||
| 777 | + | ||
| 778 | + // A non-audio file: unchanged | ||
| 779 | + let text_file = download_path.join("notes.txt"); | ||
| 780 | + fs::write(&text_file, "text").unwrap(); | ||
| 781 | + | ||
| 782 | + let renamed = rename_existing_files_to_tag_filenames(download_path); | ||
| 783 | + // old_episode.mp3 collides with the already-matching file, so only | ||
| 784 | + // the collision-free renames are counted; here none can happen | ||
| 785 | + assert_eq!(renamed, 0); | ||
| 786 | + assert!(old_file.exists()); | ||
| 787 | + | ||
| 788 | + // Without the collision, the old file is renamed | ||
| 789 | + fs::remove_file(&matching_file).unwrap(); | ||
| 790 | + let renamed = rename_existing_files_to_tag_filenames(download_path); | ||
| 791 | + assert_eq!(renamed, 1); | ||
| 792 | + assert!(matching_file.exists()); | ||
| 793 | + assert!(!old_file.exists()); | ||
| 794 | + assert!(untagged_file.exists()); | ||
| 795 | + assert!(text_file.exists()); | ||
| 796 | + | ||
| 797 | + // A second pass finds nothing left to rename | ||
| 798 | + assert_eq!(rename_existing_files_to_tag_filenames(download_path), 0); | ||
| 799 | + } | ||
| 800 | + | ||
| 801 | + #[test] | ||
| 802 | + fn test_find_downloaded_path_transcoded_m4a() { | ||
| 803 | + let mut episode = Episode::new( | ||
| 804 | + "ep-123", | ||
| 805 | + "Episode Title", | ||
| 806 | + "https://example.com/episode.m4a", | ||
| 807 | + Utc::now(), | ||
| 808 | + ); | ||
| 809 | + episode.artist = Some("Artist Name".to_string()); | ||
| 810 | + let temp_dir = tempdir().unwrap(); | ||
| 811 | + let download_path = temp_dir.path(); | ||
| 812 | + | ||
| 813 | + // A .m4a left as is (no ffmpeg): detected under its original name | ||
| 814 | + let m4a_path = download_path.join("episode.m4a"); | ||
| 815 | + fs::write(&m4a_path, "audio").unwrap(); | ||
| 816 | + assert_eq!( | ||
| 817 | + find_downloaded_path(&episode, download_path), | ||
| 818 | + Some(m4a_path.clone()) | ||
| 819 | + ); | ||
| 820 | + assert!(!needs_download(&episode, download_path)); | ||
| 821 | + | ||
| 822 | + // The transcoded .mp3 is the canonical file: preferred over the | ||
| 823 | + // original when both exist | ||
| 824 | + let mp3_path = download_path.join("episode.mp3"); | ||
| 825 | + fs::write(&mp3_path, "audio").unwrap(); | ||
| 826 | + assert_eq!( | ||
| 827 | + find_downloaded_path(&episode, download_path), | ||
| 828 | + Some(mp3_path.clone()) | ||
| 829 | + ); | ||
| 830 | + | ||
| 831 | + // And detected on its own | ||
| 832 | + fs::remove_file(&m4a_path).unwrap(); | ||
| 833 | + assert_eq!(find_downloaded_path(&episode, download_path), Some(mp3_path)); | ||
| 834 | + | ||
| 835 | + // The tag-based name of a re-encoded download carries the .mp3 | ||
| 836 | + // extension | ||
| 837 | + assert_eq!( | ||
| 838 | + episode_tag_filename(&episode).as_deref(), | ||
| 839 | + Some("Artist Name - Episode Title.mp3") | ||
| 840 | + ); | ||
| 841 | + } | ||
| 842 | + | ||
| 843 | + #[test] | ||
| 844 | + fn test_refresh_downloaded_file() { | ||
| 845 | + let mut episode = Episode::new( | ||
| 846 | + "ep-123", | ||
| 847 | + "Episode Title", | ||
| 848 | + "https://example.com/episode.mp3", | ||
| 849 | + Utc::now(), | ||
| 850 | + ); | ||
| 851 | + episode.artist = Some("Artist Name".to_string()); | ||
| 852 | + let temp_dir = tempdir().unwrap(); | ||
| 853 | + let download_path = temp_dir.path(); | ||
| 854 | + | ||
| 855 | + // An old download without tags, under its URL-derived name: the | ||
| 856 | + // catch-up must write the tags from the episode metadata and rename | ||
| 857 | + let old_path = download_path.join(&episode.audio_filename); | ||
| 858 | + fs::write(&old_path, "audio payload").unwrap(); | ||
| 859 | + | ||
| 860 | + let path = refresh_downloaded_file(&episode, download_path, true).unwrap(); | ||
| 861 | + let expected = download_path.join("Artist Name - Episode Title.mp3"); | ||
| 862 | + assert_eq!(path, expected); | ||
| 863 | + assert!(expected.exists()); | ||
| 864 | + assert!(!old_path.exists()); | ||
| 865 | + | ||
| 866 | + let tags = read_id3_tags(&expected).unwrap(); | ||
| 867 | + assert_eq!(tags.title.as_deref(), Some("Episode Title")); | ||
| 868 | + assert_eq!(tags.artist.as_deref(), Some("Artist Name")); | ||
| 869 | + } | ||
| 870 | + | ||
| 871 | + /// Non-régression : quand le .mp3 transcodé est le fichier canonique et | ||
| 872 | + /// qu'un .m4a original traîne à côté (suppression échouée lors d'une | ||
| 873 | + /// passe précédente), le nettoyage doit supprimer le .m4a sans que | ||
| 874 | + /// l'épisode soit de nouveau considéré comme à télécharger. | ||
| 875 | + #[test] | ||
| 876 | + fn test_refresh_downloaded_file_removes_leftover_m4a() { | ||
| 877 | + let episode = Episode::new( | ||
| 878 | + "ep-123", | ||
| 879 | + "Episode Title", | ||
| 880 | + "https://example.com/episode.m4a", | ||
| 881 | + Utc::now(), | ||
| 882 | + ); | ||
| 883 | + let temp_dir = tempdir().unwrap(); | ||
| 884 | + let download_path = temp_dir.path(); | ||
| 885 | + | ||
| 886 | + // The .mp3 is canonical (already transcoded) and a leftover .m4a | ||
| 887 | + // sits next to it | ||
| 888 | + let mp3_path = download_path.join("episode.mp3"); | ||
| 889 | + let m4a_path = download_path.join("episode.m4a"); | ||
| 890 | + fs::write(&mp3_path, "transcoded audio").unwrap(); | ||
| 891 | + fs::write(&m4a_path, "original audio").unwrap(); | ||
| 892 | + | ||
| 893 | + let path = refresh_downloaded_file(&episode, download_path, true).unwrap(); | ||
| 894 | + assert_eq!(path, mp3_path); | ||
| 895 | + assert!(mp3_path.exists()); | ||
| 896 | + assert!(!m4a_path.exists(), "the leftover .m4a must be removed"); | ||
| 897 | + | ||
| 898 | + // The episode is still detected as downloaded: no re-download | ||
| 899 | + assert!(!needs_download(&episode, download_path)); | ||
| 900 | + assert_eq!( | ||
| 901 | + find_downloaded_path(&episode, download_path), | ||
| 902 | + Some(mp3_path) | ||
| 903 | + ); | ||
| 904 | + } | ||
| 905 | + | ||
| 906 | + /// Non-régression : un .m4a téléchargé puis converti en .mp3 doit voir | ||
| 907 | + /// son original supprimé, et l'épisode rester marqué comme téléchargé | ||
| 908 | + /// (pas de re-téléchargement à la synchro suivante). | ||
| 909 | + #[test] | ||
| 910 | + fn test_download_episode_transcode_success_removes_m4a() { | ||
| 911 | + if !crate::rss::transcode::ffmpeg_available() { | ||
| 912 | + return; // ffmpeg not installed: the transcode is skipped at runtime | ||
| 913 | + } | ||
| 914 | + | ||
| 915 | + let episode = Episode::new( | ||
| 916 | + "ep-123", | ||
| 917 | + "Episode Title", | ||
| 918 | + "https://example.com/episode.m4a", | ||
| 919 | + Utc::now(), | ||
| 920 | + ); | ||
| 921 | + let temp_dir = tempdir().unwrap(); | ||
| 922 | + let download_path = temp_dir.path(); | ||
| 923 | + | ||
| 924 | + // Generate a real .m4a file (as if it had just been downloaded) | ||
| 925 | + let m4a_path = download_path.join("episode.m4a"); | ||
| 926 | + let status = std::process::Command::new("ffmpeg") | ||
| 927 | + .args([ | ||
| 928 | + "-y", | ||
| 929 | + "-hide_banner", | ||
| 930 | + "-loglevel", | ||
| 931 | + "error", | ||
| 932 | + "-f", | ||
| 933 | + "lavfi", | ||
| 934 | + "-i", | ||
| 935 | + "sine=frequency=440:duration=1", | ||
| 936 | + "-codec:a", | ||
| 937 | + "aac", | ||
| 938 | + ]) | ||
| 939 | + .arg(&m4a_path) | ||
| 940 | + .stdout(std::process::Stdio::null()) | ||
| 941 | + .stderr(std::process::Stdio::null()) | ||
| 942 | + .status() | ||
| 943 | + .unwrap(); | ||
| 944 | + assert!(status.success(), "failed to generate the test .m4a"); | ||
| 945 | + | ||
| 946 | + // The file already exists: no network call, the transcode happens | ||
| 947 | + // through the refresh path | ||
| 948 | + let result = download_episode(&episode, download_path, Box::new(|_| {}), true); | ||
| 949 | + assert!(result.is_ok()); | ||
| 950 | + let path = result.unwrap(); | ||
| 951 | + assert_eq!(path, download_path.join("episode.mp3")); | ||
| 952 | + assert!(path.exists()); | ||
| 953 | + assert!(!m4a_path.exists(), "the original .m4a must be removed"); | ||
| 954 | + | ||
| 955 | + // The episode stays downloaded: no re-download on the next sync | ||
| 956 | + assert!(!needs_download(&episode, download_path)); | ||
| 957 | + } | ||
| 958 | + | ||
| 959 | + /// Non-régression : un échec de conversion .m4a → .mp3 ne doit pas faire | ||
| 960 | + /// échouer le téléchargement de l'épisode. Le fichier original est | ||
| 961 | + /// conservé et `download_episode` retourne `Ok` — la boucle de synchro | ||
| 962 | + /// (`async_sync.rs`) peut ainsi passer à l'épisode suivant. | ||
| 963 | + #[test] | ||
| 964 | + fn test_download_episode_transcode_failure_keeps_original() { | ||
| 965 | + let mut episode = Episode::new( | ||
| 966 | + "ep-123", | ||
| 967 | + "Episode Title", | ||
| 968 | + "https://example.com/episode.m4a", | ||
| 969 | + Utc::now(), | ||
| 970 | + ); | ||
| 971 | + episode.artist = Some("Artist Name".to_string()); | ||
| 972 | + let temp_dir = tempdir().unwrap(); | ||
| 973 | + let download_path = temp_dir.path(); | ||
| 974 | + | ||
| 975 | + // Fichier .m4a invalide déjà présent : simule un téléchargement | ||
| 976 | + // terminé dont la conversion va échouer (contenu non audio) | ||
| 977 | + let m4a_path = download_path.join("episode.m4a"); | ||
| 978 | + fs::write(&m4a_path, "not a real audio file").unwrap(); | ||
| 979 | + | ||
| 980 | + // La conversion est activée ; le fichier existe déjà donc aucun | ||
| 981 | + // appel réseau n'est fait (chemin refresh_downloaded_file) | ||
| 982 | + let result = download_episode(&episode, download_path, Box::new(|_| {}), true); | ||
| 983 | + assert!( | ||
| 984 | + result.is_ok(), | ||
| 985 | + "un échec de conversion ne doit pas faire échouer le téléchargement" | ||
| 986 | + ); | ||
| 987 | + let path = result.unwrap(); | ||
| 988 | + assert_eq!(path, m4a_path); | ||
| 989 | + assert!(m4a_path.exists(), "le fichier original doit être conservé"); | ||
| 990 | + assert!( | ||
| 991 | + !download_path.join("episode.mp3").exists(), | ||
| 992 | + "aucun .mp3 partiel ne doit être créé" | ||
| 993 | + ); | ||
| 994 | + } | ||
| 995 | + | ||
| 996 | + #[test] | ||
| 997 | + fn test_refresh_downloaded_file_not_downloaded() { | ||
| 998 | + let episode = Episode::new( | ||
| 999 | + "ep-123", | ||
| 1000 | + "Test Episode", | ||
| 1001 | + "https://example.com/episode.mp3", | ||
| 1002 | + Utc::now(), | ||
| 1003 | + ); | ||
| 1004 | + let temp_dir = tempdir().unwrap(); | ||
| 1005 | + | ||
| 1006 | + assert_eq!(refresh_downloaded_file(&episode, temp_dir.path(), true), None); | ||
| 1007 | + } | ||
| 1008 | + | ||
| 1009 | + #[test] | ||
| 1010 | + fn test_refresh_downloaded_file_transcode_disabled() { | ||
| 1011 | + let mut episode = Episode::new( | ||
| 1012 | + "ep-123", | ||
| 1013 | + "Episode Title", | ||
| 1014 | + "https://example.com/episode.m4a", | ||
| 1015 | + Utc::now(), | ||
| 1016 | + ); | ||
| 1017 | + episode.artist = Some("Artist Name".to_string()); | ||
| 1018 | + let temp_dir = tempdir().unwrap(); | ||
| 1019 | + let download_path = temp_dir.path(); | ||
| 1020 | + | ||
| 1021 | + let m4a_path = download_path.join("episode.m4a"); | ||
| 1022 | + fs::write(&m4a_path, "audio payload").unwrap(); | ||
| 1023 | + | ||
| 1024 | + // The option is disabled: the .m4a is returned untouched, without | ||
| 1025 | + // any re-encoding, tagging or renaming attempt | ||
| 1026 | + let path = refresh_downloaded_file(&episode, download_path, false).unwrap(); | ||
| 1027 | + assert_eq!(path, m4a_path); | ||
| 1028 | + assert_eq!(fs::read(&path).unwrap(), b"audio payload".to_vec()); | ||
| 1029 | + assert!(!download_path.join("episode.mp3").exists()); | ||
| 1030 | + assert_eq!( | ||
| 1031 | + fs::read_dir(download_path).unwrap().count(), | ||
| 1032 | + 1, | ||
| 1033 | + "no extra file must be created" | ||
| 1034 | + ); | ||
| 1035 | + } | ||
| 1036 | + | ||
| 1037 | + #[test] | ||
| 1038 | + fn test_rename_existing_files_missing_directory() { | ||
| 1039 | + let temp_dir = tempdir().unwrap(); | ||
| 1040 | + let missing = temp_dir.path().join("does_not_exist"); | ||
| 1041 | + assert_eq!(rename_existing_files_to_tag_filenames(&missing), 0); | ||
| 1042 | + } | ||
| 1043 | + | ||
| 1044 | + #[test] | ||
| 1045 | + fn test_ensure_id3_tags_on_untagged_file() { | ||
| 1046 | + use crate::rss::audio_metadata::{ensure_id3_tags, read_id3_tags}; | ||
| 1047 | + | ||
| 1048 | + let temp_dir = tempdir().unwrap(); | ||
| 1049 | + let file_path = temp_dir.path().join("episode.mp3"); | ||
| 1050 | + fs::write(&file_path, "fake audio payload").unwrap(); | ||
| 1051 | + | ||
| 1052 | + // A file without any ID3 tags must still receive the episode tags | ||
| 1053 | + ensure_id3_tags(&file_path, "Episode Title", Some("Artist Name"), Some("Album")).unwrap(); | ||
| 1054 | + | ||
| 1055 | + let tags = read_id3_tags(&file_path).unwrap(); | ||
| 1056 | + assert_eq!(tags.title.as_deref(), Some("Episode Title")); | ||
| 1057 | + assert_eq!(tags.artist.as_deref(), Some("Artist Name")); | ||
| 1058 | + assert_eq!(tags.album.as_deref(), Some("Album")); | ||
| 1059 | + | ||
| 1060 | + // Writing the tags must not destroy the audio content | ||
| 1061 | + let tagged = fs::read(&file_path).unwrap(); | ||
| 1062 | + let payload = b"fake audio payload"; | ||
| 1063 | + assert!( | ||
| 1064 | + tagged.windows(payload.len()).any(|w| w == payload), | ||
| 1065 | + "audio payload lost after tagging" | ||
| 1066 | + ); | ||
| 1067 | + } | ||
| 1068 | + | ||
| 320 | #[test] | 1069 | #[test] |
| 321 | fn test_download_error_display() { | 1070 | fn test_download_error_display() { |
| 322 | let error = DownloadError::InvalidUrl("test".to_string()); | 1071 | let error = DownloadError::InvalidUrl("test".to_string()); |
modified
src/rss/episode_extractor.rs +1 -1 | @@ -33,7 +33,7 @@ const AUDIO_MIME_TYPES: &[&str] = &[ | ||
| 33 | 33 | ]; |
| 34 | 34 | |
| 35 | 35 | /// Audio file extensions to look for in enclosures |
| 36 | -const AUDIO_EXTENSIONS: &[&str] = &[ | |
| 36 | +pub const AUDIO_EXTENSIONS: &[&str] = &[ | |
| 37 | 37 | "mp3", "mp4", "m4a", "aac", "ogg", "wav", "flac", "m4b", "opus", "oga", |
| 38 | 38 | ]; |
| 39 | 39 | |
| @@ -33,7 +33,7 @@ const AUDIO_MIME_TYPES: &[&str] = &[ | |||
| 33 | ]; | 33 | ]; |
| 34 | 34 | ||
| 35 | /// Audio file extensions to look for in enclosures | 35 | /// Audio file extensions to look for in enclosures |
| 36 | -const AUDIO_EXTENSIONS: &[&str] = &[ | 36 | +pub const AUDIO_EXTENSIONS: &[&str] = &[ |
| 37 | "mp3", "mp4", "m4a", "aac", "ogg", "wav", "flac", "m4b", "opus", "oga", | 37 | "mp3", "mp4", "m4a", "aac", "ogg", "wav", "flac", "m4b", "opus", "oga", |
| 38 | ]; | 38 | ]; |
| 39 | 39 | ||
modified
src/rss/mod.rs +2 -0 | @@ -7,3 +7,5 @@ pub mod audio_metadata; | ||
| 7 | 7 | pub mod downloader; |
| 8 | 8 | pub mod episode_extractor; |
| 9 | 9 | pub mod parser; |
| 10 | +pub mod status_file; | |
| 11 | +pub mod transcode; | |
| @@ -7,3 +7,5 @@ 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 | +pub mod status_file; | ||
| 11 | +pub mod transcode; | ||
added
src/rss/status_file.rs +295 -0 | new file mode 100644 | ||
| @@ -0,0 +1,295 @@ | ||
| 1 | +//! Sync status file module for RSS Podcast Downloader | |
| 2 | +//! | |
| 3 | +//! This module maintains a `status.txt` file in each feed's download | |
| 4 | +//! directory. The file lists the audio files downloaded for the feed and | |
| 5 | +//! serves as the baseline to compute the delta (the new episodes) on the | |
| 6 | +//! next synchronization. | |
| 7 | + | |
| 8 | +use crate::config::Episode; | |
| 9 | +use crate::rss::downloader::possible_filenames; | |
| 10 | +use crate::rss::episode_extractor::AUDIO_EXTENSIONS; | |
| 11 | +use std::collections::HashSet; | |
| 12 | +use std::fs; | |
| 13 | +use std::io; | |
| 14 | +use std::path::Path; | |
| 15 | +use std::path::PathBuf; | |
| 16 | + | |
| 17 | +/// Name of the per-feed status file kept in the download directory | |
| 18 | +pub const STATUS_FILENAME: &str = "status.txt"; | |
| 19 | + | |
| 20 | +/// Returns the path of the status file for a download directory | |
| 21 | +pub fn status_file_path(download_path: &Path) -> PathBuf { | |
| 22 | + download_path.join(STATUS_FILENAME) | |
| 23 | +} | |
| 24 | + | |
| 25 | +/// Reads the list of downloaded files recorded by the previous sync. | |
| 26 | +/// Returns an empty list when the file does not exist yet (first sync) | |
| 27 | +/// or cannot be read. | |
| 28 | +pub fn read_status_files(download_path: &Path) -> Vec<String> { | |
| 29 | + let content = match fs::read_to_string(status_file_path(download_path)) { | |
| 30 | + Ok(content) => content, | |
| 31 | + Err(_) => return Vec::new(), | |
| 32 | + }; | |
| 33 | + content | |
| 34 | + .lines() | |
| 35 | + .map(str::trim) | |
| 36 | + .filter(|line| !line.is_empty()) | |
| 37 | + .map(str::to_string) | |
| 38 | + .collect() | |
| 39 | +} | |
| 40 | + | |
| 41 | +/// Writes the list of downloaded files for the next delta computation. | |
| 42 | +/// The file is written atomically (a `.part` file renamed once complete), | |
| 43 | +/// with entries sorted and deduplicated so the content stays stable | |
| 44 | +/// between syncs. | |
| 45 | +pub fn write_status_files(download_path: &Path, files: &[String]) -> io::Result<()> { | |
| 46 | + let mut sorted = files.to_vec(); | |
| 47 | + sorted.sort(); | |
| 48 | + sorted.dedup(); | |
| 49 | + | |
| 50 | + let mut content = sorted.join("\n"); | |
| 51 | + if !content.is_empty() { | |
| 52 | + content.push('\n'); | |
| 53 | + } | |
| 54 | + | |
| 55 | + // The download directory may not exist yet (nothing downloaded so far) | |
| 56 | + fs::create_dir_all(download_path)?; | |
| 57 | + | |
| 58 | + let status_path = status_file_path(download_path); | |
| 59 | + let part_path = part_file_path(&status_path); | |
| 60 | + fs::write(&part_path, content)?; | |
| 61 | + | |
| 62 | + // On Windows, rename fails if the destination exists: remove it first | |
| 63 | + if status_path.exists() { | |
| 64 | + fs::remove_file(&status_path)?; | |
| 65 | + } | |
| 66 | + fs::rename(&part_path, &status_path)?; | |
| 67 | + Ok(()) | |
| 68 | +} | |
| 69 | + | |
| 70 | +/// Returns the temporary ".part" path for a destination file | |
| 71 | +fn part_file_path(dest: &Path) -> PathBuf { | |
| 72 | + let mut name = dest | |
| 73 | + .file_name() | |
| 74 | + .map(|n| n.to_os_string()) | |
| 75 | + .unwrap_or_default(); | |
| 76 | + name.push(".part"); | |
| 77 | + dest.with_file_name(name) | |
| 78 | +} | |
| 79 | + | |
| 80 | +/// Lists the complete audio files present in a download directory (the | |
| 81 | +/// downloaded episodes). The status file itself, non-audio files, empty | |
| 82 | +/// files (incomplete downloads) and interrupted ".part" downloads are | |
| 83 | +/// excluded. | |
| 84 | +pub fn list_downloaded_audio_files(download_path: &Path) -> Vec<String> { | |
| 85 | + let entries = match fs::read_dir(download_path) { | |
| 86 | + Ok(entries) => entries, | |
| 87 | + // The directory does not exist: nothing downloaded yet | |
| 88 | + Err(_) => return Vec::new(), | |
| 89 | + }; | |
| 90 | + | |
| 91 | + let mut files = Vec::new(); | |
| 92 | + for entry in entries.flatten() { | |
| 93 | + let path = entry.path(); | |
| 94 | + if !path.is_file() { | |
| 95 | + continue; | |
| 96 | + } | |
| 97 | + | |
| 98 | + let name = entry.file_name().to_string_lossy().into_owned(); | |
| 99 | + if name == STATUS_FILENAME { | |
| 100 | + continue; | |
| 101 | + } | |
| 102 | + | |
| 103 | + let Some(extension) = path | |
| 104 | + .extension() | |
| 105 | + .and_then(|e| e.to_str()) | |
| 106 | + .map(|e| e.to_lowercase()) | |
| 107 | + else { | |
| 108 | + continue; | |
| 109 | + }; | |
| 110 | + if !AUDIO_EXTENSIONS.contains(&extension.as_str()) { | |
| 111 | + continue; | |
| 112 | + } | |
| 113 | + | |
| 114 | + // An empty file is an incomplete download: not counted | |
| 115 | + if fs::metadata(&path).map(|m| m.len() == 0).unwrap_or(true) { | |
| 116 | + continue; | |
| 117 | + } | |
| 118 | + | |
| 119 | + files.push(name); | |
| 120 | + } | |
| 121 | + files | |
| 122 | +} | |
| 123 | + | |
| 124 | +/// Computes the delta between a feed's episodes and the files recorded by | |
| 125 | +/// the previous sync: returns the episodes whose downloaded file (under | |
| 126 | +/// any of its possible names) is not listed in `previous_files`. Those are | |
| 127 | +/// the new episodes since the last synchronization. | |
| 128 | +pub fn compute_new_episodes<'a>( | |
| 129 | + episodes: &'a [Episode], | |
| 130 | + previous_files: &[String], | |
| 131 | +) -> Vec<&'a Episode> { | |
| 132 | + let previous: HashSet<&str> = previous_files.iter().map(String::as_str).collect(); | |
| 133 | + episodes | |
| 134 | + .iter() | |
| 135 | + .filter(|episode| { | |
| 136 | + !possible_filenames(episode) | |
| 137 | + .iter() | |
| 138 | + .any(|name| previous.contains(name.as_str())) | |
| 139 | + }) | |
| 140 | + .collect() | |
| 141 | +} | |
| 142 | + | |
| 143 | +#[cfg(test)] | |
| 144 | +mod tests { | |
| 145 | + use super::*; | |
| 146 | + use chrono::Utc; | |
| 147 | + use tempfile::tempdir; | |
| 148 | + | |
| 149 | + #[test] | |
| 150 | + fn test_read_status_files_missing() { | |
| 151 | + let temp_dir = tempdir().unwrap(); | |
| 152 | + assert!(read_status_files(temp_dir.path()).is_empty()); | |
| 153 | + } | |
| 154 | + | |
| 155 | + #[test] | |
| 156 | + fn test_read_status_files_ignores_blank_lines() { | |
| 157 | + let temp_dir = tempdir().unwrap(); | |
| 158 | + fs::write(status_file_path(temp_dir.path()), "a.mp3\n\n \nb.mp3\n").unwrap(); | |
| 159 | + assert_eq!( | |
| 160 | + read_status_files(temp_dir.path()), | |
| 161 | + vec!["a.mp3".to_string(), "b.mp3".to_string()] | |
| 162 | + ); | |
| 163 | + } | |
| 164 | + | |
| 165 | + #[test] | |
| 166 | + fn test_write_and_read_status_files() { | |
| 167 | + let temp_dir = tempdir().unwrap(); | |
| 168 | + let files = vec![ | |
| 169 | + "b.mp3".to_string(), | |
| 170 | + "a.mp3".to_string(), | |
| 171 | + "a.mp3".to_string(), | |
| 172 | + ]; | |
| 173 | + write_status_files(temp_dir.path(), &files).unwrap(); | |
| 174 | + | |
| 175 | + // Entries are sorted and deduplicated | |
| 176 | + assert_eq!( | |
| 177 | + read_status_files(temp_dir.path()), | |
| 178 | + vec!["a.mp3".to_string(), "b.mp3".to_string()] | |
| 179 | + ); | |
| 180 | + assert!(status_file_path(temp_dir.path()).exists()); | |
| 181 | + // No leftover .part file | |
| 182 | + assert!(!temp_dir.path().join("status.txt.part").exists()); | |
| 183 | + } | |
| 184 | + | |
| 185 | + #[test] | |
| 186 | + fn test_write_status_files_overwrites_previous() { | |
| 187 | + let temp_dir = tempdir().unwrap(); | |
| 188 | + write_status_files(temp_dir.path(), &["old.mp3".to_string()]).unwrap(); | |
| 189 | + write_status_files(temp_dir.path(), &["new.mp3".to_string()]).unwrap(); | |
| 190 | + assert_eq!( | |
| 191 | + read_status_files(temp_dir.path()), | |
| 192 | + vec!["new.mp3".to_string()] | |
| 193 | + ); | |
| 194 | + } | |
| 195 | + | |
| 196 | + #[test] | |
| 197 | + fn test_write_status_files_empty() { | |
| 198 | + let temp_dir = tempdir().unwrap(); | |
| 199 | + write_status_files(temp_dir.path(), &[]).unwrap(); | |
| 200 | + assert_eq!(read_status_files(temp_dir.path()), Vec::<String>::new()); | |
| 201 | + // The status file is still created | |
| 202 | + assert!(status_file_path(temp_dir.path()).exists()); | |
| 203 | + } | |
| 204 | + | |
| 205 | + #[test] | |
| 206 | + fn test_write_status_files_creates_missing_dir() { | |
| 207 | + let temp_dir = tempdir().unwrap(); | |
| 208 | + let download_path = temp_dir.path().join("feed").join("downloads"); | |
| 209 | + write_status_files(&download_path, &["ep.mp3".to_string()]).unwrap(); | |
| 210 | + assert_eq!( | |
| 211 | + read_status_files(&download_path), | |
| 212 | + vec!["ep.mp3".to_string()] | |
| 213 | + ); | |
| 214 | + } | |
| 215 | + | |
| 216 | + #[test] | |
| 217 | + fn test_list_downloaded_audio_files() { | |
| 218 | + let temp_dir = tempdir().unwrap(); | |
| 219 | + let dir = temp_dir.path(); | |
| 220 | + fs::write(dir.join("ep1.mp3"), "audio").unwrap(); | |
| 221 | + fs::write(dir.join("ep2.m4a"), "audio").unwrap(); | |
| 222 | + fs::write(dir.join("notes.txt"), "text").unwrap(); // not audio | |
| 223 | + fs::write(dir.join("ep3.mp3.part"), "partial").unwrap(); // interrupted | |
| 224 | + fs::write(dir.join("empty.mp3"), "").unwrap(); // incomplete | |
| 225 | + fs::write(dir.join("status.txt"), "ep1.mp3\n").unwrap(); // the status file itself | |
| 226 | + | |
| 227 | + let mut files = list_downloaded_audio_files(dir); | |
| 228 | + files.sort(); | |
| 229 | + assert_eq!(files, vec!["ep1.mp3".to_string(), "ep2.m4a".to_string()]); | |
| 230 | + } | |
| 231 | + | |
| 232 | + #[test] | |
| 233 | + fn test_list_downloaded_audio_files_missing_dir() { | |
| 234 | + let temp_dir = tempdir().unwrap(); | |
| 235 | + let missing = temp_dir.path().join("does_not_exist"); | |
| 236 | + assert!(list_downloaded_audio_files(&missing).is_empty()); | |
| 237 | + } | |
| 238 | + | |
| 239 | + #[test] | |
| 240 | + fn test_compute_new_episodes() { | |
| 241 | + let mut old = Episode::new("ep-1", "Old", "https://example.com/old.mp3", Utc::now()); | |
| 242 | + old.artist = Some("Artist".to_string()); | |
| 243 | + let fresh = Episode::new("ep-2", "Fresh", "https://example.com/fresh.mp3", Utc::now()); | |
| 244 | + | |
| 245 | + // The previous sync recorded the old episode under its tag-based name | |
| 246 | + let previous = vec!["Artist - Old.mp3".to_string()]; | |
| 247 | + let episodes = [old, fresh]; | |
| 248 | + let new = compute_new_episodes(&episodes, &previous); | |
| 249 | + assert_eq!(new.len(), 1); | |
| 250 | + assert_eq!(new[0].id, "ep-2"); | |
| 251 | + } | |
| 252 | + | |
| 253 | + #[test] | |
| 254 | + fn test_compute_new_episodes_first_sync() { | |
| 255 | + let episodes = vec![ | |
| 256 | + Episode::new("ep-1", "One", "https://example.com/1.mp3", Utc::now()), | |
| 257 | + Episode::new("ep-2", "Two", "https://example.com/2.mp3", Utc::now()), | |
| 258 | + ]; | |
| 259 | + // No status file yet: every episode is new | |
| 260 | + assert_eq!(compute_new_episodes(&episodes, &[]).len(), 2); | |
| 261 | + } | |
| 262 | + | |
| 263 | + #[test] | |
| 264 | + fn test_compute_new_episodes_matches_url_filename() { | |
| 265 | + let episode = Episode::new("ep-1", "Title", "https://example.com/ep.mp3", Utc::now()); | |
| 266 | + let previous = vec!["ep.mp3".to_string()]; | |
| 267 | + assert!(compute_new_episodes(&[episode], &previous).is_empty()); | |
| 268 | + } | |
| 269 | + | |
| 270 | + #[test] | |
| 271 | + fn test_compute_new_episodes_matches_mp3_counterpart() { | |
| 272 | + // A re-encoded .m4a download is recorded under its .mp3 name | |
| 273 | + let episode = Episode::new("ep-1", "Title", "https://example.com/ep.m4a", Utc::now()); | |
| 274 | + let previous = vec!["ep.mp3".to_string()]; | |
| 275 | + assert!(compute_new_episodes(&[episode], &previous).is_empty()); | |
| 276 | + } | |
| 277 | + | |
| 278 | + /// Non-régression : après la conversion d'un .m4a en .mp3 et la | |
| 279 | + /// suppression de l'original, l'épisode doit rester téléchargé — pas de | |
| 280 | + /// re-téléchargement, et pas de delta à la synchro suivante. | |
| 281 | + #[test] | |
| 282 | + fn test_transcoded_episode_stays_downloaded_after_source_removed() { | |
| 283 | + let episode = Episode::new("ep-1", "Title", "https://example.com/ep.m4a", Utc::now()); | |
| 284 | + let temp_dir = tempdir().unwrap(); | |
| 285 | + let download_path = temp_dir.path(); | |
| 286 | + fs::write(download_path.join("ep.mp3"), "transcoded").unwrap(); | |
| 287 | + | |
| 288 | + assert!(!crate::rss::downloader::needs_download(&episode, download_path)); | |
| 289 | + | |
| 290 | + // The status.txt written by the previous sync lists the .mp3: the | |
| 291 | + // episode is not part of the delta of the next sync | |
| 292 | + let previous = vec!["ep.mp3".to_string()]; | |
| 293 | + assert!(compute_new_episodes(&[episode], &previous).is_empty()); | |
| 294 | + } | |
| 295 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,295 @@ | |||
| 1 | +//! Sync status file module for RSS Podcast Downloader | ||
| 2 | +//! | ||
| 3 | +//! This module maintains a `status.txt` file in each feed's download | ||
| 4 | +//! directory. The file lists the audio files downloaded for the feed and | ||
| 5 | +//! serves as the baseline to compute the delta (the new episodes) on the | ||
| 6 | +//! next synchronization. | ||
| 7 | + | ||
| 8 | +use crate::config::Episode; | ||
| 9 | +use crate::rss::downloader::possible_filenames; | ||
| 10 | +use crate::rss::episode_extractor::AUDIO_EXTENSIONS; | ||
| 11 | +use std::collections::HashSet; | ||
| 12 | +use std::fs; | ||
| 13 | +use std::io; | ||
| 14 | +use std::path::Path; | ||
| 15 | +use std::path::PathBuf; | ||
| 16 | + | ||
| 17 | +/// Name of the per-feed status file kept in the download directory | ||
| 18 | +pub const STATUS_FILENAME: &str = "status.txt"; | ||
| 19 | + | ||
| 20 | +/// Returns the path of the status file for a download directory | ||
| 21 | +pub fn status_file_path(download_path: &Path) -> PathBuf { | ||
| 22 | + download_path.join(STATUS_FILENAME) | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +/// Reads the list of downloaded files recorded by the previous sync. | ||
| 26 | +/// Returns an empty list when the file does not exist yet (first sync) | ||
| 27 | +/// or cannot be read. | ||
| 28 | +pub fn read_status_files(download_path: &Path) -> Vec<String> { | ||
| 29 | + let content = match fs::read_to_string(status_file_path(download_path)) { | ||
| 30 | + Ok(content) => content, | ||
| 31 | + Err(_) => return Vec::new(), | ||
| 32 | + }; | ||
| 33 | + content | ||
| 34 | + .lines() | ||
| 35 | + .map(str::trim) | ||
| 36 | + .filter(|line| !line.is_empty()) | ||
| 37 | + .map(str::to_string) | ||
| 38 | + .collect() | ||
| 39 | +} | ||
| 40 | + | ||
| 41 | +/// Writes the list of downloaded files for the next delta computation. | ||
| 42 | +/// The file is written atomically (a `.part` file renamed once complete), | ||
| 43 | +/// with entries sorted and deduplicated so the content stays stable | ||
| 44 | +/// between syncs. | ||
| 45 | +pub fn write_status_files(download_path: &Path, files: &[String]) -> io::Result<()> { | ||
| 46 | + let mut sorted = files.to_vec(); | ||
| 47 | + sorted.sort(); | ||
| 48 | + sorted.dedup(); | ||
| 49 | + | ||
| 50 | + let mut content = sorted.join("\n"); | ||
| 51 | + if !content.is_empty() { | ||
| 52 | + content.push('\n'); | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | + // The download directory may not exist yet (nothing downloaded so far) | ||
| 56 | + fs::create_dir_all(download_path)?; | ||
| 57 | + | ||
| 58 | + let status_path = status_file_path(download_path); | ||
| 59 | + let part_path = part_file_path(&status_path); | ||
| 60 | + fs::write(&part_path, content)?; | ||
| 61 | + | ||
| 62 | + // On Windows, rename fails if the destination exists: remove it first | ||
| 63 | + if status_path.exists() { | ||
| 64 | + fs::remove_file(&status_path)?; | ||
| 65 | + } | ||
| 66 | + fs::rename(&part_path, &status_path)?; | ||
| 67 | + Ok(()) | ||
| 68 | +} | ||
| 69 | + | ||
| 70 | +/// Returns the temporary ".part" path for a destination file | ||
| 71 | +fn part_file_path(dest: &Path) -> PathBuf { | ||
| 72 | + let mut name = dest | ||
| 73 | + .file_name() | ||
| 74 | + .map(|n| n.to_os_string()) | ||
| 75 | + .unwrap_or_default(); | ||
| 76 | + name.push(".part"); | ||
| 77 | + dest.with_file_name(name) | ||
| 78 | +} | ||
| 79 | + | ||
| 80 | +/// Lists the complete audio files present in a download directory (the | ||
| 81 | +/// downloaded episodes). The status file itself, non-audio files, empty | ||
| 82 | +/// files (incomplete downloads) and interrupted ".part" downloads are | ||
| 83 | +/// excluded. | ||
| 84 | +pub fn list_downloaded_audio_files(download_path: &Path) -> Vec<String> { | ||
| 85 | + let entries = match fs::read_dir(download_path) { | ||
| 86 | + Ok(entries) => entries, | ||
| 87 | + // The directory does not exist: nothing downloaded yet | ||
| 88 | + Err(_) => return Vec::new(), | ||
| 89 | + }; | ||
| 90 | + | ||
| 91 | + let mut files = Vec::new(); | ||
| 92 | + for entry in entries.flatten() { | ||
| 93 | + let path = entry.path(); | ||
| 94 | + if !path.is_file() { | ||
| 95 | + continue; | ||
| 96 | + } | ||
| 97 | + | ||
| 98 | + let name = entry.file_name().to_string_lossy().into_owned(); | ||
| 99 | + if name == STATUS_FILENAME { | ||
| 100 | + continue; | ||
| 101 | + } | ||
| 102 | + | ||
| 103 | + let Some(extension) = path | ||
| 104 | + .extension() | ||
| 105 | + .and_then(|e| e.to_str()) | ||
| 106 | + .map(|e| e.to_lowercase()) | ||
| 107 | + else { | ||
| 108 | + continue; | ||
| 109 | + }; | ||
| 110 | + if !AUDIO_EXTENSIONS.contains(&extension.as_str()) { | ||
| 111 | + continue; | ||
| 112 | + } | ||
| 113 | + | ||
| 114 | + // An empty file is an incomplete download: not counted | ||
| 115 | + if fs::metadata(&path).map(|m| m.len() == 0).unwrap_or(true) { | ||
| 116 | + continue; | ||
| 117 | + } | ||
| 118 | + | ||
| 119 | + files.push(name); | ||
| 120 | + } | ||
| 121 | + files | ||
| 122 | +} | ||
| 123 | + | ||
| 124 | +/// Computes the delta between a feed's episodes and the files recorded by | ||
| 125 | +/// the previous sync: returns the episodes whose downloaded file (under | ||
| 126 | +/// any of its possible names) is not listed in `previous_files`. Those are | ||
| 127 | +/// the new episodes since the last synchronization. | ||
| 128 | +pub fn compute_new_episodes<'a>( | ||
| 129 | + episodes: &'a [Episode], | ||
| 130 | + previous_files: &[String], | ||
| 131 | +) -> Vec<&'a Episode> { | ||
| 132 | + let previous: HashSet<&str> = previous_files.iter().map(String::as_str).collect(); | ||
| 133 | + episodes | ||
| 134 | + .iter() | ||
| 135 | + .filter(|episode| { | ||
| 136 | + !possible_filenames(episode) | ||
| 137 | + .iter() | ||
| 138 | + .any(|name| previous.contains(name.as_str())) | ||
| 139 | + }) | ||
| 140 | + .collect() | ||
| 141 | +} | ||
| 142 | + | ||
| 143 | +#[cfg(test)] | ||
| 144 | +mod tests { | ||
| 145 | + use super::*; | ||
| 146 | + use chrono::Utc; | ||
| 147 | + use tempfile::tempdir; | ||
| 148 | + | ||
| 149 | + #[test] | ||
| 150 | + fn test_read_status_files_missing() { | ||
| 151 | + let temp_dir = tempdir().unwrap(); | ||
| 152 | + assert!(read_status_files(temp_dir.path()).is_empty()); | ||
| 153 | + } | ||
| 154 | + | ||
| 155 | + #[test] | ||
| 156 | + fn test_read_status_files_ignores_blank_lines() { | ||
| 157 | + let temp_dir = tempdir().unwrap(); | ||
| 158 | + fs::write(status_file_path(temp_dir.path()), "a.mp3\n\n \nb.mp3\n").unwrap(); | ||
| 159 | + assert_eq!( | ||
| 160 | + read_status_files(temp_dir.path()), | ||
| 161 | + vec!["a.mp3".to_string(), "b.mp3".to_string()] | ||
| 162 | + ); | ||
| 163 | + } | ||
| 164 | + | ||
| 165 | + #[test] | ||
| 166 | + fn test_write_and_read_status_files() { | ||
| 167 | + let temp_dir = tempdir().unwrap(); | ||
| 168 | + let files = vec![ | ||
| 169 | + "b.mp3".to_string(), | ||
| 170 | + "a.mp3".to_string(), | ||
| 171 | + "a.mp3".to_string(), | ||
| 172 | + ]; | ||
| 173 | + write_status_files(temp_dir.path(), &files).unwrap(); | ||
| 174 | + | ||
| 175 | + // Entries are sorted and deduplicated | ||
| 176 | + assert_eq!( | ||
| 177 | + read_status_files(temp_dir.path()), | ||
| 178 | + vec!["a.mp3".to_string(), "b.mp3".to_string()] | ||
| 179 | + ); | ||
| 180 | + assert!(status_file_path(temp_dir.path()).exists()); | ||
| 181 | + // No leftover .part file | ||
| 182 | + assert!(!temp_dir.path().join("status.txt.part").exists()); | ||
| 183 | + } | ||
| 184 | + | ||
| 185 | + #[test] | ||
| 186 | + fn test_write_status_files_overwrites_previous() { | ||
| 187 | + let temp_dir = tempdir().unwrap(); | ||
| 188 | + write_status_files(temp_dir.path(), &["old.mp3".to_string()]).unwrap(); | ||
| 189 | + write_status_files(temp_dir.path(), &["new.mp3".to_string()]).unwrap(); | ||
| 190 | + assert_eq!( | ||
| 191 | + read_status_files(temp_dir.path()), | ||
| 192 | + vec!["new.mp3".to_string()] | ||
| 193 | + ); | ||
| 194 | + } | ||
| 195 | + | ||
| 196 | + #[test] | ||
| 197 | + fn test_write_status_files_empty() { | ||
| 198 | + let temp_dir = tempdir().unwrap(); | ||
| 199 | + write_status_files(temp_dir.path(), &[]).unwrap(); | ||
| 200 | + assert_eq!(read_status_files(temp_dir.path()), Vec::<String>::new()); | ||
| 201 | + // The status file is still created | ||
| 202 | + assert!(status_file_path(temp_dir.path()).exists()); | ||
| 203 | + } | ||
| 204 | + | ||
| 205 | + #[test] | ||
| 206 | + fn test_write_status_files_creates_missing_dir() { | ||
| 207 | + let temp_dir = tempdir().unwrap(); | ||
| 208 | + let download_path = temp_dir.path().join("feed").join("downloads"); | ||
| 209 | + write_status_files(&download_path, &["ep.mp3".to_string()]).unwrap(); | ||
| 210 | + assert_eq!( | ||
| 211 | + read_status_files(&download_path), | ||
| 212 | + vec!["ep.mp3".to_string()] | ||
| 213 | + ); | ||
| 214 | + } | ||
| 215 | + | ||
| 216 | + #[test] | ||
| 217 | + fn test_list_downloaded_audio_files() { | ||
| 218 | + let temp_dir = tempdir().unwrap(); | ||
| 219 | + let dir = temp_dir.path(); | ||
| 220 | + fs::write(dir.join("ep1.mp3"), "audio").unwrap(); | ||
| 221 | + fs::write(dir.join("ep2.m4a"), "audio").unwrap(); | ||
| 222 | + fs::write(dir.join("notes.txt"), "text").unwrap(); // not audio | ||
| 223 | + fs::write(dir.join("ep3.mp3.part"), "partial").unwrap(); // interrupted | ||
| 224 | + fs::write(dir.join("empty.mp3"), "").unwrap(); // incomplete | ||
| 225 | + fs::write(dir.join("status.txt"), "ep1.mp3\n").unwrap(); // the status file itself | ||
| 226 | + | ||
| 227 | + let mut files = list_downloaded_audio_files(dir); | ||
| 228 | + files.sort(); | ||
| 229 | + assert_eq!(files, vec!["ep1.mp3".to_string(), "ep2.m4a".to_string()]); | ||
| 230 | + } | ||
| 231 | + | ||
| 232 | + #[test] | ||
| 233 | + fn test_list_downloaded_audio_files_missing_dir() { | ||
| 234 | + let temp_dir = tempdir().unwrap(); | ||
| 235 | + let missing = temp_dir.path().join("does_not_exist"); | ||
| 236 | + assert!(list_downloaded_audio_files(&missing).is_empty()); | ||
| 237 | + } | ||
| 238 | + | ||
| 239 | + #[test] | ||
| 240 | + fn test_compute_new_episodes() { | ||
| 241 | + let mut old = Episode::new("ep-1", "Old", "https://example.com/old.mp3", Utc::now()); | ||
| 242 | + old.artist = Some("Artist".to_string()); | ||
| 243 | + let fresh = Episode::new("ep-2", "Fresh", "https://example.com/fresh.mp3", Utc::now()); | ||
| 244 | + | ||
| 245 | + // The previous sync recorded the old episode under its tag-based name | ||
| 246 | + let previous = vec!["Artist - Old.mp3".to_string()]; | ||
| 247 | + let episodes = [old, fresh]; | ||
| 248 | + let new = compute_new_episodes(&episodes, &previous); | ||
| 249 | + assert_eq!(new.len(), 1); | ||
| 250 | + assert_eq!(new[0].id, "ep-2"); | ||
| 251 | + } | ||
| 252 | + | ||
| 253 | + #[test] | ||
| 254 | + fn test_compute_new_episodes_first_sync() { | ||
| 255 | + let episodes = vec![ | ||
| 256 | + Episode::new("ep-1", "One", "https://example.com/1.mp3", Utc::now()), | ||
| 257 | + Episode::new("ep-2", "Two", "https://example.com/2.mp3", Utc::now()), | ||
| 258 | + ]; | ||
| 259 | + // No status file yet: every episode is new | ||
| 260 | + assert_eq!(compute_new_episodes(&episodes, &[]).len(), 2); | ||
| 261 | + } | ||
| 262 | + | ||
| 263 | + #[test] | ||
| 264 | + fn test_compute_new_episodes_matches_url_filename() { | ||
| 265 | + let episode = Episode::new("ep-1", "Title", "https://example.com/ep.mp3", Utc::now()); | ||
| 266 | + let previous = vec!["ep.mp3".to_string()]; | ||
| 267 | + assert!(compute_new_episodes(&[episode], &previous).is_empty()); | ||
| 268 | + } | ||
| 269 | + | ||
| 270 | + #[test] | ||
| 271 | + fn test_compute_new_episodes_matches_mp3_counterpart() { | ||
| 272 | + // A re-encoded .m4a download is recorded under its .mp3 name | ||
| 273 | + let episode = Episode::new("ep-1", "Title", "https://example.com/ep.m4a", Utc::now()); | ||
| 274 | + let previous = vec!["ep.mp3".to_string()]; | ||
| 275 | + assert!(compute_new_episodes(&[episode], &previous).is_empty()); | ||
| 276 | + } | ||
| 277 | + | ||
| 278 | + /// Non-régression : après la conversion d'un .m4a en .mp3 et la | ||
| 279 | + /// suppression de l'original, l'épisode doit rester téléchargé — pas de | ||
| 280 | + /// re-téléchargement, et pas de delta à la synchro suivante. | ||
| 281 | + #[test] | ||
| 282 | + fn test_transcoded_episode_stays_downloaded_after_source_removed() { | ||
| 283 | + let episode = Episode::new("ep-1", "Title", "https://example.com/ep.m4a", Utc::now()); | ||
| 284 | + let temp_dir = tempdir().unwrap(); | ||
| 285 | + let download_path = temp_dir.path(); | ||
| 286 | + fs::write(download_path.join("ep.mp3"), "transcoded").unwrap(); | ||
| 287 | + | ||
| 288 | + assert!(!crate::rss::downloader::needs_download(&episode, download_path)); | ||
| 289 | + | ||
| 290 | + // The status.txt written by the previous sync lists the .mp3: the | ||
| 291 | + // episode is not part of the delta of the next sync | ||
| 292 | + let previous = vec!["ep.mp3".to_string()]; | ||
| 293 | + assert!(compute_new_episodes(&[episode], &previous).is_empty()); | ||
| 294 | + } | ||
| 295 | +} | ||
added
src/rss/transcode.rs +300 -0 | new file mode 100644 | ||
| @@ -0,0 +1,300 @@ | ||
| 1 | +//! Transcoding module for RSS Podcast Downloader | |
| 2 | +//! | |
| 3 | +//! Some podcast feeds serve audio in formats whose metadata is not ID3 | |
| 4 | +//! (e.g. .m4a). To keep a single tag-based pipeline, those files are | |
| 5 | +//! re-encoded to .mp3 with ffmpeg. The original file is kept untouched when | |
| 6 | +//! ffmpeg is unavailable or the encoding fails. | |
| 7 | + | |
| 8 | +use log::{info, warn}; | |
| 9 | +use std::fs; | |
| 10 | +use std::path::{Path, PathBuf}; | |
| 11 | +use std::process::{Command, Stdio}; | |
| 12 | + | |
| 13 | +/// Extensions re-encoded to .mp3 (their metadata container is not ID3) | |
| 14 | +pub const TRANSCODE_EXTENSIONS: &[&str] = &["m4a"]; | |
| 15 | + | |
| 16 | +/// Returns true when the file must be re-encoded to .mp3 | |
| 17 | +pub fn is_transcode_source(path: &Path) -> bool { | |
| 18 | + path.extension() | |
| 19 | + .and_then(|e| e.to_str()) | |
| 20 | + .map(|e| e.to_ascii_lowercase()) | |
| 21 | + .is_some_and(|e| TRANSCODE_EXTENSIONS.contains(&e.as_str())) | |
| 22 | +} | |
| 23 | + | |
| 24 | +/// Returns the .mp3 counterpart of a transcode source path, or None when | |
| 25 | +/// the path is not a transcode source | |
| 26 | +pub fn mp3_counterpart(path: &Path) -> Option<PathBuf> { | |
| 27 | + if !is_transcode_source(path) { | |
| 28 | + return None; | |
| 29 | + } | |
| 30 | + Some(path.with_extension("mp3")) | |
| 31 | +} | |
| 32 | + | |
| 33 | +/// Returns true when the ffmpeg executable is available | |
| 34 | +pub fn ffmpeg_available() -> bool { | |
| 35 | + Command::new("ffmpeg") | |
| 36 | + .arg("-version") | |
| 37 | + .stdout(Stdio::null()) | |
| 38 | + .stderr(Stdio::null()) | |
| 39 | + .status() | |
| 40 | + .is_ok_and(|status| status.success()) | |
| 41 | +} | |
| 42 | + | |
| 43 | +/// Re-encodes an audio file to .mp3, removing the original on success. The | |
| 44 | +/// output is written to a .part file and renamed once complete, so an | |
| 45 | +/// interrupted encoding is never mistaken for a valid .mp3. Returns true and | |
| 46 | +/// updates `file_path` when the transcoded .mp3 is in place; returns false | |
| 47 | +/// and keeps the file untouched otherwise (not a transcode source, no | |
| 48 | +/// ffmpeg, or an encoding failure). | |
| 49 | +pub fn transcode_to_mp3(file_path: &mut PathBuf) -> bool { | |
| 50 | + let Some(mp3_path) = mp3_counterpart(file_path) else { | |
| 51 | + return false; | |
| 52 | + }; | |
| 53 | + | |
| 54 | + // A leftover original next to its .mp3 (e.g. the removal failed during | |
| 55 | + // a previous pass): the .mp3 is the canonical file, only clean up | |
| 56 | + if mp3_path.exists() { | |
| 57 | + info!( | |
| 58 | + "Transcoded file already exists, removing leftover original: {}", | |
| 59 | + file_path.display() | |
| 60 | + ); | |
| 61 | + remove_best_effort(file_path); | |
| 62 | + *file_path = mp3_path; | |
| 63 | + return true; | |
| 64 | + } | |
| 65 | + | |
| 66 | + if !ffmpeg_available() { | |
| 67 | + warn!( | |
| 68 | + "ffmpeg not available: keeping original audio file {}", | |
| 69 | + file_path.display() | |
| 70 | + ); | |
| 71 | + return false; | |
| 72 | + } | |
| 73 | + | |
| 74 | + let mut part_name = mp3_path | |
| 75 | + .file_name() | |
| 76 | + .map(|n| n.to_os_string()) | |
| 77 | + .unwrap_or_default(); | |
| 78 | + part_name.push(".part"); | |
| 79 | + let part_path = mp3_path.with_file_name(part_name); | |
| 80 | + let _ = fs::remove_file(&part_path); | |
| 81 | + | |
| 82 | + // -f mp3 is required: the .part extension hides the output format from | |
| 83 | + // ffmpeg, which otherwise fails with EINVAL (exit code 0xffffffea) | |
| 84 | + let status = Command::new("ffmpeg") | |
| 85 | + .args(["-y", "-hide_banner", "-loglevel", "error", "-i"]) | |
| 86 | + .arg(&*file_path) | |
| 87 | + .args(["-vn", "-codec:a", "libmp3lame", "-q:a", "2", "-f", "mp3"]) | |
| 88 | + .arg(&part_path) | |
| 89 | + .stdout(Stdio::null()) | |
| 90 | + .stderr(Stdio::null()) | |
| 91 | + .status(); | |
| 92 | + | |
| 93 | + match status { | |
| 94 | + Ok(status) if status.success() => {} | |
| 95 | + Ok(status) => { | |
| 96 | + warn!( | |
| 97 | + "ffmpeg failed to transcode {}: exit status {}", | |
| 98 | + file_path.display(), | |
| 99 | + status | |
| 100 | + ); | |
| 101 | + let _ = fs::remove_file(&part_path); | |
| 102 | + return false; | |
| 103 | + } | |
| 104 | + Err(e) => { | |
| 105 | + warn!( | |
| 106 | + "Failed to run ffmpeg on {}: {}", | |
| 107 | + file_path.display(), | |
| 108 | + e | |
| 109 | + ); | |
| 110 | + let _ = fs::remove_file(&part_path); | |
| 111 | + return false; | |
| 112 | + } | |
| 113 | + } | |
| 114 | + | |
| 115 | + if let Err(e) = fs::rename(&part_path, &mp3_path) { | |
| 116 | + warn!( | |
| 117 | + "Failed to finalize transcoded file {}: {}", | |
| 118 | + mp3_path.display(), | |
| 119 | + e | |
| 120 | + ); | |
| 121 | + let _ = fs::remove_file(&part_path); | |
| 122 | + return false; | |
| 123 | + } | |
| 124 | + | |
| 125 | + info!( | |
| 126 | + "Transcoded {} to {}", | |
| 127 | + file_path.display(), | |
| 128 | + mp3_path.display() | |
| 129 | + ); | |
| 130 | + remove_best_effort(file_path); | |
| 131 | + *file_path = mp3_path; | |
| 132 | + true | |
| 133 | +} | |
| 134 | + | |
| 135 | +/// Removes the original file after a successful transcode. A failure is not | |
| 136 | +/// fatal — the .mp3 is the canonical file and preferred by the download | |
| 137 | +/// detection — but it is reported so the leftover can be cleaned up manually. | |
| 138 | +fn remove_best_effort(path: &Path) { | |
| 139 | + if let Err(e) = fs::remove_file(path) { | |
| 140 | + warn!( | |
| 141 | + "Failed to remove transcoded original {}: {}", | |
| 142 | + path.display(), | |
| 143 | + e | |
| 144 | + ); | |
| 145 | + } | |
| 146 | +} | |
| 147 | + | |
| 148 | +/// Removes the original transcode source (.m4a) sitting next to its | |
| 149 | +/// transcoded .mp3, when present (e.g. its removal failed during a previous | |
| 150 | +/// pass). The .mp3 is the canonical file: removing the original never | |
| 151 | +/// changes the downloaded status of the episode, which is never downloaded | |
| 152 | +/// again. | |
| 153 | +pub fn remove_transcode_source_next_to(mp3_path: &Path) { | |
| 154 | + let Some(source) = transcode_source_of(mp3_path) else { | |
| 155 | + return; | |
| 156 | + }; | |
| 157 | + if source.is_file() { | |
| 158 | + remove_best_effort(&source); | |
| 159 | + } | |
| 160 | +} | |
| 161 | + | |
| 162 | +/// Returns the original transcode source (.m4a) corresponding to a | |
| 163 | +/// transcoded .mp3 path, or None when the path is not a .mp3 | |
| 164 | +fn transcode_source_of(mp3_path: &Path) -> Option<PathBuf> { | |
| 165 | + if !mp3_path.extension()?.eq_ignore_ascii_case("mp3") { | |
| 166 | + return None; | |
| 167 | + } | |
| 168 | + let source = mp3_path.with_extension("m4a"); | |
| 169 | + is_transcode_source(&source).then_some(source) | |
| 170 | +} | |
| 171 | + | |
| 172 | +#[cfg(test)] | |
| 173 | +mod tests { | |
| 174 | + use super::*; | |
| 175 | + use tempfile::tempdir; | |
| 176 | + | |
| 177 | + #[test] | |
| 178 | + fn test_is_transcode_source() { | |
| 179 | + assert!(is_transcode_source(Path::new("episode.m4a"))); | |
| 180 | + assert!(is_transcode_source(Path::new("episode.M4A"))); | |
| 181 | + assert!(!is_transcode_source(Path::new("episode.mp3"))); | |
| 182 | + assert!(!is_transcode_source(Path::new("episode.ogg"))); | |
| 183 | + assert!(!is_transcode_source(Path::new("noext"))); | |
| 184 | + } | |
| 185 | + | |
| 186 | + #[test] | |
| 187 | + fn test_mp3_counterpart() { | |
| 188 | + assert_eq!( | |
| 189 | + mp3_counterpart(Path::new("downloads/episode.m4a")), | |
| 190 | + Some(PathBuf::from("downloads/episode.mp3")) | |
| 191 | + ); | |
| 192 | + // Not a transcode source: no counterpart | |
| 193 | + assert_eq!(mp3_counterpart(Path::new("episode.mp3")), None); | |
| 194 | + } | |
| 195 | + | |
| 196 | + #[test] | |
| 197 | + fn test_transcode_to_mp3_not_a_source() { | |
| 198 | + let temp_dir = tempdir().unwrap(); | |
| 199 | + let mut path = temp_dir.path().join("episode.mp3"); | |
| 200 | + fs::write(&path, "audio").unwrap(); | |
| 201 | + | |
| 202 | + // Not a transcode source: untouched, returns false | |
| 203 | + assert!(!transcode_to_mp3(&mut path)); | |
| 204 | + assert!(path.exists()); | |
| 205 | + } | |
| 206 | + | |
| 207 | + #[test] | |
| 208 | + fn test_transcode_to_mp3_ffmpeg_failure_keeps_original() { | |
| 209 | + if !ffmpeg_available() { | |
| 210 | + return; // ffmpeg not installed: nothing to assert about encoding | |
| 211 | + } | |
| 212 | + | |
| 213 | + let temp_dir = tempdir().unwrap(); | |
| 214 | + let mut path = temp_dir.path().join("episode.m4a"); | |
| 215 | + // Not real audio: the encoding must fail and the file must be kept | |
| 216 | + fs::write(&path, "not an audio file").unwrap(); | |
| 217 | + | |
| 218 | + assert!(!transcode_to_mp3(&mut path)); | |
| 219 | + assert!(path.exists(), "the original file must be kept"); | |
| 220 | + assert!(!temp_dir.path().join("episode.mp3").exists()); | |
| 221 | + assert!( | |
| 222 | + !temp_dir.path().join("episode.mp3.part").exists(), | |
| 223 | + "no partial file must remain" | |
| 224 | + ); | |
| 225 | + } | |
| 226 | + | |
| 227 | + #[test] | |
| 228 | + fn test_transcode_to_mp3_with_ffmpeg() { | |
| 229 | + if !ffmpeg_available() { | |
| 230 | + return; // ffmpeg not installed: the transcode is skipped at runtime | |
| 231 | + } | |
| 232 | + | |
| 233 | + let temp_dir = tempdir().unwrap(); | |
| 234 | + let m4a_path = temp_dir.path().join("episode.m4a"); | |
| 235 | + | |
| 236 | + // Generate a real short AAC file with ffmpeg itself | |
| 237 | + let status = Command::new("ffmpeg") | |
| 238 | + .args(["-y", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "sine=frequency=440:duration=1", "-codec:a", "aac"]) | |
| 239 | + .arg(&m4a_path) | |
| 240 | + .stdout(Stdio::null()) | |
| 241 | + .stderr(Stdio::null()) | |
| 242 | + .status() | |
| 243 | + .unwrap(); | |
| 244 | + assert!(status.success(), "failed to generate the test .m4a"); | |
| 245 | + | |
| 246 | + let mut path = m4a_path.clone(); | |
| 247 | + assert!(transcode_to_mp3(&mut path)); | |
| 248 | + | |
| 249 | + let mp3_path = temp_dir.path().join("episode.mp3"); | |
| 250 | + assert_eq!(path, mp3_path); | |
| 251 | + assert!(mp3_path.exists()); | |
| 252 | + assert!(fs::metadata(&mp3_path).unwrap().len() > 0); | |
| 253 | + assert!(!m4a_path.exists(), "the original must be removed"); | |
| 254 | + assert!(!temp_dir.path().join("episode.mp3.part").exists()); | |
| 255 | + | |
| 256 | + // An ID3 tag can now be written into the transcoded file | |
| 257 | + crate::rss::audio_metadata::ensure_id3_tags(&mp3_path, "Title", Some("Artist"), None) | |
| 258 | + .unwrap(); | |
| 259 | + } | |
| 260 | + | |
| 261 | + #[test] | |
| 262 | + fn test_remove_transcode_source_next_to() { | |
| 263 | + let temp_dir = tempdir().unwrap(); | |
| 264 | + | |
| 265 | + // A leftover .m4a next to its transcoded .mp3 is removed | |
| 266 | + let mp3_path = temp_dir.path().join("episode.mp3"); | |
| 267 | + let m4a_path = temp_dir.path().join("episode.m4a"); | |
| 268 | + fs::write(&mp3_path, "transcoded").unwrap(); | |
| 269 | + fs::write(&m4a_path, "original").unwrap(); | |
| 270 | + remove_transcode_source_next_to(&mp3_path); | |
| 271 | + assert!(mp3_path.exists(), "the .mp3 must be kept"); | |
| 272 | + assert!(!m4a_path.exists(), "the leftover .m4a must be removed"); | |
| 273 | + | |
| 274 | + // Without a leftover, nothing happens | |
| 275 | + remove_transcode_source_next_to(&mp3_path); | |
| 276 | + assert!(mp3_path.exists()); | |
| 277 | + | |
| 278 | + // A non-.mp3 path is ignored | |
| 279 | + let ogg_path = temp_dir.path().join("episode.ogg"); | |
| 280 | + fs::write(&ogg_path, "audio").unwrap(); | |
| 281 | + remove_transcode_source_next_to(&ogg_path); | |
| 282 | + assert!(ogg_path.exists()); | |
| 283 | + } | |
| 284 | + | |
| 285 | + #[test] | |
| 286 | + fn test_transcode_to_mp3_existing_counterpart() { | |
| 287 | + let temp_dir = tempdir().unwrap(); | |
| 288 | + let mut path = temp_dir.path().join("episode.m4a"); | |
| 289 | + fs::write(&path, "leftover original").unwrap(); | |
| 290 | + let mp3_path = temp_dir.path().join("episode.mp3"); | |
| 291 | + fs::write(&mp3_path, "transcoded").unwrap(); | |
| 292 | + | |
| 293 | + // The .mp3 already exists: it is canonical, the leftover original is | |
| 294 | + // removed without re-encoding | |
| 295 | + assert!(transcode_to_mp3(&mut path)); | |
| 296 | + assert_eq!(path, mp3_path); | |
| 297 | + assert!(mp3_path.exists()); | |
| 298 | + assert!(!temp_dir.path().join("episode.m4a").exists()); | |
| 299 | + } | |
| 300 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,300 @@ | |||
| 1 | +//! Transcoding module for RSS Podcast Downloader | ||
| 2 | +//! | ||
| 3 | +//! Some podcast feeds serve audio in formats whose metadata is not ID3 | ||
| 4 | +//! (e.g. .m4a). To keep a single tag-based pipeline, those files are | ||
| 5 | +//! re-encoded to .mp3 with ffmpeg. The original file is kept untouched when | ||
| 6 | +//! ffmpeg is unavailable or the encoding fails. | ||
| 7 | + | ||
| 8 | +use log::{info, warn}; | ||
| 9 | +use std::fs; | ||
| 10 | +use std::path::{Path, PathBuf}; | ||
| 11 | +use std::process::{Command, Stdio}; | ||
| 12 | + | ||
| 13 | +/// Extensions re-encoded to .mp3 (their metadata container is not ID3) | ||
| 14 | +pub const TRANSCODE_EXTENSIONS: &[&str] = &["m4a"]; | ||
| 15 | + | ||
| 16 | +/// Returns true when the file must be re-encoded to .mp3 | ||
| 17 | +pub fn is_transcode_source(path: &Path) -> bool { | ||
| 18 | + path.extension() | ||
| 19 | + .and_then(|e| e.to_str()) | ||
| 20 | + .map(|e| e.to_ascii_lowercase()) | ||
| 21 | + .is_some_and(|e| TRANSCODE_EXTENSIONS.contains(&e.as_str())) | ||
| 22 | +} | ||
| 23 | + | ||
| 24 | +/// Returns the .mp3 counterpart of a transcode source path, or None when | ||
| 25 | +/// the path is not a transcode source | ||
| 26 | +pub fn mp3_counterpart(path: &Path) -> Option<PathBuf> { | ||
| 27 | + if !is_transcode_source(path) { | ||
| 28 | + return None; | ||
| 29 | + } | ||
| 30 | + Some(path.with_extension("mp3")) | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +/// Returns true when the ffmpeg executable is available | ||
| 34 | +pub fn ffmpeg_available() -> bool { | ||
| 35 | + Command::new("ffmpeg") | ||
| 36 | + .arg("-version") | ||
| 37 | + .stdout(Stdio::null()) | ||
| 38 | + .stderr(Stdio::null()) | ||
| 39 | + .status() | ||
| 40 | + .is_ok_and(|status| status.success()) | ||
| 41 | +} | ||
| 42 | + | ||
| 43 | +/// Re-encodes an audio file to .mp3, removing the original on success. The | ||
| 44 | +/// output is written to a .part file and renamed once complete, so an | ||
| 45 | +/// interrupted encoding is never mistaken for a valid .mp3. Returns true and | ||
| 46 | +/// updates `file_path` when the transcoded .mp3 is in place; returns false | ||
| 47 | +/// and keeps the file untouched otherwise (not a transcode source, no | ||
| 48 | +/// ffmpeg, or an encoding failure). | ||
| 49 | +pub fn transcode_to_mp3(file_path: &mut PathBuf) -> bool { | ||
| 50 | + let Some(mp3_path) = mp3_counterpart(file_path) else { | ||
| 51 | + return false; | ||
| 52 | + }; | ||
| 53 | + | ||
| 54 | + // A leftover original next to its .mp3 (e.g. the removal failed during | ||
| 55 | + // a previous pass): the .mp3 is the canonical file, only clean up | ||
| 56 | + if mp3_path.exists() { | ||
| 57 | + info!( | ||
| 58 | + "Transcoded file already exists, removing leftover original: {}", | ||
| 59 | + file_path.display() | ||
| 60 | + ); | ||
| 61 | + remove_best_effort(file_path); | ||
| 62 | + *file_path = mp3_path; | ||
| 63 | + return true; | ||
| 64 | + } | ||
| 65 | + | ||
| 66 | + if !ffmpeg_available() { | ||
| 67 | + warn!( | ||
| 68 | + "ffmpeg not available: keeping original audio file {}", | ||
| 69 | + file_path.display() | ||
| 70 | + ); | ||
| 71 | + return false; | ||
| 72 | + } | ||
| 73 | + | ||
| 74 | + let mut part_name = mp3_path | ||
| 75 | + .file_name() | ||
| 76 | + .map(|n| n.to_os_string()) | ||
| 77 | + .unwrap_or_default(); | ||
| 78 | + part_name.push(".part"); | ||
| 79 | + let part_path = mp3_path.with_file_name(part_name); | ||
| 80 | + let _ = fs::remove_file(&part_path); | ||
| 81 | + | ||
| 82 | + // -f mp3 is required: the .part extension hides the output format from | ||
| 83 | + // ffmpeg, which otherwise fails with EINVAL (exit code 0xffffffea) | ||
| 84 | + let status = Command::new("ffmpeg") | ||
| 85 | + .args(["-y", "-hide_banner", "-loglevel", "error", "-i"]) | ||
| 86 | + .arg(&*file_path) | ||
| 87 | + .args(["-vn", "-codec:a", "libmp3lame", "-q:a", "2", "-f", "mp3"]) | ||
| 88 | + .arg(&part_path) | ||
| 89 | + .stdout(Stdio::null()) | ||
| 90 | + .stderr(Stdio::null()) | ||
| 91 | + .status(); | ||
| 92 | + | ||
| 93 | + match status { | ||
| 94 | + Ok(status) if status.success() => {} | ||
| 95 | + Ok(status) => { | ||
| 96 | + warn!( | ||
| 97 | + "ffmpeg failed to transcode {}: exit status {}", | ||
| 98 | + file_path.display(), | ||
| 99 | + status | ||
| 100 | + ); | ||
| 101 | + let _ = fs::remove_file(&part_path); | ||
| 102 | + return false; | ||
| 103 | + } | ||
| 104 | + Err(e) => { | ||
| 105 | + warn!( | ||
| 106 | + "Failed to run ffmpeg on {}: {}", | ||
| 107 | + file_path.display(), | ||
| 108 | + e | ||
| 109 | + ); | ||
| 110 | + let _ = fs::remove_file(&part_path); | ||
| 111 | + return false; | ||
| 112 | + } | ||
| 113 | + } | ||
| 114 | + | ||
| 115 | + if let Err(e) = fs::rename(&part_path, &mp3_path) { | ||
| 116 | + warn!( | ||
| 117 | + "Failed to finalize transcoded file {}: {}", | ||
| 118 | + mp3_path.display(), | ||
| 119 | + e | ||
| 120 | + ); | ||
| 121 | + let _ = fs::remove_file(&part_path); | ||
| 122 | + return false; | ||
| 123 | + } | ||
| 124 | + | ||
| 125 | + info!( | ||
| 126 | + "Transcoded {} to {}", | ||
| 127 | + file_path.display(), | ||
| 128 | + mp3_path.display() | ||
| 129 | + ); | ||
| 130 | + remove_best_effort(file_path); | ||
| 131 | + *file_path = mp3_path; | ||
| 132 | + true | ||
| 133 | +} | ||
| 134 | + | ||
| 135 | +/// Removes the original file after a successful transcode. A failure is not | ||
| 136 | +/// fatal — the .mp3 is the canonical file and preferred by the download | ||
| 137 | +/// detection — but it is reported so the leftover can be cleaned up manually. | ||
| 138 | +fn remove_best_effort(path: &Path) { | ||
| 139 | + if let Err(e) = fs::remove_file(path) { | ||
| 140 | + warn!( | ||
| 141 | + "Failed to remove transcoded original {}: {}", | ||
| 142 | + path.display(), | ||
| 143 | + e | ||
| 144 | + ); | ||
| 145 | + } | ||
| 146 | +} | ||
| 147 | + | ||
| 148 | +/// Removes the original transcode source (.m4a) sitting next to its | ||
| 149 | +/// transcoded .mp3, when present (e.g. its removal failed during a previous | ||
| 150 | +/// pass). The .mp3 is the canonical file: removing the original never | ||
| 151 | +/// changes the downloaded status of the episode, which is never downloaded | ||
| 152 | +/// again. | ||
| 153 | +pub fn remove_transcode_source_next_to(mp3_path: &Path) { | ||
| 154 | + let Some(source) = transcode_source_of(mp3_path) else { | ||
| 155 | + return; | ||
| 156 | + }; | ||
| 157 | + if source.is_file() { | ||
| 158 | + remove_best_effort(&source); | ||
| 159 | + } | ||
| 160 | +} | ||
| 161 | + | ||
| 162 | +/// Returns the original transcode source (.m4a) corresponding to a | ||
| 163 | +/// transcoded .mp3 path, or None when the path is not a .mp3 | ||
| 164 | +fn transcode_source_of(mp3_path: &Path) -> Option<PathBuf> { | ||
| 165 | + if !mp3_path.extension()?.eq_ignore_ascii_case("mp3") { | ||
| 166 | + return None; | ||
| 167 | + } | ||
| 168 | + let source = mp3_path.with_extension("m4a"); | ||
| 169 | + is_transcode_source(&source).then_some(source) | ||
| 170 | +} | ||
| 171 | + | ||
| 172 | +#[cfg(test)] | ||
| 173 | +mod tests { | ||
| 174 | + use super::*; | ||
| 175 | + use tempfile::tempdir; | ||
| 176 | + | ||
| 177 | + #[test] | ||
| 178 | + fn test_is_transcode_source() { | ||
| 179 | + assert!(is_transcode_source(Path::new("episode.m4a"))); | ||
| 180 | + assert!(is_transcode_source(Path::new("episode.M4A"))); | ||
| 181 | + assert!(!is_transcode_source(Path::new("episode.mp3"))); | ||
| 182 | + assert!(!is_transcode_source(Path::new("episode.ogg"))); | ||
| 183 | + assert!(!is_transcode_source(Path::new("noext"))); | ||
| 184 | + } | ||
| 185 | + | ||
| 186 | + #[test] | ||
| 187 | + fn test_mp3_counterpart() { | ||
| 188 | + assert_eq!( | ||
| 189 | + mp3_counterpart(Path::new("downloads/episode.m4a")), | ||
| 190 | + Some(PathBuf::from("downloads/episode.mp3")) | ||
| 191 | + ); | ||
| 192 | + // Not a transcode source: no counterpart | ||
| 193 | + assert_eq!(mp3_counterpart(Path::new("episode.mp3")), None); | ||
| 194 | + } | ||
| 195 | + | ||
| 196 | + #[test] | ||
| 197 | + fn test_transcode_to_mp3_not_a_source() { | ||
| 198 | + let temp_dir = tempdir().unwrap(); | ||
| 199 | + let mut path = temp_dir.path().join("episode.mp3"); | ||
| 200 | + fs::write(&path, "audio").unwrap(); | ||
| 201 | + | ||
| 202 | + // Not a transcode source: untouched, returns false | ||
| 203 | + assert!(!transcode_to_mp3(&mut path)); | ||
| 204 | + assert!(path.exists()); | ||
| 205 | + } | ||
| 206 | + | ||
| 207 | + #[test] | ||
| 208 | + fn test_transcode_to_mp3_ffmpeg_failure_keeps_original() { | ||
| 209 | + if !ffmpeg_available() { | ||
| 210 | + return; // ffmpeg not installed: nothing to assert about encoding | ||
| 211 | + } | ||
| 212 | + | ||
| 213 | + let temp_dir = tempdir().unwrap(); | ||
| 214 | + let mut path = temp_dir.path().join("episode.m4a"); | ||
| 215 | + // Not real audio: the encoding must fail and the file must be kept | ||
| 216 | + fs::write(&path, "not an audio file").unwrap(); | ||
| 217 | + | ||
| 218 | + assert!(!transcode_to_mp3(&mut path)); | ||
| 219 | + assert!(path.exists(), "the original file must be kept"); | ||
| 220 | + assert!(!temp_dir.path().join("episode.mp3").exists()); | ||
| 221 | + assert!( | ||
| 222 | + !temp_dir.path().join("episode.mp3.part").exists(), | ||
| 223 | + "no partial file must remain" | ||
| 224 | + ); | ||
| 225 | + } | ||
| 226 | + | ||
| 227 | + #[test] | ||
| 228 | + fn test_transcode_to_mp3_with_ffmpeg() { | ||
| 229 | + if !ffmpeg_available() { | ||
| 230 | + return; // ffmpeg not installed: the transcode is skipped at runtime | ||
| 231 | + } | ||
| 232 | + | ||
| 233 | + let temp_dir = tempdir().unwrap(); | ||
| 234 | + let m4a_path = temp_dir.path().join("episode.m4a"); | ||
| 235 | + | ||
| 236 | + // Generate a real short AAC file with ffmpeg itself | ||
| 237 | + let status = Command::new("ffmpeg") | ||
| 238 | + .args(["-y", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "sine=frequency=440:duration=1", "-codec:a", "aac"]) | ||
| 239 | + .arg(&m4a_path) | ||
| 240 | + .stdout(Stdio::null()) | ||
| 241 | + .stderr(Stdio::null()) | ||
| 242 | + .status() | ||
| 243 | + .unwrap(); | ||
| 244 | + assert!(status.success(), "failed to generate the test .m4a"); | ||
| 245 | + | ||
| 246 | + let mut path = m4a_path.clone(); | ||
| 247 | + assert!(transcode_to_mp3(&mut path)); | ||
| 248 | + | ||
| 249 | + let mp3_path = temp_dir.path().join("episode.mp3"); | ||
| 250 | + assert_eq!(path, mp3_path); | ||
| 251 | + assert!(mp3_path.exists()); | ||
| 252 | + assert!(fs::metadata(&mp3_path).unwrap().len() > 0); | ||
| 253 | + assert!(!m4a_path.exists(), "the original must be removed"); | ||
| 254 | + assert!(!temp_dir.path().join("episode.mp3.part").exists()); | ||
| 255 | + | ||
| 256 | + // An ID3 tag can now be written into the transcoded file | ||
| 257 | + crate::rss::audio_metadata::ensure_id3_tags(&mp3_path, "Title", Some("Artist"), None) | ||
| 258 | + .unwrap(); | ||
| 259 | + } | ||
| 260 | + | ||
| 261 | + #[test] | ||
| 262 | + fn test_remove_transcode_source_next_to() { | ||
| 263 | + let temp_dir = tempdir().unwrap(); | ||
| 264 | + | ||
| 265 | + // A leftover .m4a next to its transcoded .mp3 is removed | ||
| 266 | + let mp3_path = temp_dir.path().join("episode.mp3"); | ||
| 267 | + let m4a_path = temp_dir.path().join("episode.m4a"); | ||
| 268 | + fs::write(&mp3_path, "transcoded").unwrap(); | ||
| 269 | + fs::write(&m4a_path, "original").unwrap(); | ||
| 270 | + remove_transcode_source_next_to(&mp3_path); | ||
| 271 | + assert!(mp3_path.exists(), "the .mp3 must be kept"); | ||
| 272 | + assert!(!m4a_path.exists(), "the leftover .m4a must be removed"); | ||
| 273 | + | ||
| 274 | + // Without a leftover, nothing happens | ||
| 275 | + remove_transcode_source_next_to(&mp3_path); | ||
| 276 | + assert!(mp3_path.exists()); | ||
| 277 | + | ||
| 278 | + // A non-.mp3 path is ignored | ||
| 279 | + let ogg_path = temp_dir.path().join("episode.ogg"); | ||
| 280 | + fs::write(&ogg_path, "audio").unwrap(); | ||
| 281 | + remove_transcode_source_next_to(&ogg_path); | ||
| 282 | + assert!(ogg_path.exists()); | ||
| 283 | + } | ||
| 284 | + | ||
| 285 | + #[test] | ||
| 286 | + fn test_transcode_to_mp3_existing_counterpart() { | ||
| 287 | + let temp_dir = tempdir().unwrap(); | ||
| 288 | + let mut path = temp_dir.path().join("episode.m4a"); | ||
| 289 | + fs::write(&path, "leftover original").unwrap(); | ||
| 290 | + let mp3_path = temp_dir.path().join("episode.mp3"); | ||
| 291 | + fs::write(&mp3_path, "transcoded").unwrap(); | ||
| 292 | + | ||
| 293 | + // The .mp3 already exists: it is canonical, the leftover original is | ||
| 294 | + // removed without re-encoding | ||
| 295 | + assert!(transcode_to_mp3(&mut path)); | ||
| 296 | + assert_eq!(path, mp3_path); | ||
| 297 | + assert!(mp3_path.exists()); | ||
| 298 | + assert!(!temp_dir.path().join("episode.m4a").exists()); | ||
| 299 | + } | ||
| 300 | +} | ||
modified
src/ui/feed_detail.rs +4 -1 | @@ -216,11 +216,14 @@ fn draw_episode_item( | ||
| 216 | 216 | { |
| 217 | 217 | let feed = state.feeds[feed_index].clone(); |
| 218 | 218 | let episode = state.detail_episodes[episode_index].clone(); |
| 219 | + let transcode_m4a = state.transcode_m4a_to_mp3; | |
| 219 | 220 | state.downloading_episode_ids.insert(episode.id.clone()); |
| 220 | 221 | state |
| 221 | 222 | .notification_manager |
| 222 | 223 | .info(format!("Téléchargement démarré : {}", truncate(&episode_title, 40))); |
| 223 | - state.sync_task_manager.start_download_episode(feed, episode); | |
| 224 | + state | |
| 225 | + .sync_task_manager | |
| 226 | + .start_download_episode(feed, episode, transcode_m4a); | |
| 224 | 227 | } |
| 225 | 228 | }); |
| 226 | 229 | }); |
| @@ -216,11 +216,14 @@ fn draw_episode_item( | |||
| 216 | { | 216 | { |
| 217 | let feed = state.feeds[feed_index].clone(); | 217 | let feed = state.feeds[feed_index].clone(); |
| 218 | let episode = state.detail_episodes[episode_index].clone(); | 218 | let episode = state.detail_episodes[episode_index].clone(); |
| 219 | + let transcode_m4a = state.transcode_m4a_to_mp3; | ||
| 219 | state.downloading_episode_ids.insert(episode.id.clone()); | 220 | state.downloading_episode_ids.insert(episode.id.clone()); |
| 220 | state | 221 | state |
| 221 | .notification_manager | 222 | .notification_manager |
| 222 | .info(format!("Téléchargement démarré : {}", truncate(&episode_title, 40))); | 223 | .info(format!("Téléchargement démarré : {}", truncate(&episode_title, 40))); |
| 223 | - state.sync_task_manager.start_download_episode(feed, episode); | 224 | + state |
| 225 | + .sync_task_manager | ||
| 226 | + .start_download_episode(feed, episode, transcode_m4a); | ||
| 224 | } | 227 | } |
| 225 | }); | 228 | }); |
| 226 | }); | 229 | }); |
modified
src/ui/feed_list.rs +112 -6 | @@ -45,6 +45,12 @@ fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) { | ||
| 45 | 45 | .feed_sync_status |
| 46 | 46 | .get(&feed_id) |
| 47 | 47 | .cloned(); |
| 48 | + let feed_sync_progress = state | |
| 49 | + .sync_task_manager | |
| 50 | + .status | |
| 51 | + .feed_progress(&feed_id) | |
| 52 | + .cloned(); | |
| 53 | + let global_sync_in_progress = state.sync_task_manager.status.global_sync_in_progress; | |
| 48 | 54 | let colors = ThemeColors::for_theme(state.get_theme()); |
| 49 | 55 | |
| 50 | 56 | // Frame avec style amélioré |
| @@ -124,11 +130,14 @@ fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) { | ||
| 124 | 130 | // Statut de synchronisation de ce flux |
| 125 | 131 | match feed_sync_status { |
| 126 | 132 | Some(crate::config::sync_task::FeedSyncStatus::InProgress) => { |
| 127 | - ui.label( | |
| 128 | - RichText::new("⏳ Synchronisation…") | |
| 129 | - .size(11.0) | |
| 130 | - .color(colors.accent), | |
| 131 | - ); | |
| 133 | + let (progress, status_text) = feed_sync_progress | |
| 134 | + .map(|p| (p.progress, p.status_text)) | |
| 135 | + .unwrap_or((0.0, "Synchronisation…".to_string())); | |
| 136 | + draw_feed_sync_bar(ui, progress, &status_text, &colors); | |
| 137 | + } | |
| 138 | + Some(crate::config::sync_task::FeedSyncStatus::Pending) => { | |
| 139 | + // En attente dans une synchro globale : barre à 0 % | |
| 140 | + draw_feed_sync_bar(ui, 0.0, "En attente…", &colors); | |
| 132 | 141 | } |
| 133 | 142 | Some(crate::config::sync_task::FeedSyncStatus::Failed { error }) => { |
| 134 | 143 | ui.label( |
| @@ -138,6 +147,11 @@ fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) { | ||
| 138 | 147 | ) |
| 139 | 148 | .on_hover_text(&error); |
| 140 | 149 | } |
| 150 | + None if global_sync_in_progress => { | |
| 151 | + // Flux ajouté pendant une synchro globale : pas | |
| 152 | + // encore de statut, mais la barre doit apparaître | |
| 153 | + draw_feed_sync_bar(ui, 0.0, "En attente…", &colors); | |
| 154 | + } | |
| 141 | 155 | _ => { |
| 142 | 156 | // Dernière vérification |
| 143 | 157 | if let Some(date) = feed_last_checked { |
| @@ -283,15 +297,29 @@ fn sync_feed_async(state: &mut AppState, index: usize) { | ||
| 283 | 297 | |
| 284 | 298 | // Clone the feed for async task |
| 285 | 299 | let feed_to_sync = state.feeds[index].clone(); |
| 300 | + let transcode_m4a = state.transcode_m4a_to_mp3; | |
| 286 | 301 | |
| 287 | 302 | // Start async sync |
| 288 | - state.sync_task_manager.start_sync_feed(feed_to_sync); | |
| 303 | + state.sync_task_manager.start_sync_feed(feed_to_sync, transcode_m4a); | |
| 289 | 304 | |
| 290 | 305 | state |
| 291 | 306 | .notification_manager |
| 292 | 307 | .info(format!("Synchronisation du flux en cours: {}", state.feeds[index].name)); |
| 293 | 308 | } |
| 294 | 309 | |
| 310 | +/// Barre de progression de synchronisation affichée sur la ligne d'un flux : | |
| 311 | +/// texte de statut (épisode en cours, attente…) au-dessus d'une barre | |
| 312 | +/// remplie proportionnellement à l'avancement. | |
| 313 | +fn draw_feed_sync_bar(ui: &mut Ui, progress: f32, status_text: &str, colors: &ThemeColors) { | |
| 314 | + ui.label( | |
| 315 | + RichText::new(format!("🔄 {}", status_text)) | |
| 316 | + .size(11.0) | |
| 317 | + .color(colors.accent), | |
| 318 | + ); | |
| 319 | + let bar_width = ui.available_width().max(80.0); | |
| 320 | + crate::ui::sync_status::draw_progress_bar(ui, progress, bar_width, colors); | |
| 321 | +} | |
| 322 | + | |
| 295 | 323 | #[cfg(test)] |
| 296 | 324 | mod tests { |
| 297 | 325 | use super::*; |
| @@ -346,4 +374,82 @@ mod tests { | ||
| 346 | 374 | ); |
| 347 | 375 | } |
| 348 | 376 | } |
| 377 | + | |
| 378 | + /// Pendant une synchro globale, chaque ligne affiche une barre de | |
| 379 | + /// progression (en cours ou en attente). Le rendu ne doit ni déborder | |
| 380 | + /// de la largeur disponible ni faire varier la largeur d'une frame | |
| 381 | + /// à l'autre. | |
| 382 | + #[test] | |
| 383 | + fn feed_rows_with_sync_progress_never_exceed_available_width() { | |
| 384 | + let mut state = crate::config::AppState::with_default_config(); | |
| 385 | + | |
| 386 | + let feed_a = Feed { | |
| 387 | + url: "https://example.com/feed-a.xml".to_string(), | |
| 388 | + name: "Flux A".to_string(), | |
| 389 | + download_path: std::path::PathBuf::from("C:\\Podcasts\\A"), | |
| 390 | + ..Default::default() | |
| 391 | + }; | |
| 392 | + let feed_b = Feed { | |
| 393 | + url: "https://example.com/feed-b.xml".to_string(), | |
| 394 | + name: "Flux B".to_string(), | |
| 395 | + download_path: std::path::PathBuf::from("C:\\Podcasts\\B"), | |
| 396 | + ..Default::default() | |
| 397 | + }; | |
| 398 | + let feed_c = Feed { | |
| 399 | + url: "https://example.com/feed-c.xml".to_string(), | |
| 400 | + name: "Flux C".to_string(), | |
| 401 | + download_path: std::path::PathBuf::from("C:\\Podcasts\\C"), | |
| 402 | + ..Default::default() | |
| 403 | + }; | |
| 404 | + let id_a = feed_a.id.clone(); | |
| 405 | + let id_b = feed_b.id.clone(); | |
| 406 | + let id_c = feed_c.id.clone(); | |
| 407 | + state.feeds.push(feed_a); | |
| 408 | + state.feeds.push(feed_b); | |
| 409 | + state.feeds.push(feed_c); | |
| 410 | + | |
| 411 | + // Simuler une synchro globale : A en cours, B en attente, C terminé | |
| 412 | + state | |
| 413 | + .sync_task_manager | |
| 414 | + .status | |
| 415 | + .start_global_sync(&[id_a.clone(), id_b.clone(), id_c.clone()]); | |
| 416 | + state.sync_task_manager.status.update_feed_progress( | |
| 417 | + id_a.clone(), | |
| 418 | + 0.5, | |
| 419 | + "Épisode 3/6: un titre d'épisode assez long pour occuper".to_string(), | |
| 420 | + ); | |
| 421 | + state.sync_task_manager.status.update_feed_status( | |
| 422 | + id_c.clone(), | |
| 423 | + crate::config::sync_task::FeedSyncStatus::Completed, | |
| 424 | + ); | |
| 425 | + | |
| 426 | + for viewport in [400.0, 700.0, 1000.0] { | |
| 427 | + let mut inner_width = viewport; | |
| 428 | + let mut widths = Vec::new(); | |
| 429 | + for _ in 0..10 { | |
| 430 | + let ctx = egui::Context::default(); | |
| 431 | + let mut row_width = 0.0; | |
| 432 | + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { | |
| 433 | + let builder = egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( | |
| 434 | + egui::Pos2::ZERO, | |
| 435 | + egui::Vec2::new(inner_width, 2000.0), | |
| 436 | + )); | |
| 437 | + let mut child = ui.new_child(builder); | |
| 438 | + draw_feed_list(&mut state, &mut child); | |
| 439 | + row_width = child.min_rect().width(); | |
| 440 | + }); | |
| 441 | + widths.push(row_width); | |
| 442 | + inner_width = inner_width.max(row_width); | |
| 443 | + } | |
| 444 | + | |
| 445 | + assert!( | |
| 446 | + widths.iter().all(|w| *w <= viewport + 1.0), | |
| 447 | + "une ligne avec barre de synchro dépasse la largeur de la fenêtre ({viewport}) : {widths:?}" | |
| 448 | + ); | |
| 449 | + assert!( | |
| 450 | + widths.iter().skip(1).all(|w| (w - widths[0]).abs() < 1.0), | |
| 451 | + "la largeur des lignes varie d'une frame à l'autre ({viewport}) : {widths:?}" | |
| 452 | + ); | |
| 453 | + } | |
| 454 | + } | |
| 349 | 455 | } |
| @@ -45,6 +45,12 @@ fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) { | |||
| 45 | .feed_sync_status | 45 | .feed_sync_status |
| 46 | .get(&feed_id) | 46 | .get(&feed_id) |
| 47 | .cloned(); | 47 | .cloned(); |
| 48 | + let feed_sync_progress = state | ||
| 49 | + .sync_task_manager | ||
| 50 | + .status | ||
| 51 | + .feed_progress(&feed_id) | ||
| 52 | + .cloned(); | ||
| 53 | + let global_sync_in_progress = state.sync_task_manager.status.global_sync_in_progress; | ||
| 48 | let colors = ThemeColors::for_theme(state.get_theme()); | 54 | let colors = ThemeColors::for_theme(state.get_theme()); |
| 49 | 55 | ||
| 50 | // Frame avec style amélioré | 56 | // Frame avec style amélioré |
| @@ -124,11 +130,14 @@ fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) { | |||
| 124 | // Statut de synchronisation de ce flux | 130 | // Statut de synchronisation de ce flux |
| 125 | match feed_sync_status { | 131 | match feed_sync_status { |
| 126 | Some(crate::config::sync_task::FeedSyncStatus::InProgress) => { | 132 | Some(crate::config::sync_task::FeedSyncStatus::InProgress) => { |
| 127 | - ui.label( | 133 | + let (progress, status_text) = feed_sync_progress |
| 128 | - RichText::new("⏳ Synchronisation…") | 134 | + .map(|p| (p.progress, p.status_text)) |
| 129 | - .size(11.0) | 135 | + .unwrap_or((0.0, "Synchronisation…".to_string())); |
| 130 | - .color(colors.accent), | 136 | + draw_feed_sync_bar(ui, progress, &status_text, &colors); |
| 131 | - ); | 137 | + } |
| 138 | + Some(crate::config::sync_task::FeedSyncStatus::Pending) => { | ||
| 139 | + // En attente dans une synchro globale : barre à 0 % | ||
| 140 | + draw_feed_sync_bar(ui, 0.0, "En attente…", &colors); | ||
| 132 | } | 141 | } |
| 133 | Some(crate::config::sync_task::FeedSyncStatus::Failed { error }) => { | 142 | Some(crate::config::sync_task::FeedSyncStatus::Failed { error }) => { |
| 134 | ui.label( | 143 | ui.label( |
| @@ -138,6 +147,11 @@ fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) { | |||
| 138 | ) | 147 | ) |
| 139 | .on_hover_text(&error); | 148 | .on_hover_text(&error); |
| 140 | } | 149 | } |
| 150 | + None if global_sync_in_progress => { | ||
| 151 | + // Flux ajouté pendant une synchro globale : pas | ||
| 152 | + // encore de statut, mais la barre doit apparaître | ||
| 153 | + draw_feed_sync_bar(ui, 0.0, "En attente…", &colors); | ||
| 154 | + } | ||
| 141 | _ => { | 155 | _ => { |
| 142 | // Dernière vérification | 156 | // Dernière vérification |
| 143 | if let Some(date) = feed_last_checked { | 157 | if let Some(date) = feed_last_checked { |
| @@ -283,15 +297,29 @@ fn sync_feed_async(state: &mut AppState, index: usize) { | |||
| 283 | 297 | ||
| 284 | // Clone the feed for async task | 298 | // Clone the feed for async task |
| 285 | let feed_to_sync = state.feeds[index].clone(); | 299 | let feed_to_sync = state.feeds[index].clone(); |
| 300 | + let transcode_m4a = state.transcode_m4a_to_mp3; | ||
| 286 | 301 | ||
| 287 | // Start async sync | 302 | // Start async sync |
| 288 | - state.sync_task_manager.start_sync_feed(feed_to_sync); | 303 | + state.sync_task_manager.start_sync_feed(feed_to_sync, transcode_m4a); |
| 289 | 304 | ||
| 290 | state | 305 | state |
| 291 | .notification_manager | 306 | .notification_manager |
| 292 | .info(format!("Synchronisation du flux en cours: {}", state.feeds[index].name)); | 307 | .info(format!("Synchronisation du flux en cours: {}", state.feeds[index].name)); |
| 293 | } | 308 | } |
| 294 | 309 | ||
| 310 | +/// Barre de progression de synchronisation affichée sur la ligne d'un flux : | ||
| 311 | +/// texte de statut (épisode en cours, attente…) au-dessus d'une barre | ||
| 312 | +/// remplie proportionnellement à l'avancement. | ||
| 313 | +fn draw_feed_sync_bar(ui: &mut Ui, progress: f32, status_text: &str, colors: &ThemeColors) { | ||
| 314 | + ui.label( | ||
| 315 | + RichText::new(format!("🔄 {}", status_text)) | ||
| 316 | + .size(11.0) | ||
| 317 | + .color(colors.accent), | ||
| 318 | + ); | ||
| 319 | + let bar_width = ui.available_width().max(80.0); | ||
| 320 | + crate::ui::sync_status::draw_progress_bar(ui, progress, bar_width, colors); | ||
| 321 | +} | ||
| 322 | + | ||
| 295 | #[cfg(test)] | 323 | #[cfg(test)] |
| 296 | mod tests { | 324 | mod tests { |
| 297 | use super::*; | 325 | use super::*; |
| @@ -346,4 +374,82 @@ mod tests { | |||
| 346 | ); | 374 | ); |
| 347 | } | 375 | } |
| 348 | } | 376 | } |
| 377 | + | ||
| 378 | + /// Pendant une synchro globale, chaque ligne affiche une barre de | ||
| 379 | + /// progression (en cours ou en attente). Le rendu ne doit ni déborder | ||
| 380 | + /// de la largeur disponible ni faire varier la largeur d'une frame | ||
| 381 | + /// à l'autre. | ||
| 382 | + #[test] | ||
| 383 | + fn feed_rows_with_sync_progress_never_exceed_available_width() { | ||
| 384 | + let mut state = crate::config::AppState::with_default_config(); | ||
| 385 | + | ||
| 386 | + let feed_a = Feed { | ||
| 387 | + url: "https://example.com/feed-a.xml".to_string(), | ||
| 388 | + name: "Flux A".to_string(), | ||
| 389 | + download_path: std::path::PathBuf::from("C:\\Podcasts\\A"), | ||
| 390 | + ..Default::default() | ||
| 391 | + }; | ||
| 392 | + let feed_b = Feed { | ||
| 393 | + url: "https://example.com/feed-b.xml".to_string(), | ||
| 394 | + name: "Flux B".to_string(), | ||
| 395 | + download_path: std::path::PathBuf::from("C:\\Podcasts\\B"), | ||
| 396 | + ..Default::default() | ||
| 397 | + }; | ||
| 398 | + let feed_c = Feed { | ||
| 399 | + url: "https://example.com/feed-c.xml".to_string(), | ||
| 400 | + name: "Flux C".to_string(), | ||
| 401 | + download_path: std::path::PathBuf::from("C:\\Podcasts\\C"), | ||
| 402 | + ..Default::default() | ||
| 403 | + }; | ||
| 404 | + let id_a = feed_a.id.clone(); | ||
| 405 | + let id_b = feed_b.id.clone(); | ||
| 406 | + let id_c = feed_c.id.clone(); | ||
| 407 | + state.feeds.push(feed_a); | ||
| 408 | + state.feeds.push(feed_b); | ||
| 409 | + state.feeds.push(feed_c); | ||
| 410 | + | ||
| 411 | + // Simuler une synchro globale : A en cours, B en attente, C terminé | ||
| 412 | + state | ||
| 413 | + .sync_task_manager | ||
| 414 | + .status | ||
| 415 | + .start_global_sync(&[id_a.clone(), id_b.clone(), id_c.clone()]); | ||
| 416 | + state.sync_task_manager.status.update_feed_progress( | ||
| 417 | + id_a.clone(), | ||
| 418 | + 0.5, | ||
| 419 | + "Épisode 3/6: un titre d'épisode assez long pour occuper".to_string(), | ||
| 420 | + ); | ||
| 421 | + state.sync_task_manager.status.update_feed_status( | ||
| 422 | + id_c.clone(), | ||
| 423 | + crate::config::sync_task::FeedSyncStatus::Completed, | ||
| 424 | + ); | ||
| 425 | + | ||
| 426 | + for viewport in [400.0, 700.0, 1000.0] { | ||
| 427 | + let mut inner_width = viewport; | ||
| 428 | + let mut widths = Vec::new(); | ||
| 429 | + for _ in 0..10 { | ||
| 430 | + let ctx = egui::Context::default(); | ||
| 431 | + let mut row_width = 0.0; | ||
| 432 | + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { | ||
| 433 | + let builder = egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( | ||
| 434 | + egui::Pos2::ZERO, | ||
| 435 | + egui::Vec2::new(inner_width, 2000.0), | ||
| 436 | + )); | ||
| 437 | + let mut child = ui.new_child(builder); | ||
| 438 | + draw_feed_list(&mut state, &mut child); | ||
| 439 | + row_width = child.min_rect().width(); | ||
| 440 | + }); | ||
| 441 | + widths.push(row_width); | ||
| 442 | + inner_width = inner_width.max(row_width); | ||
| 443 | + } | ||
| 444 | + | ||
| 445 | + assert!( | ||
| 446 | + widths.iter().all(|w| *w <= viewport + 1.0), | ||
| 447 | + "une ligne avec barre de synchro dépasse la largeur de la fenêtre ({viewport}) : {widths:?}" | ||
| 448 | + ); | ||
| 449 | + assert!( | ||
| 450 | + widths.iter().skip(1).all(|w| (w - widths[0]).abs() < 1.0), | ||
| 451 | + "la largeur des lignes varie d'une frame à l'autre ({viewport}) : {widths:?}" | ||
| 452 | + ); | ||
| 453 | + } | ||
| 454 | + } | ||
| 349 | } | 455 | } |
modified
src/ui/home.rs +5 -0 | @@ -31,6 +31,9 @@ pub fn draw_home(state: &mut AppState, ui: &mut Ui) { | ||
| 31 | 31 | // Barre d'outils : recherche, filtre par période et actions |
| 32 | 32 | crate::ui::toolbar::draw_toolbar(state, ui); |
| 33 | 33 | |
| 34 | + // Barre de statut de la synchronisation globale | |
| 35 | + crate::ui::sync_status::draw_global_sync_bar(state, ui); | |
| 36 | + | |
| 34 | 37 | ui.add_space(12.0); |
| 35 | 38 | |
| 36 | 39 | if state.detail_feed_id.is_some() { |
| @@ -229,6 +232,8 @@ fn process_sync_messages(state: &mut AppState) { | ||
| 229 | 232 | ); |
| 230 | 233 | } |
| 231 | 234 | SyncMessage::SyncStarted { .. } => {} |
| 235 | + // Géré directement dans SyncTaskManager::handle_message | |
| 236 | + SyncMessage::SyncProgress { .. } => {} | |
| 232 | 237 | } |
| 233 | 238 | } |
| 234 | 239 | |
| @@ -31,6 +31,9 @@ pub fn draw_home(state: &mut AppState, ui: &mut Ui) { | |||
| 31 | // Barre d'outils : recherche, filtre par période et actions | 31 | // Barre d'outils : recherche, filtre par période et actions |
| 32 | crate::ui::toolbar::draw_toolbar(state, ui); | 32 | crate::ui::toolbar::draw_toolbar(state, ui); |
| 33 | 33 | ||
| 34 | + // Barre de statut de la synchronisation globale | ||
| 35 | + crate::ui::sync_status::draw_global_sync_bar(state, ui); | ||
| 36 | + | ||
| 34 | ui.add_space(12.0); | 37 | ui.add_space(12.0); |
| 35 | 38 | ||
| 36 | if state.detail_feed_id.is_some() { | 39 | if state.detail_feed_id.is_some() { |
| @@ -229,6 +232,8 @@ fn process_sync_messages(state: &mut AppState) { | |||
| 229 | ); | 232 | ); |
| 230 | } | 233 | } |
| 231 | SyncMessage::SyncStarted { .. } => {} | 234 | SyncMessage::SyncStarted { .. } => {} |
| 235 | + // Géré directement dans SyncTaskManager::handle_message | ||
| 236 | + SyncMessage::SyncProgress { .. } => {} | ||
| 232 | } | 237 | } |
| 233 | } | 238 | } |
| 234 | 239 | ||
modified
src/ui/sync_status.rs +203 -1 | @@ -2,12 +2,13 @@ | ||
| 2 | 2 | //! |
| 3 | 3 | //! This module displays the synchronization status and progress in the status bar. |
| 4 | 4 | |
| 5 | +use crate::config::sync_task::FeedSyncStatus; | |
| 5 | 6 | use crate::config::AppState; |
| 6 | 7 | use crate::ui::theme::ThemeColors; |
| 7 | 8 | use egui::{RichText, Ui}; |
| 8 | 9 | |
| 9 | 10 | /// Draws a progress bar with theme colors |
| 10 | -fn draw_progress_bar(ui: &mut Ui, progress: f32, width: f32, colors: &ThemeColors) { | |
| 11 | +pub(crate) fn draw_progress_bar(ui: &mut Ui, progress: f32, width: f32, colors: &ThemeColors) { | |
| 11 | 12 | let progress_clamped = progress.clamp(0.0, 1.0); |
| 12 | 13 | |
| 13 | 14 | // Background |
| @@ -112,3 +113,204 @@ pub fn draw_sync_indicator(state: &mut AppState, ui: &mut Ui) { | ||
| 112 | 113 | } |
| 113 | 114 | }); |
| 114 | 115 | } |
| 116 | + | |
| 117 | +/// Draws the global sync status bar, displayed under the toolbar. | |
| 118 | +/// | |
| 119 | +/// While a global synchronization is running, it shows the overall | |
| 120 | +/// progress (completed/total feeds) with a progress bar. Once it ends, a | |
| 121 | +/// summary stays visible until dismissed with the ✕ button. | |
| 122 | +pub fn draw_global_sync_bar(state: &mut AppState, ui: &mut Ui) { | |
| 123 | + let colors = ThemeColors::for_theme(state.get_theme()); | |
| 124 | + | |
| 125 | + // Extract the values up front: the status borrow must end before the | |
| 126 | + // dismiss button can mutate the state | |
| 127 | + let (in_progress, total_feeds, completed_feeds, overall_progress) = { | |
| 128 | + let status = &state.sync_task_manager.status; | |
| 129 | + ( | |
| 130 | + status.global_sync_in_progress, | |
| 131 | + status.total_feeds, | |
| 132 | + status.completed_feeds, | |
| 133 | + status.get_overall_progress(), | |
| 134 | + ) | |
| 135 | + }; | |
| 136 | + let (completed_count, failed_errors) = { | |
| 137 | + let status = &state.sync_task_manager.status; | |
| 138 | + let completed = status | |
| 139 | + .feed_sync_status | |
| 140 | + .values() | |
| 141 | + .filter(|s| matches!(s, FeedSyncStatus::Completed)) | |
| 142 | + .count(); | |
| 143 | + let errors = status | |
| 144 | + .feed_sync_status | |
| 145 | + .values() | |
| 146 | + .filter_map(|s| match s { | |
| 147 | + FeedSyncStatus::Failed { error } => Some(error.clone()), | |
| 148 | + _ => None, | |
| 149 | + }) | |
| 150 | + .collect::<Vec<String>>(); | |
| 151 | + (completed, errors) | |
| 152 | + }; | |
| 153 | + let failed_count = failed_errors.len(); | |
| 154 | + | |
| 155 | + let show_summary = | |
| 156 | + !in_progress && total_feeds > 0 && (completed_count > 0 || failed_count > 0); | |
| 157 | + if !in_progress && !show_summary { | |
| 158 | + return; | |
| 159 | + } | |
| 160 | + | |
| 161 | + egui::Frame { | |
| 162 | + fill: colors.bg_primary, | |
| 163 | + stroke: egui::Stroke::new(1.0, colors.border), | |
| 164 | + corner_radius: 6.0.into(), | |
| 165 | + inner_margin: egui::Margin::symmetric(12, 6), | |
| 166 | + ..Default::default() | |
| 167 | + } | |
| 168 | + .show(ui, |ui| { | |
| 169 | + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); | |
| 170 | + ui.horizontal(|ui| { | |
| 171 | + if in_progress { | |
| 172 | + ui.label(RichText::new("🔄").size(14.0)); | |
| 173 | + ui.label( | |
| 174 | + RichText::new(format!( | |
| 175 | + "Synchronisation globale : {}/{} flux", | |
| 176 | + completed_feeds, total_feeds | |
| 177 | + )) | |
| 178 | + .size(13.0) | |
| 179 | + .color(colors.accent), | |
| 180 | + ); | |
| 181 | + if failed_count > 0 { | |
| 182 | + ui.label( | |
| 183 | + RichText::new(format!("· {} erreur(s)", failed_count)) | |
| 184 | + .size(13.0) | |
| 185 | + .color(colors.error), | |
| 186 | + ); | |
| 187 | + } | |
| 188 | + let bar_width = ui.available_width().max(80.0); | |
| 189 | + draw_progress_bar(ui, overall_progress, bar_width, &colors); | |
| 190 | + } else { | |
| 191 | + let all_ok = failed_count == 0; | |
| 192 | + let (icon, color) = if all_ok { | |
| 193 | + ("✅", colors.success) | |
| 194 | + } else { | |
| 195 | + ("⚠️", colors.warning) | |
| 196 | + }; | |
| 197 | + ui.label(RichText::new(icon).size(14.0).color(color)); | |
| 198 | + let summary = format!( | |
| 199 | + "Synchro globale terminée : {} flux ({} OK, {} erreur(s))", | |
| 200 | + completed_count + failed_count, | |
| 201 | + completed_count, | |
| 202 | + failed_count | |
| 203 | + ); | |
| 204 | + let response = ui.label(RichText::new(summary).size(13.0).color(color)); | |
| 205 | + if !failed_errors.is_empty() { | |
| 206 | + response.on_hover_text(failed_errors.join("\n")); | |
| 207 | + } | |
| 208 | + if ui | |
| 209 | + .button(RichText::new("✕").size(12.0)) | |
| 210 | + .on_hover_text("Masquer ce résumé") | |
| 211 | + .clicked() | |
| 212 | + { | |
| 213 | + state.sync_task_manager.clear_completed_sync_status(); | |
| 214 | + } | |
| 215 | + } | |
| 216 | + }); | |
| 217 | + }); | |
| 218 | +} | |
| 219 | + | |
| 220 | +#[cfg(test)] | |
| 221 | +mod tests { | |
| 222 | + use super::*; | |
| 223 | + | |
| 224 | + /// La barre de statut globale vit dans la `ScrollArea::vertical()` de | |
| 225 | + /// l'écran principal : elle ne doit jamais dépasser la largeur qui lui | |
| 226 | + /// est donnée, que la synchro soit en cours ou terminée. | |
| 227 | + #[test] | |
| 228 | + fn global_sync_bar_never_exceeds_available_width() { | |
| 229 | + for scenario in ["idle", "in_progress", "finished"] { | |
| 230 | + let mut state = crate::config::AppState::with_default_config(); | |
| 231 | + match scenario { | |
| 232 | + "in_progress" => { | |
| 233 | + state.sync_task_manager.status.start_global_sync(&[ | |
| 234 | + "a".to_string(), | |
| 235 | + "b".to_string(), | |
| 236 | + "c".to_string(), | |
| 237 | + ]); | |
| 238 | + state.sync_task_manager.status.update_feed_progress( | |
| 239 | + "a".to_string(), | |
| 240 | + 0.5, | |
| 241 | + "Épisode 3/6".to_string(), | |
| 242 | + ); | |
| 243 | + state.sync_task_manager.status.update_feed_status( | |
| 244 | + "c".to_string(), | |
| 245 | + FeedSyncStatus::Completed, | |
| 246 | + ); | |
| 247 | + } | |
| 248 | + "finished" => { | |
| 249 | + state | |
| 250 | + .sync_task_manager | |
| 251 | + .status | |
| 252 | + .start_global_sync(&["a".to_string(), "b".to_string()]); | |
| 253 | + state.sync_task_manager.status.update_feed_status( | |
| 254 | + "a".to_string(), | |
| 255 | + FeedSyncStatus::Completed, | |
| 256 | + ); | |
| 257 | + state.sync_task_manager.status.update_feed_status( | |
| 258 | + "b".to_string(), | |
| 259 | + FeedSyncStatus::Failed { | |
| 260 | + error: "réseau indisponible".to_string(), | |
| 261 | + }, | |
| 262 | + ); | |
| 263 | + state.sync_task_manager.status.complete_global_sync(); | |
| 264 | + } | |
| 265 | + _ => {} | |
| 266 | + } | |
| 267 | + | |
| 268 | + for viewport in [400.0, 700.0, 1000.0] { | |
| 269 | + let mut inner_width = viewport; | |
| 270 | + let mut widths = Vec::new(); | |
| 271 | + for _ in 0..10 { | |
| 272 | + let ctx = egui::Context::default(); | |
| 273 | + let mut bar_width = 0.0; | |
| 274 | + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { | |
| 275 | + let builder = egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( | |
| 276 | + egui::Pos2::ZERO, | |
| 277 | + egui::Vec2::new(inner_width, 2000.0), | |
| 278 | + )); | |
| 279 | + let mut child = ui.new_child(builder); | |
| 280 | + draw_global_sync_bar(&mut state, &mut child); | |
| 281 | + bar_width = child.min_rect().width(); | |
| 282 | + }); | |
| 283 | + widths.push(bar_width); | |
| 284 | + inner_width = inner_width.max(bar_width); | |
| 285 | + } | |
| 286 | + | |
| 287 | + assert!( | |
| 288 | + widths.iter().all(|w| *w <= viewport + 1.0), | |
| 289 | + "la barre de synchro globale dépasse la largeur ({scenario}, {viewport}) : {widths:?}" | |
| 290 | + ); | |
| 291 | + assert!( | |
| 292 | + widths.iter().skip(1).all(|w| (w - widths[0]).abs() < 1.0), | |
| 293 | + "la largeur de la barre varie d'une frame à l'autre ({scenario}, {viewport}) : {widths:?}" | |
| 294 | + ); | |
| 295 | + } | |
| 296 | + } | |
| 297 | + } | |
| 298 | + | |
| 299 | + /// Sans synchro globale (en cours ou terminée), la barre n'affiche rien. | |
| 300 | + #[test] | |
| 301 | + fn global_sync_bar_hidden_when_idle() { | |
| 302 | + let mut state = crate::config::AppState::with_default_config(); | |
| 303 | + let ctx = egui::Context::default(); | |
| 304 | + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { | |
| 305 | + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( | |
| 306 | + egui::Pos2::ZERO, | |
| 307 | + egui::Vec2::new(1000.0, 2000.0), | |
| 308 | + ))); | |
| 309 | + draw_global_sync_bar(&mut state, &mut child); | |
| 310 | + assert!( | |
| 311 | + child.min_rect().height() < 1.0, | |
| 312 | + "la barre doit être invisible sans synchro globale" | |
| 313 | + ); | |
| 314 | + }); | |
| 315 | + } | |
| 316 | +} | |
| @@ -2,12 +2,13 @@ | |||
| 2 | //! | 2 | //! |
| 3 | //! This module displays the synchronization status and progress in the status bar. | 3 | //! This module displays the synchronization status and progress in the status bar. |
| 4 | 4 | ||
| 5 | +use crate::config::sync_task::FeedSyncStatus; | ||
| 5 | use crate::config::AppState; | 6 | use crate::config::AppState; |
| 6 | use crate::ui::theme::ThemeColors; | 7 | use crate::ui::theme::ThemeColors; |
| 7 | use egui::{RichText, Ui}; | 8 | use egui::{RichText, Ui}; |
| 8 | 9 | ||
| 9 | /// Draws a progress bar with theme colors | 10 | /// Draws a progress bar with theme colors |
| 10 | -fn draw_progress_bar(ui: &mut Ui, progress: f32, width: f32, colors: &ThemeColors) { | 11 | +pub(crate) fn draw_progress_bar(ui: &mut Ui, progress: f32, width: f32, colors: &ThemeColors) { |
| 11 | let progress_clamped = progress.clamp(0.0, 1.0); | 12 | let progress_clamped = progress.clamp(0.0, 1.0); |
| 12 | 13 | ||
| 13 | // Background | 14 | // Background |
| @@ -112,3 +113,204 @@ pub fn draw_sync_indicator(state: &mut AppState, ui: &mut Ui) { | |||
| 112 | } | 113 | } |
| 113 | }); | 114 | }); |
| 114 | } | 115 | } |
| 116 | + | ||
| 117 | +/// Draws the global sync status bar, displayed under the toolbar. | ||
| 118 | +/// | ||
| 119 | +/// While a global synchronization is running, it shows the overall | ||
| 120 | +/// progress (completed/total feeds) with a progress bar. Once it ends, a | ||
| 121 | +/// summary stays visible until dismissed with the ✕ button. | ||
| 122 | +pub fn draw_global_sync_bar(state: &mut AppState, ui: &mut Ui) { | ||
| 123 | + let colors = ThemeColors::for_theme(state.get_theme()); | ||
| 124 | + | ||
| 125 | + // Extract the values up front: the status borrow must end before the | ||
| 126 | + // dismiss button can mutate the state | ||
| 127 | + let (in_progress, total_feeds, completed_feeds, overall_progress) = { | ||
| 128 | + let status = &state.sync_task_manager.status; | ||
| 129 | + ( | ||
| 130 | + status.global_sync_in_progress, | ||
| 131 | + status.total_feeds, | ||
| 132 | + status.completed_feeds, | ||
| 133 | + status.get_overall_progress(), | ||
| 134 | + ) | ||
| 135 | + }; | ||
| 136 | + let (completed_count, failed_errors) = { | ||
| 137 | + let status = &state.sync_task_manager.status; | ||
| 138 | + let completed = status | ||
| 139 | + .feed_sync_status | ||
| 140 | + .values() | ||
| 141 | + .filter(|s| matches!(s, FeedSyncStatus::Completed)) | ||
| 142 | + .count(); | ||
| 143 | + let errors = status | ||
| 144 | + .feed_sync_status | ||
| 145 | + .values() | ||
| 146 | + .filter_map(|s| match s { | ||
| 147 | + FeedSyncStatus::Failed { error } => Some(error.clone()), | ||
| 148 | + _ => None, | ||
| 149 | + }) | ||
| 150 | + .collect::<Vec<String>>(); | ||
| 151 | + (completed, errors) | ||
| 152 | + }; | ||
| 153 | + let failed_count = failed_errors.len(); | ||
| 154 | + | ||
| 155 | + let show_summary = | ||
| 156 | + !in_progress && total_feeds > 0 && (completed_count > 0 || failed_count > 0); | ||
| 157 | + if !in_progress && !show_summary { | ||
| 158 | + return; | ||
| 159 | + } | ||
| 160 | + | ||
| 161 | + egui::Frame { | ||
| 162 | + fill: colors.bg_primary, | ||
| 163 | + stroke: egui::Stroke::new(1.0, colors.border), | ||
| 164 | + corner_radius: 6.0.into(), | ||
| 165 | + inner_margin: egui::Margin::symmetric(12, 6), | ||
| 166 | + ..Default::default() | ||
| 167 | + } | ||
| 168 | + .show(ui, |ui| { | ||
| 169 | + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); | ||
| 170 | + ui.horizontal(|ui| { | ||
| 171 | + if in_progress { | ||
| 172 | + ui.label(RichText::new("🔄").size(14.0)); | ||
| 173 | + ui.label( | ||
| 174 | + RichText::new(format!( | ||
| 175 | + "Synchronisation globale : {}/{} flux", | ||
| 176 | + completed_feeds, total_feeds | ||
| 177 | + )) | ||
| 178 | + .size(13.0) | ||
| 179 | + .color(colors.accent), | ||
| 180 | + ); | ||
| 181 | + if failed_count > 0 { | ||
| 182 | + ui.label( | ||
| 183 | + RichText::new(format!("· {} erreur(s)", failed_count)) | ||
| 184 | + .size(13.0) | ||
| 185 | + .color(colors.error), | ||
| 186 | + ); | ||
| 187 | + } | ||
| 188 | + let bar_width = ui.available_width().max(80.0); | ||
| 189 | + draw_progress_bar(ui, overall_progress, bar_width, &colors); | ||
| 190 | + } else { | ||
| 191 | + let all_ok = failed_count == 0; | ||
| 192 | + let (icon, color) = if all_ok { | ||
| 193 | + ("✅", colors.success) | ||
| 194 | + } else { | ||
| 195 | + ("⚠️", colors.warning) | ||
| 196 | + }; | ||
| 197 | + ui.label(RichText::new(icon).size(14.0).color(color)); | ||
| 198 | + let summary = format!( | ||
| 199 | + "Synchro globale terminée : {} flux ({} OK, {} erreur(s))", | ||
| 200 | + completed_count + failed_count, | ||
| 201 | + completed_count, | ||
| 202 | + failed_count | ||
| 203 | + ); | ||
| 204 | + let response = ui.label(RichText::new(summary).size(13.0).color(color)); | ||
| 205 | + if !failed_errors.is_empty() { | ||
| 206 | + response.on_hover_text(failed_errors.join("\n")); | ||
| 207 | + } | ||
| 208 | + if ui | ||
| 209 | + .button(RichText::new("✕").size(12.0)) | ||
| 210 | + .on_hover_text("Masquer ce résumé") | ||
| 211 | + .clicked() | ||
| 212 | + { | ||
| 213 | + state.sync_task_manager.clear_completed_sync_status(); | ||
| 214 | + } | ||
| 215 | + } | ||
| 216 | + }); | ||
| 217 | + }); | ||
| 218 | +} | ||
| 219 | + | ||
| 220 | +#[cfg(test)] | ||
| 221 | +mod tests { | ||
| 222 | + use super::*; | ||
| 223 | + | ||
| 224 | + /// La barre de statut globale vit dans la `ScrollArea::vertical()` de | ||
| 225 | + /// l'écran principal : elle ne doit jamais dépasser la largeur qui lui | ||
| 226 | + /// est donnée, que la synchro soit en cours ou terminée. | ||
| 227 | + #[test] | ||
| 228 | + fn global_sync_bar_never_exceeds_available_width() { | ||
| 229 | + for scenario in ["idle", "in_progress", "finished"] { | ||
| 230 | + let mut state = crate::config::AppState::with_default_config(); | ||
| 231 | + match scenario { | ||
| 232 | + "in_progress" => { | ||
| 233 | + state.sync_task_manager.status.start_global_sync(&[ | ||
| 234 | + "a".to_string(), | ||
| 235 | + "b".to_string(), | ||
| 236 | + "c".to_string(), | ||
| 237 | + ]); | ||
| 238 | + state.sync_task_manager.status.update_feed_progress( | ||
| 239 | + "a".to_string(), | ||
| 240 | + 0.5, | ||
| 241 | + "Épisode 3/6".to_string(), | ||
| 242 | + ); | ||
| 243 | + state.sync_task_manager.status.update_feed_status( | ||
| 244 | + "c".to_string(), | ||
| 245 | + FeedSyncStatus::Completed, | ||
| 246 | + ); | ||
| 247 | + } | ||
| 248 | + "finished" => { | ||
| 249 | + state | ||
| 250 | + .sync_task_manager | ||
| 251 | + .status | ||
| 252 | + .start_global_sync(&["a".to_string(), "b".to_string()]); | ||
| 253 | + state.sync_task_manager.status.update_feed_status( | ||
| 254 | + "a".to_string(), | ||
| 255 | + FeedSyncStatus::Completed, | ||
| 256 | + ); | ||
| 257 | + state.sync_task_manager.status.update_feed_status( | ||
| 258 | + "b".to_string(), | ||
| 259 | + FeedSyncStatus::Failed { | ||
| 260 | + error: "réseau indisponible".to_string(), | ||
| 261 | + }, | ||
| 262 | + ); | ||
| 263 | + state.sync_task_manager.status.complete_global_sync(); | ||
| 264 | + } | ||
| 265 | + _ => {} | ||
| 266 | + } | ||
| 267 | + | ||
| 268 | + for viewport in [400.0, 700.0, 1000.0] { | ||
| 269 | + let mut inner_width = viewport; | ||
| 270 | + let mut widths = Vec::new(); | ||
| 271 | + for _ in 0..10 { | ||
| 272 | + let ctx = egui::Context::default(); | ||
| 273 | + let mut bar_width = 0.0; | ||
| 274 | + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { | ||
| 275 | + let builder = egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( | ||
| 276 | + egui::Pos2::ZERO, | ||
| 277 | + egui::Vec2::new(inner_width, 2000.0), | ||
| 278 | + )); | ||
| 279 | + let mut child = ui.new_child(builder); | ||
| 280 | + draw_global_sync_bar(&mut state, &mut child); | ||
| 281 | + bar_width = child.min_rect().width(); | ||
| 282 | + }); | ||
| 283 | + widths.push(bar_width); | ||
| 284 | + inner_width = inner_width.max(bar_width); | ||
| 285 | + } | ||
| 286 | + | ||
| 287 | + assert!( | ||
| 288 | + widths.iter().all(|w| *w <= viewport + 1.0), | ||
| 289 | + "la barre de synchro globale dépasse la largeur ({scenario}, {viewport}) : {widths:?}" | ||
| 290 | + ); | ||
| 291 | + assert!( | ||
| 292 | + widths.iter().skip(1).all(|w| (w - widths[0]).abs() < 1.0), | ||
| 293 | + "la largeur de la barre varie d'une frame à l'autre ({scenario}, {viewport}) : {widths:?}" | ||
| 294 | + ); | ||
| 295 | + } | ||
| 296 | + } | ||
| 297 | + } | ||
| 298 | + | ||
| 299 | + /// Sans synchro globale (en cours ou terminée), la barre n'affiche rien. | ||
| 300 | + #[test] | ||
| 301 | + fn global_sync_bar_hidden_when_idle() { | ||
| 302 | + let mut state = crate::config::AppState::with_default_config(); | ||
| 303 | + let ctx = egui::Context::default(); | ||
| 304 | + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { | ||
| 305 | + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( | ||
| 306 | + egui::Pos2::ZERO, | ||
| 307 | + egui::Vec2::new(1000.0, 2000.0), | ||
| 308 | + ))); | ||
| 309 | + draw_global_sync_bar(&mut state, &mut child); | ||
| 310 | + assert!( | ||
| 311 | + child.min_rect().height() < 1.0, | ||
| 312 | + "la barre doit être invisible sans synchro globale" | ||
| 313 | + ); | ||
| 314 | + }); | ||
| 315 | + } | ||
| 316 | +} | ||