Skip to main content

xil_audio/
tags.rs

1//! ID3 tag access. Mirrors the mutagen calls in `sfx_common.py`.
2
3use std::path::Path;
4
5/// The user-text frame that carries an SFX quality grade.
6pub const SFX_GRADE_FRAME: &str = "XIL_GRADE";
7pub const SFX_GRADE_ACCURATE: &str = "accurate";
8pub const SFX_GRADE_REJECTED: &str = "rejected";
9
10/// Value of the `TXXX` frame whose description is `desc`, if present.
11/// Multi-value frames yield their first value, like mutagen's `text[0]`.
12pub 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
21/// `'accurate'` / `'rejected'` from the file's grade frame, else `""`. Never
22/// fails: a missing file, missing header, or unknown value all read as ungraded.
23pub 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/// The text frames `discover-sfx` reads: `str(tags["TIT2"])`,
38/// `str(tags["TPE1"])`, and the first `USLT` frame's text.
39#[derive(Debug, Default, Clone, PartialEq)]
40pub struct TextTags {
41    pub title: String,
42    pub artist: String,
43    pub lyrics: String,
44}
45
46/// Read the three frames, blank on any failure — `ID3(path)` raising is
47/// swallowed by the caller in Python. Like mutagen, a file with no ID3v2
48/// header falls back to its ID3v1 tag (title and artist only).
49pub 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            // Multi-value text frames read back joined on NUL, as
55            // mutagen's `"".join(frame.text)` spells them.
56            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                // The id3 crate keeps the frame's NUL terminator; mutagen
70                // does not.
71                .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
84/// `_retag_copy`: replace `TIT2` with `title` and `COMM` (eng, no
85/// description) with `comment`, leaving every other frame alone. A file
86/// with no tag gets a fresh one.
87pub 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
101/// The frames `tag_mp3` / `tag_wav` always write: album, genre, year.
102fn 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
115/// `str(datetime.date.today().year)` — the local date, as Python reads it.
116fn chrono_year() -> String {
117    use chrono::Datelike;
118    chrono::Local::now().year().to_string()
119}
120
121/// `tag_mp3(path, show, title, artist, lyrics, comments, cover_art_path)`:
122/// load any existing ID3 tag, replace these frames, save as ID3v2.4.
123pub 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
133/// `tag_mp3` with every optional frame: USLT lyrics and an eng COMM comment.
134///
135/// Written through [`crate::id3w`], so the file is byte-identical to what
136/// mutagen saves — the pipeline hashes these files.
137pub 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    // `ID3(path)` on a missing file raises (a MutagenError wrapping ENOENT);
148    // only a file with no ID3 header falls back to a fresh tag.
149    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
206/// `write_sfx_grade(path, status)`: set the grade frame, or clear it when
207/// `status` is not a known grade. Every other frame is kept in place.
208///
209/// Written through [`crate::id3w`] like mutagen's `tags.save`, so the Rust and
210/// Python GUIs leave the same bytes behind. A missing file is an error, as
211/// `ID3(path)` raising is.
212pub 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
230/// `tag_wav(path, show, title, artist)`: an ID3v2.4 chunk in the RIFF file.
231pub 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}