Skip to main content

audiobox/
metadata.rs

1//! Publishing tags for MP3 (ID3v2.4), FLAC and WAV INFO.
2//!
3//! [`Tags`] gives known fields one spelling across containers; the native
4//! [`AudioMetadata`] map remains available for custom fields and existing callers.
5//! [`EncodeOptions::tags`] and [`DecodeInfo::tags`](crate::DecodeInfo::tags) carry
6//! structured tags; whole decoding exposes them through [`Audio::info`](crate::Audio::info).
7//! Incremental `stream::Decoder` reads samples and does not expose tag tables.
8//!
9//! MP3 and FLAC support every field and a JPEG/PNG front cover. WAV INFO supports
10//! title, artist, album, track, year, genre and comment. Unsupported structured
11//! fields/covers return `EncodeError`; they are never silently omitted. Native WAV
12//! keys remain four-character codes, and FLAC keys remain lowercase Vorbis names.
13//! MP3 native keys use the structured field names, with custom keys in TXXX frames.
14//!
15//! Text is UTF-8 on writing; embedded NUL is rejected. Readers validate UTF-8 in
16//! new tag data (MP3 also reads Latin-1/UTF-16). Metadata is bounded before copying
17//! by [`MAX_METADATA_BYTES`] and [`MAX_METADATA_FIELDS`], in addition to container
18//! limits. These bound encoded payloads, not allocator overhead or duplicate native
19//! and structured strings. Cover bytes are preserved without decoding the image;
20//! MIME/signature validation is not a full image integrity check. FLAC's informational
21//! image dimensions are written as zero (unknown), as specified in RFC 9639 ยง8.8.
22use crate::{AudioFormat, AudioMetadata, EncodeOptions, Error, Result};
23
24pub(crate) mod id3;
25
26/// Maximum combined supported metadata payload, 16 MiB minus one byte.
27pub const MAX_METADATA_BYTES: usize = 0xff_ffff;
28/// Maximum number of text fields/frames accepted in a tag table.
29pub const MAX_METADATA_FIELDS: usize = 4096;
30
31/// Caller-supplied JPEG/PNG bytes for a front cover. No image transcoding occurs.
32#[derive(Clone, Debug, Eq, PartialEq)]
33pub struct CoverArt {
34    pub mime_type: String,
35    pub data: Vec<u8>,
36}
37
38impl CoverArt {
39    pub fn new(mime_type: impl Into<String>, data: Vec<u8>) -> Result<Self> {
40        let cover = Self {
41            mime_type: mime_type.into(),
42            data,
43        };
44        cover.validate()?;
45        Ok(cover)
46    }
47
48    pub(crate) fn validate(&self) -> Result<()> {
49        if self.data.len() > MAX_METADATA_BYTES {
50            return Err(Error::limit("Cover exceeds metadata limit"));
51        }
52        let valid = match self.mime_type.as_str() {
53            "image/png" => self.data.starts_with(b"\x89PNG\r\n\x1a\n"),
54            "image/jpeg" => self.data.starts_with(b"\xff\xd8\xff"),
55            _ => false,
56        };
57        if !valid {
58            return Err(Error::encode(
59                "A cover needs matching image/jpeg or image/png bytes",
60            ));
61        }
62        Ok(())
63    }
64}
65
66/// Structured publishing fields. Track/disc text can be `1` or `1/12`; year is
67/// recording-date text, normally `2026` (MP3 TDRC / FLAC DATE / WAV ICRD).
68/// Optional values are distinct from absent fields, including empty strings.
69#[derive(Clone, Debug, Default, Eq, PartialEq)]
70pub struct Tags {
71    pub title: Option<String>,
72    pub artist: Option<String>,
73    pub album: Option<String>,
74    pub album_artist: Option<String>,
75    pub track: Option<String>,
76    pub disc: Option<String>,
77    pub year: Option<String>,
78    pub genre: Option<String>,
79    pub comment: Option<String>,
80    pub isrc: Option<String>,
81    pub cover: Option<CoverArt>,
82}
83
84impl Tags {
85    pub fn is_empty(&self) -> bool {
86        self.cover.is_none() && self.fields().all(|(_, value)| value.is_none())
87    }
88
89    pub(crate) fn fields(&self) -> impl Iterator<Item = (&'static str, Option<&String>)> {
90        [
91            ("title", self.title.as_ref()),
92            ("artist", self.artist.as_ref()),
93            ("album", self.album.as_ref()),
94            ("album_artist", self.album_artist.as_ref()),
95            ("track", self.track.as_ref()),
96            ("disc", self.disc.as_ref()),
97            ("year", self.year.as_ref()),
98            ("genre", self.genre.as_ref()),
99            ("comment", self.comment.as_ref()),
100            ("isrc", self.isrc.as_ref()),
101        ]
102        .into_iter()
103    }
104
105    pub(crate) fn from_native(metadata: &AudioMetadata) -> Self {
106        let mut out = Self::default();
107        for (key, value) in metadata {
108            let field = match canonical(key) {
109                Some("title") => &mut out.title,
110                Some("artist") => &mut out.artist,
111                Some("album") => &mut out.album,
112                Some("album_artist") => &mut out.album_artist,
113                Some("track") => &mut out.track,
114                Some("disc") => &mut out.disc,
115                Some("year") => &mut out.year,
116                Some("genre") => &mut out.genre,
117                Some("comment") => &mut out.comment,
118                Some("isrc") => &mut out.isrc,
119                _ => continue,
120            };
121            *field = Some(value.clone());
122        }
123        out
124    }
125}
126
127pub(crate) fn canonical(key: &str) -> Option<&'static str> {
128    match key.to_ascii_lowercase().as_str() {
129        "title" | "inam" => Some("title"),
130        "artist" | "iart" => Some("artist"),
131        "album" | "iprd" => Some("album"),
132        "album_artist" | "albumartist" => Some("album_artist"),
133        "track" | "tracknumber" | "iprt" | "itrk" => Some("track"),
134        "disc" | "discnumber" => Some("disc"),
135        "year" | "date" | "icrd" => Some("year"),
136        "genre" | "ignr" => Some("genre"),
137        "comment" | "description" | "icmt" => Some("comment"),
138        "isrc" => Some("isrc"),
139        _ => None,
140    }
141}
142
143pub(crate) fn validate_options(options: &EncodeOptions, format: AudioFormat) -> Result<()> {
144    if options.wav_channel_mask.is_some() && format != AudioFormat::Wav {
145        return Err(Error::invalid("A WAVE channel mask requires WAV output"));
146    }
147    if format == AudioFormat::Wav
148        && (options.tags.album_artist.is_some()
149            || options.tags.disc.is_some()
150            || options.tags.isrc.is_some())
151    {
152        return Err(Error::encode(
153            "WAV INFO has no supported album artist, disc or ISRC field",
154        ));
155    }
156    if !options.tags.is_empty()
157        && !matches!(
158            format,
159            AudioFormat::Wav | AudioFormat::Flac | AudioFormat::Mp3
160        )
161    {
162        return Err(Error::encode(
163            "Structured tags are supported by WAV, FLAC and MP3",
164        ));
165    }
166    if options.tags.cover.is_some() && !matches!(format, AudioFormat::Flac | AudioFormat::Mp3) {
167        return Err(Error::encode("Front covers are supported by FLAC and MP3"));
168    }
169    let mut budget = Budget::default();
170    for (key, value) in &options.metadata {
171        if key.is_empty() {
172            return Err(Error::encode("Metadata keys cannot be empty"));
173        }
174        text(key)?;
175        text(value)?;
176        budget.field()?;
177        budget.add(key.len())?;
178        budget.add(value.len())?;
179    }
180    for (key, value) in options.tags.fields() {
181        if let Some(value) = value {
182            text(value)?;
183            budget.field()?;
184            budget.add(key.len())?;
185            budget.add(value.len())?;
186            if options
187                .metadata
188                .keys()
189                .any(|native| canonical(native) == Some(key))
190            {
191                return Err(Error::encode(format!(
192                    "Tag {key} is supplied in both tags and metadata"
193                )));
194            }
195        }
196    }
197    if let Some(cover) = &options.tags.cover {
198        cover.validate()?;
199        budget.add(cover.data.len())?;
200        budget.add(cover.mime_type.len())?;
201    }
202    Ok(())
203}
204
205pub(crate) fn merged_text(options: &EncodeOptions) -> AudioMetadata {
206    let mut out = options.metadata.clone();
207    for (key, value) in options.tags.fields() {
208        if let Some(value) = value {
209            out.insert(key.into(), value.clone());
210        }
211    }
212    out
213}
214
215pub(crate) fn wav_text(options: &EncodeOptions) -> AudioMetadata {
216    let mut out = options.metadata.clone();
217    for (key, value) in options.tags.fields() {
218        let native = match key {
219            "title" => "INAM",
220            "artist" => "IART",
221            "album" => "IPRD",
222            "track" => "IPRT",
223            "year" => "ICRD",
224            "genre" => "IGNR",
225            "comment" => "ICMT",
226            _ => continue,
227        };
228        if let Some(value) = value {
229            out.insert(native.into(), value.clone());
230        }
231    }
232    out
233}
234
235fn text(value: &str) -> Result<()> {
236    if value.contains('\0') {
237        Err(Error::encode("Metadata text cannot contain NUL"))
238    } else {
239        Ok(())
240    }
241}
242
243pub(crate) fn utf8(bytes: &[u8]) -> Result<&str> {
244    std::str::from_utf8(bytes).map_err(|_| Error::decode("Invalid UTF-8 metadata"))
245}
246
247#[derive(Default)]
248pub(crate) struct Budget {
249    bytes: usize,
250    fields: usize,
251}
252impl Budget {
253    pub fn add(&mut self, bytes: usize) -> Result<()> {
254        self.bytes = self
255            .bytes
256            .checked_add(bytes)
257            .filter(|&n| n <= MAX_METADATA_BYTES)
258            .ok_or_else(|| Error::limit("Metadata exceeds 16 MiB minus one byte"))?;
259        Ok(())
260    }
261    pub fn field(&mut self) -> Result<()> {
262        self.fields += 1;
263        if self.fields > MAX_METADATA_FIELDS {
264            Err(Error::limit("Too many metadata fields"))
265        } else {
266            Ok(())
267        }
268    }
269}
270
271pub(crate) fn flac_comments(options: &EncodeOptions) -> Result<Option<Vec<u8>>> {
272    let mut metadata = options.metadata.clone();
273    for (key, value) in options.tags.fields() {
274        let name = match key {
275            "album_artist" => "albumartist",
276            "track" => "tracknumber",
277            "disc" => "discnumber",
278            "year" => "date",
279            _ => key,
280        };
281        if let Some(value) = value {
282            metadata.insert(name.into(), value.clone());
283        }
284    }
285    if metadata.is_empty() {
286        return Ok(None);
287    }
288    let mut budget = Budget::default();
289    budget.add(16)?; // vendor length, "audiobox", field count
290    let mut normalized = AudioMetadata::new();
291    for (key, value) in metadata {
292        let name = key.to_ascii_uppercase();
293        if name.is_empty()
294            || !name
295                .bytes()
296                .all(|b| (0x20..=0x7d).contains(&b) && b != b'=')
297        {
298            return Err(Error::encode("Invalid FLAC metadata key"));
299        }
300        if normalized.contains_key(&name) {
301            return Err(Error::encode("Duplicate FLAC metadata alias"));
302        }
303        budget.field()?;
304        budget.add(5 + name.len())?;
305        budget.add(value.len())?;
306        normalized.insert(name, value);
307    }
308    let mut out = Vec::new();
309    crate::io::put32(&mut out, 8, true);
310    out.extend_from_slice(b"audiobox");
311    crate::io::put32(&mut out, normalized.len() as u32, true);
312    for (key, value) in normalized {
313        crate::io::put32(&mut out, (key.len() + 1 + value.len()) as u32, true);
314        out.extend_from_slice(key.as_bytes());
315        out.push(b'=');
316        out.extend_from_slice(value.as_bytes());
317    }
318    Ok(Some(out))
319}
320
321pub(crate) fn flac_picture(cover: &CoverArt) -> Result<Vec<u8>> {
322    cover.validate()?;
323    let size = 32 + cover.mime_type.len() + cover.data.len();
324    Budget::default().add(size)?;
325    let mut out = Vec::with_capacity(size);
326    crate::io::put32(&mut out, 3, false);
327    crate::io::put32(&mut out, cover.mime_type.len() as u32, false);
328    out.extend_from_slice(cover.mime_type.as_bytes());
329    for _ in 0..5 {
330        crate::io::put32(&mut out, 0, false);
331    } // description, dimensions/depth/colours unknown
332    crate::io::put32(&mut out, cover.data.len() as u32, false);
333    out.extend_from_slice(&cover.data);
334    Ok(out)
335}
336
337pub(crate) fn read_flac_picture(bytes: &[u8]) -> Result<Option<CoverArt>> {
338    let mut r = crate::io::Reader::new(bytes);
339    let kind = r.u32(false)?;
340    let n = r.u32(false)? as usize;
341    let mime = utf8(r.take(n)?)?;
342    let n = r.u32(false)? as usize;
343    utf8(r.take(n)?)?;
344    r.take(16)?;
345    let n = r.u32(false)? as usize;
346    let data = r.take(n)?;
347    if r.pos != bytes.len() {
348        return Err(Error::decode("Trailing FLAC picture data"));
349    }
350    if kind != 3 || !matches!(mime, "image/jpeg" | "image/png") {
351        return Ok(None);
352    }
353    let cover = CoverArt {
354        mime_type: mime.into(),
355        data: data.to_vec(),
356    };
357    cover
358        .validate()
359        .map_err(|_| Error::decode("Invalid FLAC front cover"))?;
360    Ok(Some(cover))
361}