1use std::path::Path;
4
5pub const SFX_GRADE_FRAME: &str = "XIL_GRADE";
7pub const SFX_GRADE_ACCURATE: &str = "accurate";
8pub const SFX_GRADE_REJECTED: &str = "rejected";
9
10pub fn read_txxx(path: &Path, desc: &str) -> Option<String> {
13 let tag = id3::Tag::read_from_path(path).ok()?;
14 let value = tag
15 .extended_texts()
16 .find(|t| t.description == desc)
17 .map(|t| t.value.split('\0').next().unwrap_or("").to_string());
18 value
19}
20
21pub fn read_sfx_grade(path: &Path) -> String {
24 match read_txxx(path, SFX_GRADE_FRAME) {
25 Some(v) => {
26 let v = v.trim().to_lowercase();
27 if v == SFX_GRADE_ACCURATE || v == SFX_GRADE_REJECTED {
28 v
29 } else {
30 String::new()
31 }
32 }
33 None => String::new(),
34 }
35}
36
37#[derive(Debug, Default, Clone, PartialEq)]
40pub struct TextTags {
41 pub title: String,
42 pub artist: String,
43 pub lyrics: String,
44}
45
46pub fn read_text_tags(path: &Path) -> TextTags {
50 let mut out = TextTags::default();
51 match id3::Tag::read_from_path(path) {
52 Ok(tag) => {
53 use id3::TagLike;
54 out.title = tag
57 .get("TIT2")
58 .and_then(|f| f.content().text())
59 .unwrap_or("")
60 .to_string();
61 out.artist = tag
62 .get("TPE1")
63 .and_then(|f| f.content().text())
64 .unwrap_or("")
65 .to_string();
66 out.lyrics = tag
67 .lyrics()
68 .next()
69 .map(|l| l.text.trim_end_matches('\0').to_string())
72 .unwrap_or_default();
73 }
74 Err(_) => {
75 if let Ok(v1) = id3::v1::Tag::read_from_path(path) {
76 out.title = v1.title.trim().to_string();
77 out.artist = v1.artist.trim().to_string();
78 }
79 }
80 }
81 out
82}
83
84pub fn retag_title_comment(path: &Path, title: &str, comment: &str) -> Result<(), id3::Error> {
88 use id3::TagLike;
89 let mut tag = id3::Tag::read_from_path(path).unwrap_or_default();
90 tag.remove("TIT2");
91 tag.set_title(title);
92 tag.remove("COMM");
93 tag.add_frame(id3::frame::Comment {
94 lang: "eng".into(),
95 description: String::new(),
96 text: comment.into(),
97 });
98 tag.write_to_path(path, id3::Version::Id3v24)
99}
100
101fn base_frames(tag: &mut id3::Tag, show: &str, title: Option<&str>, artist: Option<&str>) {
103 use id3::TagLike;
104 tag.set_text("TALB", show);
105 tag.set_text("TCON", "Podcast");
106 tag.set_text("TDRC", chrono_year());
107 if let Some(t) = title.filter(|t| !t.is_empty()) {
108 tag.set_text("TIT2", t);
109 }
110 if let Some(a) = artist.filter(|a| !a.is_empty()) {
111 tag.set_text("TPE1", a);
112 }
113}
114
115fn chrono_year() -> String {
117 use chrono::Datelike;
118 chrono::Local::now().year().to_string()
119}
120
121pub fn tag_mp3(
124 path: &Path,
125 show: &str,
126 title: Option<&str>,
127 artist: Option<&str>,
128 cover_art_path: Option<&Path>,
129) -> std::io::Result<()> {
130 tag_mp3_full(path, show, title, artist, None, None, cover_art_path)
131}
132
133pub fn tag_mp3_full(
138 path: &Path,
139 show: &str,
140 title: Option<&str>,
141 artist: Option<&str>,
142 lyrics: Option<&str>,
143 comments: Option<&str>,
144 cover_art_path: Option<&Path>,
145) -> std::io::Result<()> {
146 use crate::id3w::{Body, Frame, Tag};
147 let data = std::fs::read(path)?;
150 let mut tag = Tag::load(&data).unwrap_or_default();
151 tag.add(Frame::text("TALB", show));
152 tag.add(Frame::text("TCON", "Podcast"));
153 tag.add(Frame::text("TDRC", &chrono_year()));
154 if let Some(t) = title.filter(|t| !t.is_empty()) {
155 tag.add(Frame::text("TIT2", t));
156 }
157 if let Some(a) = artist.filter(|a| !a.is_empty()) {
158 tag.add(Frame::text("TPE1", a));
159 }
160 if let Some(l) = lyrics.filter(|l| !l.is_empty()) {
161 tag.add(Frame {
162 id: "USLT".into(),
163 body: Body::Uslt {
164 enc: 3,
165 lang: *b"eng",
166 desc: String::new(),
167 text: l.into(),
168 },
169 });
170 }
171 if let Some(c) = comments.filter(|c| !c.is_empty()) {
172 tag.add(Frame {
173 id: "COMM".into(),
174 body: Body::Comm {
175 enc: 3,
176 lang: *b"eng",
177 desc: String::new(),
178 values: vec![c.into()],
179 },
180 });
181 }
182 if let Some(cover) = cover_art_path.filter(|p| p.exists()) {
183 let ext = cover
184 .extension()
185 .map(|e| e.to_string_lossy().to_lowercase())
186 .unwrap_or_default();
187 let mime = if ext == "jpg" || ext == "jpeg" {
188 "image/jpeg"
189 } else {
190 "image/png"
191 };
192 tag.add(Frame {
193 id: "APIC".into(),
194 body: Body::Apic {
195 enc: 3,
196 mime: mime.into(),
197 ptype: 3,
198 desc: String::new(),
199 data: std::fs::read(cover)?,
200 },
201 });
202 }
203 tag.save(path)
204}
205
206pub fn write_sfx_grade(path: &Path, status: &str) -> std::io::Result<()> {
213 use crate::id3w::{Body, Frame, Tag};
214 let data = std::fs::read(path)?;
215 let mut tag = Tag::load(&data).unwrap_or_default();
216 tag.frames.shift_remove(&format!("TXXX:{SFX_GRADE_FRAME}"));
217 if status == SFX_GRADE_ACCURATE || status == SFX_GRADE_REJECTED {
218 tag.add(Frame {
219 id: "TXXX".into(),
220 body: Body::Txxx {
221 enc: 3,
222 desc: SFX_GRADE_FRAME.into(),
223 values: vec![status.into()],
224 },
225 });
226 }
227 tag.save(path)
228}
229
230pub fn tag_wav(
232 path: &Path,
233 show: &str,
234 title: Option<&str>,
235 artist: Option<&str>,
236) -> Result<(), id3::Error> {
237 let mut tag = id3::Tag::read_from_path(path).unwrap_or_default();
238 base_frames(&mut tag, show, title, artist);
239 tag.write_to_path(path, id3::Version::Id3v24)
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use id3::TagLike;
246
247 #[test]
248 fn text_tags_read_back_and_retag_keeps_the_rest() {
249 let tmp = tempfile::tempdir().unwrap();
250 let p = tmp.path().join("t.mp3");
251 std::fs::write(&p, b"").unwrap();
252 let mut tag = id3::Tag::new();
253 tag.set_title("SFX: DOOR");
254 tag.set_artist("xil");
255 tag.add_frame(id3::frame::Lyrics {
256 lang: "eng".into(),
257 description: String::new(),
258 text: "a door closing".into(),
259 });
260 tag.add_frame(id3::frame::ExtendedText {
261 description: SFX_GRADE_FRAME.into(),
262 value: "accurate".into(),
263 });
264 tag.write_to_path(&p, id3::Version::Id3v24).unwrap();
265 assert_eq!(
266 read_text_tags(&p),
267 TextTags {
268 title: "SFX: DOOR".into(),
269 artist: "xil".into(),
270 lyrics: "a door closing".into()
271 }
272 );
273 retag_title_comment(&p, "SFX: DOOR CLOSES", "copied by test").unwrap();
274 let t = read_text_tags(&p);
275 assert_eq!(t.title, "SFX: DOOR CLOSES");
276 assert_eq!(t.lyrics, "a door closing", "USLT survives");
277 assert_eq!(read_sfx_grade(&p), "accurate", "TXXX survives");
278 let tag = id3::Tag::read_from_path(&p).unwrap();
279 assert_eq!(tag.comments().next().unwrap().text, "copied by test");
280 }
281
282 #[test]
283 fn untagged_file_reads_blank() {
284 let tmp = tempfile::tempdir().unwrap();
285 let p = tmp.path().join("u.mp3");
286 std::fs::write(&p, b"ID3\x03\x00door").unwrap();
287 assert_eq!(read_text_tags(&p), TextTags::default());
288 assert_eq!(
289 read_text_tags(&tmp.path().join("nope.mp3")),
290 TextTags::default()
291 );
292 }
293
294 #[test]
295 fn grade_round_trips_through_txxx() {
296 let tmp = tempfile::tempdir().unwrap();
297 let p = tmp.path().join("x.mp3");
298 std::fs::write(&p, b"").unwrap();
299 let mut tag = id3::Tag::new();
300 tag.add_frame(id3::frame::ExtendedText {
301 description: SFX_GRADE_FRAME.into(),
302 value: " Rejected ".into(),
303 });
304 tag.write_to_path(&p, id3::Version::Id3v24).unwrap();
305 assert_eq!(read_sfx_grade(&p), "rejected");
306 assert_eq!(read_sfx_grade(tmp.path().join("missing.mp3").as_path()), "");
307 }
308
309 #[test]
310 fn grade_writer_sets_replaces_and_clears() {
311 let tmp = tempfile::tempdir().unwrap();
312 let p = tmp.path().join("g.mp3");
313 std::fs::write(&p, [0xFFu8, 0xFB, 0x90, 0x00]).unwrap();
314 tag_mp3(&p, "Show", Some("Title"), None, None).unwrap();
315 write_sfx_grade(&p, "accurate").unwrap();
316 assert_eq!(read_sfx_grade(&p), "accurate");
317 write_sfx_grade(&p, "rejected").unwrap();
318 assert_eq!(read_sfx_grade(&p), "rejected");
319 write_sfx_grade(&p, "").unwrap();
320 assert_eq!(read_sfx_grade(&p), "");
321 assert_eq!(read_text_tags(&p).title, "Title", "other frames survive");
322 assert!(write_sfx_grade(&tmp.path().join("missing.mp3"), "accurate").is_err());
323 }
324
325 #[test]
326 fn unknown_grade_reads_as_ungraded() {
327 let tmp = tempfile::tempdir().unwrap();
328 let p = tmp.path().join("y.mp3");
329 std::fs::write(&p, b"").unwrap();
330 let mut tag = id3::Tag::new();
331 tag.add_frame(id3::frame::ExtendedText {
332 description: SFX_GRADE_FRAME.into(),
333 value: "meh".into(),
334 });
335 tag.write_to_path(&p, id3::Version::Id3v24).unwrap();
336 assert_eq!(read_sfx_grade(&p), "");
337 }
338}