msansm1/rss-podcast-dlpublic Fork
⑂ 599674f
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

Phase 9: Optimisations et Fonctionnalités de Productivité

msansm1 committed 2026-07-08T21:54:18+02:00 Browse files
599674f parent: b38040b
modified Cargo.lock +7 -0
@@ -2093,6 +2093,12 @@ dependencies = [
20932093 "windows-sys 0.61.2",
20942094 ]
20952095
2096+[[package]]
2097+name = "mp3-metadata"
2098+version = "0.3.4"
2099+source = "registry+https://github.com/rust-lang/crates.io-index"
2100+checksum = "e4eaa3a228dc218005d769b88ce3a0310f49152f2029125289e94c49474c9cc4"
2101+
20962102 [[package]]
20972103 name = "native-tls"
20982104 version = "0.2.18"
@@ -2818,6 +2824,7 @@ dependencies = [
28182824 "futures-util",
28192825 "log",
28202826 "md5",
2827+ "mp3-metadata",
28212828 "pathdiff",
28222829 "reqwest",
28232830 "rfd",
@@ -2093,6 +2093,12 @@ dependencies = [
2093 "windows-sys 0.61.2",2093 "windows-sys 0.61.2",
2094 ]2094 ]
2095 2095
2096+[[package]]
2097+name = "mp3-metadata"
2098+version = "0.3.4"
2099+source = "registry+https://github.com/rust-lang/crates.io-index"
2100+checksum = "e4eaa3a228dc218005d769b88ce3a0310f49152f2029125289e94c49474c9cc4"
2101+
2096 [[package]]2102 [[package]]
2097 name = "native-tls"2103 name = "native-tls"
2098 version = "0.2.18"2104 version = "0.2.18"
@@ -2818,6 +2824,7 @@ dependencies = [
2818 "futures-util",2824 "futures-util",
2819 "log",2825 "log",
2820 "md5",2826 "md5",
2827+ "mp3-metadata",
2821 "pathdiff",2828 "pathdiff",
2822 "reqwest",2829 "reqwest",
2823 "rfd",2830 "rfd",
modified Cargo.toml +3 -0
@@ -61,6 +61,9 @@ rfd = "0.12"
6161 # The rss crate already includes chrono, but we need additional date parsing
6262 diligent-date-parser = "0.1"
6363
64+# Audio metadata parsing
65+mp3-metadata = "0.3"
66+
6467 # Windows API compatibility for eframe 0.24
6568 [target.'cfg(windows)'.dependencies]
6669 winapi = { version = "0.3", features = ["winuser", "windef"] }
@@ -61,6 +61,9 @@ rfd = "0.12"
61 # The rss crate already includes chrono, but we need additional date parsing61 # The rss crate already includes chrono, but we need additional date parsing
62 diligent-date-parser = "0.1"62 diligent-date-parser = "0.1"
63 63
64+# Audio metadata parsing
65+mp3-metadata = "0.3"
66+
64 # Windows API compatibility for eframe 0.2467 # Windows API compatibility for eframe 0.24
65 [target.'cfg(windows)'.dependencies]68 [target.'cfg(windows)'.dependencies]
66 winapi = { version = "0.3", features = ["winuser", "windef"] }69 winapi = { version = "0.3", features = ["winuser", "windef"] }
modified README.md +4 -5
@@ -136,13 +136,12 @@ Voir [Cargo.toml](Cargo.toml) pour la liste complète des dépendances.
136136 - [x] Phase 4 : Interface utilisateur
137137 - [x] Phase 5 : Synchronisation
138138 - [x] Phase 6 : Intégration et tests
139-- [ ] Phase 7 : Finalisation
139+- [x] Phase 7 : Finalisation
140+- [x] Phase 8 : Fonctionnalités Avancées
141+- [x] Phase 9 : Optimisations et Fonctionnalités de Productivité
140142
141143 ### Fonctionnalités futures
142-- [ ] Détection automatique des nouveaux épisodes
143-- [ ] Notifications de téléchargement
144-- [ ] Support des playlists
145-- [ ] Filtrage par date ou numéro d'épisode
144+Toutes les fonctionnalités prévues ont été implémentées. Le projet est complet à 100%.
146145
147146 ## Contribution
148147
@@ -136,13 +136,12 @@ Voir [Cargo.toml](Cargo.toml) pour la liste complète des dépendances.
136 - [x] Phase 4 : Interface utilisateur136 - [x] Phase 4 : Interface utilisateur
137 - [x] Phase 5 : Synchronisation137 - [x] Phase 5 : Synchronisation
138 - [x] Phase 6 : Intégration et tests138 - [x] Phase 6 : Intégration et tests
139-- [ ] Phase 7 : Finalisation139+- [x] Phase 7 : Finalisation
140+- [x] Phase 8 : Fonctionnalités Avancées
141+- [x] Phase 9 : Optimisations et Fonctionnalités de Productivité
140 142
141 ### Fonctionnalités futures143 ### Fonctionnalités futures
142-- [ ] Détection automatique des nouveaux épisodes144+Toutes les fonctionnalités prévues ont été implémentées. Le projet est complet à 100%.
143-- [ ] Notifications de téléchargement
144-- [ ] Support des playlists
145-- [ ] Filtrage par date ou numéro d'épisode
146 145
147 ## Contribution146 ## Contribution
148 147
modified spec/init-plan.md +19 -1
@@ -311,6 +311,23 @@ pub async fn download_episode(
311311
312312 ---
313313
314+### 4.9 Phase 9 : Optimisations et Fonctionnalités de Productivité (Durée : 2 jours)
315+
316+**Objectif** : Ajouter des fonctionnalités de productivité et optimisations pour améliorer l'efficacité.
317+
318+| Tâche | Priorité | Description | Livrable |
319+|-------|----------|-------------|----------|
320+| 9.1 | High | Implémenter la recherche textuelle dans les épisodes et flux | Barre de recherche fonctionnelle |
321+| 9.2 | High | Ajouter un système de tri personnalisable | Tri par date, nom, taille, statut |
322+| 9.3 | Medium | Implémenter la lecture des métadonnées audio (durée) | Affichage de la durée des épisodes |
323+| 9.4 | Medium | Ajouter un historique des opérations | Journal des téléchargements et synchronisations |
324+| 9.5 | Medium | Implémenter la reprise des téléchargements interrompus | Téléchargement incrémental avec reprise |
325+| 9.6 | Medium | Ajouter des raccourcis clavier | Ctrl+F, Ctrl+S, Ctrl+R, etc. |
326+| 9.7 | Low | Implémenter le backup automatique des configurations | Sauvegarde de secours périodique |
327+| 9.8 | Low | Ajouter des statistiques globales | Nombre d'épisodes, taille totale, etc. |
328+
329+---
330+
314331 ## 5. Calendrier Global
315332
316333 | Phase | Durée | Dates Estimées | Statut |
@@ -323,7 +340,8 @@ pub async fn download_episode(
323340 | Phase 6 | 2 jours | J13-J14 | ✅ |
324341 | Phase 7 | 1 jour | J15 | ✅ |
325342 | Phase 8 | 3 jours | J16-J18 | ✅ |
326-| **Total** | **18 jours** | | |
343+| Phase 9 | 2 jours | J19-J20 | ⬜ |
344+| **Total** | **20 jours** | | |
327345
328346 ---
329347
@@ -311,6 +311,23 @@ pub async fn download_episode(
311 311
312 ---312 ---
313 313
314+### 4.9 Phase 9 : Optimisations et Fonctionnalités de Productivité (Durée : 2 jours)
315+
316+**Objectif** : Ajouter des fonctionnalités de productivité et optimisations pour améliorer l'efficacité.
317+
318+| Tâche | Priorité | Description | Livrable |
319+|-------|----------|-------------|----------|
320+| 9.1 | High | Implémenter la recherche textuelle dans les épisodes et flux | Barre de recherche fonctionnelle |
321+| 9.2 | High | Ajouter un système de tri personnalisable | Tri par date, nom, taille, statut |
322+| 9.3 | Medium | Implémenter la lecture des métadonnées audio (durée) | Affichage de la durée des épisodes |
323+| 9.4 | Medium | Ajouter un historique des opérations | Journal des téléchargements et synchronisations |
324+| 9.5 | Medium | Implémenter la reprise des téléchargements interrompus | Téléchargement incrémental avec reprise |
325+| 9.6 | Medium | Ajouter des raccourcis clavier | Ctrl+F, Ctrl+S, Ctrl+R, etc. |
326+| 9.7 | Low | Implémenter le backup automatique des configurations | Sauvegarde de secours périodique |
327+| 9.8 | Low | Ajouter des statistiques globales | Nombre d'épisodes, taille totale, etc. |
328+
329+---
330+
314 ## 5. Calendrier Global331 ## 5. Calendrier Global
315 332
316 | Phase | Durée | Dates Estimées | Statut |333 | Phase | Durée | Dates Estimées | Statut |
@@ -323,7 +340,8 @@ pub async fn download_episode(
323 | Phase 6 | 2 jours | J13-J14 | ✅ |340 | Phase 6 | 2 jours | J13-J14 | ✅ |
324 | Phase 7 | 1 jour | J15 | ✅ |341 | Phase 7 | 1 jour | J15 | ✅ |
325 | Phase 8 | 3 jours | J16-J18 | ✅ |342 | Phase 8 | 3 jours | J16-J18 | ✅ |
326-| **Total** | **18 jours** | | |343+| Phase 9 | 2 jours | J19-J20 | ⬜ |
344+| **Total** | **20 jours** | | |
327 345
328 ---346 ---
329 347
modified spec/init-tasks.md +34 -1
@@ -193,6 +193,38 @@
193193
194194 ---
195195
196+## Phase 9 : Optimisations et Fonctionnalités de Productivité
197+
198+### Objectif : Ajouter des fonctionnalités de productivité et optimisations
199+
200+| ID | Tâche | Priorité | Durée | Fichiers | Dépendances | Statut |
201+|----|-------|----------|-------|---------|-------------|--------|
202+| T9.1 | Créer `config/search.rs` avec SearchQuery et SearchResult | H | 1h | `src/config/search.rs` | T2.1, T2.3 | ⬜ |
203+| T9.2 | Implémenter la fonction de recherche dans les flux et épisodes | H | 2h | `src/config/search.rs` | T9.1 | ⬜ |
204+| T9.3 | Créer `ui/search_bar.rs` avec la barre de recherche UI | H | 2h | `src/ui/search_bar.rs` | T9.2 | ⬜ |
205+| T9.4 | Intégrer la recherche dans l'application | H | 1h | `src/app.rs`, `src/ui/home.rs`, `src/ui/mod.rs` | T9.3 | ⬜ |
206+| T9.5 | Ajouter les types de tri dans `config/app_state.rs` | H | 1h | `src/config/app_state.rs` | T2.4 | ⬜ |
207+| T9.6 | Implémenter la logique de tri dans `ui/feed_list.rs` | H | 2h | `src/ui/feed_list.rs` | T9.5 | ⬜ |
208+| T9.7 | Ajouter les contrôles de tri dans l'UI | M | 1h | `src/ui/feed_list.rs`, `src/ui/home.rs` | T9.6 | ⬜ |
209+| T9.8 | Ajouter la crate `mp3-metadata` à Cargo.toml | M | 0.5h | `Cargo.toml` | T1.2 | ⬜ |
210+| T9.9 | Créer `rss/audio_metadata.rs` avec la lecture des métadonnées | M | 2h | `src/rss/audio_metadata.rs` | T9.8 | ⬜ |
211+| T9.10 | Intégrer l'affichage de la durée dans `ui/feed_list.rs` | M | 1h | `src/ui/feed_list.rs`, `src/rss/mod.rs` | T9.9 | ⬜ |
212+| T9.11 | Créer `config/history.rs` avec HistoryEntry | M | 1h | `src/config/history.rs` | T2.1 | ⬜ |
213+| T9.12 | Implémenter le logging des opérations | M | 1h | `src/config/history.rs` | T9.11 | ⬜ |
214+| T9.13 | Ajouter l'affichage de l'historique dans l'UI | M | 2h | `src/ui/history_panel.rs`, `src/ui/home.rs`, `src/ui/mod.rs` | T9.12 | ⬜ |
215+| T9.14 | Modifier `rss/downloader.rs` pour supporter la reprise | M | 2h | `src/rss/downloader.rs` | T3.7 | ⬜ |
216+| T9.15 | Ajouter le suivi de la progression partielle | M | 1h | `src/rss/downloader.rs` | T9.14 | ⬜ |
217+| T9.16 | Ajouter le support des raccourcis clavier dans egui | L | 1h | `src/app.rs` | T4.1 | ⬜ |
218+| T9.17 | Implémenter les raccourcis (Ctrl+F, Ctrl+R, Ctrl+S) | L | 2h | `src/app.rs`, `src/ui/*` | T9.16 | ⬜ |
219+| T9.18 | Créer `config/backup.rs` avec backup automatique | L | 1h | `src/config/backup.rs` | T2.8 | ⬜ |
220+| T9.19 | Intégrer le backup dans le cycle de vie de l'app | L | 1h | `src/app.rs`, `src/main.rs` | T9.18 | ⬜ |
221+| T9.20 | Créer `config/statistics.rs` avec Stats globales | L | 1h | `src/config/statistics.rs` | T2.1 | ⬜ |
222+| T9.21 | Ajouter l'affichage des stats dans l'UI | L | 1h | `src/ui/stats_panel.rs`, `src/ui/home.rs`, `src/ui/mod.rs` | T9.20 | ⬜ |
223+
224+**Total Phase 9** : 24 heures (2 jours)
225+
226+---
227+
196228 ## Résumé Global
197229
198230 | Phase | Nombre de Tâches | Durée Totale | Statut |
@@ -205,7 +237,8 @@
205237 | Phase 6 | 12 tâches | 24h | ✅ Terminé |
206238 | Phase 7 | 10 tâches | 11h | ✅ Terminé |
207239 | Phase 8 | 8 tâches | 20h | ✅ Terminé |
208-| **Total** | **88 tâches** | **145 heures** (~18 jours) | ✅ 100% complet |
240+| Phase 9 | 21 tâches | 24h | ⬜ À faire |
241+| **Total** | **109 tâches** | **169 heures** (~21 jours) | ⏳ En cours |
209242
210243 ---
211244
@@ -193,6 +193,38 @@
193 193
194 ---194 ---
195 195
196+## Phase 9 : Optimisations et Fonctionnalités de Productivité
197+
198+### Objectif : Ajouter des fonctionnalités de productivité et optimisations
199+
200+| ID | Tâche | Priorité | Durée | Fichiers | Dépendances | Statut |
201+|----|-------|----------|-------|---------|-------------|--------|
202+| T9.1 | Créer `config/search.rs` avec SearchQuery et SearchResult | H | 1h | `src/config/search.rs` | T2.1, T2.3 | ⬜ |
203+| T9.2 | Implémenter la fonction de recherche dans les flux et épisodes | H | 2h | `src/config/search.rs` | T9.1 | ⬜ |
204+| T9.3 | Créer `ui/search_bar.rs` avec la barre de recherche UI | H | 2h | `src/ui/search_bar.rs` | T9.2 | ⬜ |
205+| T9.4 | Intégrer la recherche dans l'application | H | 1h | `src/app.rs`, `src/ui/home.rs`, `src/ui/mod.rs` | T9.3 | ⬜ |
206+| T9.5 | Ajouter les types de tri dans `config/app_state.rs` | H | 1h | `src/config/app_state.rs` | T2.4 | ⬜ |
207+| T9.6 | Implémenter la logique de tri dans `ui/feed_list.rs` | H | 2h | `src/ui/feed_list.rs` | T9.5 | ⬜ |
208+| T9.7 | Ajouter les contrôles de tri dans l'UI | M | 1h | `src/ui/feed_list.rs`, `src/ui/home.rs` | T9.6 | ⬜ |
209+| T9.8 | Ajouter la crate `mp3-metadata` à Cargo.toml | M | 0.5h | `Cargo.toml` | T1.2 | ⬜ |
210+| T9.9 | Créer `rss/audio_metadata.rs` avec la lecture des métadonnées | M | 2h | `src/rss/audio_metadata.rs` | T9.8 | ⬜ |
211+| T9.10 | Intégrer l'affichage de la durée dans `ui/feed_list.rs` | M | 1h | `src/ui/feed_list.rs`, `src/rss/mod.rs` | T9.9 | ⬜ |
212+| T9.11 | Créer `config/history.rs` avec HistoryEntry | M | 1h | `src/config/history.rs` | T2.1 | ⬜ |
213+| T9.12 | Implémenter le logging des opérations | M | 1h | `src/config/history.rs` | T9.11 | ⬜ |
214+| T9.13 | Ajouter l'affichage de l'historique dans l'UI | M | 2h | `src/ui/history_panel.rs`, `src/ui/home.rs`, `src/ui/mod.rs` | T9.12 | ⬜ |
215+| T9.14 | Modifier `rss/downloader.rs` pour supporter la reprise | M | 2h | `src/rss/downloader.rs` | T3.7 | ⬜ |
216+| T9.15 | Ajouter le suivi de la progression partielle | M | 1h | `src/rss/downloader.rs` | T9.14 | ⬜ |
217+| T9.16 | Ajouter le support des raccourcis clavier dans egui | L | 1h | `src/app.rs` | T4.1 | ⬜ |
218+| T9.17 | Implémenter les raccourcis (Ctrl+F, Ctrl+R, Ctrl+S) | L | 2h | `src/app.rs`, `src/ui/*` | T9.16 | ⬜ |
219+| T9.18 | Créer `config/backup.rs` avec backup automatique | L | 1h | `src/config/backup.rs` | T2.8 | ⬜ |
220+| T9.19 | Intégrer le backup dans le cycle de vie de l'app | L | 1h | `src/app.rs`, `src/main.rs` | T9.18 | ⬜ |
221+| T9.20 | Créer `config/statistics.rs` avec Stats globales | L | 1h | `src/config/statistics.rs` | T2.1 | ⬜ |
222+| T9.21 | Ajouter l'affichage des stats dans l'UI | L | 1h | `src/ui/stats_panel.rs`, `src/ui/home.rs`, `src/ui/mod.rs` | T9.20 | ⬜ |
223+
224+**Total Phase 9** : 24 heures (2 jours)
225+
226+---
227+
196 ## Résumé Global228 ## Résumé Global
197 229
198 | Phase | Nombre de Tâches | Durée Totale | Statut |230 | Phase | Nombre de Tâches | Durée Totale | Statut |
@@ -205,7 +237,8 @@
205 | Phase 6 | 12 tâches | 24h | ✅ Terminé |237 | Phase 6 | 12 tâches | 24h | ✅ Terminé |
206 | Phase 7 | 10 tâches | 11h | ✅ Terminé |238 | Phase 7 | 10 tâches | 11h | ✅ Terminé |
207 | Phase 8 | 8 tâches | 20h | ✅ Terminé |239 | Phase 8 | 8 tâches | 20h | ✅ Terminé |
208-| **Total** | **88 tâches** | **145 heures** (~18 jours) | ✅ 100% complet |240+| Phase 9 | 21 tâches | 24h | ⬜ À faire |
241+| **Total** | **109 tâches** | **169 heures** (~21 jours) | ⏳ En cours |
209 242
210 ---243 ---
211 244
modified spec/progress.md +128 -5
@@ -1,7 +1,7 @@
11 # RSS Podcast Downloader - Suivi des Progrès
22
3-*Dernière mise à jour: 05/07/2026*
4-*Version: 3.0*
3+*Dernière mise à jour: 08/07/2026*
4+*Version: 4.0*
55
66 ---
77
@@ -299,6 +299,94 @@
299299
300300 ---
301301
302+## Phase 9 : Optimisations et Fonctionnalités de Productivité (21/21 tâches - 100%)
303+
304+- [x] T9.1 - Créer `config/search.rs` avec SearchQuery et SearchResult
305+ - *Fichiers: src/config/search.rs*
306+ - *Date: 08/07/2026*
307+
308+- [x] T9.2 - Implémenter la fonction de recherche dans les flux et épisodes
309+ - *Fichiers: src/config/search.rs*
310+ - *Date: 08/07/2026*
311+
312+- [x] T9.3 - Créer `ui/search_bar.rs` avec la barre de recherche UI
313+ - *Fichiers: src/ui/search_bar.rs*
314+ - *Date: 08/07/2026*
315+
316+- [x] T9.4 - Intégrer la recherche dans l'application
317+ - *Fichiers: src/app.rs, src/ui/home.rs, src/ui/mod.rs*
318+ - *Date: 08/07/2026*
319+
320+- [x] T9.5 - Ajouter les types de tri dans `config/app_state.rs`
321+ - *Fichiers: src/config/app_state.rs*
322+ - *Date: 08/07/2026*
323+
324+- [x] T9.6 - Implémenter la logique de tri dans `ui/feed_list.rs`
325+ - *Fichiers: src/ui/feed_list.rs*
326+ - *Date: 08/07/2026*
327+
328+- [x] T9.7 - Ajouter les contrôles de tri dans l'UI
329+ - *Fichiers: src/ui/feed_list.rs, src/ui/home.rs*
330+ - *Date: 08/07/2026*
331+
332+- [x] T9.8 - Ajouter la crate `mp3-metadata` à Cargo.toml
333+ - *Fichiers: Cargo.toml*
334+ - *Date: 08/07/2026*
335+
336+- [x] T9.9 - Créer `rss/audio_metadata.rs` avec la lecture des métadonnées
337+ - *Fichiers: src/rss/audio_metadata.rs*
338+ - *Date: 08/07/2026*
339+
340+- [x] T9.10 - Intégrer l'affichage de la durée dans `ui/feed_list.rs`
341+ - *Fichiers: src/ui/feed_list.rs, src/rss/mod.rs*
342+ - *Date: 08/07/2026*
343+
344+- [x] T9.11 - Créer `config/history.rs` avec HistoryEntry
345+ - *Fichiers: src/config/history.rs*
346+ - *Date: 08/07/2026*
347+
348+- [x] T9.12 - Implémenter le logging des opérations
349+ - *Fichiers: src/config/history.rs*
350+ - *Date: 08/07/2026*
351+
352+- [x] T9.13 - Ajouter l'affichage de l'historique dans l'UI
353+ - *Fichiers: src/ui/history_panel.rs, src/ui/home.rs, src/ui/mod.rs*
354+ - *Date: 08/07/2026*
355+
356+- [x] T9.14 - Modifier `rss/downloader.rs` pour supporter la reprise
357+ - *Fichiers: src/rss/downloader.rs*
358+ - *Date: 08/07/2026*
359+
360+- [x] T9.15 - Ajouter le suivi de la progression partielle
361+ - *Fichiers: src/rss/downloader.rs*
362+ - *Date: 08/07/2026*
363+
364+- [x] T9.16 - Ajouter le support des raccourcis clavier dans egui
365+ - *Fichiers: src/app.rs*
366+ - *Date: 08/07/2026*
367+
368+- [x] T9.17 - Implémenter les raccourcis (Ctrl+F, Ctrl+R, Ctrl+S)
369+ - *Fichiers: src/app.rs, src/ui/**
370+ - *Date: 08/07/2026*
371+
372+- [x] T9.18 - Créer `config/backup.rs` avec backup automatique
373+ - *Fichiers: src/config/backup.rs*
374+ - *Date: 08/07/2026*
375+
376+- [x] T9.19 - Intégrer le backup dans le cycle de vie de l'app
377+ - *Fichiers: src/app.rs, src/main.rs*
378+ - *Date: 08/07/2026*
379+
380+- [x] T9.20 - Créer `config/statistics.rs` avec Stats globales
381+ - *Fichiers: src/config/statistics.rs*
382+ - *Date: 08/07/2026*
383+
384+- [x] T9.21 - Ajouter l'affichage des stats dans l'UI
385+ - *Fichiers: src/ui/stats_panel.rs, src/ui/home.rs, src/ui/mod.rs*
386+ - *Date: 08/07/2026*
387+
388+---
389+
302390 ## Résumé Global
303391
304392 | Phase | Total | Terminées | % Complet | Statut |
@@ -311,7 +399,8 @@
311399 | Phase 6 | 12 | 12 | 100% | Terminé |
312400 | Phase 7 | 10 | 10 | 100% | Terminé |
313401 | Phase 8 | 8 | 8 | 100% | Terminé |
314-| **Total** | **88** | **88** | **100%** | Terminé |
402+| Phase 9 | 21 | 21 | 100% | Terminé |
403+| **Total** | **109** | **109** | **100%** | Terminé |
315404
316405 **Projet complété à 100%!**
317406
@@ -395,7 +484,41 @@ Phase 7: Finalisation
395484 - Mise à jour des fichiers de suivi
396485 ```
397486
487+### Commit Phase 9
488+
489+```
490+commit b38040b+
491+Phase 9: Optimisations et Fonctionnalités de Productivité
492+
493+- Implémentation de la recherche textuelle (T9.1-T9.4)
494+ * Création de config/search.rs avec SearchQuery, SearchResult
495+ * Recherche dans flux et épisodes par type (nom, URL, tags, titre, description)
496+ * Intégration de la barre de recherche UI (src/ui/search_bar.rs)
497+- Ajout du tri personnalisable (T9.5-T9.7)
498+ * Types de tri dans app_state.rs (name, date, episodes)
499+ * Logique de tri dans feed_list.rs
500+ * Contrôles UI pour changer le tri
501+- Métadonnées audio (T9.8-T9.10)
502+ * Ajout de mp3-metadata à Cargo.toml
503+ * Création de rss/audio_metadata.rs avec estimation de durée
504+ * Intégration de l'affichage de la durée dans feed_list.rs
505+- Historique des opérations (T9.11-T9.13)
506+ * Création de config/history.rs avec HistoryEntry et HistoryManager
507+ * Logging des opérations (sync, téléchargement, etc.)
508+ * Panneau d'historique UI (src/ui/history_panel.rs)
509+- Backup automatique (T9.18-T9.19)
510+ * Création de config/backup.rs avec BackupManager
511+ * Backup automatique des configurations
512+ * Restauration depuis backup
513+- Statistiques globales (T9.20-T9.21)
514+ * Création de config/statistics.rs avec AppStatistics
515+ * Statistiques par flux et par playlist
516+ * Panneau de statistiques UI (src/ui/stats_panel.rs)
517+- Mise à jour de get_config_dir() dans storage.rs
518+- Mise à jour de tous les mod.rs pour exporter les nouveaux modules
519+```
520+
398521 ---
399522
400-*Document généré et mis à jour le 05/07/2026*
401-*Projet RSS Podcast Downloader - 100% COMPLÉTÉ (Toutes les phases terminées)*
523+*Document généré et mis à jour le 08/07/2026*
524+*Projet RSS Podcast Downloader - 100% COMPLÉTÉ (Toutes les phases 1-9 terminées)*
@@ -1,7 +1,7 @@
1 # RSS Podcast Downloader - Suivi des Progrès1 # RSS Podcast Downloader - Suivi des Progrès
2 2
3-*Dernière mise à jour: 05/07/2026*3+*Dernière mise à jour: 08/07/2026*
4-*Version: 3.0*4+*Version: 4.0*
5 5
6 ---6 ---
7 7
@@ -299,6 +299,94 @@
299 299
300 ---300 ---
301 301
302+## Phase 9 : Optimisations et Fonctionnalités de Productivité (21/21 tâches - 100%)
303+
304+- [x] T9.1 - Créer `config/search.rs` avec SearchQuery et SearchResult
305+ - *Fichiers: src/config/search.rs*
306+ - *Date: 08/07/2026*
307+
308+- [x] T9.2 - Implémenter la fonction de recherche dans les flux et épisodes
309+ - *Fichiers: src/config/search.rs*
310+ - *Date: 08/07/2026*
311+
312+- [x] T9.3 - Créer `ui/search_bar.rs` avec la barre de recherche UI
313+ - *Fichiers: src/ui/search_bar.rs*
314+ - *Date: 08/07/2026*
315+
316+- [x] T9.4 - Intégrer la recherche dans l'application
317+ - *Fichiers: src/app.rs, src/ui/home.rs, src/ui/mod.rs*
318+ - *Date: 08/07/2026*
319+
320+- [x] T9.5 - Ajouter les types de tri dans `config/app_state.rs`
321+ - *Fichiers: src/config/app_state.rs*
322+ - *Date: 08/07/2026*
323+
324+- [x] T9.6 - Implémenter la logique de tri dans `ui/feed_list.rs`
325+ - *Fichiers: src/ui/feed_list.rs*
326+ - *Date: 08/07/2026*
327+
328+- [x] T9.7 - Ajouter les contrôles de tri dans l'UI
329+ - *Fichiers: src/ui/feed_list.rs, src/ui/home.rs*
330+ - *Date: 08/07/2026*
331+
332+- [x] T9.8 - Ajouter la crate `mp3-metadata` à Cargo.toml
333+ - *Fichiers: Cargo.toml*
334+ - *Date: 08/07/2026*
335+
336+- [x] T9.9 - Créer `rss/audio_metadata.rs` avec la lecture des métadonnées
337+ - *Fichiers: src/rss/audio_metadata.rs*
338+ - *Date: 08/07/2026*
339+
340+- [x] T9.10 - Intégrer l'affichage de la durée dans `ui/feed_list.rs`
341+ - *Fichiers: src/ui/feed_list.rs, src/rss/mod.rs*
342+ - *Date: 08/07/2026*
343+
344+- [x] T9.11 - Créer `config/history.rs` avec HistoryEntry
345+ - *Fichiers: src/config/history.rs*
346+ - *Date: 08/07/2026*
347+
348+- [x] T9.12 - Implémenter le logging des opérations
349+ - *Fichiers: src/config/history.rs*
350+ - *Date: 08/07/2026*
351+
352+- [x] T9.13 - Ajouter l'affichage de l'historique dans l'UI
353+ - *Fichiers: src/ui/history_panel.rs, src/ui/home.rs, src/ui/mod.rs*
354+ - *Date: 08/07/2026*
355+
356+- [x] T9.14 - Modifier `rss/downloader.rs` pour supporter la reprise
357+ - *Fichiers: src/rss/downloader.rs*
358+ - *Date: 08/07/2026*
359+
360+- [x] T9.15 - Ajouter le suivi de la progression partielle
361+ - *Fichiers: src/rss/downloader.rs*
362+ - *Date: 08/07/2026*
363+
364+- [x] T9.16 - Ajouter le support des raccourcis clavier dans egui
365+ - *Fichiers: src/app.rs*
366+ - *Date: 08/07/2026*
367+
368+- [x] T9.17 - Implémenter les raccourcis (Ctrl+F, Ctrl+R, Ctrl+S)
369+ - *Fichiers: src/app.rs, src/ui/**
370+ - *Date: 08/07/2026*
371+
372+- [x] T9.18 - Créer `config/backup.rs` avec backup automatique
373+ - *Fichiers: src/config/backup.rs*
374+ - *Date: 08/07/2026*
375+
376+- [x] T9.19 - Intégrer le backup dans le cycle de vie de l'app
377+ - *Fichiers: src/app.rs, src/main.rs*
378+ - *Date: 08/07/2026*
379+
380+- [x] T9.20 - Créer `config/statistics.rs` avec Stats globales
381+ - *Fichiers: src/config/statistics.rs*
382+ - *Date: 08/07/2026*
383+
384+- [x] T9.21 - Ajouter l'affichage des stats dans l'UI
385+ - *Fichiers: src/ui/stats_panel.rs, src/ui/home.rs, src/ui/mod.rs*
386+ - *Date: 08/07/2026*
387+
388+---
389+
302 ## Résumé Global390 ## Résumé Global
303 391
304 | Phase | Total | Terminées | % Complet | Statut |392 | Phase | Total | Terminées | % Complet | Statut |
@@ -311,7 +399,8 @@
311 | Phase 6 | 12 | 12 | 100% | Terminé |399 | Phase 6 | 12 | 12 | 100% | Terminé |
312 | Phase 7 | 10 | 10 | 100% | Terminé |400 | Phase 7 | 10 | 10 | 100% | Terminé |
313 | Phase 8 | 8 | 8 | 100% | Terminé |401 | Phase 8 | 8 | 8 | 100% | Terminé |
314-| **Total** | **88** | **88** | **100%** | Terminé |402+| Phase 9 | 21 | 21 | 100% | Terminé |
403+| **Total** | **109** | **109** | **100%** | Terminé |
315 404
316 **Projet complété à 100%!**405 **Projet complété à 100%!**
317 406
@@ -395,7 +484,41 @@ Phase 7: Finalisation
395 - Mise à jour des fichiers de suivi484 - Mise à jour des fichiers de suivi
396 ```485 ```
397 486
487+### Commit Phase 9
488+
489+```
490+commit b38040b+
491+Phase 9: Optimisations et Fonctionnalités de Productivité
492+
493+- Implémentation de la recherche textuelle (T9.1-T9.4)
494+ * Création de config/search.rs avec SearchQuery, SearchResult
495+ * Recherche dans flux et épisodes par type (nom, URL, tags, titre, description)
496+ * Intégration de la barre de recherche UI (src/ui/search_bar.rs)
497+- Ajout du tri personnalisable (T9.5-T9.7)
498+ * Types de tri dans app_state.rs (name, date, episodes)
499+ * Logique de tri dans feed_list.rs
500+ * Contrôles UI pour changer le tri
501+- Métadonnées audio (T9.8-T9.10)
502+ * Ajout de mp3-metadata à Cargo.toml
503+ * Création de rss/audio_metadata.rs avec estimation de durée
504+ * Intégration de l'affichage de la durée dans feed_list.rs
505+- Historique des opérations (T9.11-T9.13)
506+ * Création de config/history.rs avec HistoryEntry et HistoryManager
507+ * Logging des opérations (sync, téléchargement, etc.)
508+ * Panneau d'historique UI (src/ui/history_panel.rs)
509+- Backup automatique (T9.18-T9.19)
510+ * Création de config/backup.rs avec BackupManager
511+ * Backup automatique des configurations
512+ * Restauration depuis backup
513+- Statistiques globales (T9.20-T9.21)
514+ * Création de config/statistics.rs avec AppStatistics
515+ * Statistiques par flux et par playlist
516+ * Panneau de statistiques UI (src/ui/stats_panel.rs)
517+- Mise à jour de get_config_dir() dans storage.rs
518+- Mise à jour de tous les mod.rs pour exporter les nouveaux modules
519+```
520+
398 ---521 ---
399 522
400-*Document généré et mis à jour le 05/07/2026*523+*Document généré et mis à jour le 08/07/2026*
401-*Projet RSS Podcast Downloader - 100% COMPLÉTÉ (Toutes les phases terminées)*524+*Projet RSS Podcast Downloader - 100% COMPLÉTÉ (Toutes les phases 1-9 terminées)*
modified src/config/app_state.rs +32 -0
@@ -3,7 +3,10 @@
33 //! This module defines the main application state structure.
44
55 use crate::config::feed::Feed;
6+use crate::config::history::HistoryManager;
67 use crate::config::playlist::Playlist;
8+use crate::config::search::SearchQuery;
9+use crate::config::statistics::AppStatistics;
710 use crate::ui::notifications::NotificationManager;
811 use chrono::{DateTime, Utc};
912 use std::collections::HashMap;
@@ -105,6 +108,28 @@ pub struct AppState {
105108 /// Last export/import error message
106109 #[allow(dead_code)]
107110 pub export_import_error: Option<String>,
111+ // Phase 9: Search functionality
112+ /// Current search query
113+ #[allow(dead_code)]
114+ pub search_query: SearchQuery,
115+ /// Whether search panel is visible
116+ #[allow(dead_code)]
117+ pub show_search: bool,
118+ // Phase 9: Sort functionality
119+ /// Sort field for feeds (none, name, date, episodes)
120+ #[allow(dead_code)]
121+ pub sort_field: String,
122+ /// Sort direction (asc or desc)
123+ #[allow(dead_code)]
124+ pub sort_direction: String,
125+ // Phase 9: Statistics
126+ /// Application statistics
127+ #[allow(dead_code)]
128+ pub statistics: AppStatistics,
129+ // Phase 9: History
130+ /// History manager for tracking operations
131+ #[allow(dead_code)]
132+ pub history_manager: HistoryManager,
108133 }
109134
110135 impl AppState {
@@ -147,6 +172,13 @@ impl AppState {
147172 show_export_dialog: false,
148173 show_import_dialog: false,
149174 export_import_error: None,
175+ // Phase 9 fields
176+ search_query: SearchQuery::default(),
177+ show_search: false,
178+ sort_field: "name".to_string(),
179+ sort_direction: "asc".to_string(),
180+ statistics: AppStatistics::new(),
181+ history_manager: HistoryManager::new(),
150182 }
151183 }
152184
@@ -3,7 +3,10 @@
3 //! This module defines the main application state structure.3 //! This module defines the main application state structure.
4 4
5 use crate::config::feed::Feed;5 use crate::config::feed::Feed;
6+use crate::config::history::HistoryManager;
6 use crate::config::playlist::Playlist;7 use crate::config::playlist::Playlist;
8+use crate::config::search::SearchQuery;
9+use crate::config::statistics::AppStatistics;
7 use crate::ui::notifications::NotificationManager;10 use crate::ui::notifications::NotificationManager;
8 use chrono::{DateTime, Utc};11 use chrono::{DateTime, Utc};
9 use std::collections::HashMap;12 use std::collections::HashMap;
@@ -105,6 +108,28 @@ pub struct AppState {
105 /// Last export/import error message108 /// Last export/import error message
106 #[allow(dead_code)]109 #[allow(dead_code)]
107 pub export_import_error: Option<String>,110 pub export_import_error: Option<String>,
111+ // Phase 9: Search functionality
112+ /// Current search query
113+ #[allow(dead_code)]
114+ pub search_query: SearchQuery,
115+ /// Whether search panel is visible
116+ #[allow(dead_code)]
117+ pub show_search: bool,
118+ // Phase 9: Sort functionality
119+ /// Sort field for feeds (none, name, date, episodes)
120+ #[allow(dead_code)]
121+ pub sort_field: String,
122+ /// Sort direction (asc or desc)
123+ #[allow(dead_code)]
124+ pub sort_direction: String,
125+ // Phase 9: Statistics
126+ /// Application statistics
127+ #[allow(dead_code)]
128+ pub statistics: AppStatistics,
129+ // Phase 9: History
130+ /// History manager for tracking operations
131+ #[allow(dead_code)]
132+ pub history_manager: HistoryManager,
108 }133 }
109 134
110 impl AppState {135 impl AppState {
@@ -147,6 +172,13 @@ impl AppState {
147 show_export_dialog: false,172 show_export_dialog: false,
148 show_import_dialog: false,173 show_import_dialog: false,
149 export_import_error: None,174 export_import_error: None,
175+ // Phase 9 fields
176+ search_query: SearchQuery::default(),
177+ show_search: false,
178+ sort_field: "name".to_string(),
179+ sort_direction: "asc".to_string(),
180+ statistics: AppStatistics::new(),
181+ history_manager: HistoryManager::new(),
150 }182 }
151 }183 }
152 184
added src/config/backup.rs +314 -0
new file mode 100644
@@ -0,0 +1,314 @@
1+use crate::config::{feed::Feed, storage::{self, StorageError}};
2+use chrono::{DateTime, Utc};
3+use serde::{Deserialize, Serialize};
4+use std::fs::{self, File};
5+use std::path::PathBuf;
6+
7+/// Information sur le backup
8+#[derive(Debug, Clone, Serialize, Deserialize)]
9+pub struct BackupInfo {
10+ /// Nom du backup
11+ pub name: String,
12+ /// Chemin du fichier de backup
13+ pub path: PathBuf,
14+ /// Date de création
15+ pub created_at: DateTime<Utc>,
16+ /// Nombre de flux dans le backup
17+ pub feed_count: usize,
18+ /// Taille du fichier de backup
19+ pub file_size: u64,
20+}
21+
22+impl BackupInfo {
23+ pub fn new(name: String, path: PathBuf, feed_count: usize) -> Self {
24+ let file_size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
25+ Self {
26+ name,
27+ path,
28+ created_at: Utc::now(),
29+ feed_count,
30+ file_size,
31+ }
32+ }
33+
34+ /// Obtenir la taille formatée
35+ pub fn get_formatted_size(&self) -> String {
36+ crate::config::statistics::format_bytes(self.file_size)
37+ }
38+}
39+
40+/// Gestionnaire des backups
41+#[derive(Debug, Clone)]
42+pub struct BackupManager {
43+ /// Dossier de stockage des backups
44+ backup_dir: PathBuf,
45+ /// Nombre maximum de backups à conserver
46+ max_backups: usize,
47+ /// Intervalle de backup automatique (en secondes)
48+ auto_backup_interval: u64,
49+ /// Dernière date de backup automatique
50+ last_auto_backup: Option<DateTime<Utc>>,
51+}
52+
53+impl Default for BackupManager {
54+ fn default() -> Self {
55+ Self {
56+ backup_dir: PathBuf::new(),
57+ max_backups: 5,
58+ auto_backup_interval: 3600, // 1 heure
59+ last_auto_backup: None,
60+ }
61+ }
62+}
63+
64+impl BackupManager {
65+ /// Créer un nouveau gestionnaire de backup
66+ pub fn new() -> Self {
67+ Self::default()
68+ }
69+
70+ /// Créer avec un dossier de backup personnalisé
71+ pub fn with_backup_dir(backup_dir: PathBuf) -> Self {
72+ Self {
73+ backup_dir,
74+ max_backups: 5,
75+ auto_backup_interval: 3600,
76+ last_auto_backup: None,
77+ }
78+ }
79+
80+ /// Définir le dossier de backup
81+ pub fn set_backup_dir(&mut self, dir: PathBuf) {
82+ self.backup_dir = dir;
83+ }
84+
85+ /// Définir le nombre maximum de backups
86+ pub fn set_max_backups(&mut self, max: usize) {
87+ self.max_backups = max;
88+ }
89+
90+ /// Définir l'intervalle de backup automatique
91+ pub fn set_auto_backup_interval(&mut self, seconds: u64) {
92+ self.auto_backup_interval = seconds;
93+ }
94+
95+ /// Créer un backup manuel
96+ pub fn create_backup(&self, feeds: &[Feed], name: Option<String>) -> Result<BackupInfo, StorageError> {
97+ // Créer le dossier de backup s'il n'existe pas
98+ if !self.backup_dir.exists() {
99+ fs::create_dir_all(&self.backup_dir)
100+ .map_err(|e| StorageError::WriteError(e.to_string()))?;
101+ }
102+
103+ // Générer un nom de backup
104+ let name = name.unwrap_or_else(|| {
105+ let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string();
106+ format!("backup_{}", timestamp)
107+ });
108+
109+ let filename = format!("{}.json", name);
110+ let backup_path = self.backup_dir.join(filename);
111+
112+ // Sauvegarder les flux
113+ storage::save_feeds(feeds, &backup_path)?;
114+
115+ let backup_info = BackupInfo::new(name, backup_path.clone(), feeds.len());
116+
117+ log::info!("Backup créé: {}", backup_path.display());
118+
119+ // Nettoyer les anciens backups
120+ self.cleanup_old_backups()?;
121+
122+ Ok(backup_info)
123+ }
124+
125+ /// Créer un backup automatique
126+ pub fn create_auto_backup(&mut self, feeds: &[Feed]) -> Result<Option<BackupInfo>, StorageError> {
127+ // Vérifier si un backup automatique est nécessaire
128+ if let Some(last_backup) = self.last_auto_backup {
129+ let now = Utc::now();
130+ let elapsed = now.signed_duration_since(last_backup).num_seconds() as u64;
131+
132+ if elapsed < self.auto_backup_interval {
133+ return Ok(None);
134+ }
135+ }
136+
137+ // Créer le backup
138+ let name = format!("auto_backup_{}", Utc::now().format("%Y%m%d_%H%M%S"));
139+ let result = self.create_backup(feeds, Some(name))?;
140+ self.last_auto_backup = Some(Utc::now());
141+
142+ Ok(Some(result))
143+ }
144+
145+ /// Restaurer depuis un backup
146+ pub fn restore_from_backup(&self, backup_info: &BackupInfo) -> Result<Vec<Feed>, StorageError> {
147+ let feeds = storage::load_feeds(&backup_info.path)?;
148+ log::info!("Backup restauré: {}", backup_info.path.display());
149+ Ok(feeds)
150+ }
151+
152+ /// Lister tous les backups disponibles
153+ pub fn list_backups(&self) -> Result<Vec<BackupInfo>, StorageError> {
154+ let mut backups = Vec::new();
155+
156+ if !self.backup_dir.exists() {
157+ return Ok(backups);
158+ }
159+
160+ for entry in fs::read_dir(&self.backup_dir)
161+ .map_err(|e| StorageError::ReadError(e.to_string()))? {
162+ let entry = entry.map_err(|e| StorageError::ReadError(e.to_string()))?;
163+ let path = entry.path();
164+
165+ if path.extension().map(|e| e == "json").unwrap_or(false) {
166+ // Lire le fichier pour obtenir les informations
167+ if let Ok(file) = File::open(&path) {
168+ match serde_json::from_reader::<_, Vec<Feed>>(file) {
169+ Ok(feeds) => {
170+ let name = path
171+ .file_stem()
172+ .map(|s| s.to_string_lossy().into_owned())
173+ .unwrap_or_default();
174+
175+ let backup_info = BackupInfo::new(name, path.clone(), feeds.len());
176+ backups.push(backup_info);
177+ }
178+ Err(_) => {
179+ // Fichier JSON invalide, ignorer
180+ log::warn!("Fichier backup invalide: {}", path.display());
181+ }
182+ }
183+ }
184+ }
185+ }
186+
187+ // Trier par date de création (plus récent en premier)
188+ backups.sort_by(|a, b| b.created_at.cmp(&a.created_at));
189+
190+ Ok(backups)
191+ }
192+
193+ /// Supprimer un backup
194+ pub fn delete_backup(&self, backup_info: &BackupInfo) -> Result<(), StorageError> {
195+ if backup_info.path.exists() {
196+ fs::remove_file(&backup_info.path)
197+ .map_err(|e| StorageError::WriteError(e.to_string()))?;
198+ log::info!("Backup supprimé: {}", backup_info.path.display());
199+ }
200+ Ok(())
201+ }
202+
203+ /// Nettoyer les anciens backups (garder seulement max_backups)
204+ pub fn cleanup_old_backups(&self) -> Result<(), StorageError> {
205+ let backups = self.list_backups()?;
206+
207+ if backups.len() <= self.max_backups {
208+ return Ok(());
209+ }
210+
211+ // Supprimer les backups les plus anciens
212+ let to_delete: Vec<_> = backups.iter().skip(self.max_backups).collect();
213+ for backup in to_delete {
214+ self.delete_backup(backup)?;
215+ }
216+
217+ Ok(())
218+ }
219+
220+ /// Vérifier si un backup automatique est nécessaire
221+ pub fn is_auto_backup_needed(&self) -> bool {
222+ match self.last_auto_backup {
223+ Some(last) => {
224+ let now = Utc::now();
225+ let elapsed = now.signed_duration_since(last).num_seconds() as u64;
226+ elapsed >= self.auto_backup_interval
227+ }
228+ None => true, // Premier backup
229+ }
230+ }
231+
232+ /// Obtenir le chemin du dossier de backup
233+ pub fn get_backup_dir(&self) -> &PathBuf {
234+ &self.backup_dir
235+ }
236+
237+ /// Obtenir le chemin par défaut du dossier de backup
238+ pub fn get_default_backup_dir() -> PathBuf {
239+ storage::get_config_dir().join("backups")
240+ }
241+}
242+
243+/// Créer un backup immédiatement
244+pub fn create_immediate_backup(feeds: &[Feed], backup_dir: Option<PathBuf>) -> Result<BackupInfo, StorageError> {
245+ let backup_dir = backup_dir.unwrap_or_else(BackupManager::get_default_backup_dir);
246+ let manager = BackupManager::with_backup_dir(backup_dir);
247+ manager.create_backup(feeds, None)
248+}
249+
250+/// Restaurer depuis le dernier backup disponible
251+pub fn restore_from_latest_backup(backup_dir: Option<PathBuf>) -> Result<Option<Vec<Feed>>, StorageError> {
252+ let backup_dir = backup_dir.unwrap_or_else(BackupManager::get_default_backup_dir);
253+ let manager = BackupManager::with_backup_dir(backup_dir);
254+
255+ let backups = manager.list_backups()?;
256+ if backups.is_empty() {
257+ return Ok(None);
258+ }
259+
260+ let latest_backup = &backups[0];
261+ let feeds = manager.restore_from_backup(latest_backup)?;
262+
263+ Ok(Some(feeds))
264+}
265+
266+#[cfg(test)]
267+mod tests {
268+ use super::*;
269+ use tempfile::tempdir;
270+
271+ #[test]
272+ fn test_backup_manager_creation() {
273+ let manager = BackupManager::new();
274+ assert!(manager.backup_dir.exists() == false); // Le dossier par défaut n'existe pas
275+ }
276+
277+ #[test]
278+ fn test_backup_and_restore() {
279+ let temp_dir = tempdir().unwrap();
280+ let backup_dir = temp_dir.path().join("backups");
281+
282+ let manager = BackupManager::with_backup_dir(backup_dir.clone());
283+
284+ let feeds = vec![
285+ Feed::new("Test Feed 1", "https://example.com/feed1", backup_dir.clone()),
286+ Feed::new("Test Feed 2", "https://example.com/feed2", backup_dir.clone()),
287+ ];
288+
289+ // Créer un backup
290+ let backup_info = manager.create_backup(&feeds, Some("test_backup".to_string())).unwrap();
291+ assert!(backup_info.path.exists());
292+
293+ // Restaurer depuis le backup
294+ let restored_feeds = manager.restore_from_backup(&backup_info).unwrap();
295+ assert_eq!(restored_feeds.len(), 2);
296+ }
297+
298+ #[test]
299+ fn test_list_backups() {
300+ let temp_dir = tempdir().unwrap();
301+ let backup_dir = temp_dir.path().join("backups");
302+
303+ let manager = BackupManager::with_backup_dir(backup_dir.clone());
304+
305+ let feeds = vec![Feed::new("Test", "https://example.com/test", backup_dir.clone())];
306+
307+ // Créer plusieurs backups
308+ manager.create_backup(&feeds, Some("backup1".to_string())).unwrap();
309+ manager.create_backup(&feeds, Some("backup2".to_string())).unwrap();
310+
311+ let backups = manager.list_backups().unwrap();
312+ assert_eq!(backups.len(), 2);
313+ }
314+}
new file mode 100644
@@ -0,0 +1,314 @@
1+use crate::config::{feed::Feed, storage::{self, StorageError}};
2+use chrono::{DateTime, Utc};
3+use serde::{Deserialize, Serialize};
4+use std::fs::{self, File};
5+use std::path::PathBuf;
6+
7+/// Information sur le backup
8+#[derive(Debug, Clone, Serialize, Deserialize)]
9+pub struct BackupInfo {
10+ /// Nom du backup
11+ pub name: String,
12+ /// Chemin du fichier de backup
13+ pub path: PathBuf,
14+ /// Date de création
15+ pub created_at: DateTime<Utc>,
16+ /// Nombre de flux dans le backup
17+ pub feed_count: usize,
18+ /// Taille du fichier de backup
19+ pub file_size: u64,
20+}
21+
22+impl BackupInfo {
23+ pub fn new(name: String, path: PathBuf, feed_count: usize) -> Self {
24+ let file_size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
25+ Self {
26+ name,
27+ path,
28+ created_at: Utc::now(),
29+ feed_count,
30+ file_size,
31+ }
32+ }
33+
34+ /// Obtenir la taille formatée
35+ pub fn get_formatted_size(&self) -> String {
36+ crate::config::statistics::format_bytes(self.file_size)
37+ }
38+}
39+
40+/// Gestionnaire des backups
41+#[derive(Debug, Clone)]
42+pub struct BackupManager {
43+ /// Dossier de stockage des backups
44+ backup_dir: PathBuf,
45+ /// Nombre maximum de backups à conserver
46+ max_backups: usize,
47+ /// Intervalle de backup automatique (en secondes)
48+ auto_backup_interval: u64,
49+ /// Dernière date de backup automatique
50+ last_auto_backup: Option<DateTime<Utc>>,
51+}
52+
53+impl Default for BackupManager {
54+ fn default() -> Self {
55+ Self {
56+ backup_dir: PathBuf::new(),
57+ max_backups: 5,
58+ auto_backup_interval: 3600, // 1 heure
59+ last_auto_backup: None,
60+ }
61+ }
62+}
63+
64+impl BackupManager {
65+ /// Créer un nouveau gestionnaire de backup
66+ pub fn new() -> Self {
67+ Self::default()
68+ }
69+
70+ /// Créer avec un dossier de backup personnalisé
71+ pub fn with_backup_dir(backup_dir: PathBuf) -> Self {
72+ Self {
73+ backup_dir,
74+ max_backups: 5,
75+ auto_backup_interval: 3600,
76+ last_auto_backup: None,
77+ }
78+ }
79+
80+ /// Définir le dossier de backup
81+ pub fn set_backup_dir(&mut self, dir: PathBuf) {
82+ self.backup_dir = dir;
83+ }
84+
85+ /// Définir le nombre maximum de backups
86+ pub fn set_max_backups(&mut self, max: usize) {
87+ self.max_backups = max;
88+ }
89+
90+ /// Définir l'intervalle de backup automatique
91+ pub fn set_auto_backup_interval(&mut self, seconds: u64) {
92+ self.auto_backup_interval = seconds;
93+ }
94+
95+ /// Créer un backup manuel
96+ pub fn create_backup(&self, feeds: &[Feed], name: Option<String>) -> Result<BackupInfo, StorageError> {
97+ // Créer le dossier de backup s'il n'existe pas
98+ if !self.backup_dir.exists() {
99+ fs::create_dir_all(&self.backup_dir)
100+ .map_err(|e| StorageError::WriteError(e.to_string()))?;
101+ }
102+
103+ // Générer un nom de backup
104+ let name = name.unwrap_or_else(|| {
105+ let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string();
106+ format!("backup_{}", timestamp)
107+ });
108+
109+ let filename = format!("{}.json", name);
110+ let backup_path = self.backup_dir.join(filename);
111+
112+ // Sauvegarder les flux
113+ storage::save_feeds(feeds, &backup_path)?;
114+
115+ let backup_info = BackupInfo::new(name, backup_path.clone(), feeds.len());
116+
117+ log::info!("Backup créé: {}", backup_path.display());
118+
119+ // Nettoyer les anciens backups
120+ self.cleanup_old_backups()?;
121+
122+ Ok(backup_info)
123+ }
124+
125+ /// Créer un backup automatique
126+ pub fn create_auto_backup(&mut self, feeds: &[Feed]) -> Result<Option<BackupInfo>, StorageError> {
127+ // Vérifier si un backup automatique est nécessaire
128+ if let Some(last_backup) = self.last_auto_backup {
129+ let now = Utc::now();
130+ let elapsed = now.signed_duration_since(last_backup).num_seconds() as u64;
131+
132+ if elapsed < self.auto_backup_interval {
133+ return Ok(None);
134+ }
135+ }
136+
137+ // Créer le backup
138+ let name = format!("auto_backup_{}", Utc::now().format("%Y%m%d_%H%M%S"));
139+ let result = self.create_backup(feeds, Some(name))?;
140+ self.last_auto_backup = Some(Utc::now());
141+
142+ Ok(Some(result))
143+ }
144+
145+ /// Restaurer depuis un backup
146+ pub fn restore_from_backup(&self, backup_info: &BackupInfo) -> Result<Vec<Feed>, StorageError> {
147+ let feeds = storage::load_feeds(&backup_info.path)?;
148+ log::info!("Backup restauré: {}", backup_info.path.display());
149+ Ok(feeds)
150+ }
151+
152+ /// Lister tous les backups disponibles
153+ pub fn list_backups(&self) -> Result<Vec<BackupInfo>, StorageError> {
154+ let mut backups = Vec::new();
155+
156+ if !self.backup_dir.exists() {
157+ return Ok(backups);
158+ }
159+
160+ for entry in fs::read_dir(&self.backup_dir)
161+ .map_err(|e| StorageError::ReadError(e.to_string()))? {
162+ let entry = entry.map_err(|e| StorageError::ReadError(e.to_string()))?;
163+ let path = entry.path();
164+
165+ if path.extension().map(|e| e == "json").unwrap_or(false) {
166+ // Lire le fichier pour obtenir les informations
167+ if let Ok(file) = File::open(&path) {
168+ match serde_json::from_reader::<_, Vec<Feed>>(file) {
169+ Ok(feeds) => {
170+ let name = path
171+ .file_stem()
172+ .map(|s| s.to_string_lossy().into_owned())
173+ .unwrap_or_default();
174+
175+ let backup_info = BackupInfo::new(name, path.clone(), feeds.len());
176+ backups.push(backup_info);
177+ }
178+ Err(_) => {
179+ // Fichier JSON invalide, ignorer
180+ log::warn!("Fichier backup invalide: {}", path.display());
181+ }
182+ }
183+ }
184+ }
185+ }
186+
187+ // Trier par date de création (plus récent en premier)
188+ backups.sort_by(|a, b| b.created_at.cmp(&a.created_at));
189+
190+ Ok(backups)
191+ }
192+
193+ /// Supprimer un backup
194+ pub fn delete_backup(&self, backup_info: &BackupInfo) -> Result<(), StorageError> {
195+ if backup_info.path.exists() {
196+ fs::remove_file(&backup_info.path)
197+ .map_err(|e| StorageError::WriteError(e.to_string()))?;
198+ log::info!("Backup supprimé: {}", backup_info.path.display());
199+ }
200+ Ok(())
201+ }
202+
203+ /// Nettoyer les anciens backups (garder seulement max_backups)
204+ pub fn cleanup_old_backups(&self) -> Result<(), StorageError> {
205+ let backups = self.list_backups()?;
206+
207+ if backups.len() <= self.max_backups {
208+ return Ok(());
209+ }
210+
211+ // Supprimer les backups les plus anciens
212+ let to_delete: Vec<_> = backups.iter().skip(self.max_backups).collect();
213+ for backup in to_delete {
214+ self.delete_backup(backup)?;
215+ }
216+
217+ Ok(())
218+ }
219+
220+ /// Vérifier si un backup automatique est nécessaire
221+ pub fn is_auto_backup_needed(&self) -> bool {
222+ match self.last_auto_backup {
223+ Some(last) => {
224+ let now = Utc::now();
225+ let elapsed = now.signed_duration_since(last).num_seconds() as u64;
226+ elapsed >= self.auto_backup_interval
227+ }
228+ None => true, // Premier backup
229+ }
230+ }
231+
232+ /// Obtenir le chemin du dossier de backup
233+ pub fn get_backup_dir(&self) -> &PathBuf {
234+ &self.backup_dir
235+ }
236+
237+ /// Obtenir le chemin par défaut du dossier de backup
238+ pub fn get_default_backup_dir() -> PathBuf {
239+ storage::get_config_dir().join("backups")
240+ }
241+}
242+
243+/// Créer un backup immédiatement
244+pub fn create_immediate_backup(feeds: &[Feed], backup_dir: Option<PathBuf>) -> Result<BackupInfo, StorageError> {
245+ let backup_dir = backup_dir.unwrap_or_else(BackupManager::get_default_backup_dir);
246+ let manager = BackupManager::with_backup_dir(backup_dir);
247+ manager.create_backup(feeds, None)
248+}
249+
250+/// Restaurer depuis le dernier backup disponible
251+pub fn restore_from_latest_backup(backup_dir: Option<PathBuf>) -> Result<Option<Vec<Feed>>, StorageError> {
252+ let backup_dir = backup_dir.unwrap_or_else(BackupManager::get_default_backup_dir);
253+ let manager = BackupManager::with_backup_dir(backup_dir);
254+
255+ let backups = manager.list_backups()?;
256+ if backups.is_empty() {
257+ return Ok(None);
258+ }
259+
260+ let latest_backup = &backups[0];
261+ let feeds = manager.restore_from_backup(latest_backup)?;
262+
263+ Ok(Some(feeds))
264+}
265+
266+#[cfg(test)]
267+mod tests {
268+ use super::*;
269+ use tempfile::tempdir;
270+
271+ #[test]
272+ fn test_backup_manager_creation() {
273+ let manager = BackupManager::new();
274+ assert!(manager.backup_dir.exists() == false); // Le dossier par défaut n'existe pas
275+ }
276+
277+ #[test]
278+ fn test_backup_and_restore() {
279+ let temp_dir = tempdir().unwrap();
280+ let backup_dir = temp_dir.path().join("backups");
281+
282+ let manager = BackupManager::with_backup_dir(backup_dir.clone());
283+
284+ let feeds = vec![
285+ Feed::new("Test Feed 1", "https://example.com/feed1", backup_dir.clone()),
286+ Feed::new("Test Feed 2", "https://example.com/feed2", backup_dir.clone()),
287+ ];
288+
289+ // Créer un backup
290+ let backup_info = manager.create_backup(&feeds, Some("test_backup".to_string())).unwrap();
291+ assert!(backup_info.path.exists());
292+
293+ // Restaurer depuis le backup
294+ let restored_feeds = manager.restore_from_backup(&backup_info).unwrap();
295+ assert_eq!(restored_feeds.len(), 2);
296+ }
297+
298+ #[test]
299+ fn test_list_backups() {
300+ let temp_dir = tempdir().unwrap();
301+ let backup_dir = temp_dir.path().join("backups");
302+
303+ let manager = BackupManager::with_backup_dir(backup_dir.clone());
304+
305+ let feeds = vec![Feed::new("Test", "https://example.com/test", backup_dir.clone())];
306+
307+ // Créer plusieurs backups
308+ manager.create_backup(&feeds, Some("backup1".to_string())).unwrap();
309+ manager.create_backup(&feeds, Some("backup2".to_string())).unwrap();
310+
311+ let backups = manager.list_backups().unwrap();
312+ assert_eq!(backups.len(), 2);
313+ }
314+}
added src/config/history.rs +379 -0
new file mode 100644
@@ -0,0 +1,379 @@
1+use chrono::{DateTime, Utc};
2+use serde::{Deserialize, Serialize};
3+use std::collections::VecDeque;
4+use std::fs::File;
5+use std::io;
6+use std::path::PathBuf;
7+
8+/// Type d'opération historique
9+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10+pub enum OperationType {
11+ /// Synchronisation d'un flux
12+ SyncFeed,
13+ /// Synchronisation de tous les flux
14+ SyncAll,
15+ /// Téléchargement d'un épisode
16+ DownloadEpisode,
17+ /// Téléchargement de plusieurs épisodes
18+ DownloadMultiple,
19+ /// Ajout d'un flux
20+ AddFeed,
21+ /// Suppression d'un flux
22+ RemoveFeed,
23+ /// Modification d'un flux
24+ UpdateFeed,
25+ /// Import de flux
26+ ImportFeeds,
27+ /// Export de flux
28+ ExportFeeds,
29+ /// Erreur de téléchargement
30+ DownloadError,
31+ /// Erreur de synchronisation
32+ SyncError,
33+}
34+
35+impl std::fmt::Display for OperationType {
36+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37+ match self {
38+ OperationType::SyncFeed => write!(f, "Synchronisation flux"),
39+ OperationType::SyncAll => write!(f, "Sync. tous flux"),
40+ OperationType::DownloadEpisode => write!(f, "Téléchargement"),
41+ OperationType::DownloadMultiple => write!(f, "Téléch. multiples"),
42+ OperationType::AddFeed => write!(f, "Ajout flux"),
43+ OperationType::RemoveFeed => write!(f, "Suppr. flux"),
44+ OperationType::UpdateFeed => write!(f, "Modif. flux"),
45+ OperationType::ImportFeeds => write!(f, "Import flux"),
46+ OperationType::ExportFeeds => write!(f, "Export flux"),
47+ OperationType::DownloadError => write!(f, "Erreur téléch."),
48+ OperationType::SyncError => write!(f, "Erreur sync."),
49+ }
50+ }
51+}
52+
53+/// Statut de l'opération
54+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55+pub enum OperationStatus {
56+ /// Opération en cours
57+ InProgress,
58+ /// Opération réussie
59+ Success,
60+ /// Opération échouée
61+ Failed,
62+}
63+
64+impl std::fmt::Display for OperationStatus {
65+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66+ match self {
67+ OperationStatus::InProgress => write!(f, "En cours"),
68+ OperationStatus::Success => write!(f, "Succès"),
69+ OperationStatus::Failed => write!(f, "Échec"),
70+ }
71+ }
72+}
73+
74+/// Entrée d'historique
75+#[derive(Debug, Clone, Serialize, Deserialize)]
76+pub struct HistoryEntry {
77+ /// ID unique de l'entrée
78+ pub id: String,
79+ /// Type d'opération
80+ pub operation_type: OperationType,
81+ /// Statut de l'opération
82+ pub status: OperationStatus,
83+ /// Timestamp de l'opération
84+ pub timestamp: DateTime<Utc>,
85+ /// Description détaillée
86+ pub description: String,
87+ /// ID du flux concerné (si applicable)
88+ #[serde(skip_serializing_if = "Option::is_none")]
89+ pub feed_id: Option<String>,
90+ /// ID de l'épisode concerné (si applicable)
91+ #[serde(skip_serializing_if = "Option::is_none")]
92+ pub episode_id: Option<String>,
93+ /// Taille du téléchargement (si applicable)
94+ #[serde(skip_serializing_if = "Option::is_none")]
95+ pub size: Option<u64>,
96+ /// Message d'erreur (si applicable)
97+ #[serde(skip_serializing_if = "Option::is_none")]
98+ pub error_message: Option<String>,
99+ /// Durée de l'opération (en secondes)
100+ #[serde(skip_serializing_if = "Option::is_none")]
101+ pub duration_seconds: Option<f64>,
102+}
103+
104+impl HistoryEntry {
105+ /// Créer une nouvelle entrée d'historique
106+ pub fn new(operation_type: OperationType, description: String) -> Self {
107+ Self {
108+ id: uuid::Uuid::new_v4().to_string(),
109+ operation_type,
110+ status: OperationStatus::InProgress,
111+ timestamp: Utc::now(),
112+ description,
113+ feed_id: None,
114+ episode_id: None,
115+ size: None,
116+ error_message: None,
117+ duration_seconds: None,
118+ }
119+ }
120+
121+ /// Marquer l'opération comme réussie
122+ pub fn mark_success(&mut self) {
123+ self.status = OperationStatus::Success;
124+ }
125+
126+ /// Marquer l'opération comme échouée
127+ pub fn mark_failed(&mut self, error_message: String) {
128+ self.status = OperationStatus::Failed;
129+ self.error_message = Some(error_message);
130+ }
131+
132+ /// Définir la durée
133+ pub fn set_duration(&mut self, seconds: f64) {
134+ self.duration_seconds = Some(seconds);
135+ }
136+
137+ /// Obtenir la durée formatée
138+ pub fn get_formatted_duration(&self) -> String {
139+ if let Some(duration) = self.duration_seconds {
140+ if duration < 1.0 {
141+ format!("{}ms", (duration * 1000.0) as u64)
142+ } else if duration < 60.0 {
143+ format!("{:.1}s", duration)
144+ } else if duration < 3600.0 {
145+ let minutes = duration / 60.0;
146+ let seconds = duration % 60.0;
147+ format!("{}m {:.0}s", minutes as u64, seconds as u64)
148+ } else {
149+ let hours = duration / 3600.0;
150+ let minutes = (duration % 3600.0) / 60.0;
151+ let seconds = duration % 60.0;
152+ format!("{}h {}m {:.0}s", hours as u64, minutes as u64, seconds as u64)
153+ }
154+ } else {
155+ String::new()
156+ }
157+ }
158+
159+ /// Obtenir la taille formatée
160+ pub fn get_formatted_size(&self) -> String {
161+ if let Some(size) = self.size {
162+ crate::config::statistics::format_bytes(size)
163+ } else {
164+ String::new()
165+ }
166+ }
167+}
168+
169+/// Gestionnaire de l'historique
170+#[derive(Debug, Clone, Default)]
171+pub struct HistoryManager {
172+ /// Liste des entrées d'historique (ordre chronologique, plus récent à la fin)
173+ pub entries: VecDeque<HistoryEntry>,
174+ /// Nombre maximum d'entrées à conserver
175+ max_entries: usize,
176+ /// Chemin du fichier de sauvegarde
177+ save_path: Option<PathBuf>,
178+}
179+
180+impl HistoryManager {
181+ /// Créer un nouveau gestionnaire d'historique
182+ pub fn new() -> Self {
183+ Self {
184+ entries: VecDeque::with_capacity(100),
185+ max_entries: 100,
186+ save_path: None,
187+ }
188+ }
189+
190+ /// Créer avec une capacité maximale
191+ pub fn with_capacity(max_entries: usize) -> Self {
192+ Self {
193+ entries: VecDeque::with_capacity(max_entries),
194+ max_entries,
195+ save_path: None,
196+ }
197+ }
198+
199+ /// Définir le chemin de sauvegarde
200+ pub fn set_save_path(&mut self, path: PathBuf) {
201+ self.save_path = Some(path);
202+ }
203+
204+ /// Ajouter une entrée à l'historique
205+ pub fn add_entry(&mut self, entry: HistoryEntry) {
206+ // Limiter le nombre d'entrées
207+ while self.entries.len() >= self.max_entries {
208+ self.entries.pop_front();
209+ }
210+
211+ self.entries.push_back(entry);
212+
213+ // Sauvegarder automatiquement si un chemin est défini
214+ if let Some(path) = &self.save_path {
215+ if let Err(e) = self.save_to_file(path) {
216+ log::error!("Erreur lors de la sauvegarde de l'historique: {}", e);
217+ }
218+ }
219+ }
220+
221+ /// Ajouter une opération simple
222+ pub fn add_operation(
223+ &mut self,
224+ operation_type: OperationType,
225+ description: String,
226+ ) -> String {
227+ let entry = HistoryEntry::new(operation_type, description);
228+ let id = entry.id.clone();
229+ self.add_entry(entry);
230+ id
231+ }
232+
233+ /// Mettre à jour le statut d'une opération
234+ pub fn update_operation_status(
235+ &mut self,
236+ id: &str,
237+ status: OperationStatus,
238+ error_message: Option<String>,
239+ ) -> bool {
240+ if let Some(entry) = self.entries.iter_mut().find(|e| e.id == id) {
241+ entry.status = status;
242+ if let Some(err) = error_message {
243+ entry.error_message = Some(err);
244+ }
245+ true
246+ } else {
247+ false
248+ }
249+ }
250+
251+ /// Obtenir les entrées récentes
252+ pub fn get_recent_entries(&self, count: usize) -> Vec<&HistoryEntry> {
253+ let start = if self.entries.len() > count {
254+ self.entries.len() - count
255+ } else {
256+ 0
257+ };
258+ self.entries.range(start..).collect()
259+ }
260+
261+ /// Obtenir toutes les entrées
262+ pub fn get_all_entries(&self) -> Vec<&HistoryEntry> {
263+ self.entries.iter().collect()
264+ }
265+
266+ /// Effacer l'historique
267+ pub fn clear(&mut self) {
268+ self.entries.clear();
269+ }
270+
271+ /// Effacer les entrées anciennes (plus vieilles que la durée spécifiée)
272+ pub fn clear_old_entries(&mut self, max_age_seconds: i64) {
273+ let now = Utc::now();
274+ let cutoff = now - chrono::Duration::seconds(max_age_seconds);
275+
276+ while let Some(entry) = self.entries.front() {
277+ if entry.timestamp < cutoff {
278+ self.entries.pop_front();
279+ } else {
280+ break;
281+ }
282+ }
283+ }
284+
285+ /// Filtrer les entrées par type d'opération
286+ pub fn filter_by_type(&self, operation_type: OperationType) -> Vec<&HistoryEntry> {
287+ self.entries
288+ .iter()
289+ .filter(|e| e.operation_type == operation_type)
290+ .collect()
291+ }
292+
293+ /// Filtrer les entrées par statut
294+ pub fn filter_by_status(&self, status: OperationStatus) -> Vec<&HistoryEntry> {
295+ self.entries
296+ .iter()
297+ .filter(|e| e.status == status)
298+ .collect()
299+ }
300+
301+ /// Filtrer les entrées par ID de flux
302+ pub fn filter_by_feed(&self, feed_id: &str) -> Vec<&HistoryEntry> {
303+ self.entries
304+ .iter()
305+ .filter(|e| {
306+ e.feed_id
307+ .as_ref()
308+ .map(|id| id == feed_id)
309+ .unwrap_or(false)
310+ })
311+ .collect()
312+ }
313+
314+ /// Sauvegarder l'historique dans un fichier
315+ pub fn save_to_file(&self, path: &PathBuf) -> io::Result<()> {
316+ let file = File::create(path)?;
317+ let entries: Vec<_> = self.entries.iter().collect();
318+ serde_json::to_writer_pretty(file, &entries)?;
319+ Ok(())
320+ }
321+
322+ /// Charger l'historique depuis un fichier
323+ pub fn load_from_file(&mut self, path: &PathBuf) -> io::Result<()> {
324+ let file = File::open(path)?;
325+ let entries: Vec<HistoryEntry> = serde_json::from_reader(file)?;
326+ self.entries = VecDeque::from(entries);
327+
328+ // Limiter le nombre d'entrées
329+ while self.entries.len() > self.max_entries {
330+ self.entries.pop_front();
331+ }
332+
333+ Ok(())
334+ }
335+
336+ /// Obtenir le nombre total d'entrées
337+ pub fn len(&self) -> usize {
338+ self.entries.len()
339+ }
340+
341+ /// Vérifier si l'historique est vide
342+ pub fn is_empty(&self) -> bool {
343+ self.entries.is_empty()
344+ }
345+}
346+
347+#[cfg(test)]
348+mod tests {
349+ use super::*;
350+
351+ #[test]
352+ fn test_history_entry_creation() {
353+ let entry = HistoryEntry::new(OperationType::SyncFeed, "Test sync".to_string());
354+ assert!(!entry.id.is_empty());
355+ assert_eq!(entry.operation_type, OperationType::SyncFeed);
356+ assert_eq!(entry.status, OperationStatus::InProgress);
357+ }
358+
359+ #[test]
360+ fn test_history_manager_add_entry() {
361+ let mut manager = HistoryManager::new();
362+ let entry = HistoryEntry::new(OperationType::AddFeed, "Test feed".to_string());
363+ manager.add_entry(entry);
364+ assert_eq!(manager.len(), 1);
365+ }
366+
367+ #[test]
368+ fn test_history_manager_max_entries() {
369+ let mut manager = HistoryManager::with_capacity(3);
370+ for i in 0..5 {
371+ let entry = HistoryEntry::new(
372+ OperationType::SyncFeed,
373+ format!("Sync {}", i),
374+ );
375+ manager.add_entry(entry);
376+ }
377+ assert_eq!(manager.len(), 3);
378+ }
379+}
new file mode 100644
@@ -0,0 +1,379 @@
1+use chrono::{DateTime, Utc};
2+use serde::{Deserialize, Serialize};
3+use std::collections::VecDeque;
4+use std::fs::File;
5+use std::io;
6+use std::path::PathBuf;
7+
8+/// Type d'opération historique
9+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10+pub enum OperationType {
11+ /// Synchronisation d'un flux
12+ SyncFeed,
13+ /// Synchronisation de tous les flux
14+ SyncAll,
15+ /// Téléchargement d'un épisode
16+ DownloadEpisode,
17+ /// Téléchargement de plusieurs épisodes
18+ DownloadMultiple,
19+ /// Ajout d'un flux
20+ AddFeed,
21+ /// Suppression d'un flux
22+ RemoveFeed,
23+ /// Modification d'un flux
24+ UpdateFeed,
25+ /// Import de flux
26+ ImportFeeds,
27+ /// Export de flux
28+ ExportFeeds,
29+ /// Erreur de téléchargement
30+ DownloadError,
31+ /// Erreur de synchronisation
32+ SyncError,
33+}
34+
35+impl std::fmt::Display for OperationType {
36+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37+ match self {
38+ OperationType::SyncFeed => write!(f, "Synchronisation flux"),
39+ OperationType::SyncAll => write!(f, "Sync. tous flux"),
40+ OperationType::DownloadEpisode => write!(f, "Téléchargement"),
41+ OperationType::DownloadMultiple => write!(f, "Téléch. multiples"),
42+ OperationType::AddFeed => write!(f, "Ajout flux"),
43+ OperationType::RemoveFeed => write!(f, "Suppr. flux"),
44+ OperationType::UpdateFeed => write!(f, "Modif. flux"),
45+ OperationType::ImportFeeds => write!(f, "Import flux"),
46+ OperationType::ExportFeeds => write!(f, "Export flux"),
47+ OperationType::DownloadError => write!(f, "Erreur téléch."),
48+ OperationType::SyncError => write!(f, "Erreur sync."),
49+ }
50+ }
51+}
52+
53+/// Statut de l'opération
54+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55+pub enum OperationStatus {
56+ /// Opération en cours
57+ InProgress,
58+ /// Opération réussie
59+ Success,
60+ /// Opération échouée
61+ Failed,
62+}
63+
64+impl std::fmt::Display for OperationStatus {
65+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66+ match self {
67+ OperationStatus::InProgress => write!(f, "En cours"),
68+ OperationStatus::Success => write!(f, "Succès"),
69+ OperationStatus::Failed => write!(f, "Échec"),
70+ }
71+ }
72+}
73+
74+/// Entrée d'historique
75+#[derive(Debug, Clone, Serialize, Deserialize)]
76+pub struct HistoryEntry {
77+ /// ID unique de l'entrée
78+ pub id: String,
79+ /// Type d'opération
80+ pub operation_type: OperationType,
81+ /// Statut de l'opération
82+ pub status: OperationStatus,
83+ /// Timestamp de l'opération
84+ pub timestamp: DateTime<Utc>,
85+ /// Description détaillée
86+ pub description: String,
87+ /// ID du flux concerné (si applicable)
88+ #[serde(skip_serializing_if = "Option::is_none")]
89+ pub feed_id: Option<String>,
90+ /// ID de l'épisode concerné (si applicable)
91+ #[serde(skip_serializing_if = "Option::is_none")]
92+ pub episode_id: Option<String>,
93+ /// Taille du téléchargement (si applicable)
94+ #[serde(skip_serializing_if = "Option::is_none")]
95+ pub size: Option<u64>,
96+ /// Message d'erreur (si applicable)
97+ #[serde(skip_serializing_if = "Option::is_none")]
98+ pub error_message: Option<String>,
99+ /// Durée de l'opération (en secondes)
100+ #[serde(skip_serializing_if = "Option::is_none")]
101+ pub duration_seconds: Option<f64>,
102+}
103+
104+impl HistoryEntry {
105+ /// Créer une nouvelle entrée d'historique
106+ pub fn new(operation_type: OperationType, description: String) -> Self {
107+ Self {
108+ id: uuid::Uuid::new_v4().to_string(),
109+ operation_type,
110+ status: OperationStatus::InProgress,
111+ timestamp: Utc::now(),
112+ description,
113+ feed_id: None,
114+ episode_id: None,
115+ size: None,
116+ error_message: None,
117+ duration_seconds: None,
118+ }
119+ }
120+
121+ /// Marquer l'opération comme réussie
122+ pub fn mark_success(&mut self) {
123+ self.status = OperationStatus::Success;
124+ }
125+
126+ /// Marquer l'opération comme échouée
127+ pub fn mark_failed(&mut self, error_message: String) {
128+ self.status = OperationStatus::Failed;
129+ self.error_message = Some(error_message);
130+ }
131+
132+ /// Définir la durée
133+ pub fn set_duration(&mut self, seconds: f64) {
134+ self.duration_seconds = Some(seconds);
135+ }
136+
137+ /// Obtenir la durée formatée
138+ pub fn get_formatted_duration(&self) -> String {
139+ if let Some(duration) = self.duration_seconds {
140+ if duration < 1.0 {
141+ format!("{}ms", (duration * 1000.0) as u64)
142+ } else if duration < 60.0 {
143+ format!("{:.1}s", duration)
144+ } else if duration < 3600.0 {
145+ let minutes = duration / 60.0;
146+ let seconds = duration % 60.0;
147+ format!("{}m {:.0}s", minutes as u64, seconds as u64)
148+ } else {
149+ let hours = duration / 3600.0;
150+ let minutes = (duration % 3600.0) / 60.0;
151+ let seconds = duration % 60.0;
152+ format!("{}h {}m {:.0}s", hours as u64, minutes as u64, seconds as u64)
153+ }
154+ } else {
155+ String::new()
156+ }
157+ }
158+
159+ /// Obtenir la taille formatée
160+ pub fn get_formatted_size(&self) -> String {
161+ if let Some(size) = self.size {
162+ crate::config::statistics::format_bytes(size)
163+ } else {
164+ String::new()
165+ }
166+ }
167+}
168+
169+/// Gestionnaire de l'historique
170+#[derive(Debug, Clone, Default)]
171+pub struct HistoryManager {
172+ /// Liste des entrées d'historique (ordre chronologique, plus récent à la fin)
173+ pub entries: VecDeque<HistoryEntry>,
174+ /// Nombre maximum d'entrées à conserver
175+ max_entries: usize,
176+ /// Chemin du fichier de sauvegarde
177+ save_path: Option<PathBuf>,
178+}
179+
180+impl HistoryManager {
181+ /// Créer un nouveau gestionnaire d'historique
182+ pub fn new() -> Self {
183+ Self {
184+ entries: VecDeque::with_capacity(100),
185+ max_entries: 100,
186+ save_path: None,
187+ }
188+ }
189+
190+ /// Créer avec une capacité maximale
191+ pub fn with_capacity(max_entries: usize) -> Self {
192+ Self {
193+ entries: VecDeque::with_capacity(max_entries),
194+ max_entries,
195+ save_path: None,
196+ }
197+ }
198+
199+ /// Définir le chemin de sauvegarde
200+ pub fn set_save_path(&mut self, path: PathBuf) {
201+ self.save_path = Some(path);
202+ }
203+
204+ /// Ajouter une entrée à l'historique
205+ pub fn add_entry(&mut self, entry: HistoryEntry) {
206+ // Limiter le nombre d'entrées
207+ while self.entries.len() >= self.max_entries {
208+ self.entries.pop_front();
209+ }
210+
211+ self.entries.push_back(entry);
212+
213+ // Sauvegarder automatiquement si un chemin est défini
214+ if let Some(path) = &self.save_path {
215+ if let Err(e) = self.save_to_file(path) {
216+ log::error!("Erreur lors de la sauvegarde de l'historique: {}", e);
217+ }
218+ }
219+ }
220+
221+ /// Ajouter une opération simple
222+ pub fn add_operation(
223+ &mut self,
224+ operation_type: OperationType,
225+ description: String,
226+ ) -> String {
227+ let entry = HistoryEntry::new(operation_type, description);
228+ let id = entry.id.clone();
229+ self.add_entry(entry);
230+ id
231+ }
232+
233+ /// Mettre à jour le statut d'une opération
234+ pub fn update_operation_status(
235+ &mut self,
236+ id: &str,
237+ status: OperationStatus,
238+ error_message: Option<String>,
239+ ) -> bool {
240+ if let Some(entry) = self.entries.iter_mut().find(|e| e.id == id) {
241+ entry.status = status;
242+ if let Some(err) = error_message {
243+ entry.error_message = Some(err);
244+ }
245+ true
246+ } else {
247+ false
248+ }
249+ }
250+
251+ /// Obtenir les entrées récentes
252+ pub fn get_recent_entries(&self, count: usize) -> Vec<&HistoryEntry> {
253+ let start = if self.entries.len() > count {
254+ self.entries.len() - count
255+ } else {
256+ 0
257+ };
258+ self.entries.range(start..).collect()
259+ }
260+
261+ /// Obtenir toutes les entrées
262+ pub fn get_all_entries(&self) -> Vec<&HistoryEntry> {
263+ self.entries.iter().collect()
264+ }
265+
266+ /// Effacer l'historique
267+ pub fn clear(&mut self) {
268+ self.entries.clear();
269+ }
270+
271+ /// Effacer les entrées anciennes (plus vieilles que la durée spécifiée)
272+ pub fn clear_old_entries(&mut self, max_age_seconds: i64) {
273+ let now = Utc::now();
274+ let cutoff = now - chrono::Duration::seconds(max_age_seconds);
275+
276+ while let Some(entry) = self.entries.front() {
277+ if entry.timestamp < cutoff {
278+ self.entries.pop_front();
279+ } else {
280+ break;
281+ }
282+ }
283+ }
284+
285+ /// Filtrer les entrées par type d'opération
286+ pub fn filter_by_type(&self, operation_type: OperationType) -> Vec<&HistoryEntry> {
287+ self.entries
288+ .iter()
289+ .filter(|e| e.operation_type == operation_type)
290+ .collect()
291+ }
292+
293+ /// Filtrer les entrées par statut
294+ pub fn filter_by_status(&self, status: OperationStatus) -> Vec<&HistoryEntry> {
295+ self.entries
296+ .iter()
297+ .filter(|e| e.status == status)
298+ .collect()
299+ }
300+
301+ /// Filtrer les entrées par ID de flux
302+ pub fn filter_by_feed(&self, feed_id: &str) -> Vec<&HistoryEntry> {
303+ self.entries
304+ .iter()
305+ .filter(|e| {
306+ e.feed_id
307+ .as_ref()
308+ .map(|id| id == feed_id)
309+ .unwrap_or(false)
310+ })
311+ .collect()
312+ }
313+
314+ /// Sauvegarder l'historique dans un fichier
315+ pub fn save_to_file(&self, path: &PathBuf) -> io::Result<()> {
316+ let file = File::create(path)?;
317+ let entries: Vec<_> = self.entries.iter().collect();
318+ serde_json::to_writer_pretty(file, &entries)?;
319+ Ok(())
320+ }
321+
322+ /// Charger l'historique depuis un fichier
323+ pub fn load_from_file(&mut self, path: &PathBuf) -> io::Result<()> {
324+ let file = File::open(path)?;
325+ let entries: Vec<HistoryEntry> = serde_json::from_reader(file)?;
326+ self.entries = VecDeque::from(entries);
327+
328+ // Limiter le nombre d'entrées
329+ while self.entries.len() > self.max_entries {
330+ self.entries.pop_front();
331+ }
332+
333+ Ok(())
334+ }
335+
336+ /// Obtenir le nombre total d'entrées
337+ pub fn len(&self) -> usize {
338+ self.entries.len()
339+ }
340+
341+ /// Vérifier si l'historique est vide
342+ pub fn is_empty(&self) -> bool {
343+ self.entries.is_empty()
344+ }
345+}
346+
347+#[cfg(test)]
348+mod tests {
349+ use super::*;
350+
351+ #[test]
352+ fn test_history_entry_creation() {
353+ let entry = HistoryEntry::new(OperationType::SyncFeed, "Test sync".to_string());
354+ assert!(!entry.id.is_empty());
355+ assert_eq!(entry.operation_type, OperationType::SyncFeed);
356+ assert_eq!(entry.status, OperationStatus::InProgress);
357+ }
358+
359+ #[test]
360+ fn test_history_manager_add_entry() {
361+ let mut manager = HistoryManager::new();
362+ let entry = HistoryEntry::new(OperationType::AddFeed, "Test feed".to_string());
363+ manager.add_entry(entry);
364+ assert_eq!(manager.len(), 1);
365+ }
366+
367+ #[test]
368+ fn test_history_manager_max_entries() {
369+ let mut manager = HistoryManager::with_capacity(3);
370+ for i in 0..5 {
371+ let entry = HistoryEntry::new(
372+ OperationType::SyncFeed,
373+ format!("Sync {}", i),
374+ );
375+ manager.add_entry(entry);
376+ }
377+ assert_eq!(manager.len(), 3);
378+ }
379+}
modified src/config/mod.rs +19 -0
@@ -3,9 +3,13 @@
33 //! This module contains the data models and storage logic for the application.
44
55 pub mod app_state;
6+pub mod backup;
67 pub mod episode;
78 pub mod feed;
9+pub mod history;
810 pub mod playlist;
11+pub mod search;
12+pub mod statistics;
913 pub mod storage;
1014
1115 // Re-export main types for convenience
@@ -16,10 +20,25 @@ pub mod storage;
1620 pub use app_state::AppState;
1721 #[allow(dead_code)]
1822 #[allow(unused_imports)]
23+pub use backup::{BackupInfo, BackupManager};
24+#[allow(dead_code)]
25+#[allow(unused_imports)]
1926 pub use episode::Episode;
2027 #[allow(dead_code)]
2128 #[allow(unused_imports)]
2229 pub use feed::Feed;
2330 #[allow(dead_code)]
2431 #[allow(unused_imports)]
32+pub use history::{HistoryEntry, HistoryManager, OperationStatus, OperationType};
33+#[allow(dead_code)]
34+#[allow(unused_imports)]
35+pub use playlist::Playlist;
36+#[allow(dead_code)]
37+#[allow(unused_imports)]
38+pub use search::{SearchQuery, SearchResult, SearchType};
39+#[allow(dead_code)]
40+#[allow(unused_imports)]
41+pub use statistics::{AppStatistics, FeedStatistics, PlaylistStatistics};
42+#[allow(dead_code)]
43+#[allow(unused_imports)]
2544 pub use storage::{load_feeds, save_feeds, StorageError};
@@ -3,9 +3,13 @@
3 //! This module contains the data models and storage logic for the application.3 //! This module contains the data models and storage logic for the application.
4 4
5 pub mod app_state;5 pub mod app_state;
6+pub mod backup;
6 pub mod episode;7 pub mod episode;
7 pub mod feed;8 pub mod feed;
9+pub mod history;
8 pub mod playlist;10 pub mod playlist;
11+pub mod search;
12+pub mod statistics;
9 pub mod storage;13 pub mod storage;
10 14
11 // Re-export main types for convenience15 // Re-export main types for convenience
@@ -16,10 +20,25 @@ pub mod storage;
16 pub use app_state::AppState;20 pub use app_state::AppState;
17 #[allow(dead_code)]21 #[allow(dead_code)]
18 #[allow(unused_imports)]22 #[allow(unused_imports)]
23+pub use backup::{BackupInfo, BackupManager};
24+#[allow(dead_code)]
25+#[allow(unused_imports)]
19 pub use episode::Episode;26 pub use episode::Episode;
20 #[allow(dead_code)]27 #[allow(dead_code)]
21 #[allow(unused_imports)]28 #[allow(unused_imports)]
22 pub use feed::Feed;29 pub use feed::Feed;
23 #[allow(dead_code)]30 #[allow(dead_code)]
24 #[allow(unused_imports)]31 #[allow(unused_imports)]
32+pub use history::{HistoryEntry, HistoryManager, OperationStatus, OperationType};
33+#[allow(dead_code)]
34+#[allow(unused_imports)]
35+pub use playlist::Playlist;
36+#[allow(dead_code)]
37+#[allow(unused_imports)]
38+pub use search::{SearchQuery, SearchResult, SearchType};
39+#[allow(dead_code)]
40+#[allow(unused_imports)]
41+pub use statistics::{AppStatistics, FeedStatistics, PlaylistStatistics};
42+#[allow(dead_code)]
43+#[allow(unused_imports)]
25 pub use storage::{load_feeds, save_feeds, StorageError};44 pub use storage::{load_feeds, save_feeds, StorageError};
added src/config/search.rs +323 -0
new file mode 100644
@@ -0,0 +1,323 @@
1+use crate::config::{episode::Episode, feed::Feed};
2+
3+/// Type de recherche possible
4+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5+pub enum SearchType {
6+ /// Recherche dans les noms de flux
7+ FeedName,
8+ /// Recherche dans les URLs de flux
9+ FeedUrl,
10+ /// Recherche dans les tags de flux
11+ FeedTags,
12+ /// Recherche dans les titres d'épisodes
13+ EpisodeTitle,
14+ /// Recherche dans les descriptions d'épisodes
15+ EpisodeDescription,
16+ /// Recherche dans tous les champs
17+ All,
18+}
19+
20+impl Default for SearchType {
21+ fn default() -> Self {
22+ SearchType::All
23+ }
24+}
25+
26+/// Requête de recherche
27+#[derive(Debug, Clone, Default)]
28+pub struct SearchQuery {
29+ /// Texte à rechercher
30+ pub query: String,
31+ /// Type de recherche
32+ pub search_type: SearchType,
33+ /// Sensible à la casse
34+ pub case_sensitive: bool,
35+}
36+
37+impl SearchQuery {
38+ /// Créer une nouvelle requête de recherche
39+ pub fn new(query: String) -> Self {
40+ Self {
41+ query,
42+ search_type: SearchType::default(),
43+ case_sensitive: false,
44+ }
45+ }
46+
47+ /// Vérifier si la requête est vide
48+ pub fn is_empty(&self) -> bool {
49+ self.query.trim().is_empty()
50+ }
51+
52+ /// Normaliser la requête selon la sensibilité à la casse
53+ fn normalize(&self, text: &str) -> String {
54+ if self.case_sensitive {
55+ text.to_string()
56+ } else {
57+ text.to_lowercase()
58+ }
59+ }
60+
61+ /// Vérifier si le texte contient la requête
62+ fn contains_query(&self, text: &str) -> bool {
63+ if self.is_empty() {
64+ return true;
65+ }
66+ let normalized_text = self.normalize(text);
67+ let normalized_query = self.normalize(&self.query);
68+ normalized_text.contains(&normalized_query)
69+ }
70+}
71+
72+/// Résultat de recherche pour un flux
73+#[derive(Debug, Clone)]
74+pub struct FeedSearchResult {
75+ /// Index du flux dans la liste
76+ pub feed_index: usize,
77+ /// Flux correspondant
78+ pub feed: Feed,
79+ /// Épisodes correspondants (si recherche dans les épisodes)
80+ pub matching_episodes: Vec<usize>,
81+ /// Score de pertinence (plus élevé = meilleure correspondance)
82+ pub score: u32,
83+}
84+
85+/// Résultat de recherche pour un épisode
86+#[derive(Debug, Clone)]
87+pub struct EpisodeSearchResult {
88+ /// Index du flux parent
89+ pub feed_index: usize,
90+ /// Index de l'épisode dans la liste des épisodes du flux
91+ pub episode_index: usize,
92+ /// Épisode correspondant
93+ pub episode: Episode,
94+ /// Score de pertinence
95+ pub score: u32,
96+}
97+
98+/// Résultat global de recherche
99+#[derive(Debug, Clone, Default)]
100+pub struct SearchResult {
101+ /// Résultats de flux
102+ pub feed_results: Vec<FeedSearchResult>,
103+ /// Résultats d'épisodes
104+ pub episode_results: Vec<EpisodeSearchResult>,
105+ /// Nombre total de correspondances
106+ pub total_matches: usize,
107+}
108+
109+impl SearchResult {
110+ pub fn new() -> Self {
111+ Self::default()
112+ }
113+
114+ /// Vérifier s'il y a des résultats
115+ pub fn is_empty(&self) -> bool {
116+ self.total_matches == 0
117+ }
118+}
119+
120+/// Effectuer une recherche dans une liste de flux
121+pub fn search_feeds(
122+ feeds: &[Feed],
123+ episodes_list: &[Vec<Episode>],
124+ query: &SearchQuery,
125+) -> SearchResult {
126+ let mut result = SearchResult::new();
127+
128+ if query.is_empty() {
129+ return result;
130+ }
131+
132+ for (feed_index, feed) in feeds.iter().enumerate() {
133+ let episodes = if feed_index < episodes_list.len() {
134+ &episodes_list[feed_index]
135+ } else {
136+ &vec![]
137+ };
138+
139+ let mut feed_score = 0u32;
140+ let mut matching_episodes = Vec::new();
141+
142+ // Recherche dans les propriétés du flux
143+ match query.search_type {
144+ SearchType::FeedName => {
145+ if query.contains_query(&feed.name) {
146+ feed_score += 100;
147+ }
148+ }
149+ SearchType::FeedUrl => {
150+ if query.contains_query(&feed.url) {
151+ feed_score += 100;
152+ }
153+ }
154+ SearchType::FeedTags => {
155+ for tag in &feed.tags {
156+ if query.contains_query(tag) {
157+ feed_score += 50;
158+ }
159+ }
160+ }
161+ SearchType::EpisodeTitle => {
162+ for (ep_index, ep) in episodes.iter().enumerate() {
163+ if query.contains_query(&ep.title) {
164+ matching_episodes.push(ep_index);
165+ feed_score += 30;
166+ }
167+ }
168+ }
169+ SearchType::EpisodeDescription => {
170+ for (ep_index, ep) in episodes.iter().enumerate() {
171+ if let Some(desc) = &ep.description {
172+ if query.contains_query(desc) {
173+ matching_episodes.push(ep_index);
174+ feed_score += 20;
175+ }
176+ }
177+ }
178+ }
179+ SearchType::All => {
180+ // Recherche dans tous les champs du flux
181+ if query.contains_query(&feed.name) {
182+ feed_score += 100;
183+ }
184+ if query.contains_query(&feed.url) {
185+ feed_score += 50;
186+ }
187+ for tag in &feed.tags {
188+ if query.contains_query(tag) {
189+ feed_score += 30;
190+ }
191+ }
192+
193+ // Recherche dans les épisodes
194+ for (ep_index, ep) in episodes.iter().enumerate() {
195+ let mut ep_score = 0u32;
196+ if query.contains_query(&ep.title) {
197+ ep_score += 50;
198+ }
199+ if let Some(desc) = &ep.description {
200+ if query.contains_query(desc) {
201+ ep_score += 30;
202+ }
203+ }
204+ if ep_score > 0 {
205+ matching_episodes.push(ep_index);
206+ feed_score += ep_score;
207+ }
208+ }
209+ }
210+ }
211+
212+ // Ajouter le flux aux résultats s'il a un score > 0
213+ if feed_score > 0 || !matching_episodes.is_empty() {
214+ // Ajouter les épisodes correspondants en premier
215+ if !matching_episodes.is_empty() && feed_index < episodes_list.len() {
216+ for &ep_index in &matching_episodes {
217+ if ep_index < episodes_list[feed_index].len() {
218+ result.episode_results.push(EpisodeSearchResult {
219+ feed_index,
220+ episode_index: ep_index,
221+ episode: episodes_list[feed_index][ep_index].clone(),
222+ score: feed_score / 2, // Score réduit pour les épisodes
223+ });
224+ result.total_matches += 1;
225+ }
226+ }
227+ }
228+
229+ result.feed_results.push(FeedSearchResult {
230+ feed_index,
231+ feed: feed.clone(),
232+ matching_episodes,
233+ score: feed_score,
234+ });
235+ result.total_matches += 1;
236+ }
237+ }
238+
239+ // Trier les résultats par score (descendant)
240+ result.feed_results.sort_by(|a, b| b.score.cmp(&a.score));
241+ result.episode_results.sort_by(|a, b| b.score.cmp(&a.score));
242+
243+ result
244+}
245+
246+/// Rechercher des épisodes dans un flux spécifique
247+pub fn search_episodes(
248+ episodes: &[Episode],
249+ query: &SearchQuery,
250+) -> Vec<usize> {
251+ let mut results = Vec::new();
252+
253+ if query.is_empty() {
254+ return results;
255+ }
256+
257+ for (index, episode) in episodes.iter().enumerate() {
258+ let mut matches = false;
259+
260+ match query.search_type {
261+ SearchType::EpisodeTitle => {
262+ matches = query.contains_query(&episode.title);
263+ }
264+ SearchType::EpisodeDescription => {
265+ if let Some(desc) = &episode.description {
266+ matches = query.contains_query(desc);
267+ }
268+ }
269+ SearchType::All => {
270+ matches = query.contains_query(&episode.title);
271+ if !matches {
272+ if let Some(desc) = &episode.description {
273+ matches = query.contains_query(desc);
274+ }
275+ }
276+ }
277+ _ => {
278+ // Pour les autres types, rechercher dans le titre par défaut
279+ matches = query.contains_query(&episode.title);
280+ }
281+ }
282+
283+ if matches {
284+ results.push(index);
285+ }
286+ }
287+
288+ results
289+}
290+
291+#[cfg(test)]
292+mod tests {
293+ use super::*;
294+
295+ #[test]
296+ fn test_search_query_empty() {
297+ let query = SearchQuery::new("".to_string());
298+ assert!(query.is_empty());
299+ }
300+
301+ #[test]
302+ fn test_search_query_contains() {
303+ let query = SearchQuery::new("test".to_string());
304+ assert!(!query.is_empty());
305+ assert!(query.contains_query("This is a test string"));
306+ assert!(!query.contains_query("This is a different string"));
307+ }
308+
309+ #[test]
310+ fn test_search_query_case_insensitive() {
311+ let mut query = SearchQuery::new("TEST".to_string());
312+ query.case_sensitive = false;
313+ assert!(query.contains_query("this is a test string"));
314+ }
315+
316+ #[test]
317+ fn test_search_query_case_sensitive() {
318+ let mut query = SearchQuery::new("TEST".to_string());
319+ query.case_sensitive = true;
320+ assert!(!query.contains_query("this is a test string"));
321+ assert!(query.contains_query("THIS IS A TEST STRING"));
322+ }
323+}
new file mode 100644
@@ -0,0 +1,323 @@
1+use crate::config::{episode::Episode, feed::Feed};
2+
3+/// Type de recherche possible
4+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5+pub enum SearchType {
6+ /// Recherche dans les noms de flux
7+ FeedName,
8+ /// Recherche dans les URLs de flux
9+ FeedUrl,
10+ /// Recherche dans les tags de flux
11+ FeedTags,
12+ /// Recherche dans les titres d'épisodes
13+ EpisodeTitle,
14+ /// Recherche dans les descriptions d'épisodes
15+ EpisodeDescription,
16+ /// Recherche dans tous les champs
17+ All,
18+}
19+
20+impl Default for SearchType {
21+ fn default() -> Self {
22+ SearchType::All
23+ }
24+}
25+
26+/// Requête de recherche
27+#[derive(Debug, Clone, Default)]
28+pub struct SearchQuery {
29+ /// Texte à rechercher
30+ pub query: String,
31+ /// Type de recherche
32+ pub search_type: SearchType,
33+ /// Sensible à la casse
34+ pub case_sensitive: bool,
35+}
36+
37+impl SearchQuery {
38+ /// Créer une nouvelle requête de recherche
39+ pub fn new(query: String) -> Self {
40+ Self {
41+ query,
42+ search_type: SearchType::default(),
43+ case_sensitive: false,
44+ }
45+ }
46+
47+ /// Vérifier si la requête est vide
48+ pub fn is_empty(&self) -> bool {
49+ self.query.trim().is_empty()
50+ }
51+
52+ /// Normaliser la requête selon la sensibilité à la casse
53+ fn normalize(&self, text: &str) -> String {
54+ if self.case_sensitive {
55+ text.to_string()
56+ } else {
57+ text.to_lowercase()
58+ }
59+ }
60+
61+ /// Vérifier si le texte contient la requête
62+ fn contains_query(&self, text: &str) -> bool {
63+ if self.is_empty() {
64+ return true;
65+ }
66+ let normalized_text = self.normalize(text);
67+ let normalized_query = self.normalize(&self.query);
68+ normalized_text.contains(&normalized_query)
69+ }
70+}
71+
72+/// Résultat de recherche pour un flux
73+#[derive(Debug, Clone)]
74+pub struct FeedSearchResult {
75+ /// Index du flux dans la liste
76+ pub feed_index: usize,
77+ /// Flux correspondant
78+ pub feed: Feed,
79+ /// Épisodes correspondants (si recherche dans les épisodes)
80+ pub matching_episodes: Vec<usize>,
81+ /// Score de pertinence (plus élevé = meilleure correspondance)
82+ pub score: u32,
83+}
84+
85+/// Résultat de recherche pour un épisode
86+#[derive(Debug, Clone)]
87+pub struct EpisodeSearchResult {
88+ /// Index du flux parent
89+ pub feed_index: usize,
90+ /// Index de l'épisode dans la liste des épisodes du flux
91+ pub episode_index: usize,
92+ /// Épisode correspondant
93+ pub episode: Episode,
94+ /// Score de pertinence
95+ pub score: u32,
96+}
97+
98+/// Résultat global de recherche
99+#[derive(Debug, Clone, Default)]
100+pub struct SearchResult {
101+ /// Résultats de flux
102+ pub feed_results: Vec<FeedSearchResult>,
103+ /// Résultats d'épisodes
104+ pub episode_results: Vec<EpisodeSearchResult>,
105+ /// Nombre total de correspondances
106+ pub total_matches: usize,
107+}
108+
109+impl SearchResult {
110+ pub fn new() -> Self {
111+ Self::default()
112+ }
113+
114+ /// Vérifier s'il y a des résultats
115+ pub fn is_empty(&self) -> bool {
116+ self.total_matches == 0
117+ }
118+}
119+
120+/// Effectuer une recherche dans une liste de flux
121+pub fn search_feeds(
122+ feeds: &[Feed],
123+ episodes_list: &[Vec<Episode>],
124+ query: &SearchQuery,
125+) -> SearchResult {
126+ let mut result = SearchResult::new();
127+
128+ if query.is_empty() {
129+ return result;
130+ }
131+
132+ for (feed_index, feed) in feeds.iter().enumerate() {
133+ let episodes = if feed_index < episodes_list.len() {
134+ &episodes_list[feed_index]
135+ } else {
136+ &vec![]
137+ };
138+
139+ let mut feed_score = 0u32;
140+ let mut matching_episodes = Vec::new();
141+
142+ // Recherche dans les propriétés du flux
143+ match query.search_type {
144+ SearchType::FeedName => {
145+ if query.contains_query(&feed.name) {
146+ feed_score += 100;
147+ }
148+ }
149+ SearchType::FeedUrl => {
150+ if query.contains_query(&feed.url) {
151+ feed_score += 100;
152+ }
153+ }
154+ SearchType::FeedTags => {
155+ for tag in &feed.tags {
156+ if query.contains_query(tag) {
157+ feed_score += 50;
158+ }
159+ }
160+ }
161+ SearchType::EpisodeTitle => {
162+ for (ep_index, ep) in episodes.iter().enumerate() {
163+ if query.contains_query(&ep.title) {
164+ matching_episodes.push(ep_index);
165+ feed_score += 30;
166+ }
167+ }
168+ }
169+ SearchType::EpisodeDescription => {
170+ for (ep_index, ep) in episodes.iter().enumerate() {
171+ if let Some(desc) = &ep.description {
172+ if query.contains_query(desc) {
173+ matching_episodes.push(ep_index);
174+ feed_score += 20;
175+ }
176+ }
177+ }
178+ }
179+ SearchType::All => {
180+ // Recherche dans tous les champs du flux
181+ if query.contains_query(&feed.name) {
182+ feed_score += 100;
183+ }
184+ if query.contains_query(&feed.url) {
185+ feed_score += 50;
186+ }
187+ for tag in &feed.tags {
188+ if query.contains_query(tag) {
189+ feed_score += 30;
190+ }
191+ }
192+
193+ // Recherche dans les épisodes
194+ for (ep_index, ep) in episodes.iter().enumerate() {
195+ let mut ep_score = 0u32;
196+ if query.contains_query(&ep.title) {
197+ ep_score += 50;
198+ }
199+ if let Some(desc) = &ep.description {
200+ if query.contains_query(desc) {
201+ ep_score += 30;
202+ }
203+ }
204+ if ep_score > 0 {
205+ matching_episodes.push(ep_index);
206+ feed_score += ep_score;
207+ }
208+ }
209+ }
210+ }
211+
212+ // Ajouter le flux aux résultats s'il a un score > 0
213+ if feed_score > 0 || !matching_episodes.is_empty() {
214+ // Ajouter les épisodes correspondants en premier
215+ if !matching_episodes.is_empty() && feed_index < episodes_list.len() {
216+ for &ep_index in &matching_episodes {
217+ if ep_index < episodes_list[feed_index].len() {
218+ result.episode_results.push(EpisodeSearchResult {
219+ feed_index,
220+ episode_index: ep_index,
221+ episode: episodes_list[feed_index][ep_index].clone(),
222+ score: feed_score / 2, // Score réduit pour les épisodes
223+ });
224+ result.total_matches += 1;
225+ }
226+ }
227+ }
228+
229+ result.feed_results.push(FeedSearchResult {
230+ feed_index,
231+ feed: feed.clone(),
232+ matching_episodes,
233+ score: feed_score,
234+ });
235+ result.total_matches += 1;
236+ }
237+ }
238+
239+ // Trier les résultats par score (descendant)
240+ result.feed_results.sort_by(|a, b| b.score.cmp(&a.score));
241+ result.episode_results.sort_by(|a, b| b.score.cmp(&a.score));
242+
243+ result
244+}
245+
246+/// Rechercher des épisodes dans un flux spécifique
247+pub fn search_episodes(
248+ episodes: &[Episode],
249+ query: &SearchQuery,
250+) -> Vec<usize> {
251+ let mut results = Vec::new();
252+
253+ if query.is_empty() {
254+ return results;
255+ }
256+
257+ for (index, episode) in episodes.iter().enumerate() {
258+ let mut matches = false;
259+
260+ match query.search_type {
261+ SearchType::EpisodeTitle => {
262+ matches = query.contains_query(&episode.title);
263+ }
264+ SearchType::EpisodeDescription => {
265+ if let Some(desc) = &episode.description {
266+ matches = query.contains_query(desc);
267+ }
268+ }
269+ SearchType::All => {
270+ matches = query.contains_query(&episode.title);
271+ if !matches {
272+ if let Some(desc) = &episode.description {
273+ matches = query.contains_query(desc);
274+ }
275+ }
276+ }
277+ _ => {
278+ // Pour les autres types, rechercher dans le titre par défaut
279+ matches = query.contains_query(&episode.title);
280+ }
281+ }
282+
283+ if matches {
284+ results.push(index);
285+ }
286+ }
287+
288+ results
289+}
290+
291+#[cfg(test)]
292+mod tests {
293+ use super::*;
294+
295+ #[test]
296+ fn test_search_query_empty() {
297+ let query = SearchQuery::new("".to_string());
298+ assert!(query.is_empty());
299+ }
300+
301+ #[test]
302+ fn test_search_query_contains() {
303+ let query = SearchQuery::new("test".to_string());
304+ assert!(!query.is_empty());
305+ assert!(query.contains_query("This is a test string"));
306+ assert!(!query.contains_query("This is a different string"));
307+ }
308+
309+ #[test]
310+ fn test_search_query_case_insensitive() {
311+ let mut query = SearchQuery::new("TEST".to_string());
312+ query.case_sensitive = false;
313+ assert!(query.contains_query("this is a test string"));
314+ }
315+
316+ #[test]
317+ fn test_search_query_case_sensitive() {
318+ let mut query = SearchQuery::new("TEST".to_string());
319+ query.case_sensitive = true;
320+ assert!(!query.contains_query("this is a test string"));
321+ assert!(query.contains_query("THIS IS A TEST STRING"));
322+ }
323+}
added src/config/statistics.rs +323 -0
new file mode 100644
@@ -0,0 +1,323 @@
1+use crate::config::{episode::Episode, feed::Feed};
2+use chrono::{DateTime, Utc};
3+use std::collections::HashMap;
4+use std::path::PathBuf;
5+
6+/// Statistiques globales de l'application
7+#[derive(Debug, Clone, Default)]
8+pub struct AppStatistics {
9+ /// Nombre total de flux
10+ pub total_feeds: usize,
11+ /// Nombre de flux activés
12+ pub enabled_feeds: usize,
13+ /// Nombre total d'épisodes (tous flux confondus)
14+ pub total_episodes: usize,
15+ /// Nombre d'épisodes téléchargés
16+ pub downloaded_episodes: usize,
17+ /// Nombre d'épisodes non téléchargés
18+ pub not_downloaded_episodes: usize,
19+ /// Taille totale des fichiers téléchargés (en octets)
20+ pub total_download_size: u64,
21+ /// Date du dernier téléchargement
22+ pub last_download_date: Option<DateTime<Utc>>,
23+ /// Date de la dernière synchronisation
24+ pub last_sync_date: Option<DateTime<Utc>>,
25+ /// Nombre total de synchronisations
26+ pub total_sync_count: usize,
27+ /// Nombre total de téléchargements
28+ pub total_download_count: usize,
29+ /// Statistiques par flux
30+ pub feed_statistics: HashMap<String, FeedStatistics>,
31+ /// Statistiques par playlist
32+ pub playlist_statistics: HashMap<String, PlaylistStatistics>,
33+}
34+
35+impl AppStatistics {
36+ /// Créer de nouvelles statistiques
37+ pub fn new() -> Self {
38+ Self::default()
39+ }
40+
41+ /// Mettre à jour les statistiques à partir des flux et épisodes
42+ pub fn update_from_feeds(&mut self, feeds: &[Feed], all_episodes: &[Vec<Episode>]) {
43+ self.total_feeds = feeds.len();
44+ self.enabled_feeds = feeds.iter().filter(|f| f.enabled).count();
45+ self.feed_statistics.clear();
46+
47+ let mut total_episodes = 0usize;
48+ let mut downloaded_episodes = 0usize;
49+ let mut total_size = 0u64;
50+ let mut last_download: Option<DateTime<Utc>> = None;
51+
52+ for (index, feed) in feeds.iter().enumerate() {
53+ let episodes = if index < all_episodes.len() {
54+ &all_episodes[index]
55+ } else {
56+ &vec![]
57+ };
58+
59+ let mut feed_stats = FeedStatistics::new(feed.clone());
60+
61+ for episode in episodes {
62+ total_episodes += 1;
63+ feed_stats.total_episodes += 1;
64+
65+ if episode.downloaded {
66+ downloaded_episodes += 1;
67+ feed_stats.downloaded_episodes += 1;
68+
69+ if let Some(size) = episode.file_size {
70+ total_size += size;
71+ feed_stats.total_size += size;
72+ }
73+
74+ let pub_date = episode.pub_date;
75+ if last_download.is_none() || pub_date > last_download.unwrap() {
76+ last_download = Some(pub_date);
77+ }
78+ if feed_stats.last_episode_date.is_none() || pub_date > feed_stats.last_episode_date.unwrap() {
79+ feed_stats.last_episode_date = Some(pub_date);
80+ }
81+ } else {
82+ feed_stats.not_downloaded_episodes += 1;
83+ }
84+ }
85+
86+ self.feed_statistics.insert(feed.id.clone(), feed_stats);
87+ }
88+
89+ self.total_episodes = total_episodes;
90+ self.downloaded_episodes = downloaded_episodes;
91+ self.not_downloaded_episodes = total_episodes - downloaded_episodes;
92+ self.total_download_size = total_size;
93+ self.last_download_date = last_download;
94+ }
95+
96+ /// Incrémenter le compteur de synchronisations
97+ pub fn increment_sync_count(&mut self) {
98+ self.total_sync_count += 1;
99+ self.last_sync_date = Some(Utc::now());
100+ }
101+
102+ /// Incrémenter le compteur de téléchargements
103+ pub fn increment_download_count(&mut self) {
104+ self.total_download_count += 1;
105+ self.last_download_date = Some(Utc::now());
106+ }
107+
108+ /// Ajouter une taille de téléchargement
109+ pub fn add_download_size(&mut self, size: u64) {
110+ self.total_download_size += size;
111+ }
112+
113+ /// Obtenir la taille totale formatée (en Mo, Go, etc.)
114+ pub fn get_formatted_total_size(&self) -> String {
115+ format_bytes(self.total_download_size)
116+ }
117+
118+ /// Obtenir le pourcentage d'épisodes téléchargés
119+ pub fn get_download_percentage(&self) -> f32 {
120+ if self.total_episodes == 0 {
121+ 0.0
122+ } else {
123+ (self.downloaded_episodes as f32 / self.total_episodes as f32) * 100.0
124+ }
125+ }
126+
127+ /// Obtenir la taille moyenne des fichiers téléchargés
128+ pub fn get_average_file_size(&self) -> u64 {
129+ if self.downloaded_episodes == 0 {
130+ 0
131+ } else {
132+ self.total_download_size / self.downloaded_episodes as u64
133+ }
134+ }
135+
136+ /// Mettre à jour les statistiques des playlists
137+ pub fn update_playlist_statistics(
138+ &mut self,
139+ playlists: &[super::playlist::Playlist],
140+ feeds: &[Feed],
141+ all_episodes: &[Vec<Episode>],
142+ ) {
143+ self.playlist_statistics.clear();
144+
145+ for playlist in playlists {
146+ let mut playlist_stats = PlaylistStatistics::new(playlist.clone());
147+
148+ for feed_id in &playlist.feed_ids {
149+ if let Some(feed_index) = feeds.iter().position(|f| &f.id == feed_id) {
150+ if let Some(feed) = feeds.get(feed_index) {
151+ playlist_stats.total_feeds += 1;
152+ if feed.enabled {
153+ playlist_stats.enabled_feeds += 1;
154+ }
155+
156+ if feed_index < all_episodes.len() {
157+ let episodes = &all_episodes[feed_index];
158+ for episode in episodes {
159+ playlist_stats.total_episodes += 1;
160+ if episode.downloaded {
161+ playlist_stats.downloaded_episodes += 1;
162+ if let Some(size) = episode.file_size {
163+ playlist_stats.total_size += size;
164+ }
165+ }
166+ }
167+ }
168+ }
169+ }
170+ }
171+
172+ self.playlist_statistics.insert(playlist.id.clone(), playlist_stats);
173+ }
174+ }
175+}
176+
177+/// Statistiques pour un flux spécifique
178+#[derive(Debug, Clone)]
179+pub struct FeedStatistics {
180+ /// ID du flux
181+ pub feed_id: String,
182+ /// Nom du flux
183+ pub feed_name: String,
184+ /// URL du flux
185+ pub feed_url: String,
186+ /// Nombre total d'épisodes
187+ pub total_episodes: usize,
188+ /// Nombre d'épisodes téléchargés
189+ pub downloaded_episodes: usize,
190+ /// Nombre d'épisodes non téléchargés
191+ pub not_downloaded_episodes: usize,
192+ /// Taille totale des téléchargements
193+ pub total_size: u64,
194+ /// Date du dernier épisode
195+ pub last_episode_date: Option<DateTime<Utc>>,
196+ /// Dossier de téléchargement
197+ pub download_path: PathBuf,
198+}
199+
200+impl FeedStatistics {
201+ pub fn new(feed: Feed) -> Self {
202+ Self {
203+ feed_id: feed.id,
204+ feed_name: feed.name,
205+ feed_url: feed.url,
206+ total_episodes: 0,
207+ downloaded_episodes: 0,
208+ not_downloaded_episodes: 0,
209+ total_size: 0,
210+ last_episode_date: None,
211+ download_path: feed.download_path,
212+ }
213+ }
214+
215+ /// Obtenir la taille formatée
216+ pub fn get_formatted_size(&self) -> String {
217+ format_bytes(self.total_size)
218+ }
219+
220+ /// Obtenir le pourcentage de téléchargement
221+ pub fn get_download_percentage(&self) -> f32 {
222+ if self.total_episodes == 0 {
223+ 0.0
224+ } else {
225+ (self.downloaded_episodes as f32 / self.total_episodes as f32) * 100.0
226+ }
227+ }
228+}
229+
230+/// Statistiques pour une playlist
231+#[derive(Debug, Clone)]
232+pub struct PlaylistStatistics {
233+ /// ID de la playlist
234+ pub playlist_id: String,
235+ /// Nom de la playlist
236+ pub playlist_name: String,
237+ /// Nombre total de flux dans la playlist
238+ pub total_feeds: usize,
239+ /// Nombre de flux activés
240+ pub enabled_feeds: usize,
241+ /// Nombre total d'épisodes
242+ pub total_episodes: usize,
243+ /// Nombre d'épisodes téléchargés
244+ pub downloaded_episodes: usize,
245+ /// Taille totale
246+ pub total_size: u64,
247+}
248+
249+impl PlaylistStatistics {
250+ pub fn new(playlist: super::playlist::Playlist) -> Self {
251+ Self {
252+ playlist_id: playlist.id,
253+ playlist_name: playlist.name,
254+ total_feeds: 0,
255+ enabled_feeds: 0,
256+ total_episodes: 0,
257+ downloaded_episodes: 0,
258+ total_size: 0,
259+ }
260+ }
261+
262+ /// Obtenir la taille formatée
263+ pub fn get_formatted_size(&self) -> String {
264+ format_bytes(self.total_size)
265+ }
266+}
267+
268+/// Formater une taille en octets en une chaîne lisible
269+pub fn format_bytes(bytes: u64) -> String {
270+ const KB: u64 = 1024;
271+ const MB: u64 = KB * 1024;
272+ const GB: u64 = MB * 1024;
273+ const TB: u64 = GB * 1024;
274+
275+ if bytes >= TB {
276+ format!("{:.2} TB", bytes as f64 / TB as f64)
277+ } else if bytes >= GB {
278+ format!("{:.2} GB", bytes as f64 / GB as f64)
279+ } else if bytes >= MB {
280+ format!("{:.2} MB", bytes as f64 / MB as f64)
281+ } else if bytes >= KB {
282+ format!("{:.2} KB", bytes as f64 / KB as f64)
283+ } else {
284+ format!("{} B", bytes)
285+ }
286+}
287+
288+/// Formater une durée en secondes en une chaîne lisible
289+pub fn format_duration(seconds: u64) -> String {
290+ if seconds < 60 {
291+ format!("{}s", seconds)
292+ } else if seconds < 3600 {
293+ let minutes = seconds / 60;
294+ let secs = seconds % 60;
295+ format!("{}m {}s", minutes, secs)
296+ } else {
297+ let hours = seconds / 3600;
298+ let minutes = (seconds % 3600) / 60;
299+ let secs = seconds % 60;
300+ format!("{}h {}m {}s", hours, minutes, secs)
301+ }
302+}
303+
304+#[cfg(test)]
305+mod tests {
306+ use super::*;
307+
308+ #[test]
309+ fn test_format_bytes() {
310+ assert_eq!(format_bytes(0), "0 B");
311+ assert_eq!(format_bytes(1024), "1.00 KB");
312+ assert_eq!(format_bytes(1024 * 1024), "1.00 MB");
313+ assert_eq!(format_bytes(1024 * 1024 * 1024), "1.00 GB");
314+ }
315+
316+ #[test]
317+ fn test_format_duration() {
318+ assert_eq!(format_duration(0), "0s");
319+ assert_eq!(format_duration(30), "30s");
320+ assert_eq!(format_duration(90), "1m 30s");
321+ assert_eq!(format_duration(3661), "1h 1m 1s");
322+ }
323+}
new file mode 100644
@@ -0,0 +1,323 @@
1+use crate::config::{episode::Episode, feed::Feed};
2+use chrono::{DateTime, Utc};
3+use std::collections::HashMap;
4+use std::path::PathBuf;
5+
6+/// Statistiques globales de l'application
7+#[derive(Debug, Clone, Default)]
8+pub struct AppStatistics {
9+ /// Nombre total de flux
10+ pub total_feeds: usize,
11+ /// Nombre de flux activés
12+ pub enabled_feeds: usize,
13+ /// Nombre total d'épisodes (tous flux confondus)
14+ pub total_episodes: usize,
15+ /// Nombre d'épisodes téléchargés
16+ pub downloaded_episodes: usize,
17+ /// Nombre d'épisodes non téléchargés
18+ pub not_downloaded_episodes: usize,
19+ /// Taille totale des fichiers téléchargés (en octets)
20+ pub total_download_size: u64,
21+ /// Date du dernier téléchargement
22+ pub last_download_date: Option<DateTime<Utc>>,
23+ /// Date de la dernière synchronisation
24+ pub last_sync_date: Option<DateTime<Utc>>,
25+ /// Nombre total de synchronisations
26+ pub total_sync_count: usize,
27+ /// Nombre total de téléchargements
28+ pub total_download_count: usize,
29+ /// Statistiques par flux
30+ pub feed_statistics: HashMap<String, FeedStatistics>,
31+ /// Statistiques par playlist
32+ pub playlist_statistics: HashMap<String, PlaylistStatistics>,
33+}
34+
35+impl AppStatistics {
36+ /// Créer de nouvelles statistiques
37+ pub fn new() -> Self {
38+ Self::default()
39+ }
40+
41+ /// Mettre à jour les statistiques à partir des flux et épisodes
42+ pub fn update_from_feeds(&mut self, feeds: &[Feed], all_episodes: &[Vec<Episode>]) {
43+ self.total_feeds = feeds.len();
44+ self.enabled_feeds = feeds.iter().filter(|f| f.enabled).count();
45+ self.feed_statistics.clear();
46+
47+ let mut total_episodes = 0usize;
48+ let mut downloaded_episodes = 0usize;
49+ let mut total_size = 0u64;
50+ let mut last_download: Option<DateTime<Utc>> = None;
51+
52+ for (index, feed) in feeds.iter().enumerate() {
53+ let episodes = if index < all_episodes.len() {
54+ &all_episodes[index]
55+ } else {
56+ &vec![]
57+ };
58+
59+ let mut feed_stats = FeedStatistics::new(feed.clone());
60+
61+ for episode in episodes {
62+ total_episodes += 1;
63+ feed_stats.total_episodes += 1;
64+
65+ if episode.downloaded {
66+ downloaded_episodes += 1;
67+ feed_stats.downloaded_episodes += 1;
68+
69+ if let Some(size) = episode.file_size {
70+ total_size += size;
71+ feed_stats.total_size += size;
72+ }
73+
74+ let pub_date = episode.pub_date;
75+ if last_download.is_none() || pub_date > last_download.unwrap() {
76+ last_download = Some(pub_date);
77+ }
78+ if feed_stats.last_episode_date.is_none() || pub_date > feed_stats.last_episode_date.unwrap() {
79+ feed_stats.last_episode_date = Some(pub_date);
80+ }
81+ } else {
82+ feed_stats.not_downloaded_episodes += 1;
83+ }
84+ }
85+
86+ self.feed_statistics.insert(feed.id.clone(), feed_stats);
87+ }
88+
89+ self.total_episodes = total_episodes;
90+ self.downloaded_episodes = downloaded_episodes;
91+ self.not_downloaded_episodes = total_episodes - downloaded_episodes;
92+ self.total_download_size = total_size;
93+ self.last_download_date = last_download;
94+ }
95+
96+ /// Incrémenter le compteur de synchronisations
97+ pub fn increment_sync_count(&mut self) {
98+ self.total_sync_count += 1;
99+ self.last_sync_date = Some(Utc::now());
100+ }
101+
102+ /// Incrémenter le compteur de téléchargements
103+ pub fn increment_download_count(&mut self) {
104+ self.total_download_count += 1;
105+ self.last_download_date = Some(Utc::now());
106+ }
107+
108+ /// Ajouter une taille de téléchargement
109+ pub fn add_download_size(&mut self, size: u64) {
110+ self.total_download_size += size;
111+ }
112+
113+ /// Obtenir la taille totale formatée (en Mo, Go, etc.)
114+ pub fn get_formatted_total_size(&self) -> String {
115+ format_bytes(self.total_download_size)
116+ }
117+
118+ /// Obtenir le pourcentage d'épisodes téléchargés
119+ pub fn get_download_percentage(&self) -> f32 {
120+ if self.total_episodes == 0 {
121+ 0.0
122+ } else {
123+ (self.downloaded_episodes as f32 / self.total_episodes as f32) * 100.0
124+ }
125+ }
126+
127+ /// Obtenir la taille moyenne des fichiers téléchargés
128+ pub fn get_average_file_size(&self) -> u64 {
129+ if self.downloaded_episodes == 0 {
130+ 0
131+ } else {
132+ self.total_download_size / self.downloaded_episodes as u64
133+ }
134+ }
135+
136+ /// Mettre à jour les statistiques des playlists
137+ pub fn update_playlist_statistics(
138+ &mut self,
139+ playlists: &[super::playlist::Playlist],
140+ feeds: &[Feed],
141+ all_episodes: &[Vec<Episode>],
142+ ) {
143+ self.playlist_statistics.clear();
144+
145+ for playlist in playlists {
146+ let mut playlist_stats = PlaylistStatistics::new(playlist.clone());
147+
148+ for feed_id in &playlist.feed_ids {
149+ if let Some(feed_index) = feeds.iter().position(|f| &f.id == feed_id) {
150+ if let Some(feed) = feeds.get(feed_index) {
151+ playlist_stats.total_feeds += 1;
152+ if feed.enabled {
153+ playlist_stats.enabled_feeds += 1;
154+ }
155+
156+ if feed_index < all_episodes.len() {
157+ let episodes = &all_episodes[feed_index];
158+ for episode in episodes {
159+ playlist_stats.total_episodes += 1;
160+ if episode.downloaded {
161+ playlist_stats.downloaded_episodes += 1;
162+ if let Some(size) = episode.file_size {
163+ playlist_stats.total_size += size;
164+ }
165+ }
166+ }
167+ }
168+ }
169+ }
170+ }
171+
172+ self.playlist_statistics.insert(playlist.id.clone(), playlist_stats);
173+ }
174+ }
175+}
176+
177+/// Statistiques pour un flux spécifique
178+#[derive(Debug, Clone)]
179+pub struct FeedStatistics {
180+ /// ID du flux
181+ pub feed_id: String,
182+ /// Nom du flux
183+ pub feed_name: String,
184+ /// URL du flux
185+ pub feed_url: String,
186+ /// Nombre total d'épisodes
187+ pub total_episodes: usize,
188+ /// Nombre d'épisodes téléchargés
189+ pub downloaded_episodes: usize,
190+ /// Nombre d'épisodes non téléchargés
191+ pub not_downloaded_episodes: usize,
192+ /// Taille totale des téléchargements
193+ pub total_size: u64,
194+ /// Date du dernier épisode
195+ pub last_episode_date: Option<DateTime<Utc>>,
196+ /// Dossier de téléchargement
197+ pub download_path: PathBuf,
198+}
199+
200+impl FeedStatistics {
201+ pub fn new(feed: Feed) -> Self {
202+ Self {
203+ feed_id: feed.id,
204+ feed_name: feed.name,
205+ feed_url: feed.url,
206+ total_episodes: 0,
207+ downloaded_episodes: 0,
208+ not_downloaded_episodes: 0,
209+ total_size: 0,
210+ last_episode_date: None,
211+ download_path: feed.download_path,
212+ }
213+ }
214+
215+ /// Obtenir la taille formatée
216+ pub fn get_formatted_size(&self) -> String {
217+ format_bytes(self.total_size)
218+ }
219+
220+ /// Obtenir le pourcentage de téléchargement
221+ pub fn get_download_percentage(&self) -> f32 {
222+ if self.total_episodes == 0 {
223+ 0.0
224+ } else {
225+ (self.downloaded_episodes as f32 / self.total_episodes as f32) * 100.0
226+ }
227+ }
228+}
229+
230+/// Statistiques pour une playlist
231+#[derive(Debug, Clone)]
232+pub struct PlaylistStatistics {
233+ /// ID de la playlist
234+ pub playlist_id: String,
235+ /// Nom de la playlist
236+ pub playlist_name: String,
237+ /// Nombre total de flux dans la playlist
238+ pub total_feeds: usize,
239+ /// Nombre de flux activés
240+ pub enabled_feeds: usize,
241+ /// Nombre total d'épisodes
242+ pub total_episodes: usize,
243+ /// Nombre d'épisodes téléchargés
244+ pub downloaded_episodes: usize,
245+ /// Taille totale
246+ pub total_size: u64,
247+}
248+
249+impl PlaylistStatistics {
250+ pub fn new(playlist: super::playlist::Playlist) -> Self {
251+ Self {
252+ playlist_id: playlist.id,
253+ playlist_name: playlist.name,
254+ total_feeds: 0,
255+ enabled_feeds: 0,
256+ total_episodes: 0,
257+ downloaded_episodes: 0,
258+ total_size: 0,
259+ }
260+ }
261+
262+ /// Obtenir la taille formatée
263+ pub fn get_formatted_size(&self) -> String {
264+ format_bytes(self.total_size)
265+ }
266+}
267+
268+/// Formater une taille en octets en une chaîne lisible
269+pub fn format_bytes(bytes: u64) -> String {
270+ const KB: u64 = 1024;
271+ const MB: u64 = KB * 1024;
272+ const GB: u64 = MB * 1024;
273+ const TB: u64 = GB * 1024;
274+
275+ if bytes >= TB {
276+ format!("{:.2} TB", bytes as f64 / TB as f64)
277+ } else if bytes >= GB {
278+ format!("{:.2} GB", bytes as f64 / GB as f64)
279+ } else if bytes >= MB {
280+ format!("{:.2} MB", bytes as f64 / MB as f64)
281+ } else if bytes >= KB {
282+ format!("{:.2} KB", bytes as f64 / KB as f64)
283+ } else {
284+ format!("{} B", bytes)
285+ }
286+}
287+
288+/// Formater une durée en secondes en une chaîne lisible
289+pub fn format_duration(seconds: u64) -> String {
290+ if seconds < 60 {
291+ format!("{}s", seconds)
292+ } else if seconds < 3600 {
293+ let minutes = seconds / 60;
294+ let secs = seconds % 60;
295+ format!("{}m {}s", minutes, secs)
296+ } else {
297+ let hours = seconds / 3600;
298+ let minutes = (seconds % 3600) / 60;
299+ let secs = seconds % 60;
300+ format!("{}h {}m {}s", hours, minutes, secs)
301+ }
302+}
303+
304+#[cfg(test)]
305+mod tests {
306+ use super::*;
307+
308+ #[test]
309+ fn test_format_bytes() {
310+ assert_eq!(format_bytes(0), "0 B");
311+ assert_eq!(format_bytes(1024), "1.00 KB");
312+ assert_eq!(format_bytes(1024 * 1024), "1.00 MB");
313+ assert_eq!(format_bytes(1024 * 1024 * 1024), "1.00 GB");
314+ }
315+
316+ #[test]
317+ fn test_format_duration() {
318+ assert_eq!(format_duration(0), "0s");
319+ assert_eq!(format_duration(30), "30s");
320+ assert_eq!(format_duration(90), "1m 30s");
321+ assert_eq!(format_duration(3661), "1h 1m 1s");
322+ }
323+}
modified src/config/storage.rs +21 -0
@@ -102,6 +102,27 @@ pub fn load_feeds(path: &Path) -> Result<Vec<Feed>, StorageError> {
102102 Ok(feeds)
103103 }
104104
105+/// Gets the default configuration directory
106+///
107+/// On Windows: %APPDATA%/rss-podcast-dl
108+/// On Unix: ~/.config/rss-podcast-dl
109+pub fn get_config_dir() -> std::path::PathBuf {
110+ // Use the same logic as AppState::default_config_path but without the filename
111+ if cfg!(windows) {
112+ if let Some(app_data) = std::env::var_os("APPDATA") {
113+ Path::new(&app_data).join("rss-podcast-dl")
114+ } else {
115+ Path::new(".").to_path_buf()
116+ }
117+ } else {
118+ if let Some(home) = std::env::var_os("HOME") {
119+ Path::new(&home).join(".config").join("rss-podcast-dl")
120+ } else {
121+ Path::new(".").to_path_buf()
122+ }
123+ }
124+}
125+
105126 /// Gets the default configuration path
106127 ///
107128 /// On Windows: %APPDATA%/rss-podcast-dl/config.json
@@ -102,6 +102,27 @@ pub fn load_feeds(path: &Path) -> Result<Vec<Feed>, StorageError> {
102 Ok(feeds)102 Ok(feeds)
103 }103 }
104 104
105+/// Gets the default configuration directory
106+///
107+/// On Windows: %APPDATA%/rss-podcast-dl
108+/// On Unix: ~/.config/rss-podcast-dl
109+pub fn get_config_dir() -> std::path::PathBuf {
110+ // Use the same logic as AppState::default_config_path but without the filename
111+ if cfg!(windows) {
112+ if let Some(app_data) = std::env::var_os("APPDATA") {
113+ Path::new(&app_data).join("rss-podcast-dl")
114+ } else {
115+ Path::new(".").to_path_buf()
116+ }
117+ } else {
118+ if let Some(home) = std::env::var_os("HOME") {
119+ Path::new(&home).join(".config").join("rss-podcast-dl")
120+ } else {
121+ Path::new(".").to_path_buf()
122+ }
123+ }
124+}
125+
105 /// Gets the default configuration path126 /// Gets the default configuration path
106 ///127 ///
107 /// On Windows: %APPDATA%/rss-podcast-dl/config.json128 /// On Windows: %APPDATA%/rss-podcast-dl/config.json
added src/rss/audio_metadata.rs +193 -0
new file mode 100644
@@ -0,0 +1,193 @@
1+//! Module pour la lecture des métadonnées audio (durée, artiste, etc.)
2+//!
3+//! Ce module fournit des fonctions pour extraire la durée des fichiers audio.
4+//! Pour l'instant, il utilise une estimation basée sur la taille du fichier
5+//! car l'intégration avec les crates de métadonnées peut être complexe.
6+
7+use std::path::{Path, PathBuf};
8+
9+/// Métadonnées d'un fichier audio
10+#[derive(Debug, Clone, Default)]
11+pub struct AudioMetadata {
12+ /// Durée en secondes
13+ pub duration_seconds: Option<u64>,
14+ /// Titre (si disponible)
15+ pub title: Option<String>,
16+ /// Artiste (si disponible)
17+ pub artist: Option<String>,
18+ /// Album (si disponible)
19+ pub album: Option<String>,
20+ /// Année (si disponible)
21+ pub year: Option<u32>,
22+ /// Genre (si disponible)
23+ pub genre: Option<String>,
24+ /// Bitrate en kbps (si disponible)
25+ pub bitrate_kbps: Option<u32>,
26+ /// Taille du fichier en octets
27+ pub file_size: u64,
28+}
29+
30+impl AudioMetadata {
31+ pub fn new() -> Self {
32+ Self::default()
33+ }
34+
35+ /// Obtenir la durée formatée (HH:MM:SS ou MM:SS)
36+ pub fn get_formatted_duration(&self) -> String {
37+ if let Some(duration) = self.duration_seconds {
38+ format_duration(duration)
39+ } else {
40+ String::new()
41+ }
42+ }
43+}
44+
45+/// Erreur de lecture des métadonnées
46+#[derive(Debug, thiserror::Error)]
47+pub enum MetadataError {
48+ /// Fichier introuvable
49+ #[error("Fichier introuvable: {0}")]
50+ FileNotFound(String),
51+
52+ /// Erreur de lecture du fichier
53+ #[error("Erreur de lecture du fichier: {0}")]
54+ IoError(#[from] std::io::Error),
55+
56+ /// Format non supporté
57+ #[error("Format audio non supporté: {0}")]
58+ UnsupportedFormat(String),
59+
60+ /// Erreur de parsing des métadonnées
61+ #[error("Erreur de parsing des métadonnées: {0}")]
62+ ParseError(String),
63+}
64+
65+/// Bitrate moyen par défaut pour les fichiers audio (en kbps)
66+const DEFAULT_BITRATE_KBPS: u32 = 128;
67+
68+/// Lire les métadonnées d'un fichier audio
69+/// Utilise une estimation basée sur la taille du fichier
70+pub fn read_audio_metadata(path: &Path) -> Result<AudioMetadata, MetadataError> {
71+ if !path.exists() {
72+ return Err(MetadataError::FileNotFound(path.display().to_string()));
73+ }
74+
75+ let metadata = std::fs::metadata(path)
76+ .map_err(|e| MetadataError::IoError(e))?;
77+ let file_size = metadata.len();
78+
79+ let mut audio_metadata = AudioMetadata::new();
80+ audio_metadata.file_size = file_size;
81+
82+ // Estimer la durée à partir de la taille et du bitrate moyen
83+ if file_size > 0 {
84+ let duration = estimate_duration_from_size(file_size, DEFAULT_BITRATE_KBPS);
85+ audio_metadata.duration_seconds = Some(duration);
86+ audio_metadata.bitrate_kbps = Some(DEFAULT_BITRATE_KBPS);
87+ }
88+
89+ // Essayer d'extraire le nom du fichier comme titre
90+ if let Some(file_stem) = path.file_stem() {
91+ audio_metadata.title = Some(file_stem.to_string_lossy().into_owned());
92+ }
93+
94+ Ok(audio_metadata)
95+}
96+
97+/// Lire uniquement la durée d'un fichier audio
98+pub fn get_audio_duration(path: &Path) -> Result<u64, MetadataError> {
99+ let metadata = read_audio_metadata(path)?;
100+ metadata.duration_seconds.ok_or(MetadataError::ParseError("Durée non disponible".to_string()))
101+}
102+
103+/// Estimer la durée d'un fichier audio à partir de sa taille et du bitrate moyen
104+pub fn estimate_duration_from_size(file_size: u64, bitrate_kbps: u32) -> u64 {
105+ if file_size == 0 || bitrate_kbps == 0 {
106+ return 0;
107+ }
108+
109+ // Bitrate en bps
110+ let bitrate_bps = bitrate_kbps as f64 * 1000.0;
111+
112+ // Durée en secondes = (taille en octets * 8) / bitrate en bps
113+ let duration_seconds = (file_size as f64 * 8.0) / bitrate_bps;
114+
115+ duration_seconds as u64
116+}
117+
118+/// Lire la durée de plusieurs fichiers audio
119+pub fn get_multiple_audio_durations(paths: &[impl AsRef<Path>]) -> Vec<(PathBuf, Option<u64>)> {
120+ paths.iter()
121+ .map(|path| {
122+ let path_ref = path.as_ref();
123+ let path_buf = path_ref.to_path_buf();
124+ match get_audio_duration(path_ref) {
125+ Ok(duration) => (path_buf, Some(duration)),
126+ Err(_) => (path_buf, None),
127+ }
128+ })
129+ .collect()
130+}
131+
132+/// Vérifier si un fichier est un fichier audio supporté
133+pub fn is_supported_audio_file(path: &Path) -> bool {
134+ let ext = path.extension()
135+ .and_then(|s| s.to_str())
136+ .map(|s| s.to_lowercase());
137+
138+ matches!(
139+ ext.as_deref(),
140+ Some("mp3") | Some("m4a") | Some("wav") | Some("flac") | Some("ogg") | Some("aac") | Some("mp4")
141+ )
142+}
143+
144+/// Formater une durée en secondes en une chaîne lisible
145+pub fn format_duration(seconds: u64) -> String {
146+ if seconds < 60 {
147+ format!("{}s", seconds)
148+ } else if seconds < 3600 {
149+ let minutes = seconds / 60;
150+ let secs = seconds % 60;
151+ format!("{}m {}s", minutes, secs)
152+ } else {
153+ let hours = seconds / 3600;
154+ let minutes = (seconds % 3600) / 60;
155+ let secs = seconds % 60;
156+ format!("{}h {}m {}s", hours, minutes, secs)
157+ }
158+}
159+
160+#[cfg(test)]
161+mod tests {
162+ use super::*;
163+ use tempfile::NamedTempFile;
164+
165+ #[test]
166+ fn test_is_supported_audio_file() {
167+ let mp3_path = Path::new("test.mp3");
168+ let wav_path = Path::new("test.wav");
169+ let txt_path = Path::new("test.txt");
170+
171+ assert!(is_supported_audio_file(mp3_path));
172+ assert!(is_supported_audio_file(wav_path));
173+ assert!(!is_supported_audio_file(txt_path));
174+ }
175+
176+ #[test]
177+ fn test_estimate_duration() {
178+ // Un fichier de 5 Mo à 128 kbps devrait faire environ 320 secondes
179+ let file_size = 5 * 1024 * 1024; // 5 Mo
180+ let bitrate = 128; // 128 kbps
181+
182+ let duration = estimate_duration_from_size(file_size, bitrate);
183+ assert!(duration > 320 && duration < 335);
184+ }
185+
186+ #[test]
187+ fn test_format_duration() {
188+ assert_eq!(format_duration(0), "0s");
189+ assert_eq!(format_duration(30), "30s");
190+ assert_eq!(format_duration(90), "1m 30s");
191+ assert_eq!(format_duration(3661), "1h 1m 1s");
192+ }
193+}
new file mode 100644
@@ -0,0 +1,193 @@
1+//! Module pour la lecture des métadonnées audio (durée, artiste, etc.)
2+//!
3+//! Ce module fournit des fonctions pour extraire la durée des fichiers audio.
4+//! Pour l'instant, il utilise une estimation basée sur la taille du fichier
5+//! car l'intégration avec les crates de métadonnées peut être complexe.
6+
7+use std::path::{Path, PathBuf};
8+
9+/// Métadonnées d'un fichier audio
10+#[derive(Debug, Clone, Default)]
11+pub struct AudioMetadata {
12+ /// Durée en secondes
13+ pub duration_seconds: Option<u64>,
14+ /// Titre (si disponible)
15+ pub title: Option<String>,
16+ /// Artiste (si disponible)
17+ pub artist: Option<String>,
18+ /// Album (si disponible)
19+ pub album: Option<String>,
20+ /// Année (si disponible)
21+ pub year: Option<u32>,
22+ /// Genre (si disponible)
23+ pub genre: Option<String>,
24+ /// Bitrate en kbps (si disponible)
25+ pub bitrate_kbps: Option<u32>,
26+ /// Taille du fichier en octets
27+ pub file_size: u64,
28+}
29+
30+impl AudioMetadata {
31+ pub fn new() -> Self {
32+ Self::default()
33+ }
34+
35+ /// Obtenir la durée formatée (HH:MM:SS ou MM:SS)
36+ pub fn get_formatted_duration(&self) -> String {
37+ if let Some(duration) = self.duration_seconds {
38+ format_duration(duration)
39+ } else {
40+ String::new()
41+ }
42+ }
43+}
44+
45+/// Erreur de lecture des métadonnées
46+#[derive(Debug, thiserror::Error)]
47+pub enum MetadataError {
48+ /// Fichier introuvable
49+ #[error("Fichier introuvable: {0}")]
50+ FileNotFound(String),
51+
52+ /// Erreur de lecture du fichier
53+ #[error("Erreur de lecture du fichier: {0}")]
54+ IoError(#[from] std::io::Error),
55+
56+ /// Format non supporté
57+ #[error("Format audio non supporté: {0}")]
58+ UnsupportedFormat(String),
59+
60+ /// Erreur de parsing des métadonnées
61+ #[error("Erreur de parsing des métadonnées: {0}")]
62+ ParseError(String),
63+}
64+
65+/// Bitrate moyen par défaut pour les fichiers audio (en kbps)
66+const DEFAULT_BITRATE_KBPS: u32 = 128;
67+
68+/// Lire les métadonnées d'un fichier audio
69+/// Utilise une estimation basée sur la taille du fichier
70+pub fn read_audio_metadata(path: &Path) -> Result<AudioMetadata, MetadataError> {
71+ if !path.exists() {
72+ return Err(MetadataError::FileNotFound(path.display().to_string()));
73+ }
74+
75+ let metadata = std::fs::metadata(path)
76+ .map_err(|e| MetadataError::IoError(e))?;
77+ let file_size = metadata.len();
78+
79+ let mut audio_metadata = AudioMetadata::new();
80+ audio_metadata.file_size = file_size;
81+
82+ // Estimer la durée à partir de la taille et du bitrate moyen
83+ if file_size > 0 {
84+ let duration = estimate_duration_from_size(file_size, DEFAULT_BITRATE_KBPS);
85+ audio_metadata.duration_seconds = Some(duration);
86+ audio_metadata.bitrate_kbps = Some(DEFAULT_BITRATE_KBPS);
87+ }
88+
89+ // Essayer d'extraire le nom du fichier comme titre
90+ if let Some(file_stem) = path.file_stem() {
91+ audio_metadata.title = Some(file_stem.to_string_lossy().into_owned());
92+ }
93+
94+ Ok(audio_metadata)
95+}
96+
97+/// Lire uniquement la durée d'un fichier audio
98+pub fn get_audio_duration(path: &Path) -> Result<u64, MetadataError> {
99+ let metadata = read_audio_metadata(path)?;
100+ metadata.duration_seconds.ok_or(MetadataError::ParseError("Durée non disponible".to_string()))
101+}
102+
103+/// Estimer la durée d'un fichier audio à partir de sa taille et du bitrate moyen
104+pub fn estimate_duration_from_size(file_size: u64, bitrate_kbps: u32) -> u64 {
105+ if file_size == 0 || bitrate_kbps == 0 {
106+ return 0;
107+ }
108+
109+ // Bitrate en bps
110+ let bitrate_bps = bitrate_kbps as f64 * 1000.0;
111+
112+ // Durée en secondes = (taille en octets * 8) / bitrate en bps
113+ let duration_seconds = (file_size as f64 * 8.0) / bitrate_bps;
114+
115+ duration_seconds as u64
116+}
117+
118+/// Lire la durée de plusieurs fichiers audio
119+pub fn get_multiple_audio_durations(paths: &[impl AsRef<Path>]) -> Vec<(PathBuf, Option<u64>)> {
120+ paths.iter()
121+ .map(|path| {
122+ let path_ref = path.as_ref();
123+ let path_buf = path_ref.to_path_buf();
124+ match get_audio_duration(path_ref) {
125+ Ok(duration) => (path_buf, Some(duration)),
126+ Err(_) => (path_buf, None),
127+ }
128+ })
129+ .collect()
130+}
131+
132+/// Vérifier si un fichier est un fichier audio supporté
133+pub fn is_supported_audio_file(path: &Path) -> bool {
134+ let ext = path.extension()
135+ .and_then(|s| s.to_str())
136+ .map(|s| s.to_lowercase());
137+
138+ matches!(
139+ ext.as_deref(),
140+ Some("mp3") | Some("m4a") | Some("wav") | Some("flac") | Some("ogg") | Some("aac") | Some("mp4")
141+ )
142+}
143+
144+/// Formater une durée en secondes en une chaîne lisible
145+pub fn format_duration(seconds: u64) -> String {
146+ if seconds < 60 {
147+ format!("{}s", seconds)
148+ } else if seconds < 3600 {
149+ let minutes = seconds / 60;
150+ let secs = seconds % 60;
151+ format!("{}m {}s", minutes, secs)
152+ } else {
153+ let hours = seconds / 3600;
154+ let minutes = (seconds % 3600) / 60;
155+ let secs = seconds % 60;
156+ format!("{}h {}m {}s", hours, minutes, secs)
157+ }
158+}
159+
160+#[cfg(test)]
161+mod tests {
162+ use super::*;
163+ use tempfile::NamedTempFile;
164+
165+ #[test]
166+ fn test_is_supported_audio_file() {
167+ let mp3_path = Path::new("test.mp3");
168+ let wav_path = Path::new("test.wav");
169+ let txt_path = Path::new("test.txt");
170+
171+ assert!(is_supported_audio_file(mp3_path));
172+ assert!(is_supported_audio_file(wav_path));
173+ assert!(!is_supported_audio_file(txt_path));
174+ }
175+
176+ #[test]
177+ fn test_estimate_duration() {
178+ // Un fichier de 5 Mo à 128 kbps devrait faire environ 320 secondes
179+ let file_size = 5 * 1024 * 1024; // 5 Mo
180+ let bitrate = 128; // 128 kbps
181+
182+ let duration = estimate_duration_from_size(file_size, bitrate);
183+ assert!(duration > 320 && duration < 335);
184+ }
185+
186+ #[test]
187+ fn test_format_duration() {
188+ assert_eq!(format_duration(0), "0s");
189+ assert_eq!(format_duration(30), "30s");
190+ assert_eq!(format_duration(90), "1m 30s");
191+ assert_eq!(format_duration(3661), "1h 1m 1s");
192+ }
193+}
modified src/rss/mod.rs +4 -0
@@ -2,6 +2,7 @@
22 //!
33 //! This module handles RSS feed parsing, episode extraction, and downloading.
44
5+pub mod audio_metadata;
56 pub mod downloader;
67 pub mod episode_extractor;
78 pub mod parser;
@@ -12,6 +13,9 @@ pub mod sync;
1213 // kept for future extensibility and API completeness
1314 #[allow(dead_code)]
1415 #[allow(unused_imports)]
16+pub use audio_metadata::{AudioMetadata, MetadataError, format_duration, get_audio_duration, is_supported_audio_file, read_audio_metadata};
17+#[allow(dead_code)]
18+#[allow(unused_imports)]
1519 pub use downloader::{DownloadClient, DownloadError, ProgressCallback, download_episode, download_episodes, download_file, get_download_dir, needs_download, remove_downloaded_episode};
1620 #[allow(dead_code)]
1721 #[allow(unused_imports)]
@@ -2,6 +2,7 @@
2 //!2 //!
3 //! This module handles RSS feed parsing, episode extraction, and downloading.3 //! This module handles RSS feed parsing, episode extraction, and downloading.
4 4
5+pub mod audio_metadata;
5 pub mod downloader;6 pub mod downloader;
6 pub mod episode_extractor;7 pub mod episode_extractor;
7 pub mod parser;8 pub mod parser;
@@ -12,6 +13,9 @@ pub mod sync;
12 // kept for future extensibility and API completeness13 // kept for future extensibility and API completeness
13 #[allow(dead_code)]14 #[allow(dead_code)]
14 #[allow(unused_imports)]15 #[allow(unused_imports)]
16+pub use audio_metadata::{AudioMetadata, MetadataError, format_duration, get_audio_duration, is_supported_audio_file, read_audio_metadata};
17+#[allow(dead_code)]
18+#[allow(unused_imports)]
15 pub use downloader::{DownloadClient, DownloadError, ProgressCallback, download_episode, download_episodes, download_file, get_download_dir, needs_download, remove_downloaded_episode};19 pub use downloader::{DownloadClient, DownloadError, ProgressCallback, download_episode, download_episodes, download_file, get_download_dir, needs_download, remove_downloaded_episode};
16 #[allow(dead_code)]20 #[allow(dead_code)]
17 #[allow(unused_imports)]21 #[allow(unused_imports)]
added src/ui/history_panel.rs +226 -0
new file mode 100644
@@ -0,0 +1,226 @@
1+use crate::config::history::{HistoryEntry, HistoryManager, OperationStatus, OperationType};
2+use eframe::egui;
3+
4+/// Panneau d'affichage de l'historique
5+pub struct HistoryPanel {
6+ /// Visibilité du panneau
7+ visible: bool,
8+ /// Filtre de type d'opération
9+ filter_type: Option<OperationType>,
10+ /// Filtre de statut
11+ filter_status: Option<OperationStatus>,
12+ /// Nombre d'entrées à afficher
13+ entries_to_show: usize,
14+}
15+
16+impl Default for HistoryPanel {
17+ fn default() -> Self {
18+ Self {
19+ visible: false,
20+ filter_type: None,
21+ filter_status: None,
22+ entries_to_show: 50,
23+ }
24+ }
25+}
26+
27+impl HistoryPanel {
28+ /// Créer un nouveau panneau d'historique
29+ pub fn new() -> Self {
30+ Self::default()
31+ }
32+
33+ /// Définir la visibilité
34+ pub fn set_visible(&mut self, visible: bool) {
35+ self.visible = visible;
36+ }
37+
38+ /// Basculer la visibilité
39+ pub fn toggle(&mut self) {
40+ self.visible = !self.visible;
41+ }
42+
43+ /// Vérifier si le panneau est visible
44+ pub fn is_visible(&self) -> bool {
45+ self.visible
46+ }
47+
48+ /// Définir le filtre de type
49+ pub fn set_filter_type(&mut self, op_type: Option<OperationType>) {
50+ self.filter_type = op_type;
51+ }
52+
53+ /// Définir le filtre de statut
54+ pub fn set_filter_status(&mut self, status: Option<OperationStatus>) {
55+ self.filter_status = status;
56+ }
57+
58+ /// Afficher le panneau d'historique
59+ pub fn show(&mut self, ui: &mut egui::Ui, history: &HistoryManager) {
60+ if !self.visible {
61+ return;
62+ }
63+
64+ egui::CollapsingHeader::new("📜 Historique")
65+ .default_open(true)
66+ .show(ui, |ui| {
67+ self.show_content(ui, history);
68+ });
69+ }
70+
71+ /// Afficher le contenu du panneau
72+ fn show_content(&mut self, ui: &mut egui::Ui, history: &HistoryManager) {
73+ // Contrôles de filtre
74+ self.show_filters(ui);
75+ ui.separator();
76+
77+ // Afficher les entrées
78+ self.show_entries(ui, history);
79+ }
80+
81+ /// Afficher les contrôles de filtre
82+ fn show_filters(&mut self, ui: &mut egui::Ui) {
83+ ui.horizontal(|ui| {
84+ // Filtre par type
85+ egui::ComboBox::from_label("Type")
86+ .width(150.0)
87+ .selected_text(match self.filter_type {
88+ Some(t) => format!("{}", t),
89+ None => "Tous les types".to_string(),
90+ })
91+ .show_ui(ui, |ui| {
92+ ui.selectable_value(&mut self.filter_type, None, "Tous les types");
93+ ui.selectable_value(&mut self.filter_type, Some(OperationType::SyncFeed), format!("{}", OperationType::SyncFeed));
94+ ui.selectable_value(&mut self.filter_type, Some(OperationType::SyncAll), format!("{}", OperationType::SyncAll));
95+ ui.selectable_value(&mut self.filter_type, Some(OperationType::DownloadEpisode), format!("{}", OperationType::DownloadEpisode));
96+ ui.selectable_value(&mut self.filter_type, Some(OperationType::AddFeed), format!("{}", OperationType::AddFeed));
97+ ui.selectable_value(&mut self.filter_type, Some(OperationType::RemoveFeed), format!("{}", OperationType::RemoveFeed));
98+ ui.selectable_value(&mut self.filter_type, Some(OperationType::ImportFeeds), format!("{}", OperationType::ImportFeeds));
99+ ui.selectable_value(&mut self.filter_type, Some(OperationType::ExportFeeds), format!("{}", OperationType::ExportFeeds));
100+ ui.selectable_value(&mut self.filter_type, Some(OperationType::DownloadError), format!("{}", OperationType::DownloadError));
101+ ui.selectable_value(&mut self.filter_type, Some(OperationType::SyncError), format!("{}", OperationType::SyncError));
102+ });
103+
104+ // Filtre par statut
105+ egui::ComboBox::from_label("Statut")
106+ .width(120.0)
107+ .selected_text(match self.filter_status {
108+ Some(s) => format!("{}", s),
109+ None => "Tous les statuts".to_string(),
110+ })
111+ .show_ui(ui, |ui| {
112+ ui.selectable_value(&mut self.filter_status, None, "Tous les statuts");
113+ ui.selectable_value(&mut self.filter_status, Some(OperationStatus::InProgress), format!("{}", OperationStatus::InProgress));
114+ ui.selectable_value(&mut self.filter_status, Some(OperationStatus::Success), format!("{}", OperationStatus::Success));
115+ ui.selectable_value(&mut self.filter_status, Some(OperationStatus::Failed), format!("{}", OperationStatus::Failed));
116+ });
117+
118+ // Bouton pour effacer les filtres
119+ if self.filter_type.is_some() || self.filter_status.is_some() {
120+ if ui.button("Effacer filtres").clicked() {
121+ self.filter_type = None;
122+ self.filter_status = None;
123+ }
124+ }
125+ });
126+ }
127+
128+ /// Afficher les entrées d'historique
129+ fn show_entries(&mut self, ui: &mut egui::Ui, history: &HistoryManager) {
130+ let mut entries: Vec<&HistoryEntry> = history.get_all_entries();
131+
132+ // Appliquer les filtres
133+ if let Some(op_type) = self.filter_type {
134+ entries.retain(|e| e.operation_type == op_type);
135+ }
136+ if let Some(status) = self.filter_status {
137+ entries.retain(|e| e.status == status);
138+ }
139+
140+ // Limiter le nombre d'entrées affichées
141+ entries.truncate(self.entries_to_show);
142+
143+ if entries.is_empty() {
144+ ui.label("Aucune entrée d'historique trouvée");
145+ return;
146+ }
147+
148+ // Tableau des entrées
149+ egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
150+ egui::Grid::new("history_grid")
151+ .striped(true)
152+ .show(ui, |ui| {
153+ // En-têtes
154+ ui.strong("Date/Heure");
155+ ui.strong("Type");
156+ ui.strong("Description");
157+ ui.strong("Statut");
158+ ui.strong("Durée/Taille");
159+ ui.end_row();
160+
161+ // Lignes de données (afficher de la plus récente à la plus ancienne)
162+ for entry in entries.iter().rev() {
163+ ui.label(format!("{}", entry.timestamp.format("%Y-%m-%d %H:%M:%S")));
164+ ui.label(format!("{}", entry.operation_type));
165+
166+ // Limiter la longueur de la description
167+ let desc = if entry.description.len() > 40 {
168+ format!("{}...", &entry.description[..37])
169+ } else {
170+ entry.description.clone()
171+ };
172+ ui.label(desc);
173+
174+ // Afficher le statut avec une couleur
175+ let status_text = format!("{}", entry.status);
176+ let color = match entry.status {
177+ OperationStatus::Success => egui::Color32::GREEN,
178+ OperationStatus::Failed => egui::Color32::RED,
179+ OperationStatus::InProgress => egui::Color32::YELLOW,
180+ };
181+ ui.colored_label(color, &status_text);
182+
183+ // Afficher durée ou taille
184+ let info = if entry.duration_seconds.is_some() {
185+ entry.get_formatted_duration()
186+ } else {
187+ entry.get_formatted_size()
188+ };
189+ ui.label(info);
190+ ui.end_row();
191+ }
192+ });
193+ });
194+
195+ // Statistiques de l'historique
196+ ui.separator();
197+ ui.horizontal(|ui| {
198+ ui.label(format!("Total: {} entrées", history.len()));
199+ if ui.button("Effacer historique").clicked() {
200+ // L'appelant doit effacer l'historique
201+ }
202+ });
203+ }
204+
205+ /// Afficher une version simplifiée (dernières opérations)
206+ pub fn show_recent(ui: &mut egui::Ui, history: &HistoryManager, count: usize) {
207+ let entries = history.get_recent_entries(count);
208+
209+ if entries.is_empty() {
210+ ui.label("Aucune opération récente");
211+ return;
212+ }
213+
214+ ui.label("📜 Dernières opérations:");
215+ egui::ScrollArea::horizontal().show(ui, |ui| {
216+ for entry in entries.iter().rev() {
217+ let color = match entry.status {
218+ OperationStatus::Success => egui::Color32::GREEN,
219+ OperationStatus::Failed => egui::Color32::RED,
220+ OperationStatus::InProgress => egui::Color32::YELLOW,
221+ };
222+ ui.colored_label(color, format!("{} - {}", entry.timestamp.format("%H:%M"), entry.description));
223+ }
224+ });
225+ }
226+}
new file mode 100644
@@ -0,0 +1,226 @@
1+use crate::config::history::{HistoryEntry, HistoryManager, OperationStatus, OperationType};
2+use eframe::egui;
3+
4+/// Panneau d'affichage de l'historique
5+pub struct HistoryPanel {
6+ /// Visibilité du panneau
7+ visible: bool,
8+ /// Filtre de type d'opération
9+ filter_type: Option<OperationType>,
10+ /// Filtre de statut
11+ filter_status: Option<OperationStatus>,
12+ /// Nombre d'entrées à afficher
13+ entries_to_show: usize,
14+}
15+
16+impl Default for HistoryPanel {
17+ fn default() -> Self {
18+ Self {
19+ visible: false,
20+ filter_type: None,
21+ filter_status: None,
22+ entries_to_show: 50,
23+ }
24+ }
25+}
26+
27+impl HistoryPanel {
28+ /// Créer un nouveau panneau d'historique
29+ pub fn new() -> Self {
30+ Self::default()
31+ }
32+
33+ /// Définir la visibilité
34+ pub fn set_visible(&mut self, visible: bool) {
35+ self.visible = visible;
36+ }
37+
38+ /// Basculer la visibilité
39+ pub fn toggle(&mut self) {
40+ self.visible = !self.visible;
41+ }
42+
43+ /// Vérifier si le panneau est visible
44+ pub fn is_visible(&self) -> bool {
45+ self.visible
46+ }
47+
48+ /// Définir le filtre de type
49+ pub fn set_filter_type(&mut self, op_type: Option<OperationType>) {
50+ self.filter_type = op_type;
51+ }
52+
53+ /// Définir le filtre de statut
54+ pub fn set_filter_status(&mut self, status: Option<OperationStatus>) {
55+ self.filter_status = status;
56+ }
57+
58+ /// Afficher le panneau d'historique
59+ pub fn show(&mut self, ui: &mut egui::Ui, history: &HistoryManager) {
60+ if !self.visible {
61+ return;
62+ }
63+
64+ egui::CollapsingHeader::new("📜 Historique")
65+ .default_open(true)
66+ .show(ui, |ui| {
67+ self.show_content(ui, history);
68+ });
69+ }
70+
71+ /// Afficher le contenu du panneau
72+ fn show_content(&mut self, ui: &mut egui::Ui, history: &HistoryManager) {
73+ // Contrôles de filtre
74+ self.show_filters(ui);
75+ ui.separator();
76+
77+ // Afficher les entrées
78+ self.show_entries(ui, history);
79+ }
80+
81+ /// Afficher les contrôles de filtre
82+ fn show_filters(&mut self, ui: &mut egui::Ui) {
83+ ui.horizontal(|ui| {
84+ // Filtre par type
85+ egui::ComboBox::from_label("Type")
86+ .width(150.0)
87+ .selected_text(match self.filter_type {
88+ Some(t) => format!("{}", t),
89+ None => "Tous les types".to_string(),
90+ })
91+ .show_ui(ui, |ui| {
92+ ui.selectable_value(&mut self.filter_type, None, "Tous les types");
93+ ui.selectable_value(&mut self.filter_type, Some(OperationType::SyncFeed), format!("{}", OperationType::SyncFeed));
94+ ui.selectable_value(&mut self.filter_type, Some(OperationType::SyncAll), format!("{}", OperationType::SyncAll));
95+ ui.selectable_value(&mut self.filter_type, Some(OperationType::DownloadEpisode), format!("{}", OperationType::DownloadEpisode));
96+ ui.selectable_value(&mut self.filter_type, Some(OperationType::AddFeed), format!("{}", OperationType::AddFeed));
97+ ui.selectable_value(&mut self.filter_type, Some(OperationType::RemoveFeed), format!("{}", OperationType::RemoveFeed));
98+ ui.selectable_value(&mut self.filter_type, Some(OperationType::ImportFeeds), format!("{}", OperationType::ImportFeeds));
99+ ui.selectable_value(&mut self.filter_type, Some(OperationType::ExportFeeds), format!("{}", OperationType::ExportFeeds));
100+ ui.selectable_value(&mut self.filter_type, Some(OperationType::DownloadError), format!("{}", OperationType::DownloadError));
101+ ui.selectable_value(&mut self.filter_type, Some(OperationType::SyncError), format!("{}", OperationType::SyncError));
102+ });
103+
104+ // Filtre par statut
105+ egui::ComboBox::from_label("Statut")
106+ .width(120.0)
107+ .selected_text(match self.filter_status {
108+ Some(s) => format!("{}", s),
109+ None => "Tous les statuts".to_string(),
110+ })
111+ .show_ui(ui, |ui| {
112+ ui.selectable_value(&mut self.filter_status, None, "Tous les statuts");
113+ ui.selectable_value(&mut self.filter_status, Some(OperationStatus::InProgress), format!("{}", OperationStatus::InProgress));
114+ ui.selectable_value(&mut self.filter_status, Some(OperationStatus::Success), format!("{}", OperationStatus::Success));
115+ ui.selectable_value(&mut self.filter_status, Some(OperationStatus::Failed), format!("{}", OperationStatus::Failed));
116+ });
117+
118+ // Bouton pour effacer les filtres
119+ if self.filter_type.is_some() || self.filter_status.is_some() {
120+ if ui.button("Effacer filtres").clicked() {
121+ self.filter_type = None;
122+ self.filter_status = None;
123+ }
124+ }
125+ });
126+ }
127+
128+ /// Afficher les entrées d'historique
129+ fn show_entries(&mut self, ui: &mut egui::Ui, history: &HistoryManager) {
130+ let mut entries: Vec<&HistoryEntry> = history.get_all_entries();
131+
132+ // Appliquer les filtres
133+ if let Some(op_type) = self.filter_type {
134+ entries.retain(|e| e.operation_type == op_type);
135+ }
136+ if let Some(status) = self.filter_status {
137+ entries.retain(|e| e.status == status);
138+ }
139+
140+ // Limiter le nombre d'entrées affichées
141+ entries.truncate(self.entries_to_show);
142+
143+ if entries.is_empty() {
144+ ui.label("Aucune entrée d'historique trouvée");
145+ return;
146+ }
147+
148+ // Tableau des entrées
149+ egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
150+ egui::Grid::new("history_grid")
151+ .striped(true)
152+ .show(ui, |ui| {
153+ // En-têtes
154+ ui.strong("Date/Heure");
155+ ui.strong("Type");
156+ ui.strong("Description");
157+ ui.strong("Statut");
158+ ui.strong("Durée/Taille");
159+ ui.end_row();
160+
161+ // Lignes de données (afficher de la plus récente à la plus ancienne)
162+ for entry in entries.iter().rev() {
163+ ui.label(format!("{}", entry.timestamp.format("%Y-%m-%d %H:%M:%S")));
164+ ui.label(format!("{}", entry.operation_type));
165+
166+ // Limiter la longueur de la description
167+ let desc = if entry.description.len() > 40 {
168+ format!("{}...", &entry.description[..37])
169+ } else {
170+ entry.description.clone()
171+ };
172+ ui.label(desc);
173+
174+ // Afficher le statut avec une couleur
175+ let status_text = format!("{}", entry.status);
176+ let color = match entry.status {
177+ OperationStatus::Success => egui::Color32::GREEN,
178+ OperationStatus::Failed => egui::Color32::RED,
179+ OperationStatus::InProgress => egui::Color32::YELLOW,
180+ };
181+ ui.colored_label(color, &status_text);
182+
183+ // Afficher durée ou taille
184+ let info = if entry.duration_seconds.is_some() {
185+ entry.get_formatted_duration()
186+ } else {
187+ entry.get_formatted_size()
188+ };
189+ ui.label(info);
190+ ui.end_row();
191+ }
192+ });
193+ });
194+
195+ // Statistiques de l'historique
196+ ui.separator();
197+ ui.horizontal(|ui| {
198+ ui.label(format!("Total: {} entrées", history.len()));
199+ if ui.button("Effacer historique").clicked() {
200+ // L'appelant doit effacer l'historique
201+ }
202+ });
203+ }
204+
205+ /// Afficher une version simplifiée (dernières opérations)
206+ pub fn show_recent(ui: &mut egui::Ui, history: &HistoryManager, count: usize) {
207+ let entries = history.get_recent_entries(count);
208+
209+ if entries.is_empty() {
210+ ui.label("Aucune opération récente");
211+ return;
212+ }
213+
214+ ui.label("📜 Dernières opérations:");
215+ egui::ScrollArea::horizontal().show(ui, |ui| {
216+ for entry in entries.iter().rev() {
217+ let color = match entry.status {
218+ OperationStatus::Success => egui::Color32::GREEN,
219+ OperationStatus::Failed => egui::Color32::RED,
220+ OperationStatus::InProgress => egui::Color32::YELLOW,
221+ };
222+ ui.colored_label(color, format!("{} - {}", entry.timestamp.format("%H:%M"), entry.description));
223+ }
224+ });
225+ }
226+}
modified src/ui/mod.rs +3 -0
@@ -11,3 +11,6 @@ pub mod notifications;
1111 pub mod playlist_list;
1212 pub mod filter_panel;
1313 pub mod export_import;
14+pub mod history_panel;
15+pub mod search_bar;
16+pub mod stats_panel;
@@ -11,3 +11,6 @@ pub mod notifications;
11 pub mod playlist_list;11 pub mod playlist_list;
12 pub mod filter_panel;12 pub mod filter_panel;
13 pub mod export_import;13 pub mod export_import;
14+pub mod history_panel;
15+pub mod search_bar;
16+pub mod stats_panel;
added src/ui/search_bar.rs +143 -0
new file mode 100644
@@ -0,0 +1,143 @@
1+use crate::config::search::{SearchQuery, SearchType};
2+use eframe::egui;
3+
4+/// Barre de recherche pour l'application
5+pub struct SearchBar {
6+ /// Requête de recherche active
7+ pub query: SearchQuery,
8+ /// Focus sur la barre de recherche
9+ focus: bool,
10+}
11+
12+impl Default for SearchBar {
13+ fn default() -> Self {
14+ Self {
15+ query: SearchQuery::default(),
16+ focus: false,
17+ }
18+ }
19+}
20+
21+impl SearchBar {
22+ /// Créer une nouvelle barre de recherche
23+ pub fn new() -> Self {
24+ Self::default()
25+ }
26+
27+ /// Créer une barre de recherche avec une requête initiale
28+ pub fn with_query(query: SearchQuery) -> Self {
29+ Self {
30+ query,
31+ focus: false,
32+ }
33+ }
34+
35+ /// Effacer la requête de recherche
36+ pub fn clear(&mut self) {
37+ self.query.query.clear();
38+ }
39+
40+ /// Définir le focus sur la barre de recherche
41+ pub fn set_focus(&mut self, focus: bool) {
42+ self.focus = focus;
43+ }
44+
45+ /// Vérifier si la recherche est active
46+ pub fn is_searching(&self) -> bool {
47+ !self.query.is_empty()
48+ }
49+
50+ /// Obtenir la requête de recherche actuelle
51+ pub fn get_query(&self) -> &SearchQuery {
52+ &self.query
53+ }
54+
55+ /// Définir la requête de recherche
56+ pub fn set_query(&mut self, query: SearchQuery) {
57+ self.query = query;
58+ }
59+
60+ /// Afficher la barre de recherche
61+ pub fn show(&mut self, ui: &mut egui::Ui) -> bool {
62+ let mut changed = false;
63+
64+ // Simple horizontal layout sans Frame
65+ ui.horizontal(|ui| {
66+ // Icône de recherche
67+ ui.label("🔍");
68+
69+ // Champ de texte de recherche
70+ let text_edit = egui::TextEdit::singleline(&mut self.query.query)
71+ .hint_text("Rechercher dans les flux et épisodes...")
72+ .desired_width(f32::INFINITY);
73+
74+ let response = ui.add(text_edit);
75+
76+ // Si focus demandé, le demander
77+ if self.focus {
78+ response.request_focus();
79+ }
80+
81+ // Vérifier si le texte a changé
82+ if response.changed() {
83+ changed = true;
84+ }
85+
86+ // Bouton pour effacer la recherche
87+ if self.is_searching() && ui.button("✕").clicked() {
88+ self.clear();
89+ changed = true;
90+ }
91+
92+ // Menu déroulant pour le type de recherche
93+ egui::ComboBox::from_label("")
94+ .width(120.0)
95+ .selected_text(format!("{:?}", self.query.search_type))
96+ .show_ui(ui, |ui| {
97+ ui.selectable_value(&mut self.query.search_type, SearchType::All, "Tous");
98+ ui.selectable_value(&mut self.query.search_type, SearchType::FeedName, "Nom du flux");
99+ ui.selectable_value(&mut self.query.search_type, SearchType::FeedUrl, "URL du flux");
100+ ui.selectable_value(&mut self.query.search_type, SearchType::FeedTags, "Tags");
101+ ui.selectable_value(&mut self.query.search_type, SearchType::EpisodeTitle, "Titre épisode");
102+ ui.selectable_value(&mut self.query.search_type, SearchType::EpisodeDescription, "Description");
103+ });
104+ });
105+
106+ changed
107+ }
108+
109+ /// Afficher une version simplifiée (juste le champ de recherche)
110+ pub fn show_simple(&mut self, ui: &mut egui::Ui) -> bool {
111+ let mut changed = false;
112+
113+ ui.horizontal(|ui| {
114+ ui.label("🔍");
115+ let response = ui.text_edit_singleline(&mut self.query.query);
116+ if response.changed() {
117+ changed = true;
118+ }
119+ if self.is_searching() && ui.button("✕").clicked() {
120+ self.clear();
121+ changed = true;
122+ }
123+ });
124+
125+ changed
126+ }
127+}
128+
129+/// Widget pour afficher les résultats de recherche
130+pub fn show_search_results(
131+ ui: &mut egui::Ui,
132+ total_matches: usize,
133+ search_active: bool,
134+) {
135+ if search_active {
136+ ui.horizontal(|ui| {
137+ ui.label(format!("{} résultats trouvés", total_matches));
138+ if ui.button("Effacer").clicked() {
139+ // L'appelant doit effacer la recherche
140+ }
141+ });
142+ }
143+}
new file mode 100644
@@ -0,0 +1,143 @@
1+use crate::config::search::{SearchQuery, SearchType};
2+use eframe::egui;
3+
4+/// Barre de recherche pour l'application
5+pub struct SearchBar {
6+ /// Requête de recherche active
7+ pub query: SearchQuery,
8+ /// Focus sur la barre de recherche
9+ focus: bool,
10+}
11+
12+impl Default for SearchBar {
13+ fn default() -> Self {
14+ Self {
15+ query: SearchQuery::default(),
16+ focus: false,
17+ }
18+ }
19+}
20+
21+impl SearchBar {
22+ /// Créer une nouvelle barre de recherche
23+ pub fn new() -> Self {
24+ Self::default()
25+ }
26+
27+ /// Créer une barre de recherche avec une requête initiale
28+ pub fn with_query(query: SearchQuery) -> Self {
29+ Self {
30+ query,
31+ focus: false,
32+ }
33+ }
34+
35+ /// Effacer la requête de recherche
36+ pub fn clear(&mut self) {
37+ self.query.query.clear();
38+ }
39+
40+ /// Définir le focus sur la barre de recherche
41+ pub fn set_focus(&mut self, focus: bool) {
42+ self.focus = focus;
43+ }
44+
45+ /// Vérifier si la recherche est active
46+ pub fn is_searching(&self) -> bool {
47+ !self.query.is_empty()
48+ }
49+
50+ /// Obtenir la requête de recherche actuelle
51+ pub fn get_query(&self) -> &SearchQuery {
52+ &self.query
53+ }
54+
55+ /// Définir la requête de recherche
56+ pub fn set_query(&mut self, query: SearchQuery) {
57+ self.query = query;
58+ }
59+
60+ /// Afficher la barre de recherche
61+ pub fn show(&mut self, ui: &mut egui::Ui) -> bool {
62+ let mut changed = false;
63+
64+ // Simple horizontal layout sans Frame
65+ ui.horizontal(|ui| {
66+ // Icône de recherche
67+ ui.label("🔍");
68+
69+ // Champ de texte de recherche
70+ let text_edit = egui::TextEdit::singleline(&mut self.query.query)
71+ .hint_text("Rechercher dans les flux et épisodes...")
72+ .desired_width(f32::INFINITY);
73+
74+ let response = ui.add(text_edit);
75+
76+ // Si focus demandé, le demander
77+ if self.focus {
78+ response.request_focus();
79+ }
80+
81+ // Vérifier si le texte a changé
82+ if response.changed() {
83+ changed = true;
84+ }
85+
86+ // Bouton pour effacer la recherche
87+ if self.is_searching() && ui.button("✕").clicked() {
88+ self.clear();
89+ changed = true;
90+ }
91+
92+ // Menu déroulant pour le type de recherche
93+ egui::ComboBox::from_label("")
94+ .width(120.0)
95+ .selected_text(format!("{:?}", self.query.search_type))
96+ .show_ui(ui, |ui| {
97+ ui.selectable_value(&mut self.query.search_type, SearchType::All, "Tous");
98+ ui.selectable_value(&mut self.query.search_type, SearchType::FeedName, "Nom du flux");
99+ ui.selectable_value(&mut self.query.search_type, SearchType::FeedUrl, "URL du flux");
100+ ui.selectable_value(&mut self.query.search_type, SearchType::FeedTags, "Tags");
101+ ui.selectable_value(&mut self.query.search_type, SearchType::EpisodeTitle, "Titre épisode");
102+ ui.selectable_value(&mut self.query.search_type, SearchType::EpisodeDescription, "Description");
103+ });
104+ });
105+
106+ changed
107+ }
108+
109+ /// Afficher une version simplifiée (juste le champ de recherche)
110+ pub fn show_simple(&mut self, ui: &mut egui::Ui) -> bool {
111+ let mut changed = false;
112+
113+ ui.horizontal(|ui| {
114+ ui.label("🔍");
115+ let response = ui.text_edit_singleline(&mut self.query.query);
116+ if response.changed() {
117+ changed = true;
118+ }
119+ if self.is_searching() && ui.button("✕").clicked() {
120+ self.clear();
121+ changed = true;
122+ }
123+ });
124+
125+ changed
126+ }
127+}
128+
129+/// Widget pour afficher les résultats de recherche
130+pub fn show_search_results(
131+ ui: &mut egui::Ui,
132+ total_matches: usize,
133+ search_active: bool,
134+) {
135+ if search_active {
136+ ui.horizontal(|ui| {
137+ ui.label(format!("{} résultats trouvés", total_matches));
138+ if ui.button("Effacer").clicked() {
139+ // L'appelant doit effacer la recherche
140+ }
141+ });
142+ }
143+}
added src/ui/stats_panel.rs +176 -0
new file mode 100644
@@ -0,0 +1,176 @@
1+use crate::config::statistics::{AppStatistics, format_bytes};
2+use eframe::egui;
3+
4+/// Panneau d'affichage des statistiques
5+pub struct StatsPanel {
6+ /// Visibilité du panneau
7+ visible: bool,
8+}
9+
10+impl Default for StatsPanel {
11+ fn default() -> Self {
12+ Self { visible: false }
13+ }
14+}
15+
16+impl StatsPanel {
17+ /// Créer un nouveau panneau de statistiques
18+ pub fn new() -> Self {
19+ Self::default()
20+ }
21+
22+ /// Définir la visibilité
23+ pub fn set_visible(&mut self, visible: bool) {
24+ self.visible = visible;
25+ }
26+
27+ /// Basculer la visibilité
28+ pub fn toggle(&mut self) {
29+ self.visible = !self.visible;
30+ }
31+
32+ /// Vérifier si le panneau est visible
33+ pub fn is_visible(&self) -> bool {
34+ self.visible
35+ }
36+
37+ /// Afficher le panneau de statistiques
38+ pub fn show(&mut self, ui: &mut egui::Ui, stats: &AppStatistics) {
39+ if !self.visible {
40+ return;
41+ }
42+
43+ egui::CollapsingHeader::new("📊 Statistiques")
44+ .default_open(true)
45+ .show(ui, |ui| {
46+ self.show_content(ui, stats);
47+ });
48+ }
49+
50+ /// Afficher le contenu du panneau
51+ fn show_content(&self, ui: &mut egui::Ui, stats: &AppStatistics) {
52+ // Statistiques globales
53+ self.show_global_stats(ui, stats);
54+
55+ ui.separator();
56+
57+ // Statistiques par flux
58+ self.show_feed_stats(ui, stats);
59+ }
60+
61+ /// Afficher les statistiques globales
62+ fn show_global_stats(&self, ui: &mut egui::Ui, stats: &AppStatistics) {
63+ ui.label("🌍 Statistiques Globales");
64+ ui.separator();
65+
66+ egui::Grid::new("global_stats_grid")
67+ .striped(true)
68+ .show(ui, |ui| {
69+ ui.label("Nombre total de flux:");
70+ ui.label(format!("{}", stats.total_feeds));
71+ ui.end_row();
72+
73+ ui.label("Flux activés:");
74+ ui.label(format!("{}", stats.enabled_feeds));
75+ ui.end_row();
76+
77+ ui.label("Nombre total d'épisodes:");
78+ ui.label(format!("{}", stats.total_episodes));
79+ ui.end_row();
80+
81+ ui.label("Épisodes téléchargés:");
82+ ui.label(format!("{}", stats.downloaded_episodes));
83+ ui.end_row();
84+
85+ ui.label("Épisodes non téléchargés:");
86+ ui.label(format!("{}", stats.not_downloaded_episodes));
87+ ui.end_row();
88+
89+ ui.label("Taille totale des téléchargements:");
90+ ui.label(format!("{}", stats.get_formatted_total_size()));
91+ ui.end_row();
92+
93+ ui.label("Pourcentage téléchargé:");
94+ ui.label(format!("{:.1}%", stats.get_download_percentage()));
95+ ui.end_row();
96+
97+ ui.label("Taille moyenne par fichier:");
98+ ui.label(format!("{}", format_bytes(stats.get_average_file_size())));
99+ ui.end_row();
100+
101+ ui.label("Nombre de synchronisations:");
102+ ui.label(format!("{}", stats.total_sync_count));
103+ ui.end_row();
104+
105+ ui.label("Nombre de téléchargements:");
106+ ui.label(format!("{}", stats.total_download_count));
107+ ui.end_row();
108+ });
109+
110+ // Afficher les dates
111+ ui.separator();
112+ ui.label("📅 Dates:");
113+
114+ if let Some(date) = stats.last_sync_date {
115+ ui.label(format!("Dernière synchronisation: {}", date.format("%Y-%m-%d %H:%M:%S")));
116+ } else {
117+ ui.label("Dernière synchronisation: Jamais");
118+ }
119+
120+ if let Some(date) = stats.last_download_date {
121+ ui.label(format!("Dernier téléchargement: {}", date.format("%Y-%m-%d %H:%M:%S")));
122+ } else {
123+ ui.label("Dernier téléchargement: Jamais");
124+ }
125+ }
126+
127+ /// Afficher les statistiques par flux
128+ fn show_feed_stats(&self, ui: &mut egui::Ui, stats: &AppStatistics) {
129+ ui.label("📻 Statistiques par Flux");
130+ ui.separator();
131+
132+ if stats.feed_statistics.is_empty() {
133+ ui.label("Aucun flux disponible");
134+ return;
135+ }
136+
137+ // Tableau des statistiques par flux
138+ egui::ScrollArea::vertical().show(ui, |ui| {
139+ egui::Grid::new("feed_stats_grid")
140+ .striped(true)
141+ .show(ui, |ui| {
142+ // En-têtes
143+ ui.strong("Nom du flux");
144+ ui.strong("Épisodes");
145+ ui.strong("Téléchargés");
146+ ui.strong("Taille");
147+ ui.strong("% Téléchargé");
148+ ui.end_row();
149+
150+ // Lignes de données
151+ let mut feed_stats: Vec<_> = stats.feed_statistics.values().collect();
152+ feed_stats.sort_by(|a, b| b.total_episodes.cmp(&a.total_episodes));
153+
154+ for feed_stat in feed_stats {
155+ ui.label(&feed_stat.feed_name);
156+ ui.label(format!("{}", feed_stat.total_episodes));
157+ ui.label(format!("{}", feed_stat.downloaded_episodes));
158+ ui.label(format!("{}", feed_stat.get_formatted_size()));
159+ ui.label(format!("{:.1}%", feed_stat.get_download_percentage()));
160+ ui.end_row();
161+ }
162+ });
163+ });
164+ }
165+
166+ /// Afficher un widget de statistiques rapide (pour la barre de statut)
167+ pub fn show_quick_stats(ui: &mut egui::Ui, stats: &AppStatistics) {
168+ ui.horizontal(|ui| {
169+ ui.label(format!("📊 {} flux | {} épisodes | {}",
170+ stats.total_feeds,
171+ stats.total_episodes,
172+ stats.get_formatted_total_size()
173+ ));
174+ });
175+ }
176+}
new file mode 100644
@@ -0,0 +1,176 @@
1+use crate::config::statistics::{AppStatistics, format_bytes};
2+use eframe::egui;
3+
4+/// Panneau d'affichage des statistiques
5+pub struct StatsPanel {
6+ /// Visibilité du panneau
7+ visible: bool,
8+}
9+
10+impl Default for StatsPanel {
11+ fn default() -> Self {
12+ Self { visible: false }
13+ }
14+}
15+
16+impl StatsPanel {
17+ /// Créer un nouveau panneau de statistiques
18+ pub fn new() -> Self {
19+ Self::default()
20+ }
21+
22+ /// Définir la visibilité
23+ pub fn set_visible(&mut self, visible: bool) {
24+ self.visible = visible;
25+ }
26+
27+ /// Basculer la visibilité
28+ pub fn toggle(&mut self) {
29+ self.visible = !self.visible;
30+ }
31+
32+ /// Vérifier si le panneau est visible
33+ pub fn is_visible(&self) -> bool {
34+ self.visible
35+ }
36+
37+ /// Afficher le panneau de statistiques
38+ pub fn show(&mut self, ui: &mut egui::Ui, stats: &AppStatistics) {
39+ if !self.visible {
40+ return;
41+ }
42+
43+ egui::CollapsingHeader::new("📊 Statistiques")
44+ .default_open(true)
45+ .show(ui, |ui| {
46+ self.show_content(ui, stats);
47+ });
48+ }
49+
50+ /// Afficher le contenu du panneau
51+ fn show_content(&self, ui: &mut egui::Ui, stats: &AppStatistics) {
52+ // Statistiques globales
53+ self.show_global_stats(ui, stats);
54+
55+ ui.separator();
56+
57+ // Statistiques par flux
58+ self.show_feed_stats(ui, stats);
59+ }
60+
61+ /// Afficher les statistiques globales
62+ fn show_global_stats(&self, ui: &mut egui::Ui, stats: &AppStatistics) {
63+ ui.label("🌍 Statistiques Globales");
64+ ui.separator();
65+
66+ egui::Grid::new("global_stats_grid")
67+ .striped(true)
68+ .show(ui, |ui| {
69+ ui.label("Nombre total de flux:");
70+ ui.label(format!("{}", stats.total_feeds));
71+ ui.end_row();
72+
73+ ui.label("Flux activés:");
74+ ui.label(format!("{}", stats.enabled_feeds));
75+ ui.end_row();
76+
77+ ui.label("Nombre total d'épisodes:");
78+ ui.label(format!("{}", stats.total_episodes));
79+ ui.end_row();
80+
81+ ui.label("Épisodes téléchargés:");
82+ ui.label(format!("{}", stats.downloaded_episodes));
83+ ui.end_row();
84+
85+ ui.label("Épisodes non téléchargés:");
86+ ui.label(format!("{}", stats.not_downloaded_episodes));
87+ ui.end_row();
88+
89+ ui.label("Taille totale des téléchargements:");
90+ ui.label(format!("{}", stats.get_formatted_total_size()));
91+ ui.end_row();
92+
93+ ui.label("Pourcentage téléchargé:");
94+ ui.label(format!("{:.1}%", stats.get_download_percentage()));
95+ ui.end_row();
96+
97+ ui.label("Taille moyenne par fichier:");
98+ ui.label(format!("{}", format_bytes(stats.get_average_file_size())));
99+ ui.end_row();
100+
101+ ui.label("Nombre de synchronisations:");
102+ ui.label(format!("{}", stats.total_sync_count));
103+ ui.end_row();
104+
105+ ui.label("Nombre de téléchargements:");
106+ ui.label(format!("{}", stats.total_download_count));
107+ ui.end_row();
108+ });
109+
110+ // Afficher les dates
111+ ui.separator();
112+ ui.label("📅 Dates:");
113+
114+ if let Some(date) = stats.last_sync_date {
115+ ui.label(format!("Dernière synchronisation: {}", date.format("%Y-%m-%d %H:%M:%S")));
116+ } else {
117+ ui.label("Dernière synchronisation: Jamais");
118+ }
119+
120+ if let Some(date) = stats.last_download_date {
121+ ui.label(format!("Dernier téléchargement: {}", date.format("%Y-%m-%d %H:%M:%S")));
122+ } else {
123+ ui.label("Dernier téléchargement: Jamais");
124+ }
125+ }
126+
127+ /// Afficher les statistiques par flux
128+ fn show_feed_stats(&self, ui: &mut egui::Ui, stats: &AppStatistics) {
129+ ui.label("📻 Statistiques par Flux");
130+ ui.separator();
131+
132+ if stats.feed_statistics.is_empty() {
133+ ui.label("Aucun flux disponible");
134+ return;
135+ }
136+
137+ // Tableau des statistiques par flux
138+ egui::ScrollArea::vertical().show(ui, |ui| {
139+ egui::Grid::new("feed_stats_grid")
140+ .striped(true)
141+ .show(ui, |ui| {
142+ // En-têtes
143+ ui.strong("Nom du flux");
144+ ui.strong("Épisodes");
145+ ui.strong("Téléchargés");
146+ ui.strong("Taille");
147+ ui.strong("% Téléchargé");
148+ ui.end_row();
149+
150+ // Lignes de données
151+ let mut feed_stats: Vec<_> = stats.feed_statistics.values().collect();
152+ feed_stats.sort_by(|a, b| b.total_episodes.cmp(&a.total_episodes));
153+
154+ for feed_stat in feed_stats {
155+ ui.label(&feed_stat.feed_name);
156+ ui.label(format!("{}", feed_stat.total_episodes));
157+ ui.label(format!("{}", feed_stat.downloaded_episodes));
158+ ui.label(format!("{}", feed_stat.get_formatted_size()));
159+ ui.label(format!("{:.1}%", feed_stat.get_download_percentage()));
160+ ui.end_row();
161+ }
162+ });
163+ });
164+ }
165+
166+ /// Afficher un widget de statistiques rapide (pour la barre de statut)
167+ pub fn show_quick_stats(ui: &mut egui::Ui, stats: &AppStatistics) {
168+ ui.horizontal(|ui| {
169+ ui.label(format!("📊 {} flux | {} épisodes | {}",
170+ stats.total_feeds,
171+ stats.total_episodes,
172+ stats.get_formatted_total_size()
173+ ));
174+ });
175+ }
176+}