From 6ce788edc7c5412f6f2ff10009689c8ff047677f Mon Sep 17 00:00:00 2001 From: Serial <69764315+Serial-ATA@users.noreply.github.com> Date: Sun, 19 Apr 2026 11:28:26 -0400 Subject: [PATCH] Timestamp: Support dot-separated dates --- CHANGELOG.md | 1 + lofty/src/tag/items/timestamp.rs | 60 +++++++++++++++++++++++++------- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bba7bacb7..4b4d0df57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **IFF**: Undersized ID3v2 chunks will no longer error outside of strict mode ([PR](https://github.com/Serial-ATA/lofty-rs/pull/644)) +- **Timestamp**: Support dot-separated dates (e.g. `2024.06.03`) ([issue](https://github.com/Serial-ATA/lofty-rs/issues/647)) ([PR](https://github.com/Serial-ATA/lofty-rs/pull/648)) ## [0.24.0] - 2026-04-12 diff --git a/lofty/src/tag/items/timestamp.rs b/lofty/src/tag/items/timestamp.rs index 4fe7425a3..ca93fc8ab 100644 --- a/lofty/src/tag/items/timestamp.rs +++ b/lofty/src/tag/items/timestamp.rs @@ -95,7 +95,7 @@ impl Timestamp { /// The maximum length of a timestamp in bytes pub const MAX_LENGTH: usize = 19; - const SEPARATORS: [u8; 3] = [b'-', b'T', b':']; + const SEPARATORS: [u8; 4] = [b'-', b'.', b'T', b':']; /// Read a [`Timestamp`] /// @@ -178,23 +178,29 @@ impl Timestamp { loop { timestamp.month = read_segment!(Self::segment::<2>( reader, - timestamp_contains_separators.then_some(b'-'), + timestamp_contains_separators.then_some(&Self::SEPARATORS[..2]), parse_mode )); timestamp.day = read_segment!(Self::segment::<2>( reader, - timestamp_contains_separators.then_some(b'-'), + timestamp_contains_separators.then_some(&Self::SEPARATORS[..2]), + parse_mode + )); + timestamp.hour = read_segment!(Self::segment::<2>( + reader, + Some(core::slice::from_ref(&Self::SEPARATORS[2])), parse_mode )); - timestamp.hour = read_segment!(Self::segment::<2>(reader, Some(b'T'), parse_mode)); timestamp.minute = read_segment!(Self::segment::<2>( reader, - timestamp_contains_separators.then_some(b':'), + timestamp_contains_separators + .then_some(core::slice::from_ref(&Self::SEPARATORS[3])), parse_mode )); timestamp.second = read_segment!(Self::segment::<2>( reader, - timestamp_contains_separators.then_some(b':'), + timestamp_contains_separators + .then_some(core::slice::from_ref(&Self::SEPARATORS[3])), parse_mode )); break; @@ -205,7 +211,7 @@ impl Timestamp { fn segment( content: &mut &[u8], - sep: Option, + sep: Option<&[u8]>, parse_mode: ParsingMode, ) -> Result<(u16, usize)> { const STOP_PARSING: (u16, usize) = (0, 0); @@ -216,11 +222,20 @@ impl Timestamp { if let Some(sep) = sep { let byte = content.read_u8()?; - if byte != sep { - if parse_mode == ParsingMode::Strict { - err!(BadTimestamp("Expected a separator")) - } - return Ok(STOP_PARSING); + match sep.iter().position(|s| *s == byte) { + // The first separator in the list is the only *strictly* valid one (by ISO 8601 standards). + // Some encoders may prefer other separators, which are harmless to pass through in + // other modes. + Some(pos) if pos > 0 && parse_mode == ParsingMode::Strict => { + err!(BadTimestamp("Unexpected separator")) + }, + Some(_) => {}, + None => { + if parse_mode == ParsingMode::Strict { + err!(BadTimestamp("Expected a separator")) + } + return Ok(STOP_PARSING); + }, } } @@ -626,4 +641,25 @@ mod tests { }) ); } + + #[test_log::test] + fn timestamp_dot_separators() { + let timestamp = "2024.06.03"; + + let parsed_timestamp_strict = + Timestamp::parse(&mut timestamp.as_bytes(), ParsingMode::Strict); + assert!(parsed_timestamp_strict.is_err()); + + let parsed_timestamp_best_attempt = + Timestamp::parse(&mut timestamp.as_bytes(), ParsingMode::BestAttempt).unwrap(); + assert_eq!( + parsed_timestamp_best_attempt, + Some(Timestamp { + year: 2024, + month: Some(6), + day: Some(3), + ..Timestamp::default() + }) + ); + } }