Skip to content

Commit

Permalink
ID3v2: Parse timestamp frames
Browse files Browse the repository at this point in the history
  • Loading branch information
Serial-ATA committed Apr 29, 2024
1 parent 1474efa commit 8b87c7c
Show file tree
Hide file tree
Showing 11 changed files with 591 additions and 6 deletions.
16 changes: 16 additions & 0 deletions lofty/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ pub enum ErrorKind {
FakeTag,
/// Errors that arise while decoding text
TextDecode(&'static str),
/// Arises when decoding OR encoding a problematic [`Timestamp`](crate::tag::items::Timestamp)
BadTimestamp(&'static str),
/// Errors that arise while reading/writing ID3v2 tags
Id3v2(Id3v2Error),

Expand All @@ -66,6 +68,8 @@ pub enum ErrorKind {
StrFromUtf8(std::str::Utf8Error),
/// Represents all cases of [`std::io::Error`].
Io(std::io::Error),
/// Represents all cases of [`std::fmt::Error`].
Fmt(std::fmt::Error),
/// Failure to allocate enough memory
Alloc(TryReserveError),
/// This should **never** be encountered
Expand Down Expand Up @@ -477,6 +481,14 @@ impl From<std::io::Error> for LoftyError {
}
}

impl From<std::fmt::Error> for LoftyError {
fn from(input: std::fmt::Error) -> Self {
Self {
kind: ErrorKind::Fmt(input),
}
}
}

impl From<std::string::FromUtf8Error> for LoftyError {
fn from(input: std::string::FromUtf8Error) -> Self {
Self {
Expand Down Expand Up @@ -517,6 +529,7 @@ impl Display for LoftyError {
ErrorKind::StringFromUtf8(ref err) => write!(f, "{err}"),
ErrorKind::StrFromUtf8(ref err) => write!(f, "{err}"),
ErrorKind::Io(ref err) => write!(f, "{err}"),
ErrorKind::Fmt(ref err) => write!(f, "{err}"),
ErrorKind::Alloc(ref err) => write!(f, "{err}"),

ErrorKind::UnknownFormat => {
Expand All @@ -532,6 +545,9 @@ impl Display for LoftyError {
),
ErrorKind::FakeTag => write!(f, "Reading: Expected a tag, found invalid data"),
ErrorKind::TextDecode(message) => write!(f, "Text decoding: {message}"),
ErrorKind::BadTimestamp(message) => {
write!(f, "Encountered an invalid timestamp: {message}")
},
ErrorKind::Id3v2(ref id3v2_err) => write!(f, "{id3v2_err}"),
ErrorKind::BadAtom(message) => write!(f, "MP4 Atom: {message}"),
ErrorKind::AtomMismatch => write!(
Expand Down
4 changes: 3 additions & 1 deletion lofty/src/id3/v2/frame/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ use crate::id3::v2::header::Id3v2Version;
use crate::id3::v2::items::{
AttachedPictureFrame, CommentFrame, EventTimingCodesFrame, ExtendedTextFrame, ExtendedUrlFrame,
KeyValueFrame, OwnershipFrame, Popularimeter, PrivateFrame, RelativeVolumeAdjustmentFrame,
TextInformationFrame, UniqueFileIdentifierFrame, UnsynchronizedTextFrame, UrlLinkFrame,
TextInformationFrame, TimestampFrame, UniqueFileIdentifierFrame, UnsynchronizedTextFrame,
UrlLinkFrame,
};
use crate::macros::err;
use crate::util::text::TextEncoding;
Expand Down Expand Up @@ -41,6 +42,7 @@ pub(super) fn parse_content<R: Read>(
"WFED" | "GRP1" | "MVNM" | "MVIN" => TextInformationFrame::parse(reader, version)?.map(FrameValue::Text),
_ if id.starts_with('W') => UrlLinkFrame::parse(reader)?.map(FrameValue::Url),
"POPM" => Some(FrameValue::Popularimeter(Popularimeter::parse(reader)?)),
"TDEN" | "TDOR" | "TDRC" | "TDRL" | "TDTG" => TimestampFrame::parse(reader, parse_mode)?.map(FrameValue::Timestamp),
// SYLT, GEOB, and any unknown frames
_ => {
let mut content = Vec::new();
Expand Down
16 changes: 14 additions & 2 deletions lofty/src/id3/v2/frame/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ use super::header::Id3v2Version;
use super::items::{
AttachedPictureFrame, CommentFrame, EventTimingCodesFrame, ExtendedTextFrame, ExtendedUrlFrame,
KeyValueFrame, OwnershipFrame, Popularimeter, PrivateFrame, RelativeVolumeAdjustmentFrame,
TextInformationFrame, UniqueFileIdentifierFrame, UnsynchronizedTextFrame, UrlLinkFrame,
TextInformationFrame, TimestampFrame, UniqueFileIdentifierFrame, UnsynchronizedTextFrame,
UrlLinkFrame,
};
use super::util::upgrade::{upgrade_v2, upgrade_v3};
use crate::error::{ErrorKind, Id3v2Error, Id3v2ErrorKind, LoftyError, Result};
Expand Down Expand Up @@ -189,6 +190,8 @@ pub enum FrameValue {
EventTimingCodes(EventTimingCodesFrame),
/// Represents a "PRIV" frame
Private(PrivateFrame),
/// Represents a timestamp for the "TDEN", "TDOR", "TDRC", "TDRL", and "TDTG" frames
Timestamp(TimestampFrame),
/// Binary data
///
/// NOTES:
Expand Down Expand Up @@ -220,7 +223,8 @@ impl FrameValue {
FrameValue::Binary(binary) => binary.is_empty(),
FrameValue::Popularimeter(_)
| FrameValue::RelativeVolumeAdjustment(_)
| FrameValue::Ownership(_) => {
| FrameValue::Ownership(_)
| FrameValue::Timestamp(_) => {
// Undefined.
return None;
},
Expand Down Expand Up @@ -336,6 +340,12 @@ impl From<PrivateFrame> for FrameValue {
}
}

impl From<TimestampFrame> for FrameValue {
fn from(value: TimestampFrame) -> Self {
Self::Timestamp(value)
}
}

impl FrameValue {
pub(super) fn as_bytes(&self) -> Result<Vec<u8>> {
Ok(match self {
Expand All @@ -353,6 +363,7 @@ impl FrameValue {
FrameValue::Ownership(frame) => frame.as_bytes()?,
FrameValue::EventTimingCodes(frame) => frame.as_bytes(),
FrameValue::Private(frame) => frame.as_bytes(),
FrameValue::Timestamp(frame) => frame.as_bytes()?,
FrameValue::Binary(binary) => binary.clone(),
})
}
Expand All @@ -374,6 +385,7 @@ impl FrameValue {
FrameValue::Ownership(_) => "Ownership",
FrameValue::EventTimingCodes(_) => "EventTimingCodes",
FrameValue::Private(_) => "Private",
FrameValue::Timestamp(_) => "Timestamp",
FrameValue::Binary(_) => "Binary",
}
}
Expand Down
2 changes: 2 additions & 0 deletions lofty/src/id3/v2/items/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mod private_frame;
mod relative_volume_adjustment_frame;
mod sync_text;
mod text_information_frame;
mod timestamp_frame;
mod unique_file_identifier;
mod url_link_frame;

Expand All @@ -31,5 +32,6 @@ pub use relative_volume_adjustment_frame::{
};
pub use sync_text::{SyncTextContentType, SynchronizedText, TimestampFormat};
pub use text_information_frame::TextInformationFrame;
pub use timestamp_frame::TimestampFrame;
pub use unique_file_identifier::UniqueFileIdentifierFrame;
pub use url_link_frame::UrlLinkFrame;
92 changes: 92 additions & 0 deletions lofty/src/id3/v2/items/timestamp_frame.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
use crate::config::ParsingMode;
use crate::error::{ErrorKind, LoftyError, Result};
use crate::macros::err;
use crate::tag::items::Timestamp;
use crate::util::text::{decode_text, encode_text, TextDecodeOptions, TextEncoding};

use std::io::Read;

use byteorder::ReadBytesExt;

/// An `ID3v2` timestamp frame
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
#[allow(missing_docs)]
pub struct TimestampFrame {
pub encoding: TextEncoding,
pub timestamp: Timestamp,
}

impl PartialOrd for TimestampFrame {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}

impl Ord for TimestampFrame {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.timestamp.cmp(&other.timestamp)
}
}

impl Default for TimestampFrame {
fn default() -> Self {
Self {
encoding: TextEncoding::UTF8,
timestamp: Timestamp::default(),
}
}
}

impl TimestampFrame {
/// Read a [`TimestampFrame`]
///
/// NOTE: This expects the frame header to have already been skipped
///
/// # Errors
///
/// * Failure to read from `reader`
#[allow(clippy::never_loop)]
pub fn parse<R>(reader: &mut R, parse_mode: ParsingMode) -> Result<Option<Self>>
where
R: Read,
{
let Ok(encoding_byte) = reader.read_u8() else {
return Ok(None);
};
let Some(encoding) = TextEncoding::from_u8(encoding_byte) else {
return Err(LoftyError::new(ErrorKind::TextDecode(
"Found invalid encoding",
)));
};

let value = decode_text(reader, TextDecodeOptions::new().encoding(encoding))?.content;
if !value.is_ascii() {
err!(BadTimestamp("Timestamp contains non-ASCII characters"))
}

let mut frame = TimestampFrame {
encoding,
timestamp: Timestamp::default(),
};

let reader = &mut value.as_bytes();

frame.timestamp = Timestamp::parse(reader, parse_mode)?;
Ok(Some(frame))
}

/// Convert an [`TimestampFrame`] to a byte vec
///
/// # Errors
///
/// * The timestamp is invalid
/// * Failure to write to the buffer
pub fn as_bytes(&self) -> Result<Vec<u8>> {
self.timestamp.verify()?;

let mut encoded_text = encode_text(&self.timestamp.to_string(), self.encoding, false);
encoded_text.insert(0, self.encoding as u8);

Ok(encoded_text)
}
}
55 changes: 53 additions & 2 deletions lofty/src/id3/v2/tag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use crate::id3::v2::util::mappings::TIPL_MAPPINGS;
use crate::id3::v2::util::pairs::{
format_number_pair, set_number, NUMBER_PAIR_KEYS, NUMBER_PAIR_SEPARATOR,
};
use crate::id3::v2::KeyValueFrame;
use crate::id3::v2::{KeyValueFrame, TimestampFrame};
use crate::picture::{Picture, PictureType, TOMBSTONE_PICTURE};
use crate::tag::{
try_parse_year, Accessor, ItemKey, ItemValue, MergeTag, SplitTag, Tag, TagExt, TagItem, TagType,
Expand All @@ -28,7 +28,9 @@ use crate::util::text::{decode_text, TextDecodeOptions, TextEncoding};
use std::borrow::Cow;
use std::io::{Cursor, Write};
use std::ops::Deref;
use std::str::FromStr;

use crate::tag::items::Timestamp;
use lofty_attr::tag;

const USER_DEFINED_TEXT_FRAME_ID: &str = "TXXX";
Expand Down Expand Up @@ -1188,6 +1190,20 @@ impl SplitTag for Id3v2Tag {
// round trips?
return true; // Keep frame
},
FrameValue::Timestamp(frame)
if !matches!(item_key, ItemKey::Unknown(_)) =>
{
if frame.timestamp.verify().is_err() {
return true; // Keep frame
}

tag.items.push(TagItem::new(
item_key,
ItemValue::Text(frame.timestamp.to_string()),
));

return false; // Frame consumed
},
FrameValue::Text(TextInformationFrame { value: content, .. }) => {
for c in content.split(V4_MULTI_VALUE_SEPARATOR) {
tag.items.push(TagItem::new(
Expand All @@ -1214,7 +1230,8 @@ impl SplitTag for Id3v2Tag {
| FrameValue::RelativeVolumeAdjustment(_)
| FrameValue::Ownership(_)
| FrameValue::EventTimingCodes(_)
| FrameValue::Private(_) => {
| FrameValue::Private(_)
| FrameValue::Timestamp(_) => {
return true; // Keep unsupported frame
},
};
Expand Down Expand Up @@ -1407,6 +1424,40 @@ impl MergeTag for SplitTagRemainder {
));
}

// Timestamps
for item_key in [&ItemKey::RecordingDate, &ItemKey::OriginalReleaseDate] {
let Some(text) = tag.take_strings(item_key).next() else {
continue;
};

let frame_id = item_key
.map_key(TagType::Id3v2, false)
.expect("valid frame id");

let frame_value;
match Timestamp::from_str(&text) {
Ok(timestamp) => {
frame_value = FrameValue::Timestamp(TimestampFrame {
encoding: TextEncoding::UTF8,
timestamp,
})
},
Err(_) => {
// We can just preserve it as a text frame
frame_value = FrameValue::Text(TextInformationFrame {
encoding: TextEncoding::UTF8,
value: text,
});
},
}

merged.insert(Frame {
id: FrameId::Valid(Cow::Borrowed(frame_id)),
value: frame_value,
flags: FrameFlags::default(),
});
}

// Insert all remaining items as single frames and deduplicate as needed
for item in tag.items {
merged.insert_item(item);
Expand Down
49 changes: 49 additions & 0 deletions lofty/src/id3/v2/tag/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ use crate::config::ParsingMode;
use crate::id3::v2::header::Id3v2Header;
use crate::id3::v2::items::Popularimeter;
use crate::id3::v2::util::pairs::DEFAULT_NUMBER_IN_PAIR;
use crate::id3::v2::TimestampFrame;
use crate::picture::MimeType;
use crate::tag::items::Timestamp;
use crate::tag::utils::test_utils::read_path;

use super::*;
Expand Down Expand Up @@ -1332,3 +1334,50 @@ fn flag_item_conversion() {
Some("0")
);
}

#[test]
fn timestamp_roundtrip() {
let mut tag = Id3v2Tag::default();
tag.insert(
Frame::new(
"TDRC",
FrameValue::Timestamp(TimestampFrame {
encoding: TextEncoding::UTF8,
timestamp: Timestamp {
year: 2024,
month: Some(6),
day: Some(3),
hour: Some(14),
minute: Some(8),
second: Some(49),
},
}),
FrameFlags::default(),
)
.unwrap(),
);

let tag: Tag = tag.into();
assert_eq!(tag.len(), 1);
assert_eq!(
tag.get_string(&ItemKey::RecordingDate),
Some("2024-06-03T14:08:49")
);

let tag: Id3v2Tag = tag.into();
assert_eq!(tag.frames.len(), 1);

let frame = tag.frames.first().unwrap();
assert_eq!(frame.id, FrameId::Valid(Cow::Borrowed("TDRC")));
match &frame.value {
FrameValue::Timestamp(frame) => {
assert_eq!(frame.timestamp.year, 2024);
assert_eq!(frame.timestamp.month, Some(6));
assert_eq!(frame.timestamp.day, Some(3));
assert_eq!(frame.timestamp.hour, Some(14));
assert_eq!(frame.timestamp.minute, Some(8));
assert_eq!(frame.timestamp.second, Some(49));
},
_ => panic!("Expected a TimestampFrame"),
}
}
3 changes: 2 additions & 1 deletion lofty/src/id3/v2/write/frame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ fn verify_frame(frame: &FrameRef<'_>) -> Result<()> {
| ("UFID", FrameValue::UniqueFileIdentifier(_))
| ("POPM", FrameValue::Popularimeter(_))
| ("TIPL" | "TMCL", FrameValue::KeyValue { .. })
| ("WFED" | "GRP1" | "MVNM" | "MVIN", FrameValue::Text { .. }) => Ok(()),
| ("WFED" | "GRP1" | "MVNM" | "MVIN", FrameValue::Text { .. })
| ("TDEN" | "TDOR" | "TDRC" | "TDRL" | "TDTG", FrameValue::Timestamp(_)) => Ok(()),
(id, FrameValue::Text { .. }) if id.starts_with('T') => Ok(()),
(id, FrameValue::Url(_)) if id.starts_with('W') => Ok(()),
(id, frame_value) => Err(Id3v2Error::new(Id3v2ErrorKind::BadFrame(
Expand Down
Loading

0 comments on commit 8b87c7c

Please # to comment.