Skip to main content

dsf/
field.rs

1//! The tag fields people expect on an album track, mapped to ID3v2 frames.
2//!
3//! This is the format-level knowledge of "which frame holds the conductor?" and "where is
4//! the date in an ID3v2.3 tag?", so applications do not each have to rediscover it.
5
6use std::fmt;
7
8use id3::{Tag, TagLike};
9
10use crate::edit::{FrameId, TagEdit};
11
12/// A well-known field of a track's tag.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
14#[non_exhaustive]
15pub enum Field {
16    /// Track title (`TIT2`).
17    Title,
18    /// Lead artist (`TPE1`).
19    Artist,
20    /// Album artist (`TPE2`).
21    AlbumArtist,
22    /// Album title (`TALB`).
23    Album,
24    /// Composer (`TCOM`).
25    Composer,
26    /// Conductor (`TPE3`).
27    Conductor,
28    /// Genre (`TCON`).
29    Genre,
30    /// Recording date (`TDRC`; `TYER` in ID3v2.3 tags).
31    Date,
32    /// Disc number, `"n"` or `"n/total"` (`TPOS`).
33    Disc,
34    /// Content group, such as a work or movement (`TIT1`; `GRP1` in some tags).
35    Grouping,
36    /// Label or publisher (`TPUB`).
37    Publisher,
38    /// Copyright message (`TCOP`).
39    Copyright,
40    /// International Standard Recording Code (`TSRC`).
41    Isrc,
42    /// General comment (`COMM` with an empty description).
43    Comment,
44}
45
46/// The kind of frame a [`Field`] is stored in.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48#[non_exhaustive]
49pub enum Target {
50    /// A text frame such as `TIT2`.
51    Text(FrameId),
52    /// The general comment frame.
53    Comment,
54}
55
56impl Field {
57    /// Every field, in a stable order.
58    pub const ALL: [Self; 14] = [
59        Self::Title,
60        Self::Artist,
61        Self::AlbumArtist,
62        Self::Album,
63        Self::Composer,
64        Self::Conductor,
65        Self::Genre,
66        Self::Date,
67        Self::Disc,
68        Self::Grouping,
69        Self::Publisher,
70        Self::Copyright,
71        Self::Isrc,
72        Self::Comment,
73    ];
74
75    /// Where this field is written.
76    pub fn target(self) -> Target {
77        match self {
78            Self::Title => Target::Text(FrameId::TITLE),
79            Self::Artist => Target::Text(FrameId::ARTIST),
80            Self::AlbumArtist => Target::Text(FrameId::ALBUM_ARTIST),
81            Self::Album => Target::Text(FrameId::ALBUM),
82            Self::Composer => Target::Text(FrameId::COMPOSER),
83            Self::Conductor => Target::Text(FrameId::CONDUCTOR),
84            Self::Genre => Target::Text(FrameId::GENRE),
85            Self::Date => Target::Text(FrameId::RECORDING_DATE),
86            Self::Disc => Target::Text(FrameId::DISC),
87            Self::Grouping => Target::Text(FrameId::GROUPING),
88            Self::Publisher => Target::Text(FrameId::PUBLISHER),
89            Self::Copyright => Target::Text(FrameId::COPYRIGHT),
90            Self::Isrc => Target::Text(FrameId::ISRC),
91            Self::Comment => Target::Comment,
92        }
93    }
94
95    /// Older frames that carry the same information in tags written by other software,
96    /// tried in order when the main frame is absent.
97    fn fallbacks(self) -> &'static [&'static str] {
98        match self {
99            Self::Date => &["TYER"],
100            Self::Grouping => &["GRP1"],
101            _ => &[],
102        }
103    }
104
105    /// The field's value in `tag`, or `None` if it is absent or blank.
106    ///
107    /// Several values stored in one frame are joined with `"; "`.
108    pub fn read(self, tag: &Tag) -> Option<String> {
109        let value = match self.target() {
110            Target::Text(id) => std::iter::once(id.as_str())
111                .chain(self.fallbacks().iter().copied())
112                .find_map(|frame| text(tag, frame)),
113            Target::Comment => tag
114                .comments()
115                .find(|c| c.description.is_empty())
116                .map(|c| c.text.clone()),
117        };
118        value.filter(|v| !v.trim().is_empty())
119    }
120}
121
122/// The text of frame `id`, with multiple values joined by `"; "`. `None` if absent.
123pub fn text(tag: &Tag, id: &str) -> Option<String> {
124    tag.get(id)
125        .and_then(|frame| frame.content().text())
126        .map(|t| t.trim_end_matches('\0').replace('\0', "; "))
127}
128
129/// Every text frame in the tag (except user-defined `TXXX` frames), in tag order.
130pub fn text_frames(tag: &Tag) -> Vec<(FrameId, String)> {
131    tag.frames()
132        .filter_map(|frame| {
133            let id = FrameId::new(frame.id()).ok()?;
134            let value = text(tag, frame.id())?;
135            Some((id, value))
136        })
137        .collect()
138}
139
140/// The user-defined text frames (`TXXX`) as `(description, value)` pairs.
141pub fn custom_fields(tag: &Tag) -> Vec<(String, String)> {
142    tag.extended_texts()
143        .map(|t| (t.description.clone(), t.value.clone()))
144        .collect()
145}
146
147/// A track's place on its disc: number and, if known, how many tracks there are.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub struct Track {
150    /// Position, from 1 (0 is used for a hidden track in front of track 1).
151    pub number: u32,
152    /// Number of tracks, if known.
153    pub total: Option<u32>,
154}
155
156impl Track {
157    /// Parses `"3"`, `"3/12"` or `" 3 / 12 "`. A total of zero counts as unknown.
158    pub fn parse(text: &str) -> Option<Self> {
159        let (number, total) = match text.split_once('/') {
160            Some((number, total)) => (number, Some(total)),
161            None => (text, None),
162        };
163        Some(Self {
164            number: number.trim().parse().ok()?,
165            total: total
166                .and_then(|t| t.trim().parse::<u32>().ok())
167                .filter(|t| *t > 0),
168        })
169    }
170}
171
172impl fmt::Display for Track {
173    /// `n` or `n/total`, as stored in a `TRCK` frame.
174    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175        match self.total {
176            Some(total) => write!(f, "{}/{total}", self.number),
177            None => write!(f, "{}", self.number),
178        }
179    }
180}
181
182/// The track position from the `TRCK` frame, if present and well formed.
183pub fn read_track(tag: &Tag) -> Option<Track> {
184    text(tag, FrameId::TRACK.as_str()).and_then(|t| Track::parse(&t))
185}
186
187impl TagEdit {
188    /// Sets a [`Field`]. A blank value removes it.
189    #[must_use]
190    pub fn set_field(self, field: Field, value: impl Into<String>) -> Self {
191        match field.target() {
192            Target::Text(id) => self.set(id, value),
193            Target::Comment => self.set_comment(value),
194        }
195    }
196
197    /// Sets the track number (and total) in the `TRCK` frame.
198    #[must_use]
199    pub fn set_track(self, track: Track) -> Self {
200        self.set(FrameId::TRACK, track.to_string())
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use std::io::Cursor;
207
208    use id3::Version;
209    use id3::frame::Comment;
210
211    use super::*;
212    use crate::{WriteOptions, read_tag, update_tag, write_tag};
213
214    type TestResult = std::result::Result<(), Box<dyn std::error::Error>>;
215
216    /// A minimal DSF file with no tag.
217    fn blank() -> std::result::Result<Cursor<Vec<u8>>, Box<dyn std::error::Error>> {
218        let audio = 4096 * 2;
219        let header = crate::DsfHeader {
220            total_file_size: crate::AUDIO_OFFSET + audio,
221            metadata_offset: 0,
222            channel_type: 2,
223            channel_count: 2,
224            sample_rate: 2_822_400,
225            bits_per_sample: 1,
226            sample_count: 32_768,
227            block_size: 4096,
228            data_chunk_size: 12 + audio,
229        };
230        let mut bytes = Vec::new();
231        header.write(&mut bytes)?;
232        bytes.extend(vec![0xA5; audio as usize]);
233        Ok(Cursor::new(bytes))
234    }
235
236    #[test]
237    fn every_field_has_its_own_frame() {
238        let mut seen: Vec<Target> = Vec::new();
239        for field in Field::ALL {
240            let target = field.target();
241            assert!(!seen.contains(&target), "{field:?} shares a frame");
242            seen.push(target);
243            if let Target::Text(id) = target {
244                assert_eq!(FrameId::new(id.as_str()).ok(), Some(id), "{field:?}");
245            }
246        }
247        assert_eq!(seen.len(), Field::ALL.len());
248    }
249
250    #[test]
251    fn every_field_round_trips_through_a_file() -> TestResult {
252        let mut file = blank()?;
253        let mut edit = TagEdit::new();
254        for (i, field) in Field::ALL.into_iter().enumerate() {
255            edit = edit.set_field(field, format!("value {i}"));
256        }
257        update_tag(&mut file, &edit, &WriteOptions::default())?;
258        let tag = read_tag(&mut file)?.ok_or("tag missing")?;
259        for (i, field) in Field::ALL.into_iter().enumerate() {
260            assert_eq!(field.read(&tag), Some(format!("value {i}")), "{field:?}");
261        }
262        Ok(())
263    }
264
265    #[test]
266    fn blank_and_absent_fields_read_as_none_and_a_blank_set_removes() -> TestResult {
267        let mut file = blank()?;
268        let edit = TagEdit::new()
269            .set_field(Field::Genre, "Jazz")
270            .set_field(Field::Title, "T");
271        update_tag(&mut file, &edit, &WriteOptions::default())?;
272        let tag = read_tag(&mut file)?.ok_or("tag missing")?;
273        assert_eq!(Field::Composer.read(&tag), None);
274
275        update_tag(
276            &mut file,
277            &TagEdit::new().set_field(Field::Genre, "  "),
278            &WriteOptions::default(),
279        )?;
280        let tag = read_tag(&mut file)?.ok_or("tag missing")?;
281        assert_eq!(Field::Genre.read(&tag), None);
282        assert_eq!(
283            Field::Title.read(&tag).as_deref(),
284            Some("T"),
285            "other fields stay"
286        );
287        Ok(())
288    }
289
290    #[test]
291    fn id3v23_dates_are_found_under_tyer() -> TestResult {
292        let mut tag = Tag::new();
293        tag.add_frame(id3::Frame::text("TYER", "1959"));
294        tag.add_frame(id3::Frame::text("GRP1", "Symphony No. 9"));
295        assert_eq!(Field::Date.read(&tag).as_deref(), Some("1959"));
296        assert_eq!(
297            Field::Grouping.read(&tag).as_deref(),
298            Some("Symphony No. 9")
299        );
300
301        tag.add_frame(id3::Frame::text("TDRC", "1960-01-02"));
302        tag.add_frame(id3::Frame::text("TIT1", "Real group"));
303        assert_eq!(
304            Field::Date.read(&tag).as_deref(),
305            Some("1960-01-02"),
306            "the modern frame wins"
307        );
308        assert_eq!(Field::Grouping.read(&tag).as_deref(), Some("Real group"));
309        Ok(())
310    }
311
312    #[test]
313    fn a_date_written_into_an_id3v23_file_reads_back() -> TestResult {
314        let mut file = blank()?;
315        write_tag(&mut file, &Tag::new(), Version::Id3v23)?;
316        let edit = TagEdit::new()
317            .set_field(Field::Date, "1959")
318            .set_field(Field::Title, "T");
319        update_tag(&mut file, &edit, &WriteOptions::default())?;
320        let tag = read_tag(&mut file)?.ok_or("tag missing")?;
321        assert_eq!(tag.version(), Version::Id3v23);
322        assert_eq!(
323            Field::Date.read(&tag).as_deref(),
324            Some("1959"),
325            "the date survives v2.3"
326        );
327        Ok(())
328    }
329
330    #[test]
331    fn multiple_values_are_joined_and_only_the_plain_comment_counts() {
332        let mut tag = Tag::new();
333        tag.set_text("TPE1", "Miles Davis\0John Coltrane");
334        tag.add_frame(Comment {
335            lang: "eng".into(),
336            description: "other".into(),
337            text: "not this one".into(),
338        });
339        assert_eq!(
340            Field::Artist.read(&tag).as_deref(),
341            Some("Miles Davis; John Coltrane")
342        );
343        assert_eq!(
344            Field::Comment.read(&tag),
345            None,
346            "described comments are not the general comment"
347        );
348        tag.add_frame(Comment {
349            lang: "eng".into(),
350            description: String::new(),
351            text: "plain".into(),
352        });
353        assert_eq!(Field::Comment.read(&tag).as_deref(), Some("plain"));
354    }
355
356    #[test]
357    fn text_frames_lists_every_text_frame_but_not_user_text() {
358        let mut tag = Tag::new();
359        tag.set_text("TIT2", "Title");
360        tag.set_text("TKEY", "Am");
361        tag.add_frame(id3::frame::ExtendedText {
362            description: "MOOD".into(),
363            value: "calm".into(),
364        });
365        let frames = text_frames(&tag);
366        let ids: Vec<&str> = frames.iter().map(|(id, _)| id.as_str()).collect();
367        assert!(ids.contains(&"TIT2") && ids.contains(&"TKEY"), "{ids:?}");
368        assert!(!ids.contains(&"TXXX"), "{ids:?}");
369        assert_eq!(
370            custom_fields(&tag),
371            [("MOOD".to_owned(), "calm".to_owned())]
372        );
373    }
374
375    #[test]
376    fn track_positions_parse_format_and_round_trip() -> TestResult {
377        assert_eq!(
378            Track::parse("3"),
379            Some(Track {
380                number: 3,
381                total: None
382            })
383        );
384        assert_eq!(
385            Track::parse(" 3 / 12 "),
386            Some(Track {
387                number: 3,
388                total: Some(12)
389            })
390        );
391        assert_eq!(
392            Track::parse("3/0"),
393            Some(Track {
394                number: 3,
395                total: None
396            })
397        );
398        assert_eq!(
399            Track::parse("0/9"),
400            Some(Track {
401                number: 0,
402                total: Some(9)
403            })
404        );
405        assert_eq!(Track::parse("three"), None);
406        assert_eq!(Track::parse(""), None);
407        assert_eq!(Track::parse("-1"), None);
408        assert_eq!(
409            Track {
410                number: 3,
411                total: Some(12)
412            }
413            .to_string(),
414            "3/12"
415        );
416        assert_eq!(
417            Track {
418                number: 3,
419                total: None
420            }
421            .to_string(),
422            "3"
423        );
424
425        let mut file = blank()?;
426        let edit = TagEdit::new().set_track(Track {
427            number: 7,
428            total: Some(10),
429        });
430        update_tag(&mut file, &edit, &WriteOptions::default())?;
431        let tag = read_tag(&mut file)?.ok_or("tag missing")?;
432        assert_eq!(
433            read_track(&tag),
434            Some(Track {
435                number: 7,
436                total: Some(10)
437            })
438        );
439        assert_eq!(text(&tag, "TRCK").as_deref(), Some("7/10"));
440        Ok(())
441    }
442}