1use std::fmt;
7
8use id3::{Tag, TagLike};
9
10use crate::edit::{FrameId, TagEdit};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
14#[non_exhaustive]
15pub enum Field {
16 Title,
18 Artist,
20 AlbumArtist,
22 Album,
24 Composer,
26 Conductor,
28 Genre,
30 Date,
32 Disc,
34 Grouping,
36 Publisher,
38 Copyright,
40 Isrc,
42 Comment,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48#[non_exhaustive]
49pub enum Target {
50 Text(FrameId),
52 Comment,
54}
55
56impl Field {
57 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 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 fn fallbacks(self) -> &'static [&'static str] {
98 match self {
99 Self::Date => &["TYER"],
100 Self::Grouping => &["GRP1"],
101 _ => &[],
102 }
103 }
104
105 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
122pub 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
129pub 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
140pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub struct Track {
150 pub number: u32,
152 pub total: Option<u32>,
154}
155
156impl Track {
157 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 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
182pub 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 #[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 #[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 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}