Skip to content

Commit 5c99661

Browse files
committed
ID3v2: Turn Frame into an enum
1 parent 4423b5a commit 5c99661

35 files changed

+1859
-1539
lines changed

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717

1818
### Changed
1919
- **VorbisComments**/**ApeTag**: Verify contents of `ItemKey::FlagCompilation` during `Tag` merge ([PR](https://github.com/Serial-ATA/lofty-rs/pull/387))
20+
- **ID3v2**:
21+
- ⚠️ Important ⚠️: `Frame` has been converted to an `enum`:
22+
- This makes it easier to validate frame contents, as one can no longer make an `AttachedPictureFrame` with the ID `"TALB"`, for example.
23+
```rust
24+
// Old:
25+
let frame = Frame::new(
26+
"TALB",
27+
FrameType::Text(TextInformationFrame {
28+
TextEncoding::UTF8,
29+
value: String::from("Foo album"),
30+
}),
31+
FrameFlags::default(),
32+
).unwrap();
33+
34+
// New:
35+
let frame = Frame::Text(TextInformationFrame::new(
36+
FrameId::new("TALB").unwrap(),
37+
FrameFlags::default(),
38+
TextEncoding::UTF8,
39+
String::from("Foo album"),
40+
));
41+
```
42+
- Renamed `Popularimeter` -> `PopularimeterFrame`
43+
- Renamed `SynchronizedText` -> `SynchronizedTextFrame`
2044

2145
## [0.19.2] - 2024-04-26
2246

lofty/src/id3/v2/frame/content.rs

Lines changed: 23 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
use crate::config::ParsingMode;
22
use crate::error::{Id3v2Error, Id3v2ErrorKind, Result};
3-
use crate::id3::v2::frame::FrameValue;
43
use crate::id3::v2::header::Id3v2Version;
54
use crate::id3::v2::items::{
65
AttachedPictureFrame, CommentFrame, EventTimingCodesFrame, ExtendedTextFrame, ExtendedUrlFrame,
7-
KeyValueFrame, OwnershipFrame, Popularimeter, PrivateFrame, RelativeVolumeAdjustmentFrame,
6+
KeyValueFrame, OwnershipFrame, PopularimeterFrame, PrivateFrame, RelativeVolumeAdjustmentFrame,
87
TextInformationFrame, TimestampFrame, UniqueFileIdentifierFrame, UnsynchronizedTextFrame,
98
UrlLinkFrame,
109
};
10+
use crate::id3::v2::{BinaryFrame, Frame, FrameFlags, FrameId};
1111
use crate::macros::err;
1212
use crate::util::text::TextEncoding;
1313

@@ -16,39 +16,36 @@ use std::io::Read;
1616
#[rustfmt::skip]
1717
pub(super) fn parse_content<R: Read>(
1818
reader: &mut R,
19-
id: &str,
19+
id: FrameId<'static>,
20+
flags: FrameFlags,
2021
version: Id3v2Version,
2122
parse_mode: ParsingMode,
22-
) -> Result<Option<FrameValue>> {
23-
Ok(match id {
23+
) -> Result<Option<Frame<'static>>> {
24+
Ok(match id.as_str() {
2425
// The ID was previously upgraded, but the content remains unchanged, so version is necessary
2526
"APIC" => {
26-
let attached_picture = AttachedPictureFrame::parse(reader, version)?;
27-
Some(FrameValue::Picture(attached_picture))
27+
Some(Frame::Picture(AttachedPictureFrame::parse(reader, flags, version)?))
2828
},
29-
"TXXX" => ExtendedTextFrame::parse(reader, version)?.map(FrameValue::UserText),
30-
"WXXX" => ExtendedUrlFrame::parse(reader, version)?.map(FrameValue::UserUrl),
31-
"COMM" => CommentFrame::parse(reader, version)?.map(FrameValue::Comment),
32-
"USLT" => UnsynchronizedTextFrame::parse(reader, version)?.map(FrameValue::UnsynchronizedText),
33-
"TIPL" | "TMCL" => KeyValueFrame::parse(reader, version)?.map(FrameValue::KeyValue),
34-
"UFID" => UniqueFileIdentifierFrame::parse(reader, parse_mode)?.map(FrameValue::UniqueFileIdentifier),
35-
"RVA2" => RelativeVolumeAdjustmentFrame::parse(reader, parse_mode)?.map(FrameValue::RelativeVolumeAdjustment),
36-
"OWNE" => OwnershipFrame::parse(reader)?.map(FrameValue::Ownership),
37-
"ETCO" => EventTimingCodesFrame::parse(reader)?.map(FrameValue::EventTimingCodes),
38-
"PRIV" => PrivateFrame::parse(reader)?.map(FrameValue::Private),
39-
_ if id.starts_with('T') => TextInformationFrame::parse(reader, version)?.map(FrameValue::Text),
29+
"TXXX" => ExtendedTextFrame::parse(reader, flags, version)?.map(Frame::UserText),
30+
"WXXX" => ExtendedUrlFrame::parse(reader, flags, version)?.map(Frame::UserUrl),
31+
"COMM" => CommentFrame::parse(reader, flags, version)?.map(Frame::Comment),
32+
"USLT" => UnsynchronizedTextFrame::parse(reader, flags, version)?.map(Frame::UnsynchronizedText),
33+
"TIPL" | "TMCL" => KeyValueFrame::parse(reader, id, flags, version)?.map(Frame::KeyValue),
34+
"UFID" => UniqueFileIdentifierFrame::parse(reader, flags, parse_mode)?.map(Frame::UniqueFileIdentifier),
35+
"RVA2" => RelativeVolumeAdjustmentFrame::parse(reader, flags, parse_mode)?.map(Frame::RelativeVolumeAdjustment),
36+
"OWNE" => OwnershipFrame::parse(reader, flags)?.map(Frame::Ownership),
37+
"ETCO" => EventTimingCodesFrame::parse(reader, flags)?.map(Frame::EventTimingCodes),
38+
"PRIV" => PrivateFrame::parse(reader, flags)?.map(Frame::Private),
39+
i if i.starts_with('T') => TextInformationFrame::parse(reader, id, flags, version)?.map(Frame::Text),
4040
// Apple proprietary frames
4141
// WFED (Podcast URL), GRP1 (Grouping), MVNM (Movement Name), MVIN (Movement Number)
42-
"WFED" | "GRP1" | "MVNM" | "MVIN" => TextInformationFrame::parse(reader, version)?.map(FrameValue::Text),
43-
_ if id.starts_with('W') => UrlLinkFrame::parse(reader)?.map(FrameValue::Url),
44-
"POPM" => Some(FrameValue::Popularimeter(Popularimeter::parse(reader)?)),
45-
"TDEN" | "TDOR" | "TDRC" | "TDRL" | "TDTG" => TimestampFrame::parse(reader, parse_mode)?.map(FrameValue::Timestamp),
42+
"WFED" | "GRP1" | "MVNM" | "MVIN" => TextInformationFrame::parse(reader, id, flags, version)?.map(Frame::Text),
43+
i if i.starts_with('W') => UrlLinkFrame::parse(reader, id, flags)?.map(Frame::Url),
44+
"POPM" => Some(Frame::Popularimeter(PopularimeterFrame::parse(reader, flags)?)),
45+
"TDEN" | "TDOR" | "TDRC" | "TDRL" | "TDTG" => TimestampFrame::parse(reader, id, flags, parse_mode)?.map(Frame::Timestamp),
4646
// SYLT, GEOB, and any unknown frames
4747
_ => {
48-
let mut content = Vec::new();
49-
reader.read_to_end(&mut content)?;
50-
51-
Some(FrameValue::Binary(content))
48+
Some(Frame::Binary(BinaryFrame::parse(reader, id, flags)?))
5249
},
5350
})
5451
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
use crate::error::{Id3v2Error, Id3v2ErrorKind, LoftyError, Result};
2+
use crate::id3::v2::frame::{
3+
FrameRef, EMPTY_CONTENT_DESCRIPTOR, MUSICBRAINZ_UFID_OWNER, UNKNOWN_LANGUAGE,
4+
};
5+
use crate::id3::v2::tag::{
6+
new_binary_frame, new_comment_frame, new_text_frame, new_unsync_text_frame, new_url_frame,
7+
new_user_text_frame, new_user_url_frame,
8+
};
9+
use crate::id3::v2::{
10+
ExtendedTextFrame, ExtendedUrlFrame, Frame, FrameFlags, FrameId, PopularimeterFrame,
11+
UniqueFileIdentifierFrame, UnsynchronizedTextFrame,
12+
};
13+
use crate::macros::err;
14+
use crate::tag::{ItemKey, ItemValue, TagItem, TagType};
15+
use crate::TextEncoding;
16+
17+
use std::borrow::Cow;
18+
19+
fn frame_from_unknown_item(id: FrameId<'_>, item_value: ItemValue) -> Result<Frame<'_>> {
20+
match item_value {
21+
ItemValue::Text(text) => Ok(new_text_frame(id, text)),
22+
ItemValue::Locator(locator) => {
23+
if TextEncoding::verify_latin1(&locator) {
24+
Ok(new_url_frame(id, locator))
25+
} else {
26+
err!(TextDecode("ID3v2 URL frames must be Latin-1"));
27+
}
28+
},
29+
ItemValue::Binary(binary) => Ok(new_binary_frame(id, binary.clone())),
30+
}
31+
}
32+
33+
impl From<TagItem> for Option<Frame<'static>> {
34+
fn from(input: TagItem) -> Self {
35+
let value;
36+
match input.key().try_into().map(FrameId::into_owned) {
37+
Ok(id) => {
38+
match (&id, input.item_value) {
39+
(FrameId::Valid(ref s), ItemValue::Text(text)) if s == "COMM" => {
40+
value = new_comment_frame(text);
41+
},
42+
(FrameId::Valid(ref s), ItemValue::Text(text)) if s == "USLT" => {
43+
value = Frame::UnsynchronizedText(UnsynchronizedTextFrame::new(
44+
TextEncoding::UTF8,
45+
UNKNOWN_LANGUAGE,
46+
EMPTY_CONTENT_DESCRIPTOR,
47+
text,
48+
));
49+
},
50+
(FrameId::Valid(ref s), ItemValue::Locator(text) | ItemValue::Text(text))
51+
if s == "WXXX" =>
52+
{
53+
value = Frame::UserUrl(ExtendedUrlFrame::new(
54+
TextEncoding::UTF8,
55+
EMPTY_CONTENT_DESCRIPTOR,
56+
text,
57+
));
58+
},
59+
(FrameId::Valid(ref s), ItemValue::Text(text)) if s == "TXXX" => {
60+
value = new_user_text_frame(EMPTY_CONTENT_DESCRIPTOR, text);
61+
},
62+
(FrameId::Valid(ref s), ItemValue::Binary(text)) if s == "POPM" => {
63+
value = Frame::Popularimeter(
64+
PopularimeterFrame::parse(&mut &text[..], FrameFlags::default())
65+
.ok()?,
66+
);
67+
},
68+
(_, item_value) => value = frame_from_unknown_item(id, item_value).ok()?,
69+
};
70+
},
71+
Err(_) => match input.item_key.map_key(TagType::Id3v2, true) {
72+
Some(desc) => match input.item_value {
73+
ItemValue::Text(text) => {
74+
value = Frame::UserText(ExtendedTextFrame::new(
75+
TextEncoding::UTF8,
76+
String::from(desc),
77+
text,
78+
))
79+
},
80+
ItemValue::Locator(locator) => {
81+
value = Frame::UserUrl(ExtendedUrlFrame::new(
82+
TextEncoding::UTF8,
83+
String::from(desc),
84+
locator,
85+
))
86+
},
87+
ItemValue::Binary(_) => return None,
88+
},
89+
None => match (input.item_key, input.item_value) {
90+
(ItemKey::MusicBrainzRecordingId, ItemValue::Text(recording_id)) => {
91+
if !recording_id.is_ascii() {
92+
return None;
93+
}
94+
let frame = UniqueFileIdentifierFrame::new(
95+
MUSICBRAINZ_UFID_OWNER.to_owned(),
96+
recording_id.into_bytes(),
97+
);
98+
value = Frame::UniqueFileIdentifier(frame);
99+
},
100+
_ => {
101+
return None;
102+
},
103+
},
104+
},
105+
}
106+
107+
Some(value)
108+
}
109+
}
110+
111+
impl<'a> TryFrom<&'a TagItem> for FrameRef<'a> {
112+
type Error = LoftyError;
113+
114+
fn try_from(tag_item: &'a TagItem) -> std::result::Result<Self, Self::Error> {
115+
let id: crate::error::Result<FrameId<'a>> = tag_item.key().try_into();
116+
let value: Frame<'_>;
117+
match id {
118+
Ok(id) => {
119+
let id_str = id.as_str();
120+
121+
match (id_str, tag_item.value()) {
122+
("COMM", ItemValue::Text(text)) => {
123+
value = new_comment_frame(text.clone());
124+
},
125+
("USLT", ItemValue::Text(text)) => {
126+
value = new_unsync_text_frame(text.clone());
127+
},
128+
("WXXX", ItemValue::Locator(text) | ItemValue::Text(text)) => {
129+
value = new_user_url_frame(EMPTY_CONTENT_DESCRIPTOR, text.clone());
130+
},
131+
(locator_id, ItemValue::Locator(text)) if locator_id.len() > 4 => {
132+
value = new_user_url_frame(String::from(locator_id), text.clone());
133+
},
134+
("TXXX", ItemValue::Text(text)) => {
135+
value = new_user_text_frame(EMPTY_CONTENT_DESCRIPTOR, text.clone());
136+
},
137+
(text_id, ItemValue::Text(text)) if text_id.len() > 4 => {
138+
value = new_user_text_frame(String::from(text_id), text.clone());
139+
},
140+
("POPM", ItemValue::Binary(contents)) => {
141+
value = Frame::Popularimeter(PopularimeterFrame::parse(
142+
&mut &contents[..],
143+
FrameFlags::default(),
144+
)?);
145+
},
146+
(_, item_value) => value = frame_from_unknown_item(id, item_value.clone())?,
147+
};
148+
},
149+
Err(_) => {
150+
let item_key = tag_item.key();
151+
let Some(desc) = item_key.map_key(TagType::Id3v2, true) else {
152+
return Err(Id3v2Error::new(Id3v2ErrorKind::UnsupportedFrameId(
153+
item_key.clone(),
154+
))
155+
.into());
156+
};
157+
158+
match tag_item.value() {
159+
ItemValue::Text(text) => {
160+
value = new_user_text_frame(String::from(desc), text.clone());
161+
},
162+
ItemValue::Locator(locator) => {
163+
value = new_user_url_frame(String::from(desc), locator.clone());
164+
},
165+
_ => {
166+
return Err(Id3v2Error::new(Id3v2ErrorKind::UnsupportedFrameId(
167+
item_key.clone(),
168+
))
169+
.into())
170+
},
171+
}
172+
},
173+
}
174+
175+
Ok(FrameRef(Cow::Owned(value)))
176+
}
177+
}

lofty/src/id3/v2/frame/id.rs renamed to lofty/src/id3/v2/frame/header/mod.rs

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,38 @@
1+
pub(super) mod parse;
2+
3+
use crate::error;
4+
use crate::error::{Id3v2Error, Id3v2ErrorKind, LoftyError};
5+
use crate::id3::v2::FrameFlags;
6+
use crate::prelude::ItemKey;
7+
use crate::tag::TagType;
8+
19
use std::borrow::Cow;
210
use std::fmt::{Display, Formatter};
311

4-
use crate::error::{Id3v2Error, Id3v2ErrorKind, LoftyError, Result};
5-
use crate::tag::{ItemKey, TagType};
12+
/// An ID3v2 frame header
13+
///
14+
/// These are rarely constructed by hand. Usually they are created in the background
15+
/// when making a new [`Frame`](crate::id3::v2::Frame).
16+
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
17+
#[allow(missing_docs)]
18+
pub struct FrameHeader<'a> {
19+
pub(crate) id: FrameId<'a>,
20+
pub flags: FrameFlags,
21+
}
22+
23+
impl<'a> FrameHeader<'a> {
24+
/// Create a new [`FrameHeader`]
25+
///
26+
/// NOTE: Once the header is created, the ID becomes immutable.
27+
pub const fn new(id: FrameId<'a>, flags: FrameFlags) -> Self {
28+
Self { id, flags }
29+
}
30+
31+
/// Get the ID of the frame
32+
pub const fn id(&self) -> &FrameId<'a> {
33+
&self.id
34+
}
35+
}
636

737
/// An `ID3v2` frame ID
838
///
@@ -22,21 +52,21 @@ pub enum FrameId<'a> {
2252
impl<'a> FrameId<'a> {
2353
/// Attempts to create a `FrameId` from an ID string
2454
///
25-
/// NOTE: This will not upgrade IDs, for that behavior use [`Frame::new`](crate::id3::v2::Frame::new).
55+
/// NOTE: This will not upgrade IDs.
2656
///
2757
/// # Errors
2858
///
2959
/// * `id` contains invalid characters (must be 'A'..='Z' and '0'..='9')
3060
/// * `id` is an invalid length (must be 3 or 4)
31-
pub fn new<I>(id: I) -> Result<Self>
61+
pub fn new<I>(id: I) -> error::Result<Self>
3262
where
3363
I: Into<Cow<'a, str>>,
3464
{
3565
Self::new_cow(id.into())
3666
}
3767

3868
// Split from generic, public method to avoid code bloat by monomorphization.
39-
pub(super) fn new_cow(id: Cow<'a, str>) -> Result<Self> {
69+
pub(in crate::id3::v2::frame) fn new_cow(id: Cow<'a, str>) -> error::Result<Self> {
4070
Self::verify_id(&id)?;
4171

4272
match id.len() {
@@ -55,7 +85,7 @@ impl<'a> FrameId<'a> {
5585
}
5686
}
5787

58-
pub(super) fn verify_id(id_str: &str) -> Result<()> {
88+
pub(in crate::id3::v2::frame) fn verify_id(id_str: &str) -> error::Result<()> {
5989
for c in id_str.chars() {
6090
if !c.is_ascii_uppercase() && !c.is_ascii_digit() {
6191
return Err(Id3v2Error::new(Id3v2ErrorKind::BadFrameId(
File renamed without changes.

0 commit comments

Comments
 (0)