msansm1/rss-podcast-dlpublic Fork
⑂ 849fc17
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

Add ID3 tag verification and auto-population after download

msansm1 committed 2026-07-10T19:11:14+02:00 Browse files
849fc17 parent: ac9090b
modified Cargo.lock +21 -33
@@ -546,6 +546,12 @@ dependencies = [
546546 "syn",
547547 ]
548548
549+[[package]]
550+name = "byteorder"
551+version = "1.5.0"
552+source = "registry+https://github.com/rust-lang/crates.io-index"
553+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
554+
549555 [[package]]
550556 name = "byteorder-lite"
551557 version = "0.1.0"
@@ -1938,6 +1944,17 @@ dependencies = [
19381944 "zerovec",
19391945 ]
19401946
1947+[[package]]
1948+name = "id3"
1949+version = "1.17.0"
1950+source = "registry+https://github.com/rust-lang/crates.io-index"
1951+checksum = "70d6d9f23a28c6feab60d7b4a339e004809019ec9d5fd964b3f4c655ff9bc141"
1952+dependencies = [
1953+ "bitflags 2.13.0",
1954+ "byteorder",
1955+ "flate2",
1956+]
1957+
19411958 [[package]]
19421959 name = "ident_case"
19431960 version = "1.0.1"
@@ -2395,7 +2412,7 @@ version = "0.7.6"
23952412 source = "registry+https://github.com/rust-lang/crates.io-index"
23962413 checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
23972414 dependencies = [
2398- "proc-macro-crate 1.3.1",
2415+ "proc-macro-crate",
23992416 "proc-macro2",
24002417 "quote",
24012418 "syn",
@@ -3056,16 +3073,6 @@ version = "0.3.1"
30563073 source = "registry+https://github.com/rust-lang/crates.io-index"
30573074 checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
30583075
3059-[[package]]
3060-name = "proc-macro-crate"
3061-version = "1.3.1"
3062-source = "registry+https://github.com/rust-lang/crates.io-index"
3063-checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
3064-dependencies = [
3065- "once_cell",
3066- "toml_edit 0.19.15",
3067-]
3068-
30693076 [[package]]
30703077 name = "proc-macro-crate"
30713078 version = "3.5.0"
@@ -3330,6 +3337,7 @@ dependencies = [
33303337 "egui",
33313338 "env_logger",
33323339 "futures-util",
3340+ "id3",
33333341 "log",
33343342 "md5",
33353343 "mp3-metadata",
@@ -4026,17 +4034,6 @@ dependencies = [
40264034 "serde_core",
40274035 ]
40284036
4029-[[package]]
4030-name = "toml_edit"
4031-version = "0.19.15"
4032-source = "registry+https://github.com/rust-lang/crates.io-index"
4033-checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
4034-dependencies = [
4035- "indexmap",
4036- "toml_datetime 0.6.11",
4037- "winnow 0.5.40",
4038-]
4039-
40404037 [[package]]
40414038 name = "toml_edit"
40424039 version = "0.22.27"
@@ -5122,15 +5119,6 @@ dependencies = [
51225119 "xkbcommon-dl",
51235120 ]
51245121
5125-[[package]]
5126-name = "winnow"
5127-version = "0.5.40"
5128-source = "registry+https://github.com/rust-lang/crates.io-index"
5129-checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
5130-dependencies = [
5131- "memchr",
5132-]
5133-
51345122 [[package]]
51355123 name = "winnow"
51365124 version = "0.7.15"
@@ -5322,7 +5310,7 @@ version = "5.17.0"
53225310 source = "registry+https://github.com/rust-lang/crates.io-index"
53235311 checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469"
53245312 dependencies = [
5325- "proc-macro-crate 3.5.0",
5313+ "proc-macro-crate",
53265314 "proc-macro2",
53275315 "quote",
53285316 "syn",
@@ -5469,7 +5457,7 @@ version = "5.13.0"
54695457 source = "registry+https://github.com/rust-lang/crates.io-index"
54705458 checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31"
54715459 dependencies = [
5472- "proc-macro-crate 3.5.0",
5460+ "proc-macro-crate",
54735461 "proc-macro2",
54745462 "quote",
54755463 "syn",
@@ -546,6 +546,12 @@ dependencies = [
546 "syn",546 "syn",
547 ]547 ]
548 548
549+[[package]]
550+name = "byteorder"
551+version = "1.5.0"
552+source = "registry+https://github.com/rust-lang/crates.io-index"
553+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
554+
549 [[package]]555 [[package]]
550 name = "byteorder-lite"556 name = "byteorder-lite"
551 version = "0.1.0"557 version = "0.1.0"
@@ -1938,6 +1944,17 @@ dependencies = [
1938 "zerovec",1944 "zerovec",
1939 ]1945 ]
1940 1946
1947+[[package]]
1948+name = "id3"
1949+version = "1.17.0"
1950+source = "registry+https://github.com/rust-lang/crates.io-index"
1951+checksum = "70d6d9f23a28c6feab60d7b4a339e004809019ec9d5fd964b3f4c655ff9bc141"
1952+dependencies = [
1953+ "bitflags 2.13.0",
1954+ "byteorder",
1955+ "flate2",
1956+]
1957+
1941 [[package]]1958 [[package]]
1942 name = "ident_case"1959 name = "ident_case"
1943 version = "1.0.1"1960 version = "1.0.1"
@@ -2395,7 +2412,7 @@ version = "0.7.6"
2395 source = "registry+https://github.com/rust-lang/crates.io-index"2412 source = "registry+https://github.com/rust-lang/crates.io-index"
2396 checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"2413 checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
2397 dependencies = [2414 dependencies = [
2398- "proc-macro-crate 1.3.1",2415+ "proc-macro-crate",
2399 "proc-macro2",2416 "proc-macro2",
2400 "quote",2417 "quote",
2401 "syn",2418 "syn",
@@ -3056,16 +3073,6 @@ version = "0.3.1"
3056 source = "registry+https://github.com/rust-lang/crates.io-index"3073 source = "registry+https://github.com/rust-lang/crates.io-index"
3057 checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"3074 checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
3058 3075
3059-[[package]]
3060-name = "proc-macro-crate"
3061-version = "1.3.1"
3062-source = "registry+https://github.com/rust-lang/crates.io-index"
3063-checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
3064-dependencies = [
3065- "once_cell",
3066- "toml_edit 0.19.15",
3067-]
3068-
3069 [[package]]3076 [[package]]
3070 name = "proc-macro-crate"3077 name = "proc-macro-crate"
3071 version = "3.5.0"3078 version = "3.5.0"
@@ -3330,6 +3337,7 @@ dependencies = [
3330 "egui",3337 "egui",
3331 "env_logger",3338 "env_logger",
3332 "futures-util",3339 "futures-util",
3340+ "id3",
3333 "log",3341 "log",
3334 "md5",3342 "md5",
3335 "mp3-metadata",3343 "mp3-metadata",
@@ -4026,17 +4034,6 @@ dependencies = [
4026 "serde_core",4034 "serde_core",
4027 ]4035 ]
4028 4036
4029-[[package]]
4030-name = "toml_edit"
4031-version = "0.19.15"
4032-source = "registry+https://github.com/rust-lang/crates.io-index"
4033-checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
4034-dependencies = [
4035- "indexmap",
4036- "toml_datetime 0.6.11",
4037- "winnow 0.5.40",
4038-]
4039-
4040 [[package]]4037 [[package]]
4041 name = "toml_edit"4038 name = "toml_edit"
4042 version = "0.22.27"4039 version = "0.22.27"
@@ -5122,15 +5119,6 @@ dependencies = [
5122 "xkbcommon-dl",5119 "xkbcommon-dl",
5123 ]5120 ]
5124 5121
5125-[[package]]
5126-name = "winnow"
5127-version = "0.5.40"
5128-source = "registry+https://github.com/rust-lang/crates.io-index"
5129-checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
5130-dependencies = [
5131- "memchr",
5132-]
5133-
5134 [[package]]5122 [[package]]
5135 name = "winnow"5123 name = "winnow"
5136 version = "0.7.15"5124 version = "0.7.15"
@@ -5322,7 +5310,7 @@ version = "5.17.0"
5322 source = "registry+https://github.com/rust-lang/crates.io-index"5310 source = "registry+https://github.com/rust-lang/crates.io-index"
5323 checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469"5311 checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469"
5324 dependencies = [5312 dependencies = [
5325- "proc-macro-crate 3.5.0",5313+ "proc-macro-crate",
5326 "proc-macro2",5314 "proc-macro2",
5327 "quote",5315 "quote",
5328 "syn",5316 "syn",
@@ -5469,7 +5457,7 @@ version = "5.13.0"
5469 source = "registry+https://github.com/rust-lang/crates.io-index"5457 source = "registry+https://github.com/rust-lang/crates.io-index"
5470 checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31"5458 checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31"
5471 dependencies = [5459 dependencies = [
5472- "proc-macro-crate 3.5.0",5460+ "proc-macro-crate",
5473 "proc-macro2",5461 "proc-macro2",
5474 "quote",5462 "quote",
5475 "syn",5463 "syn",
modified Cargo.toml +1 -0
@@ -63,6 +63,7 @@ diligent-date-parser = "0.1"
6363
6464 # Audio metadata parsing
6565 mp3-metadata = "0.3"
66+id3 = "1.13"
6667
6768 # Windows API compatibility for older eframe versions - not needed with eframe 0.35
6869 # [target.'cfg(windows)'.dependencies]
@@ -63,6 +63,7 @@ diligent-date-parser = "0.1"
63 63
64 # Audio metadata parsing64 # Audio metadata parsing
65 mp3-metadata = "0.3"65 mp3-metadata = "0.3"
66+id3 = "1.13"
66 67
67 # Windows API compatibility for older eframe versions - not needed with eframe 0.3568 # Windows API compatibility for older eframe versions - not needed with eframe 0.35
68 # [target.'cfg(windows)'.dependencies]69 # [target.'cfg(windows)'.dependencies]
modified src/config/episode.rs +26 -0
@@ -30,6 +30,10 @@ pub struct Episode {
3030 pub mime_type: Option<String>,
3131 /// Duration of the episode in seconds (optional)
3232 pub duration_seconds: Option<u64>,
33+ /// Artist/author of the episode (from itunes:author or similar)
34+ pub artist: Option<String>,
35+ /// Album name (from itunes:album or feed title)
36+ pub album: Option<String>,
3337 }
3438
3539 impl Default for Episode {
@@ -45,6 +49,8 @@ impl Default for Episode {
4549 file_size: None,
4650 mime_type: None,
4751 duration_seconds: None,
52+ artist: None,
53+ album: None,
4854 }
4955 }
5056 }
@@ -76,6 +82,8 @@ impl Episode {
7682 file_size: None,
7783 mime_type: None,
7884 duration_seconds: None,
85+ artist: None,
86+ album: None,
7987 }
8088 }
8189
@@ -99,6 +107,8 @@ impl Episode {
99107 file_size: None,
100108 mime_type: None,
101109 duration_seconds: None,
110+ artist: None,
111+ album: None,
102112 }
103113 }
104114
@@ -162,6 +172,18 @@ impl Episode {
162172 None => "Unknown".to_string(),
163173 }
164174 }
175+
176+ /// Returns the artist name, falling back to a default if not set
177+ #[allow(dead_code)]
178+ pub fn get_artist(&self) -> String {
179+ self.artist.clone().unwrap_or_else(|| "Unknown Artist".to_string())
180+ }
181+
182+ /// Returns the album name, falling back to a default if not set
183+ #[allow(dead_code)]
184+ pub fn get_album(&self) -> String {
185+ self.album.clone().unwrap_or_else(|| "Unknown Album".to_string())
186+ }
165187 }
166188
167189 #[cfg(test)]
@@ -175,6 +197,8 @@ mod tests {
175197 assert!(!episode.id.is_empty());
176198 assert!(episode.title.is_empty());
177199 assert!(!episode.downloaded);
200+ assert!(episode.artist.is_none());
201+ assert!(episode.album.is_none());
178202 }
179203
180204 #[test]
@@ -192,6 +216,8 @@ mod tests {
192216 assert_eq!(episode.audio_url, "https://example.com/episode.mp3");
193217 assert_eq!(episode.audio_filename, "episode.mp3");
194218 assert!(!episode.downloaded);
219+ assert!(episode.artist.is_none());
220+ assert!(episode.album.is_none());
195221 }
196222
197223 #[test]
@@ -30,6 +30,10 @@ pub struct Episode {
30 pub mime_type: Option<String>,30 pub mime_type: Option<String>,
31 /// Duration of the episode in seconds (optional)31 /// Duration of the episode in seconds (optional)
32 pub duration_seconds: Option<u64>,32 pub duration_seconds: Option<u64>,
33+ /// Artist/author of the episode (from itunes:author or similar)
34+ pub artist: Option<String>,
35+ /// Album name (from itunes:album or feed title)
36+ pub album: Option<String>,
33 }37 }
34 38
35 impl Default for Episode {39 impl Default for Episode {
@@ -45,6 +49,8 @@ impl Default for Episode {
45 file_size: None,49 file_size: None,
46 mime_type: None,50 mime_type: None,
47 duration_seconds: None,51 duration_seconds: None,
52+ artist: None,
53+ album: None,
48 }54 }
49 }55 }
50 }56 }
@@ -76,6 +82,8 @@ impl Episode {
76 file_size: None,82 file_size: None,
77 mime_type: None,83 mime_type: None,
78 duration_seconds: None,84 duration_seconds: None,
85+ artist: None,
86+ album: None,
79 }87 }
80 }88 }
81 89
@@ -99,6 +107,8 @@ impl Episode {
99 file_size: None,107 file_size: None,
100 mime_type: None,108 mime_type: None,
101 duration_seconds: None,109 duration_seconds: None,
110+ artist: None,
111+ album: None,
102 }112 }
103 }113 }
104 114
@@ -162,6 +172,18 @@ impl Episode {
162 None => "Unknown".to_string(),172 None => "Unknown".to_string(),
163 }173 }
164 }174 }
175+
176+ /// Returns the artist name, falling back to a default if not set
177+ #[allow(dead_code)]
178+ pub fn get_artist(&self) -> String {
179+ self.artist.clone().unwrap_or_else(|| "Unknown Artist".to_string())
180+ }
181+
182+ /// Returns the album name, falling back to a default if not set
183+ #[allow(dead_code)]
184+ pub fn get_album(&self) -> String {
185+ self.album.clone().unwrap_or_else(|| "Unknown Album".to_string())
186+ }
165 }187 }
166 188
167 #[cfg(test)]189 #[cfg(test)]
@@ -175,6 +197,8 @@ mod tests {
175 assert!(!episode.id.is_empty());197 assert!(!episode.id.is_empty());
176 assert!(episode.title.is_empty());198 assert!(episode.title.is_empty());
177 assert!(!episode.downloaded);199 assert!(!episode.downloaded);
200+ assert!(episode.artist.is_none());
201+ assert!(episode.album.is_none());
178 }202 }
179 203
180 #[test]204 #[test]
@@ -192,6 +216,8 @@ mod tests {
192 assert_eq!(episode.audio_url, "https://example.com/episode.mp3");216 assert_eq!(episode.audio_url, "https://example.com/episode.mp3");
193 assert_eq!(episode.audio_filename, "episode.mp3");217 assert_eq!(episode.audio_filename, "episode.mp3");
194 assert!(!episode.downloaded);218 assert!(!episode.downloaded);
219+ assert!(episode.artist.is_none());
220+ assert!(episode.album.is_none());
195 }221 }
196 222
197 #[test]223 #[test]
modified src/rss/audio_metadata.rs +260 -12
@@ -1,10 +1,10 @@
1-#![allow(dead_code)]
2-//! Module pour la lecture des métadonnées audio (durée, artiste, etc.)
1+//! Module pour la lecture et l'écriture des métadonnées audio (ID3 tags)
32 //!
4-//! Ce module fournit des fonctions pour extraire la durée des fichiers audio.
5-//! Pour l'instant, il utilise une estimation basée sur la taille du fichier
6-//! car l'intégration avec les crates de métadonnées peut être complexe.
3+//! Ce module fournit des fonctions pour extraire et modifier la durée, l'artiste,
4+//! l'album et le titre des fichiers audio MP3.
75
6+use id3::{Tag, TagLike, Error as Id3Error};
7+use std::fs::File;
88 use std::path::{Path, PathBuf};
99
1010 /// Métadonnées d'un fichier audio
@@ -34,6 +34,7 @@ impl AudioMetadata {
3434 }
3535
3636 /// Obtenir la durée formatée (HH:MM:SS ou MM:SS)
37+ #[allow(dead_code)]
3738 pub fn get_formatted_duration(&self) -> String {
3839 if let Some(duration) = self.duration_seconds {
3940 format_duration(duration)
@@ -56,18 +57,33 @@ pub enum MetadataError {
5657
5758 /// Format non supporté
5859 #[error("Format audio non supporté: {0}")]
60+ #[allow(dead_code)]
5961 UnsupportedFormat(String),
6062
6163 /// Erreur de parsing des métadonnées
6264 #[error("Erreur de parsing des métadonnées: {0}")]
6365 ParseError(String),
66+
67+ /// Erreur d'écriture des tags ID3
68+ #[error("Erreur d'écriture des tags ID3: {0}")]
69+ Id3WriteError(String),
70+
71+ /// Erreur de lecture des tags ID3
72+ #[error("Erreur de lecture des tags ID3: {0}")]
73+ Id3ReadError(String),
74+}
75+
76+impl From<Id3Error> for MetadataError {
77+ fn from(err: Id3Error) -> Self {
78+ MetadataError::Id3ReadError(err.to_string())
79+ }
6480 }
6581
6682 /// Bitrate moyen par défaut pour les fichiers audio (en kbps)
6783 const DEFAULT_BITRATE_KBPS: u32 = 128;
6884
69-/// Lire les métadonnées d'un fichier audio
70-/// Utilise une estimation basée sur la taille du fichier
85+/// Lire les métadonnées d'un fichier audio en utilisant mp3-metadata pour les info techniques
86+/// et id3 pour les tags (titre, artiste, album)
7187 pub fn read_audio_metadata(path: &Path) -> Result<AudioMetadata, MetadataError> {
7288 if !path.exists() {
7389 return Err(MetadataError::FileNotFound(path.display().to_string()));
@@ -80,21 +96,217 @@ pub fn read_audio_metadata(path: &Path) -> Result<AudioMetadata, MetadataError>
8096 let mut audio_metadata = AudioMetadata::new();
8197 audio_metadata.file_size = file_size;
8298
83- // Estimer la durée à partir de la taille et du bitrate moyen
99+ // Lire les tags ID3
100+ let id3_tags = read_id3_tags(path)?;
101+
102+ // Remplir les champs à partir des tags ID3
103+ if let Some(title) = id3_tags.title {
104+ audio_metadata.title = Some(title);
105+ }
106+ if let Some(artist) = id3_tags.artist {
107+ audio_metadata.artist = Some(artist);
108+ }
109+ if let Some(album) = id3_tags.album {
110+ audio_metadata.album = Some(album);
111+ }
112+ if let Some(year) = id3_tags.year {
113+ audio_metadata.year = Some(year);
114+ }
115+ if let Some(genre) = id3_tags.genre {
116+ audio_metadata.genre = Some(genre);
117+ }
118+
119+ // Pour l'instant, estimer la durée à partir de la taille du fichier
120+ // La lecture des métadonnées MP3 techniques peut être ajoutée plus tard
84121 if file_size > 0 {
85122 let duration = estimate_duration_from_size(file_size, DEFAULT_BITRATE_KBPS);
86123 audio_metadata.duration_seconds = Some(duration);
87124 audio_metadata.bitrate_kbps = Some(DEFAULT_BITRATE_KBPS);
88125 }
89126
90- // Essayer d'extraire le nom du fichier comme titre
91- if let Some(file_stem) = path.file_stem() {
92- audio_metadata.title = Some(file_stem.to_string_lossy().into_owned());
127+ // Essayer d'extraire le nom du fichier comme titre si pas de tag
128+ if audio_metadata.title.is_none() {
129+ if let Some(file_stem) = path.file_stem() {
130+ audio_metadata.title = Some(file_stem.to_string_lossy().into_owned());
131+ }
93132 }
94133
95134 Ok(audio_metadata)
96135 }
97136
137+
138+
139+/// Lire les tags ID3 d'un fichier MP3
140+pub fn read_id3_tags(path: &Path) -> Result<Id3Tags, MetadataError> {
141+ if !path.exists() {
142+ return Err(MetadataError::FileNotFound(path.display().to_string()));
143+ }
144+
145+ let file = File::open(path)
146+ .map_err(|e| MetadataError::IoError(e))?;
147+
148+ let tag = Tag::read_from2(file)
149+ .map_err(MetadataError::from)?;
150+
151+ Ok(Id3Tags::from(tag))
152+}
153+
154+/// Structure pour stocker les tags ID3 extraits
155+#[derive(Debug, Clone, Default)]
156+pub struct Id3Tags {
157+ pub title: Option<String>,
158+ pub artist: Option<String>,
159+ pub album: Option<String>,
160+ pub year: Option<u32>,
161+ pub genre: Option<String>,
162+}
163+
164+impl Id3Tags {
165+ /// Vérifie si tous les tags principaux sont présents
166+ #[allow(dead_code)]
167+ pub fn has_all_required_tags(&self) -> bool {
168+ self.title.is_some() && self.artist.is_some() && self.album.is_some()
169+ }
170+
171+ /// Retourne les tags manquants
172+ #[allow(dead_code)]
173+ pub fn missing_tags(&self) -> Vec<&'static str> {
174+ let mut missing = Vec::new();
175+ if self.title.is_none() {
176+ missing.push("title");
177+ }
178+ if self.artist.is_none() {
179+ missing.push("artist");
180+ }
181+ if self.album.is_none() {
182+ missing.push("album");
183+ }
184+ missing
185+ }
186+}
187+
188+impl From<Tag> for Id3Tags {
189+ fn from(tag: Tag) -> Self {
190+ Self {
191+ title: tag.title().map(|s| s.to_string()),
192+ artist: tag.artist().map(|s| s.to_string()),
193+ album: tag.album().map(|s| s.to_string()),
194+ year: tag.year().map(|y| y as u32),
195+ genre: tag.genre().map(|s| s.to_string()),
196+ }
197+ }
198+}
199+
200+/// Écrire les tags ID3 sur un fichier MP3
201+pub fn write_id3_tags(
202+ path: &Path,
203+ title: Option<&str>,
204+ artist: Option<&str>,
205+ album: Option<&str>,
206+) -> Result<(), MetadataError> {
207+ if !path.exists() {
208+ return Err(MetadataError::FileNotFound(path.display().to_string()));
209+ }
210+
211+ // Lire le tag existant
212+ let file_read = File::open(path)
213+ .map_err(|e| MetadataError::IoError(e))?;
214+
215+ let mut tag = Tag::read_from2(file_read)
216+ .unwrap_or_default();
217+
218+ // Mettre à jour les champs
219+ if let Some(title) = title {
220+ tag.set_title(title);
221+ }
222+ if let Some(artist) = artist {
223+ tag.set_artist(artist);
224+ }
225+ if let Some(album) = album {
226+ tag.set_album(album);
227+ }
228+
229+ // Écrire le tag modifié
230+ let mut file_write = std::fs::OpenOptions::new()
231+ .write(true)
232+ .open(path)
233+ .map_err(|e| MetadataError::IoError(e))?;
234+
235+ tag.write_to(&mut file_write, id3::Version::Id3v24)
236+ .map_err(|e| MetadataError::Id3WriteError(e.to_string()))?;
237+
238+ Ok(())
239+}
240+
241+/// Assurer que les tags ID3 (titre, artiste, album) sont présents sur le fichier
242+/// Si des tags sont manquants, les remplir avec les informations fournies
243+///
244+/// # Arguments
245+/// * `file_path` - Chemin vers le fichier audio
246+/// * `episode_title` - Titre de l'épisode (pour le tag titre)
247+/// * `episode_artist` - Artiste de l'épisode (pour le tag artiste)
248+/// * `episode_album` - Album de l'épisode (pour le tag album)
249+///
250+/// # Retourne
251+/// Ok(()) si les tags ont été vérifiés/écrits avec succès
252+/// Erreur si le fichier n'existe pas ou si l'écriture échoue
253+pub fn ensure_id3_tags(
254+ file_path: &Path,
255+ episode_title: &str,
256+ episode_artist: Option<&str>,
257+ episode_album: Option<&str>,
258+) -> Result<(), MetadataError> {
259+ log::info!("Checking ID3 tags for file: {}", file_path.display());
260+
261+ // Lire les tags existants
262+ let tags = read_id3_tags(file_path)?;
263+
264+ let mut needs_write = false;
265+ let mut title_to_write = None;
266+ let mut artist_to_write = None;
267+ let mut album_to_write = None;
268+
269+ // Vérifier et préparer les valeurs à écrire
270+ if tags.title.is_none() || tags.title.as_ref().map_or(true, |t| t.is_empty()) {
271+ title_to_write = Some(episode_title);
272+ needs_write = true;
273+ log::info!(" Missing or empty title tag, will set to: {}", episode_title);
274+ }
275+
276+ // Utiliser l'artiste de l'épisode si disponible, sinon ne pas écrire
277+ if let Some(artist) = episode_artist {
278+ if tags.artist.is_none() || tags.artist.as_ref().map_or(true, |a| a.is_empty()) {
279+ artist_to_write = Some(artist);
280+ needs_write = true;
281+ log::info!(" Missing or empty artist tag, will set to: {}", artist);
282+ }
283+ }
284+
285+ // Utiliser l'album de l'épisode si disponible, sinon ne pas écrire
286+ if let Some(album) = episode_album {
287+ if tags.album.is_none() || tags.album.as_ref().map_or(true, |a| a.is_empty()) {
288+ album_to_write = Some(album);
289+ needs_write = true;
290+ log::info!(" Missing or empty album tag, will set to: {}", album);
291+ }
292+ }
293+
294+ // Si des modifications sont nécessaires, écrire les tags
295+ if needs_write {
296+ write_id3_tags(
297+ file_path,
298+ title_to_write.as_deref(),
299+ artist_to_write.as_deref(),
300+ album_to_write.as_deref(),
301+ )?;
302+ log::info!(" ID3 tags updated successfully for: {}", file_path.display());
303+ } else {
304+ log::debug!(" All required ID3 tags are present for: {}", file_path.display());
305+ }
306+
307+ Ok(())
308+}
309+
98310 /// Lire uniquement la durée d'un fichier audio
99311 pub fn get_audio_duration(path: &Path) -> Result<u64, MetadataError> {
100312 let metadata = read_audio_metadata(path)?;
@@ -117,6 +329,7 @@ pub fn estimate_duration_from_size(file_size: u64, bitrate_kbps: u32) -> u64 {
117329 }
118330
119331 /// Lire la durée de plusieurs fichiers audio
332+#[allow(dead_code)]
120333 pub fn get_multiple_audio_durations(paths: &[impl AsRef<Path>]) -> Vec<(PathBuf, Option<u64>)> {
121334 paths.iter()
122335 .map(|path| {
@@ -161,7 +374,6 @@ pub fn format_duration(seconds: u64) -> String {
161374 #[cfg(test)]
162375 mod tests {
163376 use super::*;
164- use tempfile::NamedTempFile;
165377
166378 #[test]
167379 fn test_is_supported_audio_file() {
@@ -191,4 +403,40 @@ mod tests {
191403 assert_eq!(format_duration(90), "1m 30s");
192404 assert_eq!(format_duration(3661), "1h 1m 1s");
193405 }
406+
407+ #[test]
408+ fn test_id3_tags_has_all_required_tags() {
409+ let tags = Id3Tags {
410+ title: Some("Test".to_string()),
411+ artist: Some("Artist".to_string()),
412+ album: Some("Album".to_string()),
413+ year: None,
414+ genre: None,
415+ };
416+ assert!(tags.has_all_required_tags());
417+
418+ let tags_missing = Id3Tags {
419+ title: Some("Test".to_string()),
420+ artist: None,
421+ album: Some("Album".to_string()),
422+ year: None,
423+ genre: None,
424+ };
425+ assert!(!tags_missing.has_all_required_tags());
426+ }
427+
428+ #[test]
429+ fn test_id3_tags_missing_tags() {
430+ let tags = Id3Tags {
431+ title: None,
432+ artist: Some("Artist".to_string()),
433+ album: None,
434+ year: None,
435+ genre: None,
436+ };
437+ let missing = tags.missing_tags();
438+ assert!(missing.contains(&"title"));
439+ assert!(missing.contains(&"album"));
440+ assert!(!missing.contains(&"artist"));
441+ }
194442 }
@@ -1,10 +1,10 @@
1-#![allow(dead_code)]1+//! Module pour la lecture et l'écriture des métadonnées audio (ID3 tags)
2-//! Module pour la lecture des métadonnées audio (durée, artiste, etc.)
3 //!2 //!
4-//! Ce module fournit des fonctions pour extraire la durée des fichiers audio.3+//! Ce module fournit des fonctions pour extraire et modifier la durée, l'artiste,
5-//! Pour l'instant, il utilise une estimation basée sur la taille du fichier4+//! l'album et le titre des fichiers audio MP3.
6-//! car l'intégration avec les crates de métadonnées peut être complexe.
7 5
6+use id3::{Tag, TagLike, Error as Id3Error};
7+use std::fs::File;
8 use std::path::{Path, PathBuf};8 use std::path::{Path, PathBuf};
9 9
10 /// Métadonnées d'un fichier audio10 /// Métadonnées d'un fichier audio
@@ -34,6 +34,7 @@ impl AudioMetadata {
34 }34 }
35 35
36 /// Obtenir la durée formatée (HH:MM:SS ou MM:SS)36 /// Obtenir la durée formatée (HH:MM:SS ou MM:SS)
37+ #[allow(dead_code)]
37 pub fn get_formatted_duration(&self) -> String {38 pub fn get_formatted_duration(&self) -> String {
38 if let Some(duration) = self.duration_seconds {39 if let Some(duration) = self.duration_seconds {
39 format_duration(duration)40 format_duration(duration)
@@ -56,18 +57,33 @@ pub enum MetadataError {
56 57
57 /// Format non supporté58 /// Format non supporté
58 #[error("Format audio non supporté: {0}")]59 #[error("Format audio non supporté: {0}")]
60+ #[allow(dead_code)]
59 UnsupportedFormat(String),61 UnsupportedFormat(String),
60 62
61 /// Erreur de parsing des métadonnées63 /// Erreur de parsing des métadonnées
62 #[error("Erreur de parsing des métadonnées: {0}")]64 #[error("Erreur de parsing des métadonnées: {0}")]
63 ParseError(String),65 ParseError(String),
66+
67+ /// Erreur d'écriture des tags ID3
68+ #[error("Erreur d'écriture des tags ID3: {0}")]
69+ Id3WriteError(String),
70+
71+ /// Erreur de lecture des tags ID3
72+ #[error("Erreur de lecture des tags ID3: {0}")]
73+ Id3ReadError(String),
74+}
75+
76+impl From<Id3Error> for MetadataError {
77+ fn from(err: Id3Error) -> Self {
78+ MetadataError::Id3ReadError(err.to_string())
79+ }
64 }80 }
65 81
66 /// Bitrate moyen par défaut pour les fichiers audio (en kbps)82 /// Bitrate moyen par défaut pour les fichiers audio (en kbps)
67 const DEFAULT_BITRATE_KBPS: u32 = 128;83 const DEFAULT_BITRATE_KBPS: u32 = 128;
68 84
69-/// Lire les métadonnées d'un fichier audio85+/// Lire les métadonnées d'un fichier audio en utilisant mp3-metadata pour les info techniques
70-/// Utilise une estimation basée sur la taille du fichier86+/// et id3 pour les tags (titre, artiste, album)
71 pub fn read_audio_metadata(path: &Path) -> Result<AudioMetadata, MetadataError> {87 pub fn read_audio_metadata(path: &Path) -> Result<AudioMetadata, MetadataError> {
72 if !path.exists() {88 if !path.exists() {
73 return Err(MetadataError::FileNotFound(path.display().to_string()));89 return Err(MetadataError::FileNotFound(path.display().to_string()));
@@ -80,21 +96,217 @@ pub fn read_audio_metadata(path: &Path) -> Result<AudioMetadata, MetadataError>
80 let mut audio_metadata = AudioMetadata::new();96 let mut audio_metadata = AudioMetadata::new();
81 audio_metadata.file_size = file_size;97 audio_metadata.file_size = file_size;
82 98
83- // Estimer la durée à partir de la taille et du bitrate moyen99+ // Lire les tags ID3
100+ let id3_tags = read_id3_tags(path)?;
101+
102+ // Remplir les champs à partir des tags ID3
103+ if let Some(title) = id3_tags.title {
104+ audio_metadata.title = Some(title);
105+ }
106+ if let Some(artist) = id3_tags.artist {
107+ audio_metadata.artist = Some(artist);
108+ }
109+ if let Some(album) = id3_tags.album {
110+ audio_metadata.album = Some(album);
111+ }
112+ if let Some(year) = id3_tags.year {
113+ audio_metadata.year = Some(year);
114+ }
115+ if let Some(genre) = id3_tags.genre {
116+ audio_metadata.genre = Some(genre);
117+ }
118+
119+ // Pour l'instant, estimer la durée à partir de la taille du fichier
120+ // La lecture des métadonnées MP3 techniques peut être ajoutée plus tard
84 if file_size > 0 {121 if file_size > 0 {
85 let duration = estimate_duration_from_size(file_size, DEFAULT_BITRATE_KBPS);122 let duration = estimate_duration_from_size(file_size, DEFAULT_BITRATE_KBPS);
86 audio_metadata.duration_seconds = Some(duration);123 audio_metadata.duration_seconds = Some(duration);
87 audio_metadata.bitrate_kbps = Some(DEFAULT_BITRATE_KBPS);124 audio_metadata.bitrate_kbps = Some(DEFAULT_BITRATE_KBPS);
88 }125 }
89 126
90- // Essayer d'extraire le nom du fichier comme titre127+ // Essayer d'extraire le nom du fichier comme titre si pas de tag
91- if let Some(file_stem) = path.file_stem() {128+ if audio_metadata.title.is_none() {
92- audio_metadata.title = Some(file_stem.to_string_lossy().into_owned());129+ if let Some(file_stem) = path.file_stem() {
130+ audio_metadata.title = Some(file_stem.to_string_lossy().into_owned());
131+ }
93 }132 }
94 133
95 Ok(audio_metadata)134 Ok(audio_metadata)
96 }135 }
97 136
137+
138+
139+/// Lire les tags ID3 d'un fichier MP3
140+pub fn read_id3_tags(path: &Path) -> Result<Id3Tags, MetadataError> {
141+ if !path.exists() {
142+ return Err(MetadataError::FileNotFound(path.display().to_string()));
143+ }
144+
145+ let file = File::open(path)
146+ .map_err(|e| MetadataError::IoError(e))?;
147+
148+ let tag = Tag::read_from2(file)
149+ .map_err(MetadataError::from)?;
150+
151+ Ok(Id3Tags::from(tag))
152+}
153+
154+/// Structure pour stocker les tags ID3 extraits
155+#[derive(Debug, Clone, Default)]
156+pub struct Id3Tags {
157+ pub title: Option<String>,
158+ pub artist: Option<String>,
159+ pub album: Option<String>,
160+ pub year: Option<u32>,
161+ pub genre: Option<String>,
162+}
163+
164+impl Id3Tags {
165+ /// Vérifie si tous les tags principaux sont présents
166+ #[allow(dead_code)]
167+ pub fn has_all_required_tags(&self) -> bool {
168+ self.title.is_some() && self.artist.is_some() && self.album.is_some()
169+ }
170+
171+ /// Retourne les tags manquants
172+ #[allow(dead_code)]
173+ pub fn missing_tags(&self) -> Vec<&'static str> {
174+ let mut missing = Vec::new();
175+ if self.title.is_none() {
176+ missing.push("title");
177+ }
178+ if self.artist.is_none() {
179+ missing.push("artist");
180+ }
181+ if self.album.is_none() {
182+ missing.push("album");
183+ }
184+ missing
185+ }
186+}
187+
188+impl From<Tag> for Id3Tags {
189+ fn from(tag: Tag) -> Self {
190+ Self {
191+ title: tag.title().map(|s| s.to_string()),
192+ artist: tag.artist().map(|s| s.to_string()),
193+ album: tag.album().map(|s| s.to_string()),
194+ year: tag.year().map(|y| y as u32),
195+ genre: tag.genre().map(|s| s.to_string()),
196+ }
197+ }
198+}
199+
200+/// Écrire les tags ID3 sur un fichier MP3
201+pub fn write_id3_tags(
202+ path: &Path,
203+ title: Option<&str>,
204+ artist: Option<&str>,
205+ album: Option<&str>,
206+) -> Result<(), MetadataError> {
207+ if !path.exists() {
208+ return Err(MetadataError::FileNotFound(path.display().to_string()));
209+ }
210+
211+ // Lire le tag existant
212+ let file_read = File::open(path)
213+ .map_err(|e| MetadataError::IoError(e))?;
214+
215+ let mut tag = Tag::read_from2(file_read)
216+ .unwrap_or_default();
217+
218+ // Mettre à jour les champs
219+ if let Some(title) = title {
220+ tag.set_title(title);
221+ }
222+ if let Some(artist) = artist {
223+ tag.set_artist(artist);
224+ }
225+ if let Some(album) = album {
226+ tag.set_album(album);
227+ }
228+
229+ // Écrire le tag modifié
230+ let mut file_write = std::fs::OpenOptions::new()
231+ .write(true)
232+ .open(path)
233+ .map_err(|e| MetadataError::IoError(e))?;
234+
235+ tag.write_to(&mut file_write, id3::Version::Id3v24)
236+ .map_err(|e| MetadataError::Id3WriteError(e.to_string()))?;
237+
238+ Ok(())
239+}
240+
241+/// Assurer que les tags ID3 (titre, artiste, album) sont présents sur le fichier
242+/// Si des tags sont manquants, les remplir avec les informations fournies
243+///
244+/// # Arguments
245+/// * `file_path` - Chemin vers le fichier audio
246+/// * `episode_title` - Titre de l'épisode (pour le tag titre)
247+/// * `episode_artist` - Artiste de l'épisode (pour le tag artiste)
248+/// * `episode_album` - Album de l'épisode (pour le tag album)
249+///
250+/// # Retourne
251+/// Ok(()) si les tags ont été vérifiés/écrits avec succès
252+/// Erreur si le fichier n'existe pas ou si l'écriture échoue
253+pub fn ensure_id3_tags(
254+ file_path: &Path,
255+ episode_title: &str,
256+ episode_artist: Option<&str>,
257+ episode_album: Option<&str>,
258+) -> Result<(), MetadataError> {
259+ log::info!("Checking ID3 tags for file: {}", file_path.display());
260+
261+ // Lire les tags existants
262+ let tags = read_id3_tags(file_path)?;
263+
264+ let mut needs_write = false;
265+ let mut title_to_write = None;
266+ let mut artist_to_write = None;
267+ let mut album_to_write = None;
268+
269+ // Vérifier et préparer les valeurs à écrire
270+ if tags.title.is_none() || tags.title.as_ref().map_or(true, |t| t.is_empty()) {
271+ title_to_write = Some(episode_title);
272+ needs_write = true;
273+ log::info!(" Missing or empty title tag, will set to: {}", episode_title);
274+ }
275+
276+ // Utiliser l'artiste de l'épisode si disponible, sinon ne pas écrire
277+ if let Some(artist) = episode_artist {
278+ if tags.artist.is_none() || tags.artist.as_ref().map_or(true, |a| a.is_empty()) {
279+ artist_to_write = Some(artist);
280+ needs_write = true;
281+ log::info!(" Missing or empty artist tag, will set to: {}", artist);
282+ }
283+ }
284+
285+ // Utiliser l'album de l'épisode si disponible, sinon ne pas écrire
286+ if let Some(album) = episode_album {
287+ if tags.album.is_none() || tags.album.as_ref().map_or(true, |a| a.is_empty()) {
288+ album_to_write = Some(album);
289+ needs_write = true;
290+ log::info!(" Missing or empty album tag, will set to: {}", album);
291+ }
292+ }
293+
294+ // Si des modifications sont nécessaires, écrire les tags
295+ if needs_write {
296+ write_id3_tags(
297+ file_path,
298+ title_to_write.as_deref(),
299+ artist_to_write.as_deref(),
300+ album_to_write.as_deref(),
301+ )?;
302+ log::info!(" ID3 tags updated successfully for: {}", file_path.display());
303+ } else {
304+ log::debug!(" All required ID3 tags are present for: {}", file_path.display());
305+ }
306+
307+ Ok(())
308+}
309+
98 /// Lire uniquement la durée d'un fichier audio310 /// Lire uniquement la durée d'un fichier audio
99 pub fn get_audio_duration(path: &Path) -> Result<u64, MetadataError> {311 pub fn get_audio_duration(path: &Path) -> Result<u64, MetadataError> {
100 let metadata = read_audio_metadata(path)?;312 let metadata = read_audio_metadata(path)?;
@@ -117,6 +329,7 @@ pub fn estimate_duration_from_size(file_size: u64, bitrate_kbps: u32) -> u64 {
117 }329 }
118 330
119 /// Lire la durée de plusieurs fichiers audio331 /// Lire la durée de plusieurs fichiers audio
332+#[allow(dead_code)]
120 pub fn get_multiple_audio_durations(paths: &[impl AsRef<Path>]) -> Vec<(PathBuf, Option<u64>)> {333 pub fn get_multiple_audio_durations(paths: &[impl AsRef<Path>]) -> Vec<(PathBuf, Option<u64>)> {
121 paths.iter()334 paths.iter()
122 .map(|path| {335 .map(|path| {
@@ -161,7 +374,6 @@ pub fn format_duration(seconds: u64) -> String {
161 #[cfg(test)]374 #[cfg(test)]
162 mod tests {375 mod tests {
163 use super::*;376 use super::*;
164- use tempfile::NamedTempFile;
165 377
166 #[test]378 #[test]
167 fn test_is_supported_audio_file() {379 fn test_is_supported_audio_file() {
@@ -191,4 +403,40 @@ mod tests {
191 assert_eq!(format_duration(90), "1m 30s");403 assert_eq!(format_duration(90), "1m 30s");
192 assert_eq!(format_duration(3661), "1h 1m 1s");404 assert_eq!(format_duration(3661), "1h 1m 1s");
193 }405 }
406+
407+ #[test]
408+ fn test_id3_tags_has_all_required_tags() {
409+ let tags = Id3Tags {
410+ title: Some("Test".to_string()),
411+ artist: Some("Artist".to_string()),
412+ album: Some("Album".to_string()),
413+ year: None,
414+ genre: None,
415+ };
416+ assert!(tags.has_all_required_tags());
417+
418+ let tags_missing = Id3Tags {
419+ title: Some("Test".to_string()),
420+ artist: None,
421+ album: Some("Album".to_string()),
422+ year: None,
423+ genre: None,
424+ };
425+ assert!(!tags_missing.has_all_required_tags());
426+ }
427+
428+ #[test]
429+ fn test_id3_tags_missing_tags() {
430+ let tags = Id3Tags {
431+ title: None,
432+ artist: Some("Artist".to_string()),
433+ album: None,
434+ year: None,
435+ genre: None,
436+ };
437+ let missing = tags.missing_tags();
438+ assert!(missing.contains(&"title"));
439+ assert!(missing.contains(&"album"));
440+ assert!(!missing.contains(&"artist"));
441+ }
194 }442 }
modified src/rss/downloader.rs +23 -0
@@ -3,6 +3,7 @@
33 //! This module handles downloading podcast episodes from URLs.
44
55 use crate::config::episode::Episode;
6+use crate::rss::audio_metadata::ensure_id3_tags;
67 use log::{info, warn};
78 use std::fs;
89 use std::io::{Read, Write};
@@ -172,6 +173,7 @@ pub fn download_file(
172173 }
173174
174175 /// Downloads an episode to its designated location
176+/// After downloading, ensures ID3 tags (title, artist, album) are present
175177 pub fn download_episode(
176178 episode: &Episode,
177179 download_path: &Path,
@@ -182,11 +184,32 @@ pub fn download_episode(
182184 // Check if already exists and has correct size (via needs_download)
183185 if !needs_download(episode, download_path) {
184186 info!("Episode already downloaded: {}", episode.title);
187+ // Still ensure ID3 tags are present even if file already exists
188+ if let Err(e) = ensure_id3_tags(
189+ &save_path,
190+ &episode.title,
191+ episode.artist.as_deref(),
192+ episode.album.as_deref(),
193+ ) {
194+ warn!("Failed to ensure ID3 tags for existing file {}: {}", save_path.display(), e);
195+ }
185196 return Ok(save_path);
186197 }
187198
188199 download_file(&episode.audio_url, &save_path, Some(progress_callback))?;
189200
201+ // Ensure ID3 tags are present after downloading
202+ ensure_id3_tags(
203+ &save_path,
204+ &episode.title,
205+ episode.artist.as_deref(),
206+ episode.album.as_deref(),
207+ ).map_err(|e| {
208+ // Log the error but don't fail the download
209+ warn!("Failed to write ID3 tags for {}: {}", save_path.display(), e);
210+ DownloadError::NetworkError(format!("ID3 tag writing failed: {}", e))
211+ })?;
212+
190213 Ok(save_path)
191214 }
192215
@@ -3,6 +3,7 @@
3 //! This module handles downloading podcast episodes from URLs.3 //! This module handles downloading podcast episodes from URLs.
4 4
5 use crate::config::episode::Episode;5 use crate::config::episode::Episode;
6+use crate::rss::audio_metadata::ensure_id3_tags;
6 use log::{info, warn};7 use log::{info, warn};
7 use std::fs;8 use std::fs;
8 use std::io::{Read, Write};9 use std::io::{Read, Write};
@@ -172,6 +173,7 @@ pub fn download_file(
172 }173 }
173 174
174 /// Downloads an episode to its designated location175 /// Downloads an episode to its designated location
176+/// After downloading, ensures ID3 tags (title, artist, album) are present
175 pub fn download_episode(177 pub fn download_episode(
176 episode: &Episode,178 episode: &Episode,
177 download_path: &Path,179 download_path: &Path,
@@ -182,11 +184,32 @@ pub fn download_episode(
182 // Check if already exists and has correct size (via needs_download)184 // Check if already exists and has correct size (via needs_download)
183 if !needs_download(episode, download_path) {185 if !needs_download(episode, download_path) {
184 info!("Episode already downloaded: {}", episode.title);186 info!("Episode already downloaded: {}", episode.title);
187+ // Still ensure ID3 tags are present even if file already exists
188+ if let Err(e) = ensure_id3_tags(
189+ &save_path,
190+ &episode.title,
191+ episode.artist.as_deref(),
192+ episode.album.as_deref(),
193+ ) {
194+ warn!("Failed to ensure ID3 tags for existing file {}: {}", save_path.display(), e);
195+ }
185 return Ok(save_path);196 return Ok(save_path);
186 }197 }
187 198
188 download_file(&episode.audio_url, &save_path, Some(progress_callback))?;199 download_file(&episode.audio_url, &save_path, Some(progress_callback))?;
189 200
201+ // Ensure ID3 tags are present after downloading
202+ ensure_id3_tags(
203+ &save_path,
204+ &episode.title,
205+ episode.artist.as_deref(),
206+ episode.album.as_deref(),
207+ ).map_err(|e| {
208+ // Log the error but don't fail the download
209+ warn!("Failed to write ID3 tags for {}: {}", save_path.display(), e);
210+ DownloadError::NetworkError(format!("ID3 tag writing failed: {}", e))
211+ })?;
212+
190 Ok(save_path)213 Ok(save_path)
191 }214 }
192 215
modified src/rss/episode_extractor.rs +45 -0
@@ -81,12 +81,50 @@ fn parse_episode_date(pub_date: Option<&str>) -> Option<DateTime<Utc>> {
8181 })
8282 }
8383
84+/// Extracts iTunes namespace information from an item
85+fn extract_itunes_info(item: &rss::Item) -> (Option<String>, Option<String>) {
86+ let mut artist: Option<String> = None;
87+ let mut album: Option<String> = None;
88+
89+ // Try to extract itunes:author (artist) and itunes:album from extensions
90+ // The rss crate stores namespace extensions in the extensions map
91+ for (ns_url, ext_map) in &item.extensions {
92+ if ns_url.contains("itunes") || ns_url.contains("podcast") {
93+ for (key, values) in ext_map {
94+ let key_lower = key.to_lowercase();
95+ if key_lower == "author" && artist.is_none() {
96+ if let Some(value) = values.first() {
97+ if let Some(ref val_str) = value.value {
98+ artist = Some(val_str.clone());
99+ }
100+ }
101+ } else if key_lower == "album" && album.is_none() {
102+ if let Some(value) = values.first() {
103+ if let Some(ref val_str) = value.value {
104+ album = Some(val_str.clone());
105+ }
106+ }
107+ }
108+ }
109+ }
110+ }
111+
112+ (artist, album)
113+}
114+
84115 /// Extracts episodes from an RSS Channel
85116 pub fn extract_episodes(channel: &Channel) -> Result<Vec<Episode>, ExtractionError> {
86117 if channel.items.is_empty() {
87118 return Err(ExtractionError::NoItemsFound);
88119 }
89120
121+ // Get feed title to use as default album name
122+ let feed_title = if channel.title.is_empty() {
123+ "Unknown Podcast".to_string()
124+ } else {
125+ channel.title.clone()
126+ };
127+
90128 let mut episodes: Vec<Episode> = Vec::new();
91129
92130 for item in &channel.items {
@@ -120,6 +158,9 @@ pub fn extract_episodes(channel: &Channel) -> Result<Vec<Episode>, ExtractionErr
120158 // Try to get duration from various sources
121159 // For now, skip itunes:duration as it requires more complex parsing
122160 let duration_seconds = None;
161+
162+ // Extract iTunes metadata (artist, album)
163+ let (itunes_artist, itunes_album) = extract_itunes_info(item);
123164
124165 let mut episode = Episode::new(
125166 Uuid::new_v4().to_string(),
@@ -134,6 +175,10 @@ pub fn extract_episodes(channel: &Channel) -> Result<Vec<Episode>, ExtractionErr
134175 episode.duration_seconds = Some(duration);
135176 }
136177 episode.mime_type = Some(enclosure_mime_type.to_string());
178+
179+ // Set artist and album from iTunes metadata, with fallbacks
180+ episode.artist = itunes_artist;
181+ episode.album = itunes_album.or(Some(feed_title.clone()));
137182
138183 episodes.push(episode);
139184 }
@@ -81,12 +81,50 @@ fn parse_episode_date(pub_date: Option<&str>) -> Option<DateTime<Utc>> {
81 })81 })
82 }82 }
83 83
84+/// Extracts iTunes namespace information from an item
85+fn extract_itunes_info(item: &rss::Item) -> (Option<String>, Option<String>) {
86+ let mut artist: Option<String> = None;
87+ let mut album: Option<String> = None;
88+
89+ // Try to extract itunes:author (artist) and itunes:album from extensions
90+ // The rss crate stores namespace extensions in the extensions map
91+ for (ns_url, ext_map) in &item.extensions {
92+ if ns_url.contains("itunes") || ns_url.contains("podcast") {
93+ for (key, values) in ext_map {
94+ let key_lower = key.to_lowercase();
95+ if key_lower == "author" && artist.is_none() {
96+ if let Some(value) = values.first() {
97+ if let Some(ref val_str) = value.value {
98+ artist = Some(val_str.clone());
99+ }
100+ }
101+ } else if key_lower == "album" && album.is_none() {
102+ if let Some(value) = values.first() {
103+ if let Some(ref val_str) = value.value {
104+ album = Some(val_str.clone());
105+ }
106+ }
107+ }
108+ }
109+ }
110+ }
111+
112+ (artist, album)
113+}
114+
84 /// Extracts episodes from an RSS Channel115 /// Extracts episodes from an RSS Channel
85 pub fn extract_episodes(channel: &Channel) -> Result<Vec<Episode>, ExtractionError> {116 pub fn extract_episodes(channel: &Channel) -> Result<Vec<Episode>, ExtractionError> {
86 if channel.items.is_empty() {117 if channel.items.is_empty() {
87 return Err(ExtractionError::NoItemsFound);118 return Err(ExtractionError::NoItemsFound);
88 }119 }
89 120
121+ // Get feed title to use as default album name
122+ let feed_title = if channel.title.is_empty() {
123+ "Unknown Podcast".to_string()
124+ } else {
125+ channel.title.clone()
126+ };
127+
90 let mut episodes: Vec<Episode> = Vec::new();128 let mut episodes: Vec<Episode> = Vec::new();
91 129
92 for item in &channel.items {130 for item in &channel.items {
@@ -120,6 +158,9 @@ pub fn extract_episodes(channel: &Channel) -> Result<Vec<Episode>, ExtractionErr
120 // Try to get duration from various sources158 // Try to get duration from various sources
121 // For now, skip itunes:duration as it requires more complex parsing159 // For now, skip itunes:duration as it requires more complex parsing
122 let duration_seconds = None;160 let duration_seconds = None;
161+
162+ // Extract iTunes metadata (artist, album)
163+ let (itunes_artist, itunes_album) = extract_itunes_info(item);
123 164
124 let mut episode = Episode::new(165 let mut episode = Episode::new(
125 Uuid::new_v4().to_string(),166 Uuid::new_v4().to_string(),
@@ -134,6 +175,10 @@ pub fn extract_episodes(channel: &Channel) -> Result<Vec<Episode>, ExtractionErr
134 episode.duration_seconds = Some(duration);175 episode.duration_seconds = Some(duration);
135 }176 }
136 episode.mime_type = Some(enclosure_mime_type.to_string());177 episode.mime_type = Some(enclosure_mime_type.to_string());
178+
179+ // Set artist and album from iTunes metadata, with fallbacks
180+ episode.artist = itunes_artist;
181+ episode.album = itunes_album.or(Some(feed_title.clone()));
137 182
138 episodes.push(episode);183 episodes.push(episode);
139 }184 }
modified src/rss/mod.rs +1 -1
@@ -13,7 +13,7 @@ pub mod sync;
1313 // kept for future extensibility and API completeness
1414 #[allow(dead_code)]
1515 #[allow(unused_imports)]
16-pub use audio_metadata::{AudioMetadata, MetadataError, format_duration, get_audio_duration, is_supported_audio_file, read_audio_metadata};
16+pub use audio_metadata::{AudioMetadata, Id3Tags, MetadataError, format_duration, get_audio_duration, is_supported_audio_file, read_audio_metadata, read_id3_tags, write_id3_tags, ensure_id3_tags};
1717 #[allow(dead_code)]
1818 #[allow(unused_imports)]
1919 pub use downloader::{DownloadClient, DownloadError, ProgressCallback, download_episode, download_episodes, download_file, get_download_dir, needs_download, remove_downloaded_episode};
@@ -13,7 +13,7 @@ pub mod sync;
13 // kept for future extensibility and API completeness13 // kept for future extensibility and API completeness
14 #[allow(dead_code)]14 #[allow(dead_code)]
15 #[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};16+pub use audio_metadata::{AudioMetadata, Id3Tags, MetadataError, format_duration, get_audio_duration, is_supported_audio_file, read_audio_metadata, read_id3_tags, write_id3_tags, ensure_id3_tags};
17 #[allow(dead_code)]17 #[allow(dead_code)]
18 #[allow(unused_imports)]18 #[allow(unused_imports)]
19 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};