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

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

Download ZIP

UX and transcode updates

msansm1 committed 2026-10-28T21:48:44+01:00 Browse files
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
77 ```bash
88 cargo build # build debug
99 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)
1111 cargo clippy --all-targets # doit passer sans aucun avertissement
1212 ```
1313
@@ -26,18 +26,23 @@ src/
2626 │ ├── storage.rs # save/load JSON dans %APPDATA%/rss-podcast-dl (unix : ~/.config/rss-podcast-dl)
2727 │ ├── settings.rs # settings.json (thème), history.json
2828 │ ├── 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
3030 ├── rss/ # I/O réseau et fichiers
3131 │ ├── parser.rs / episode_extractor.rs # récupération + parsing du flux (crate rss)
32+│ ├── async_sync.rs # synchro asynchrone : émet SyncProgress épisode par épisode
3233 │ ├── downloader.rs # téléchargement atomique : .part puis rename, via reqwest blocking
34+│ ├── transcode.rs # réencodage .m4a -> .mp3 via ffmpeg (fallback : original conservé)
3335 │ └── audio_metadata.rs # tags ID3 (crate id3)
3436 └── 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)
3539 ```
3640
3741 ## Patterns à respecter
3842
3943 - **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.
4044 - **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.
4146 - **Téléchargements atomiques** : toujours écrire via un fichier `.part` puis renommer. Ne jamais écrire directement le fichier final.
4247 - **Persistance** : sauvegarder le JSON après chaque mutation de feeds/playlists (`storage::save_*_default`). Historique auto-persisté via `HistoryManager::set_save_path`.
4348 - **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 ```bash7 ```bash
8 cargo build # build debug8 cargo build # build debug
9 cargo run # lance l'app9 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 avertissement11 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.json27 │ ├── 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::mpsc29+│ └── sync_task.rs # SyncTaskManager : threads + mpsc, statut + progression par flux
30 ├── rss/ # I/O réseau et fichiers30 ├── 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 blocking33 │ ├── 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 à respecter41 ## 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
88 - **Synchronisation asynchrone** : synchronisation globale ou flux par flux, sans bloquer l'interface, avec indicateur de progression
99 - **Vue détaillée par flux** : liste des épisodes avec dates, tailles et durées, téléchargement individuel, filtre par titre, actualisation
1010 - **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
1213 - **Recherche** : filtrage instantané des flux par nom, URL ou tags, avec compteur de résultats
1314 - **Filtres par date** : périodes personnalisées ou prédéfinies (7/30 derniers jours)
1415 - **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 progression8 - **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, actualisation9 - **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 complet10 - **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és11+- **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ésultats13 - **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 description15 - **Playlists** : regroupement des flux avec description
modified src/app.rs +11 -5
@@ -38,6 +38,9 @@ impl eframe::App for RssPodcastApp {
3838 // Appliquer le thème centralisé et récupérer la palette active
3939 let colors = crate::ui::theme::apply_custom_theme(&ctx, self.state.get_theme());
4040
41+ // Récupérer le résultat de la détection ffmpeg lancée au démarrage
42+ self.state.poll_ffmpeg_detection();
43+
4144 // Raccourcis clavier globaux (Ctrl+N, F5, Ctrl+F, Échap)
4245 crate::ui::menu_bar::handle_shortcuts(&mut self.state, &ctx);
4346
@@ -106,11 +109,14 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
106109 }
107110 }
108111
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();
114120
115121 // Load the operation history and persist new entries automatically
116122 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 active38 // 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 automatically121 // 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};
88 use crate::config::playlist::Playlist;
99 use crate::config::search::SearchQuery;
1010 use crate::config::sync_task::SyncTaskManager;
11+use crate::config::settings::Settings;
1112 use crate::ui::notifications::NotificationManager;
1213 use chrono::{DateTime, Utc};
1314 use serde::{Deserialize, Serialize};
1415 use std::collections::HashSet;
16+use std::sync::atomic::{AtomicU8, Ordering};
17+use std::sync::Arc;
18+use std::thread;
1519
1620 /// Theme options for the application
1721 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -149,6 +153,15 @@ pub struct AppState {
149153 pub detail_episode_filter: String,
150154 /// IDs of episodes currently being downloaded individually
151155 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,
152165 /// Manager for async synchronization tasks
153166 pub sync_task_manager: SyncTaskManager,
154167 }
@@ -203,6 +216,10 @@ impl AppState {
203216 detail_episode_filter: String::new(),
204217 downloading_episode_ids: HashSet::new(),
205218 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,
206223 sync_task_manager: SyncTaskManager::new(),
207224 }
208225 }
@@ -269,7 +286,9 @@ impl AppState {
269286 log::info!("Starting asynchronous synchronization of all feeds");
270287
271288 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);
273292 self.record_history(
274293 OperationType::SyncAll,
275294 "Synchronisation de tous les flux lancée".to_string(),
@@ -429,13 +448,76 @@ impl AppState {
429448 Theme::Dark => "sombre",
430449 Theme::Light => "clair",
431450 };
432- if let Err(e) = crate::config::settings::save_theme(self.theme) {
451+ if let Err(e) = self.save_settings() {
433452 log::error!("Failed to save theme: {}", e);
434453 }
435454 self.notification_manager
436455 .info(format!("Thème basculé vers: {}", theme_name));
437456 }
438457
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+
439521 /// Gets the current theme
440522 pub fn get_theme(&self) -> Theme {
441523 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 application20 /// 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 individually154 /// 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 tasks165 /// 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_manager454 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 theme521 /// Gets the current theme
440 pub fn get_theme(&self) -> Theme {522 pub fn get_theme(&self) -> Theme {
441 self.theme523 self.theme
modified src/config/settings.rs +42 -12
@@ -18,11 +18,29 @@ pub enum SettingsError {
1818 SerializeError(String),
1919 }
2020
21+/// Default value of the m4a transcoding setting
22+fn default_transcode_m4a_to_mp3() -> bool {
23+ true
24+}
25+
2126 /// Application settings persisted across sessions
22-#[derive(Debug, Default, Serialize, Deserialize)]
27+#[derive(Debug, Serialize, Deserialize)]
2328 pub struct Settings {
2429 /// UI theme (dark or light)
30+ #[serde(default)]
2531 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+ }
2644 }
2745
2846 /// Returns the path of the settings file in the configuration directory
@@ -30,9 +48,8 @@ pub fn get_settings_path() -> PathBuf {
3048 crate::config::storage::get_config_dir().join("settings.json")
3149 }
3250
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> {
3653 let path = get_settings_path();
3754
3855 if let Some(parent) = path.parent() {
@@ -43,34 +60,36 @@ pub fn save_theme(theme: Theme) -> Result<(), SettingsError> {
4360 }
4461 }
4562
46- let json = serde_json::to_string_pretty(&settings)
63+ let json = serde_json::to_string_pretty(settings)
4764 .map_err(|e| SettingsError::SerializeError(e.to_string()))?;
4865 fs::write(&path, json).map_err(|e| SettingsError::WriteError(e.to_string()))?;
4966
50- info!("Saved theme settings to {}", path.display());
67+ info!("Saved settings to {}", path.display());
5168 Ok(())
5269 }
5370
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 {
5675 let path = get_settings_path();
5776 if !path.exists() {
58- return None;
77+ return Settings::default();
5978 }
6079
6180 let content = match fs::read_to_string(&path) {
6281 Ok(content) => content,
6382 Err(e) => {
6483 warn!("Failed to read settings file {}: {}", path.display(), e);
65- return None;
84+ return Settings::default();
6685 }
6786 };
6887
6988 match serde_json::from_str::<Settings>(&content) {
70- Ok(settings) => Some(settings.theme),
89+ Ok(settings) => settings,
7190 Err(e) => {
7291 warn!("Failed to parse settings file {}: {}", path.display(), e);
73- None
92+ Settings::default()
7493 }
7594 }
7695 }
@@ -88,15 +107,26 @@ mod tests {
88107 fn test_settings_default() {
89108 let settings = Settings::default();
90109 assert_eq!(settings.theme, Theme::Dark);
110+ assert!(settings.transcode_m4a_to_mp3);
91111 }
92112
93113 #[test]
94114 fn test_settings_roundtrip() {
95115 let settings = Settings {
96116 theme: Theme::Light,
117+ transcode_m4a_to_mp3: false,
97118 };
98119 let json = serde_json::to_string(&settings).unwrap();
99120 let parsed: Settings = serde_json::from_str(&json).unwrap();
100121 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);
101131 }
102132 }
@@ -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 sessions26 /// 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 directory46 /// 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 file51+/// 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 valid71+/// 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- None92+ 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 {
5252 episode_title: String,
5353 size: Option<u64>,
5454 },
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+ },
5561 }
5662
5763 /// Status of a feed synchronization
@@ -60,6 +66,8 @@ pub enum FeedSyncStatus {
6066 /// Sync not started or completed
6167 #[default]
6268 Idle,
69+ /// En attente de début dans une synchronisation globale
70+ Pending,
6371 /// Sync in progress
6472 InProgress,
6573 /// Sync completed successfully
@@ -70,6 +78,15 @@ pub enum FeedSyncStatus {
7078 },
7179 }
7280
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+
7390 /// Status of all synchronization tasks
7491 #[derive(Debug, Default)]
7592 pub struct SyncStatus {
@@ -77,6 +94,8 @@ pub struct SyncStatus {
7794 pub global_sync_in_progress: bool,
7895 /// Individual feed sync status (feed_id -> status)
7996 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>,
8099 /// Total feeds to sync
81100 pub total_feeds: usize,
82101 /// Feeds completed
@@ -91,23 +110,36 @@ impl SyncStatus {
91110 Self::default()
92111 }
93112
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]) {
96116 self.global_sync_in_progress = true;
97- self.total_feeds = feed_count;
117+ self.total_feeds = feed_ids.len();
98118 self.completed_feeds = 0;
99119 self.overall_progress = 0.0;
100120 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+ }
101125 }
102126
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.
104129 pub fn complete_global_sync(&mut self) {
105130 self.global_sync_in_progress = false;
106131 self.overall_progress = 1.0;
132+ self.feed_sync_progress.clear();
107133 }
108134
109135 /// Update feed sync status
110136 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+
111143 self.feed_sync_status.insert(feed_id, status);
112144
113145 // Update overall progress
@@ -133,13 +165,38 @@ impl SyncStatus {
133165 self.global_sync_in_progress || !self.feed_sync_status.is_empty()
134166 }
135167
136- /// Get syncing feed count
168+ /// Get syncing feed count (actively downloading, not pending)
137169 pub fn syncing_feed_count(&self) -> usize {
138170 self.feed_sync_status
139171 .values()
140172 .filter(|s| matches!(s, FeedSyncStatus::InProgress))
141173 .count()
142174 }
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+ }
143200 }
144201
145202 /// Manager for async sync tasks
@@ -218,6 +275,14 @@ impl SyncTaskManager {
218275 }
219276 }
220277 }
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+ }
221286 SyncMessage::EpisodeDownloadFailed { .. }
222287 | SyncMessage::EpisodesLoaded { .. }
223288 | SyncMessage::EpisodesLoadFailed { .. }
@@ -228,18 +293,19 @@ impl SyncTaskManager {
228293 }
229294
230295 /// 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);
233299
234300 let sender = self.sender.clone();
235301
236302 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);
238304 });
239305 }
240306
241307 /// 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) {
243309 let sender = self.sender.clone();
244310 let feed_id = feed.id.clone();
245311
@@ -248,7 +314,7 @@ impl SyncTaskManager {
248314 .update_feed_status(feed_id, FeedSyncStatus::InProgress);
249315
250316 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);
252318 });
253319 }
254320
@@ -285,6 +351,7 @@ impl SyncTaskManager {
285351 &mut self,
286352 feed: crate::config::feed::Feed,
287353 episode: crate::config::episode::Episode,
354+ transcode_m4a: bool,
288355 ) {
289356 let sender = self.sender.clone();
290357 let feed_id = feed.id.clone();
@@ -294,6 +361,7 @@ impl SyncTaskManager {
294361 &episode,
295362 &feed.download_path,
296363 Box::new(|_| {}),
364+ transcode_m4a,
297365 ) {
298366 Ok(path) => {
299367 let size = std::fs::metadata(&path).map(|m| m.len()).ok();
@@ -318,8 +386,9 @@ impl SyncTaskManager {
318386 /// Clears completed and failed sync statuses
319387 pub fn clear_completed_sync_status(&mut self) {
320388 self.status.feed_sync_status.retain(|_, status| {
321- matches!(status, FeedSyncStatus::InProgress)
389+ matches!(status, FeedSyncStatus::InProgress | FeedSyncStatus::Pending)
322390 });
391+ self.status.feed_sync_progress.clear();
323392 }
324393 }
325394
@@ -345,18 +414,25 @@ mod tests {
345414 #[test]
346415 fn test_sync_status_start_global_sync() {
347416 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);
349419
350420 assert!(status.global_sync_in_progress);
351421 assert_eq!(status.total_feeds, 5);
352422 assert_eq!(status.completed_feeds, 0);
353423 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)));
354430 }
355431
356432 #[test]
357433 fn test_sync_status_overall_progress() {
358434 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()]);
360436
361437 // Add completed feeds
362438 status.update_feed_status("feed-1".to_string(), FeedSyncStatus::Completed);
@@ -388,4 +464,110 @@ mod tests {
388464 let manager = SyncTaskManager::new();
389465 assert!(!manager.status.global_sync_in_progress);
390466 }
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+ }
391573 }
@@ -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 synchronization63 /// Status of a feed synchronization
@@ -60,6 +66,8 @@ pub enum FeedSyncStatus {
60 /// Sync not started or completed66 /// 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 progress71 /// Sync in progress
64 InProgress,72 InProgress,
65 /// Sync completed successfully73 /// 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 tasks90 /// 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 sync99 /// Total feeds to sync
81 pub total_feeds: usize,100 pub total_feeds: usize,
82 /// Feeds completed101 /// Feeds completed
@@ -91,23 +110,36 @@ impl SyncStatus {
91 Self::default()110 Self::default()
92 }111 }
93 112
94- /// Start global sync113+ /// 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 sync127+ /// 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 status135 /// 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 progress145 // 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 count168+ /// 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_status170 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 tasks202 /// 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 asynchronously295 /// 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 asynchronously307 /// 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 statuses386 /// 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 feeds437 // 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 @@
55
66 use crate::config::{Episode, Feed};
77 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+};
911 use crate::rss::episode_extractor::extract_episodes;
1012 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+};
1116 use chrono::Utc;
1217 use log::{error, info, warn};
1318 use std::sync::mpsc::Sender;
@@ -15,7 +20,9 @@ use std::sync::mpsc::Sender;
1520 /// Synchronizes a single feed asynchronously
1621 ///
1722 /// 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) {
1926 let feed_id = feed.id.clone();
2027
2128 // Send sync started message
@@ -30,6 +37,25 @@ pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) {
3037
3138 info!("Starting async synchronization for feed: {}", feed_id);
3239
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+
3359 // Validate the feed configuration
3460 if let Err(err) = validate_feed(&feed) {
3561 send_failed(&sender, &feed_id, err);
@@ -80,15 +106,53 @@ pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) {
80106
81107 let mut feed_mut = feed;
82108 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+ });
83132
84- for episode in &episodes {
85- // Skip episodes that are already downloaded
86133 if is_episode_downloaded(&feed_mut, episode) {
87134 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+ );
88143 continue;
89144 }
90145
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+ ) {
92156 Ok(_) => {
93157 info!("Successfully downloaded episode: {}", episode.title);
94158 downloaded_count += 1;
@@ -109,6 +173,13 @@ pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) {
109173 }
110174 }
111175
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+
112183 // Recompute the downloaded episode count from the files actually
113184 // present on disk: the displayed counter must reflect the current
114185 // state, not a cumulative total that drifts over time.
@@ -123,6 +194,20 @@ pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) {
123194 feed_mut.last_episode_date = Some(latest.pub_date);
124195 }
125196
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+
126211 // The caller saves the feeds once sync completes (see ui/home.rs)
127212 let _ = sender.send(SyncMessage::SyncCompleted {
128213 feed_id,
@@ -163,7 +248,7 @@ fn send_failed(sender: &Sender<SyncMessage>, feed_id: &str, error: String) {
163248 }
164249
165250 /// 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) {
167252 info!("Starting async synchronization of all {} feeds", feeds.len());
168253
169254 for feed in feeds {
@@ -171,7 +256,7 @@ pub fn sync_all_feeds_async(feeds: Vec<Feed>, sender: Sender<SyncMessage>) {
171256
172257 // Spawn a separate thread for each feed sync
173258 std::thread::spawn(move || {
174- sync_feed_async(feed, sender_clone);
259+ sync_feed_async(feed, sender_clone, transcode_m4a);
175260 });
176261 }
177262 }
@@ -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 asynchronously20 /// 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 message28 // 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 configuration59 // 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 actually183 // Recompute the downloaded episode count from the files actually
113 // present on disk: the displayed counter must reflect the current184 // 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 asynchronously250 /// 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 sync257 // 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(
9595 tag.set_album(album);
9696 }
9797
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).
99101 let mut file_write = std::fs::OpenOptions::new()
102+ .read(true)
100103 .write(true)
101104 .open(path)
102105 .map_err(MetadataError::IoError)?;
103106
104- tag.write_to(&mut file_write, id3::Version::Id3v24)
107+ tag.write_to_file(&mut file_write, id3::Version::Id3v24)
105108 .map_err(|e| MetadataError::Id3WriteError(e.to_string()))?;
106109
107110 Ok(())
@@ -127,8 +130,11 @@ pub fn ensure_id3_tags(
127130 ) -> Result<(), MetadataError> {
128131 log::info!("Checking ID3 tags for file: {}", file_path.display());
129132
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();
132138
133139 let mut needs_write = false;
134140 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 existants133+ // 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 @@
33 //! This module handles downloading podcast episodes from URLs.
44
55 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+};
711 use log::{info, warn};
812 use std::fs;
913 use std::io::{Read, Write};
@@ -181,32 +185,41 @@ fn stream_to_file(
181185 Ok(downloaded)
182186 }
183187
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+
184192 /// 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.
186197 pub fn download_episode(
187198 episode: &Episode,
188199 download_path: &Path,
189200 progress_callback: ProgressCallback,
201+ transcode_m4a: bool,
190202 ) -> Result<PathBuf, DownloadError> {
191- let save_path = episode.get_save_path(download_path);
192-
193203 // 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+ {
195207 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);
206209 }
207210
211+ let mut save_path = episode.get_save_path(download_path);
208212 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+
210223 // Ensure ID3 tags are present after downloading. A tag failure must not
211224 // fail the whole download: the audio file is already complete on disk
212225 // and would otherwise never be counted as downloaded.
@@ -218,27 +231,279 @@ pub fn download_episode(
218231 ) {
219232 warn!("Failed to write ID3 tags for {}: {}", save_path.display(), e);
220233 }
221-
234+
235+ rename_to_tag_filename(&mut save_path);
236+
222237 Ok(save_path)
223238 }
224239
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).
226244 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);
238317 }
239318 }
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(&current_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()
242507 }
243508
244509 #[cfg(test)]
@@ -317,6 +582,490 @@ mod tests {
317582 assert!(nested_path.parent().unwrap().exists());
318583 }
319584
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+
3201069 #[test]
3211070 fn test_download_error_display() {
3221071 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 location192 /// Downloads an episode to its designated location
185-/// After downloading, ensures ID3 tags (title, artist, album) are present193+/// 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 exists208+ 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 not223 // Ensure ID3 tags are present after downloading. A tag failure must not
211 // fail the whole download: the audio file is already complete on disk224 // 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 size240+/// 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 download247+
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 download252+/// 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- false320+ 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(&current_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] = &[
3333 ];
3434
3535 /// Audio file extensions to look for in enclosures
36-const AUDIO_EXTENSIONS: &[&str] = &[
36+pub const AUDIO_EXTENSIONS: &[&str] = &[
3737 "mp3", "mp4", "m4a", "aac", "ogg", "wav", "flac", "m4b", "opus", "oga",
3838 ];
3939
@@ -33,7 +33,7 @@ const AUDIO_MIME_TYPES: &[&str] = &[
33 ];33 ];
34 34
35 /// Audio file extensions to look for in enclosures35 /// 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;
77 pub mod downloader;
88 pub mod episode_extractor;
99 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(
216216 {
217217 let feed = state.feeds[feed_index].clone();
218218 let episode = state.detail_episodes[episode_index].clone();
219+ let transcode_m4a = state.transcode_m4a_to_mp3;
219220 state.downloading_episode_ids.insert(episode.id.clone());
220221 state
221222 .notification_manager
222223 .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);
224227 }
225228 });
226229 });
@@ -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 state221 state
221 .notification_manager222 .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) {
4545 .feed_sync_status
4646 .get(&feed_id)
4747 .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;
4854 let colors = ThemeColors::for_theme(state.get_theme());
4955
5056 // Frame avec style amélioré
@@ -124,11 +130,14 @@ fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) {
124130 // Statut de synchronisation de ce flux
125131 match feed_sync_status {
126132 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);
132141 }
133142 Some(crate::config::sync_task::FeedSyncStatus::Failed { error }) => {
134143 ui.label(
@@ -138,6 +147,11 @@ fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) {
138147 )
139148 .on_hover_text(&error);
140149 }
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+ }
141155 _ => {
142156 // Dernière vérification
143157 if let Some(date) = feed_last_checked {
@@ -283,15 +297,29 @@ fn sync_feed_async(state: &mut AppState, index: usize) {
283297
284298 // Clone the feed for async task
285299 let feed_to_sync = state.feeds[index].clone();
300+ let transcode_m4a = state.transcode_m4a_to_mp3;
286301
287302 // 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);
289304
290305 state
291306 .notification_manager
292307 .info(format!("Synchronisation du flux en cours: {}", state.feeds[index].name));
293308 }
294309
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+
295323 #[cfg(test)]
296324 mod tests {
297325 use super::*;
@@ -346,4 +374,82 @@ mod tests {
346374 );
347375 }
348376 }
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+ }
349455 }
@@ -45,6 +45,12 @@ fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) {
45 .feed_sync_status45 .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 flux130 // 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érification156 // 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 task298 // 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 sync302 // 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 state305 state
291 .notification_manager306 .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) {
3131 // Barre d'outils : recherche, filtre par période et actions
3232 crate::ui::toolbar::draw_toolbar(state, ui);
3333
34+ // Barre de statut de la synchronisation globale
35+ crate::ui::sync_status::draw_global_sync_bar(state, ui);
36+
3437 ui.add_space(12.0);
3538
3639 if state.detail_feed_id.is_some() {
@@ -229,6 +232,8 @@ fn process_sync_messages(state: &mut AppState) {
229232 );
230233 }
231234 SyncMessage::SyncStarted { .. } => {}
235+ // Géré directement dans SyncTaskManager::handle_message
236+ SyncMessage::SyncProgress { .. } => {}
232237 }
233238 }
234239
@@ -31,6 +31,9 @@ pub fn draw_home(state: &mut AppState, ui: &mut Ui) {
31 // Barre d'outils : recherche, filtre par période et actions31 // 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/menu_bar.rs +34 -0
@@ -43,6 +43,40 @@ pub fn draw_menu(state: &mut AppState, ui: &mut Ui) {
4343 }
4444 });
4545
46+ ui.menu_button("Paramètres", |ui| {
47+ // État de la détection ffmpeg (lancée au démarrage, en arrière-plan)
48+ match state.ffmpeg_available {
49+ Some(true) => {
50+ ui.label("ffmpeg détecté");
51+ }
52+ Some(false) => {
53+ ui.label("ffmpeg non détecté");
54+ }
55+ None => {
56+ ui.label("Détection de ffmpeg…");
57+ }
58+ }
59+ if ui.button("Redétecter ffmpeg").clicked() {
60+ state.start_ffmpeg_detection();
61+ }
62+ ui.separator();
63+
64+ // Le réencodage ne peut pas s'appliquer sans ffmpeg: la case est
65+ // désactivée tant que la détection ne l'a pas trouvé
66+ let mut value = state.transcode_m4a_to_mp3;
67+ let enabled = state.ffmpeg_available == Some(true);
68+ let response = ui.add_enabled(
69+ enabled,
70+ egui::Checkbox::new(&mut value, "Réencoder les .m4a en .mp3"),
71+ );
72+ if response.changed() {
73+ state.set_transcode_m4a_to_mp3(value);
74+ }
75+ if !enabled {
76+ response.on_disabled_hover_text("ffmpeg est requis pour le réencodage");
77+ }
78+ });
79+
4680 ui.menu_button("Affichage", |ui| {
4781 let (label, icon) = match state.get_theme() {
4882 Theme::Dark => ("Thème clair", "☀️"),
@@ -43,6 +43,40 @@ pub fn draw_menu(state: &mut AppState, ui: &mut Ui) {
43 }43 }
44 });44 });
45 45
46+ ui.menu_button("Paramètres", |ui| {
47+ // État de la détection ffmpeg (lancée au démarrage, en arrière-plan)
48+ match state.ffmpeg_available {
49+ Some(true) => {
50+ ui.label("ffmpeg détecté");
51+ }
52+ Some(false) => {
53+ ui.label("ffmpeg non détecté");
54+ }
55+ None => {
56+ ui.label("Détection de ffmpeg…");
57+ }
58+ }
59+ if ui.button("Redétecter ffmpeg").clicked() {
60+ state.start_ffmpeg_detection();
61+ }
62+ ui.separator();
63+
64+ // Le réencodage ne peut pas s'appliquer sans ffmpeg: la case est
65+ // désactivée tant que la détection ne l'a pas trouvé
66+ let mut value = state.transcode_m4a_to_mp3;
67+ let enabled = state.ffmpeg_available == Some(true);
68+ let response = ui.add_enabled(
69+ enabled,
70+ egui::Checkbox::new(&mut value, "Réencoder les .m4a en .mp3"),
71+ );
72+ if response.changed() {
73+ state.set_transcode_m4a_to_mp3(value);
74+ }
75+ if !enabled {
76+ response.on_disabled_hover_text("ffmpeg est requis pour le réencodage");
77+ }
78+ });
79+
46 ui.menu_button("Affichage", |ui| {80 ui.menu_button("Affichage", |ui| {
47 let (label, icon) = match state.get_theme() {81 let (label, icon) = match state.get_theme() {
48 Theme::Dark => ("Thème clair", "☀️"),82 Theme::Dark => ("Thème clair", "☀️"),
modified src/ui/sync_status.rs +203 -1
@@ -2,12 +2,13 @@
22 //!
33 //! This module displays the synchronization status and progress in the status bar.
44
5+use crate::config::sync_task::FeedSyncStatus;
56 use crate::config::AppState;
67 use crate::ui::theme::ThemeColors;
78 use egui::{RichText, Ui};
89
910 /// 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) {
1112 let progress_clamped = progress.clamp(0.0, 1.0);
1213
1314 // Background
@@ -112,3 +113,204 @@ pub fn draw_sync_indicator(state: &mut AppState, ui: &mut Ui) {
112113 }
113114 });
114115 }
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 colors10 /// 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 // Background14 // 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+}