Skip to main content

xil_audio/
id3w.rs

1//! An ID3v2.4 reader/writer that saves exactly what `mutagen` saves.
2//!
3//! The pipeline records SHA-256 digests of stems in manifests and logs, so
4//! a tag that merely *reads* the same is not enough: the bytes must match.
5//! This follows `mutagen.id3` 1.47's save path:
6//!
7//! * frames sorted by `(priority, len(frame data), HashKey)`, priority being
8//!   the index in `TIT2 TPE1 TRCK TALB TPOS TDRC TCON`, then everything else,
9//!   then `APIC` (whose relative order is kept);
10//! * every encoded string followed by its terminator, text frames with an
11//!   empty `"\0".join(text)` dropped entirely;
12//! * padding from `PaddingInfo.get_default_padding` — keep the space already
13//!   there unless it exceeds 10 KiB + 1 % of the file, otherwise 1 KiB +
14//!   0.1 % — with the old tag region resized in place.
15//!
16//! Frames this module does not model are carried over byte for byte.
17
18use std::fs;
19use std::io;
20use std::path::Path;
21
22use indexmap::IndexMap;
23
24const ORDER: [&str; 7] = ["TIT2", "TPE1", "TRCK", "TALB", "TPOS", "TDRC", "TCON"];
25
26#[derive(Clone, Debug, PartialEq)]
27pub enum Body {
28    /// `T???` text frames (and `TDRC`-style timestamps).
29    Text { enc: u8, values: Vec<String> },
30    Txxx {
31        enc: u8,
32        desc: String,
33        values: Vec<String>,
34    },
35    Comm {
36        enc: u8,
37        lang: [u8; 3],
38        desc: String,
39        values: Vec<String>,
40    },
41    Uslt {
42        enc: u8,
43        lang: [u8; 3],
44        desc: String,
45        text: String,
46    },
47    Apic {
48        enc: u8,
49        mime: String,
50        ptype: u8,
51        desc: String,
52        data: Vec<u8>,
53    },
54    /// Anything else, written back unchanged.
55    Raw(Vec<u8>),
56}
57
58#[derive(Clone, Debug, PartialEq)]
59pub struct Frame {
60    pub id: String,
61    pub body: Body,
62}
63
64impl Frame {
65    pub fn text(id: &str, value: &str) -> Frame {
66        Frame {
67            id: id.into(),
68            body: Body::Text {
69                enc: 3,
70                values: vec![value.into()],
71            },
72        }
73    }
74
75    /// mutagen's `HashKey`: the key under which `tags.add` replaces a frame.
76    pub fn hash_key(&self) -> String {
77        match &self.body {
78            Body::Txxx { desc, .. } => format!("TXXX:{desc}"),
79            Body::Comm { desc, lang, .. } => {
80                format!("COMM:{desc}:{}", String::from_utf8_lossy(lang))
81            }
82            Body::Uslt { desc, lang, .. } => {
83                format!("USLT:{desc}:{}", String::from_utf8_lossy(lang))
84            }
85            Body::Apic { desc, .. } => format!("APIC:{desc}"),
86            _ => self.id.clone(),
87        }
88    }
89}
90
91/// A loaded (or fresh) tag, frames keyed and ordered as mutagen's dict.
92#[derive(Clone, Debug, Default)]
93pub struct Tag {
94    pub frames: IndexMap<String, Frame>,
95}
96
97fn syncsafe(n: usize) -> [u8; 4] {
98    [
99        ((n >> 21) & 0x7f) as u8,
100        ((n >> 14) & 0x7f) as u8,
101        ((n >> 7) & 0x7f) as u8,
102        (n & 0x7f) as u8,
103    ]
104}
105
106fn read_syncsafe(b: &[u8]) -> usize {
107    b.iter()
108        .fold(0usize, |acc, &x| (acc << 7) | (x & 0x7f) as usize)
109}
110
111fn encode(enc: u8, s: &str) -> Vec<u8> {
112    match enc {
113        0 => s
114            .chars()
115            .map(|c| if (c as u32) < 256 { c as u8 } else { b'?' })
116            .collect(),
117        1 => {
118            let mut v = vec![0xff, 0xfe];
119            v.extend(s.encode_utf16().flat_map(|u| u.to_le_bytes()));
120            v
121        }
122        2 => s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect(),
123        _ => s.as_bytes().to_vec(),
124    }
125}
126
127fn term(enc: u8) -> &'static [u8] {
128    if enc == 1 || enc == 2 {
129        &[0, 0]
130    } else {
131        &[0]
132    }
133}
134
135fn encoded_text(enc: u8, s: &str) -> Vec<u8> {
136    let mut v = encode(enc, s);
137    v.extend_from_slice(term(enc));
138    v
139}
140
141/// Split one terminated string off the front of `data`.
142fn read_encoded(enc: u8, data: &[u8]) -> (String, usize) {
143    let t = term(enc);
144    let step = t.len();
145    let mut i = 0;
146    let end = loop {
147        if i + step > data.len() {
148            break None;
149        }
150        if &data[i..i + step] == t {
151            break Some(i);
152        }
153        i += step;
154    };
155    let (raw, consumed) = match end {
156        Some(e) => (&data[..e], e + step),
157        None => (data, data.len()),
158    };
159    let s = match enc {
160        0 => raw.iter().map(|&b| b as char).collect(),
161        1 => {
162            let (le, body) = match raw {
163                [0xfe, 0xff, rest @ ..] => (false, rest),
164                [0xff, 0xfe, rest @ ..] => (true, rest),
165                rest => (true, rest),
166            };
167            let units: Vec<u16> = body
168                .chunks_exact(2)
169                .map(|c| {
170                    if le {
171                        u16::from_le_bytes([c[0], c[1]])
172                    } else {
173                        u16::from_be_bytes([c[0], c[1]])
174                    }
175                })
176                .collect();
177            String::from_utf16_lossy(&units)
178        }
179        2 => {
180            let units: Vec<u16> = raw
181                .chunks_exact(2)
182                .map(|c| u16::from_be_bytes([c[0], c[1]]))
183                .collect();
184            String::from_utf16_lossy(&units)
185        }
186        _ => String::from_utf8_lossy(raw).into_owned(),
187    };
188    (s, consumed)
189}
190
191fn read_multi(enc: u8, mut data: &[u8]) -> Vec<String> {
192    let mut out = Vec::new();
193    while !data.is_empty() {
194        let (s, n) = read_encoded(enc, data);
195        out.push(s);
196        data = &data[n..];
197    }
198    out
199}
200
201fn parse_body(id: &str, data: &[u8]) -> Body {
202    let raw = || Body::Raw(data.to_vec());
203    if data.is_empty() {
204        return raw();
205    }
206    let enc = data[0];
207    if enc > 3 {
208        return raw();
209    }
210    let rest = &data[1..];
211    match id {
212        "TXXX" => {
213            let (desc, n) = read_encoded(enc, rest);
214            Body::Txxx {
215                enc,
216                desc,
217                values: read_multi(enc, &rest[n..]),
218            }
219        }
220        "COMM" | "USLT" if rest.len() >= 3 => {
221            let lang = [rest[0], rest[1], rest[2]];
222            let (desc, n) = read_encoded(enc, &rest[3..]);
223            let after = &rest[3 + n..];
224            if id == "COMM" {
225                Body::Comm {
226                    enc,
227                    lang,
228                    desc,
229                    values: read_multi(enc, after),
230                }
231            } else {
232                Body::Uslt {
233                    enc,
234                    lang,
235                    desc,
236                    text: read_encoded(enc, after).0,
237                }
238            }
239        }
240        "APIC" => {
241            let Some(mend) = rest.iter().position(|&b| b == 0) else {
242                return raw();
243            };
244            let mime: String = rest[..mend].iter().map(|&b| b as char).collect();
245            let after = &rest[mend + 1..];
246            if after.is_empty() {
247                return raw();
248            }
249            let ptype = after[0];
250            let (desc, n) = read_encoded(enc, &after[1..]);
251            Body::Apic {
252                enc,
253                mime,
254                ptype,
255                desc,
256                data: after[1 + n..].to_vec(),
257            }
258        }
259        t if t.starts_with('T') => Body::Text {
260            enc,
261            values: read_multi(enc, rest),
262        },
263        _ => raw(),
264    }
265}
266
267fn write_body(f: &Frame) -> Option<Vec<u8>> {
268    let mut v = Vec::new();
269    match &f.body {
270        Body::Text { enc, values } => {
271            if values.join("\0").is_empty() {
272                return None;
273            }
274            v.push(*enc);
275            for s in values {
276                let s = if f.id == "TDRC"
277                    || f.id == "TDOR"
278                    || f.id == "TDRL"
279                    || f.id == "TDTG"
280                    || f.id == "TDEN"
281                {
282                    s.replace(' ', "T")
283                } else {
284                    s.clone()
285                };
286                v.extend(encoded_text(*enc, &s));
287            }
288        }
289        Body::Txxx { enc, desc, values } => {
290            v.push(*enc);
291            v.extend(encoded_text(*enc, desc));
292            for s in values {
293                v.extend(encoded_text(*enc, s));
294            }
295        }
296        Body::Comm {
297            enc,
298            lang,
299            desc,
300            values,
301        } => {
302            v.push(*enc);
303            v.extend_from_slice(lang);
304            v.extend(encoded_text(*enc, desc));
305            for s in values {
306                v.extend(encoded_text(*enc, s));
307            }
308        }
309        Body::Uslt {
310            enc,
311            lang,
312            desc,
313            text,
314        } => {
315            v.push(*enc);
316            v.extend_from_slice(lang);
317            v.extend(encoded_text(*enc, desc));
318            v.extend(encoded_text(*enc, text));
319        }
320        Body::Apic {
321            enc,
322            mime,
323            ptype,
324            desc,
325            data,
326        } => {
327            v.push(*enc);
328            v.extend(mime.bytes());
329            v.push(0);
330            v.push(*ptype);
331            v.extend(encoded_text(*enc, desc));
332            v.extend_from_slice(data);
333        }
334        Body::Raw(data) => v.extend_from_slice(data),
335    }
336    Some(v)
337}
338
339/// The size of an existing ID3v2 tag at the start of `data` (header
340/// included), or 0.
341fn existing_tag_size(data: &[u8]) -> usize {
342    if data.len() < 10 || &data[..3] != b"ID3" {
343        return 0;
344    }
345    let flags = data[5];
346    let footer = if data[3] == 4 && flags & 0x10 != 0 {
347        10
348    } else {
349        0
350    };
351    read_syncsafe(&data[6..10]) + 10 + footer
352}
353
354impl Tag {
355    /// `ID3(path)` — `None` when the file has no ID3v2 header
356    /// (`ID3NoHeaderError`).
357    pub fn load(data: &[u8]) -> Option<Tag> {
358        let size = existing_tag_size(data);
359        if size == 0 {
360            return None;
361        }
362        let version = data[3];
363        let flags = data[5];
364        let end = (size.min(data.len())).max(10);
365        let mut pos = 10;
366        if flags & 0x40 != 0 && pos + 4 <= end {
367            // Extended header.
368            let ext = if version == 4 {
369                read_syncsafe(&data[pos..pos + 4])
370            } else {
371                u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]])
372                    as usize
373                    + 4
374            };
375            pos += ext;
376        }
377        let mut tag = Tag::default();
378        while pos + 10 <= end {
379            let id = &data[pos..pos + 4];
380            if id[0] == 0 {
381                break;
382            }
383            let fsize = if version == 4 {
384                read_syncsafe(&data[pos + 4..pos + 8])
385            } else {
386                u32::from_be_bytes([data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]])
387                    as usize
388            };
389            let body_start = pos + 10;
390            let body_end = (body_start + fsize).min(end);
391            let id = String::from_utf8_lossy(id).into_owned();
392            let frame = Frame {
393                id: id.clone(),
394                body: parse_body(&id, &data[body_start..body_end]),
395            };
396            tag.add(frame);
397            pos = body_start + fsize;
398        }
399        Some(tag)
400    }
401
402    /// `tags.add(frame)` — replaces a frame with the same HashKey in place.
403    pub fn add(&mut self, frame: Frame) {
404        self.frames.insert(frame.hash_key(), frame);
405    }
406
407    pub fn get(&self, key: &str) -> Option<&Frame> {
408        self.frames.get(key)
409    }
410
411    /// `ID3._write`: the frame bytes in mutagen's order.
412    fn frame_data(&self) -> Vec<u8> {
413        let mut items: Vec<(usize, &Frame, Vec<u8>)> = Vec::new();
414        for (i, f) in self.frames.values().enumerate() {
415            let Some(body) = write_body(f) else { continue };
416            let mut data = Vec::with_capacity(body.len() + 10);
417            data.extend_from_slice(&f.id.as_bytes()[..4.min(f.id.len())]);
418            data.extend_from_slice(&syncsafe(body.len()));
419            data.extend_from_slice(&[0, 0]);
420            data.extend(body);
421            items.push((i, f, data));
422        }
423        let prio = |f: &Frame| match ORDER.iter().position(|o| *o == f.id) {
424            Some(p) => p,
425            None if f.id == "APIC" => ORDER.len() + 1,
426            None => ORDER.len(),
427        };
428        items.sort_by(|a, b| {
429            let ka = (
430                prio(a.1),
431                if a.1.id == "APIC" { a.0 } else { a.2.len() },
432                a.1.hash_key(),
433            );
434            let kb = (
435                prio(b.1),
436                if b.1.id == "APIC" { b.0 } else { b.2.len() },
437                b.1.hash_key(),
438            );
439            ka.cmp(&kb)
440        });
441        items.into_iter().flat_map(|(_, _, d)| d).collect()
442    }
443
444    /// `tags.save(path)` with mutagen's defaults (v2.4, default padding).
445    pub fn save(&self, path: &Path) -> io::Result<()> {
446        let file = fs::read(path).unwrap_or_default();
447        let old_size = existing_tag_size(&file).min(file.len());
448        let frames = self.frame_data();
449        let needed = frames.len() + 10;
450        let trailing = file.len() as i64;
451        let available = old_size as i64 - needed as i64;
452        let high = 1024 * 10 + trailing / 100;
453        let low = 1024 + trailing / 1000;
454        let padding = if available >= 0 {
455            if available > high {
456                low
457            } else {
458                available
459            }
460        } else {
461            low
462        } as usize;
463        let new_size = needed + padding;
464        let mut out = Vec::with_capacity(new_size + file.len() - old_size);
465        out.extend_from_slice(b"ID3");
466        out.extend_from_slice(&[4, 0, 0]);
467        out.extend_from_slice(&syncsafe(new_size - 10));
468        out.extend(frames);
469        out.resize(new_size, 0);
470        out.extend_from_slice(&file[old_size..]);
471        fs::write(path, out)
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    #[test]
480    fn syncsafe_round_trip() {
481        assert_eq!(syncsafe(1024), [0, 0, 8, 0]);
482        assert_eq!(read_syncsafe(&syncsafe(123_456)), 123_456);
483    }
484
485    #[test]
486    fn text_frames_round_trip_and_sort() {
487        let tmp = tempfile::tempdir().unwrap();
488        let p = tmp.path().join("a.mp3");
489        fs::write(&p, b"\xff\xfb\x90\x00audio").unwrap();
490        let mut t = Tag::default();
491        t.add(Frame::text("TCON", "Podcast"));
492        t.add(Frame::text("TALB", "Show"));
493        t.add(Frame::text("TIT2", "Title"));
494        t.add(Frame::text("TPE1", ""));
495        t.save(&p).unwrap();
496        let data = fs::read(&p).unwrap();
497        let loaded = Tag::load(&data).unwrap();
498        let ids: Vec<&str> = loaded.frames.values().map(|f| f.id.as_str()).collect();
499        assert_eq!(
500            ids,
501            ["TIT2", "TALB", "TCON"],
502            "empty TPE1 dropped, priority order"
503        );
504        assert!(data.ends_with(b"\xff\xfb\x90\x00audio"));
505        // 1 KiB + 0.1 % of the 9-byte file.
506        assert_eq!(existing_tag_size(&data), 10 + 17 + 16 + 19 + 1024);
507    }
508}