Add ID3 tag verification and auto-population after download
849fc17 parent: ac9090b modified
Cargo.lock +21 -33 | @@ -546,6 +546,12 @@ dependencies = [ | ||
| 546 | 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 | 555 | [[package]] |
| 550 | 556 | name = "byteorder-lite" |
| 551 | 557 | version = "0.1.0" |
| @@ -1938,6 +1944,17 @@ dependencies = [ | ||
| 1938 | 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 | 1958 | [[package]] |
| 1942 | 1959 | name = "ident_case" |
| 1943 | 1960 | version = "1.0.1" |
| @@ -2395,7 +2412,7 @@ version = "0.7.6" | ||
| 2395 | 2412 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2396 | 2413 | checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" |
| 2397 | 2414 | dependencies = [ |
| 2398 | - "proc-macro-crate 1.3.1", | |
| 2415 | + "proc-macro-crate", | |
| 2399 | 2416 | "proc-macro2", |
| 2400 | 2417 | "quote", |
| 2401 | 2418 | "syn", |
| @@ -3056,16 +3073,6 @@ version = "0.3.1" | ||
| 3056 | 3073 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 3057 | 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 | 3076 | [[package]] |
| 3070 | 3077 | name = "proc-macro-crate" |
| 3071 | 3078 | version = "3.5.0" |
| @@ -3330,6 +3337,7 @@ dependencies = [ | ||
| 3330 | 3337 | "egui", |
| 3331 | 3338 | "env_logger", |
| 3332 | 3339 | "futures-util", |
| 3340 | + "id3", | |
| 3333 | 3341 | "log", |
| 3334 | 3342 | "md5", |
| 3335 | 3343 | "mp3-metadata", |
| @@ -4026,17 +4034,6 @@ dependencies = [ | ||
| 4026 | 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 | 4037 | [[package]] |
| 4041 | 4038 | name = "toml_edit" |
| 4042 | 4039 | version = "0.22.27" |
| @@ -5122,15 +5119,6 @@ dependencies = [ | ||
| 5122 | 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 | 5122 | [[package]] |
| 5135 | 5123 | name = "winnow" |
| 5136 | 5124 | version = "0.7.15" |
| @@ -5322,7 +5310,7 @@ version = "5.17.0" | ||
| 5322 | 5310 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 5323 | 5311 | checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469" |
| 5324 | 5312 | dependencies = [ |
| 5325 | - "proc-macro-crate 3.5.0", | |
| 5313 | + "proc-macro-crate", | |
| 5326 | 5314 | "proc-macro2", |
| 5327 | 5315 | "quote", |
| 5328 | 5316 | "syn", |
| @@ -5469,7 +5457,7 @@ version = "5.13.0" | ||
| 5469 | 5457 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 5470 | 5458 | checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31" |
| 5471 | 5459 | dependencies = [ |
| 5472 | - "proc-macro-crate 3.5.0", | |
| 5460 | + "proc-macro-crate", | |
| 5473 | 5461 | "proc-macro2", |
| 5474 | 5462 | "quote", |
| 5475 | 5463 | "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" | ||
| 63 | 63 | |
| 64 | 64 | # Audio metadata parsing |
| 65 | 65 | mp3-metadata = "0.3" |
| 66 | +id3 = "1.13" | |
| 66 | 67 | |
| 67 | 68 | # Windows API compatibility for older eframe versions - not needed with eframe 0.35 |
| 68 | 69 | # [target.'cfg(windows)'.dependencies] |
| @@ -63,6 +63,7 @@ diligent-date-parser = "0.1" | |||
| 63 | 63 | ||
| 64 | # Audio metadata parsing | 64 | # 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.35 | 68 | # 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 { | ||
| 30 | 30 | pub mime_type: Option<String>, |
| 31 | 31 | /// Duration of the episode in seconds (optional) |
| 32 | 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 | 39 | impl Default for Episode { |
| @@ -45,6 +49,8 @@ impl Default for Episode { | ||
| 45 | 49 | file_size: None, |
| 46 | 50 | mime_type: None, |
| 47 | 51 | duration_seconds: None, |
| 52 | + artist: None, | |
| 53 | + album: None, | |
| 48 | 54 | } |
| 49 | 55 | } |
| 50 | 56 | } |
| @@ -76,6 +82,8 @@ impl Episode { | ||
| 76 | 82 | file_size: None, |
| 77 | 83 | mime_type: None, |
| 78 | 84 | duration_seconds: None, |
| 85 | + artist: None, | |
| 86 | + album: None, | |
| 79 | 87 | } |
| 80 | 88 | } |
| 81 | 89 | |
| @@ -99,6 +107,8 @@ impl Episode { | ||
| 99 | 107 | file_size: None, |
| 100 | 108 | mime_type: None, |
| 101 | 109 | duration_seconds: None, |
| 110 | + artist: None, | |
| 111 | + album: None, | |
| 102 | 112 | } |
| 103 | 113 | } |
| 104 | 114 | |
| @@ -162,6 +172,18 @@ impl Episode { | ||
| 162 | 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 | 189 | #[cfg(test)] |
| @@ -175,6 +197,8 @@ mod tests { | ||
| 175 | 197 | assert!(!episode.id.is_empty()); |
| 176 | 198 | assert!(episode.title.is_empty()); |
| 177 | 199 | assert!(!episode.downloaded); |
| 200 | + assert!(episode.artist.is_none()); | |
| 201 | + assert!(episode.album.is_none()); | |
| 178 | 202 | } |
| 179 | 203 | |
| 180 | 204 | #[test] |
| @@ -192,6 +216,8 @@ mod tests { | ||
| 192 | 216 | assert_eq!(episode.audio_url, "https://example.com/episode.mp3"); |
| 193 | 217 | assert_eq!(episode.audio_filename, "episode.mp3"); |
| 194 | 218 | assert!(!episode.downloaded); |
| 219 | + assert!(episode.artist.is_none()); | |
| 220 | + assert!(episode.album.is_none()); | |
| 195 | 221 | } |
| 196 | 222 | |
| 197 | 223 | #[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) | |
| 3 | 2 | //! |
| 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. | |
| 7 | 5 | |
| 6 | +use id3::{Tag, TagLike, Error as Id3Error}; | |
| 7 | +use std::fs::File; | |
| 8 | 8 | use std::path::{Path, PathBuf}; |
| 9 | 9 | |
| 10 | 10 | /// Métadonnées d'un fichier audio |
| @@ -34,6 +34,7 @@ impl AudioMetadata { | ||
| 34 | 34 | } |
| 35 | 35 | |
| 36 | 36 | /// Obtenir la durée formatée (HH:MM:SS ou MM:SS) |
| 37 | + #[allow(dead_code)] | |
| 37 | 38 | pub fn get_formatted_duration(&self) -> String { |
| 38 | 39 | if let Some(duration) = self.duration_seconds { |
| 39 | 40 | format_duration(duration) |
| @@ -56,18 +57,33 @@ pub enum MetadataError { | ||
| 56 | 57 | |
| 57 | 58 | /// Format non supporté |
| 58 | 59 | #[error("Format audio non supporté: {0}")] |
| 60 | + #[allow(dead_code)] | |
| 59 | 61 | UnsupportedFormat(String), |
| 60 | 62 | |
| 61 | 63 | /// Erreur de parsing des métadonnées |
| 62 | 64 | #[error("Erreur de parsing des métadonnées: {0}")] |
| 63 | 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 | 82 | /// Bitrate moyen par défaut pour les fichiers audio (en kbps) |
| 67 | 83 | const DEFAULT_BITRATE_KBPS: u32 = 128; |
| 68 | 84 | |
| 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) | |
| 71 | 87 | pub fn read_audio_metadata(path: &Path) -> Result<AudioMetadata, MetadataError> { |
| 72 | 88 | if !path.exists() { |
| 73 | 89 | return Err(MetadataError::FileNotFound(path.display().to_string())); |
| @@ -80,21 +96,217 @@ pub fn read_audio_metadata(path: &Path) -> Result<AudioMetadata, MetadataError> | ||
| 80 | 96 | let mut audio_metadata = AudioMetadata::new(); |
| 81 | 97 | audio_metadata.file_size = file_size; |
| 82 | 98 | |
| 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 | |
| 84 | 121 | if file_size > 0 { |
| 85 | 122 | let duration = estimate_duration_from_size(file_size, DEFAULT_BITRATE_KBPS); |
| 86 | 123 | audio_metadata.duration_seconds = Some(duration); |
| 87 | 124 | audio_metadata.bitrate_kbps = Some(DEFAULT_BITRATE_KBPS); |
| 88 | 125 | } |
| 89 | 126 | |
| 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 | + } | |
| 93 | 132 | } |
| 94 | 133 | |
| 95 | 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 | 310 | /// Lire uniquement la durée d'un fichier audio |
| 99 | 311 | pub fn get_audio_duration(path: &Path) -> Result<u64, MetadataError> { |
| 100 | 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 | 331 | /// Lire la durée de plusieurs fichiers audio |
| 332 | +#[allow(dead_code)] | |
| 120 | 333 | pub fn get_multiple_audio_durations(paths: &[impl AsRef<Path>]) -> Vec<(PathBuf, Option<u64>)> { |
| 121 | 334 | paths.iter() |
| 122 | 335 | .map(|path| { |
| @@ -161,7 +374,6 @@ pub fn format_duration(seconds: u64) -> String { | ||
| 161 | 374 | #[cfg(test)] |
| 162 | 375 | mod tests { |
| 163 | 376 | use super::*; |
| 164 | - use tempfile::NamedTempFile; | |
| 165 | 377 | |
| 166 | 378 | #[test] |
| 167 | 379 | fn test_is_supported_audio_file() { |
| @@ -191,4 +403,40 @@ mod tests { | ||
| 191 | 403 | assert_eq!(format_duration(90), "1m 30s"); |
| 192 | 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 | } |
| @@ -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 fichier | 4 | +//! 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 audio | 10 | /// 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ées | 63 | /// 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 audio | 85 | +/// 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 fichier | 86 | +/// 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 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 | ||
| 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 titre | 127 | + // 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 audio | 310 | /// 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 audio | 331 | /// 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 @@ | ||
| 3 | 3 | //! This module handles downloading podcast episodes from URLs. |
| 4 | 4 | |
| 5 | 5 | use crate::config::episode::Episode; |
| 6 | +use crate::rss::audio_metadata::ensure_id3_tags; | |
| 6 | 7 | use log::{info, warn}; |
| 7 | 8 | use std::fs; |
| 8 | 9 | use std::io::{Read, Write}; |
| @@ -172,6 +173,7 @@ pub fn download_file( | ||
| 172 | 173 | } |
| 173 | 174 | |
| 174 | 175 | /// Downloads an episode to its designated location |
| 176 | +/// After downloading, ensures ID3 tags (title, artist, album) are present | |
| 175 | 177 | pub fn download_episode( |
| 176 | 178 | episode: &Episode, |
| 177 | 179 | download_path: &Path, |
| @@ -182,11 +184,32 @@ pub fn download_episode( | ||
| 182 | 184 | // Check if already exists and has correct size (via needs_download) |
| 183 | 185 | if !needs_download(episode, download_path) { |
| 184 | 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 | 196 | return Ok(save_path); |
| 186 | 197 | } |
| 187 | 198 | |
| 188 | 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 | 213 | Ok(save_path) |
| 191 | 214 | } |
| 192 | 215 | |
| @@ -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 location | 175 | /// 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>> { | ||
| 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 | 115 | /// Extracts episodes from an RSS Channel |
| 85 | 116 | pub fn extract_episodes(channel: &Channel) -> Result<Vec<Episode>, ExtractionError> { |
| 86 | 117 | if channel.items.is_empty() { |
| 87 | 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 | 128 | let mut episodes: Vec<Episode> = Vec::new(); |
| 91 | 129 | |
| 92 | 130 | for item in &channel.items { |
| @@ -120,6 +158,9 @@ pub fn extract_episodes(channel: &Channel) -> Result<Vec<Episode>, ExtractionErr | ||
| 120 | 158 | // Try to get duration from various sources |
| 121 | 159 | // For now, skip itunes:duration as it requires more complex parsing |
| 122 | 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 | 165 | let mut episode = Episode::new( |
| 125 | 166 | Uuid::new_v4().to_string(), |
| @@ -134,6 +175,10 @@ pub fn extract_episodes(channel: &Channel) -> Result<Vec<Episode>, ExtractionErr | ||
| 134 | 175 | episode.duration_seconds = Some(duration); |
| 135 | 176 | } |
| 136 | 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 | 183 | episodes.push(episode); |
| 139 | 184 | } |
| @@ -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 Channel | 115 | /// 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 sources | 158 | // Try to get duration from various sources |
| 121 | // For now, skip itunes:duration as it requires more complex parsing | 159 | // 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; | ||
| 13 | 13 | // kept for future extensibility and API completeness |
| 14 | 14 | #[allow(dead_code)] |
| 15 | 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 | 17 | #[allow(dead_code)] |
| 18 | 18 | #[allow(unused_imports)] |
| 19 | 19 | 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 completeness | 13 | // 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}; |