Skip to content

Commit 165fb11

Browse files
committed
Accessor: Replace *_year() with *_date()
Since almost all formats support full dates, the `Accessor` methods can now accept and return full `Timestamp`s, rather than just a year. For ID3v1, the timestamp will just be truncated to its year. This also changes the `year` field of `Id3v1Tag` to a `u16`, rather than a `String`.
1 parent 55b024c commit 165fb11

File tree

21 files changed

+286
-140
lines changed

21 files changed

+286
-140
lines changed

CHANGELOG.md

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

2929
### Changed
3030
- **ID3v2**: Check `TXXX:ALBUMARTIST` and `TXXX:ALBUM ARTIST` for `ItemKey::AlbumArtist` conversions
31+
- **ID3v1**: The `year` field in `Id3v1Tag` is now a `u16`, instead of a `String` ([PR](https://github.com/Serial-ATA/lofty-rs/pull/574))
3132
- **Vorbis Comments**: Check `ALBUM ARTIST` for `ItemKey::AlbumArtist` conversions
3233
- **Vorbis Comments**: Support `DISCNUMBER` fields with the `current/total` format. ([issue](https://github.com/Serial-ATA/lofty-rs/issues/543)) ([PR](https://github.com/Serial-ATA/lofty-rs/pull/544))
3334
- These fields will now properly be split into `DISCNUMBER` and `DISCTOTAL`, making it possible to use them with
@@ -51,6 +52,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5152
* `Tag` is now intended for generic metadata editing only, with format-specific items only being available through concrete tag types.
5253
See <https://github.com/Serial-ATA/lofty-rs/issues/521> for the rationale.
5354
* **Picture**: `Picture::new_unchecked()`, replaced with `Picture::unchecked()` returning a builder ([issue](https://github.com/Serial-ATA/lofty-rs/issues/468)) ([PR](https://github.com/Serial-ATA/lofty-rs/pull/569))
55+
* **Accessor**: `Accessor::*_year()` methods, replaced with `Accessor::*_date()` ([issue](https://github.com/Serial-ATA/lofty-rs/issues/565)) ([PR](https://github.com/Serial-ATA/lofty-rs/pull/574))
56+
- Since all formats (*except ID3v1*) have full date support, the generic API now accepts `Timestamp`s. For ID3v1, the date will be truncated
57+
down to the year for conversions/writing.
58+
- Year tags can still be set manually with `ItemKey::Year`
5459

5560
## [0.22.4] - 2025-04-29
5661

benches/create_tag.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use lofty::iff::wav::RiffInfoList;
99
use lofty::mp4::Ilst;
1010
use lofty::ogg::VorbisComments;
1111
use lofty::picture::{MimeType, Picture, PictureType};
12+
use lofty::tag::items::Timestamp;
1213
use lofty::tag::{Accessor, TagExt};
1314

1415
use std::borrow::Cow;
@@ -28,7 +29,10 @@ macro_rules! bench_tag_write {
2829
$tag_.set_artist(String::from("Dave Eddy"));
2930
$tag_.set_title(String::from("TempleOS Hymn Risen (Remix)"));
3031
$tag_.set_album(String::from("Summer"));
31-
$tag_.set_year(2017);
32+
$tag_.set_date(Timestamp {
33+
year: 2017,
34+
..Timestamp::default()
35+
});
3236
$tag_.set_track(1);
3337
$tag_.set_genre(String::from("Electronic"));
3438
$extra_block;

lofty/src/ape/tag/mod.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ use crate::config::WriteOptions;
77
use crate::error::{LoftyError, Result};
88
use crate::id3::v2::util::pairs::{NUMBER_PAIR_KEYS, format_number_pair, set_number};
99
use crate::tag::item::ItemValueRef;
10+
use crate::tag::items::Timestamp;
1011
use crate::tag::{
11-
Accessor, ItemKey, ItemValue, MergeTag, SplitTag, Tag, TagExt, TagItem, TagType, try_parse_year,
12+
Accessor, ItemKey, ItemValue, MergeTag, SplitTag, Tag, TagExt, TagItem, TagType,
13+
try_parse_timestamp,
1214
};
1315
use crate::util::flag_item;
1416
use crate::util::io::{FileLike, Truncate};
@@ -293,23 +295,24 @@ impl Accessor for ApeTag {
293295
}
294296
}
295297

296-
fn year(&self) -> Option<u32> {
298+
// For some reason, the ecosystem agreed on the key "Year", even for full date strings.
299+
fn date(&self) -> Option<Timestamp> {
297300
if let Some(ApeItem {
298301
value: ItemValue::Text(text),
299302
..
300303
}) = self.get("Year")
301304
{
302-
return try_parse_year(text);
305+
return try_parse_timestamp(text);
303306
}
304307

305308
None
306309
}
307310

308-
fn set_year(&mut self, value: u32) {
311+
fn set_date(&mut self, value: Timestamp) {
309312
self.insert(ApeItem::text("Year", value.to_string()));
310313
}
311314

312-
fn remove_year(&mut self) {
315+
fn remove_date(&mut self) {
313316
self.remove("Year");
314317
}
315318
}

lofty/src/id3/v1/constants.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,12 +195,14 @@ pub const GENRES: [&str; 192] = [
195195
];
196196

197197
use crate::tag::ItemKey;
198-
pub(crate) const VALID_ITEMKEYS: [ItemKey; 7] = [
198+
pub(crate) const VALID_ITEMKEYS: [ItemKey; 8] = [
199199
ItemKey::TrackTitle,
200200
ItemKey::TrackArtist,
201201
ItemKey::AlbumTitle,
202-
ItemKey::Year,
203202
ItemKey::Comment,
204203
ItemKey::TrackNumber,
205204
ItemKey::Genre,
205+
// These two are used identically. ID3v1 is the only format that *only* supports year.
206+
ItemKey::Year,
207+
ItemKey::RecordingDate,
206208
];

lofty/src/id3/v1/read.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ pub fn parse_id3v1(reader: [u8; 128]) -> Id3v1Tag {
1717
tag.title = decode_text(&reader[..30]);
1818
tag.artist = decode_text(&reader[30..60]);
1919
tag.album = decode_text(&reader[60..90]);
20-
tag.year = decode_text(&reader[90..94]);
20+
21+
let year = try_parse_year(&reader[90..94]).unwrap_or(0);
22+
if year != 0 {
23+
tag.year = Some(year);
24+
}
2125

2226
// Determine the range of the comment (30 bytes for ID3v1 and 28 for ID3v1.1)
2327
// We check for the null terminator 28 bytes in, and for a non-zero track number after it.
@@ -48,3 +52,13 @@ fn decode_text(data: &[u8]) -> Option<String> {
4852

4953
if read.is_empty() { None } else { Some(read) }
5054
}
55+
56+
fn try_parse_year(input: &[u8]) -> Option<u16> {
57+
let (num_digits, year) = input
58+
.iter()
59+
.take_while(|c| (**c).is_ascii_digit())
60+
.fold((0usize, 0u16), |(num_digits, year), c| {
61+
(num_digits + 1, year * 10 + u16::from(*c - b'0'))
62+
});
63+
(num_digits == 4).then_some(year)
64+
}

lofty/src/id3/v1/tag.rs

Lines changed: 69 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use crate::config::WriteOptions;
22
use crate::error::{LoftyError, Result};
33
use crate::id3::v1::constants::GENRES;
4+
use crate::tag::items::Timestamp;
45
use crate::tag::{Accessor, ItemKey, ItemValue, MergeTag, SplitTag, Tag, TagExt, TagItem, TagType};
56
use crate::util::io::{FileLike, Length, Truncate};
67

@@ -80,8 +81,8 @@ pub struct Id3v1Tag {
8081
pub artist: Option<String>,
8182
/// Album title, 30 bytes max
8283
pub album: Option<String>,
83-
/// Release year, 4 bytes max
84-
pub year: Option<String>,
84+
/// Release year (max 9999)
85+
pub year: Option<u16>,
8586
/// A short comment
8687
///
8788
/// The number of bytes differs between versions, but not much.
@@ -186,21 +187,18 @@ impl Accessor for Id3v1Tag {
186187
self.comment = None;
187188
}
188189

189-
fn year(&self) -> Option<u32> {
190-
if let Some(ref year) = self.year {
191-
if let Ok(y) = year.parse() {
192-
return Some(y);
193-
}
194-
}
195-
196-
None
190+
fn date(&self) -> Option<Timestamp> {
191+
self.year.map(|year| Timestamp {
192+
year,
193+
..Default::default()
194+
})
197195
}
198196

199-
fn set_year(&mut self, value: u32) {
200-
self.year = Some(value.to_string());
197+
fn set_date(&mut self, value: Timestamp) {
198+
self.year = Some(value.year);
201199
}
202200

203-
fn remove_year(&mut self) {
201+
fn remove_date(&mut self) {
204202
self.year = None;
205203
}
206204
}
@@ -309,7 +307,9 @@ impl SplitTag for Id3v1Tag {
309307
self.album
310308
.take()
311309
.map(|a| tag.insert_text(ItemKey::AlbumTitle, a));
312-
self.year.take().map(|y| tag.insert_text(ItemKey::Year, y));
310+
self.year
311+
.take()
312+
.map(|y| tag.insert_text(ItemKey::Year, y.to_string()));
313313
self.comment
314314
.take()
315315
.map(|c| tag.insert_text(ItemKey::Comment, c));
@@ -350,7 +350,10 @@ impl From<Tag> for Id3v1Tag {
350350
let title = input.take_strings(ItemKey::TrackTitle).next();
351351
let artist = input.take_strings(ItemKey::TrackArtist).next();
352352
let album = input.take_strings(ItemKey::AlbumTitle).next();
353-
let year = input.year().map(|y| y.to_string());
353+
let year = input
354+
.get_string(ItemKey::Year)
355+
.and_then(|year| year.parse().ok())
356+
.or_else(|| input.date().map(|y| y.year));
354357
let comment = input.take_strings(ItemKey::Comment).next();
355358
Self {
356359
title,
@@ -379,7 +382,7 @@ pub(crate) struct Id3v1TagRef<'a> {
379382
pub title: Option<&'a str>,
380383
pub artist: Option<&'a str>,
381384
pub album: Option<&'a str>,
382-
pub year: Option<&'a str>,
385+
pub year: Option<u16>,
383386
pub comment: Option<&'a str>,
384387
pub track_number: Option<u8>,
385388
pub genre: Option<u8>,
@@ -391,7 +394,7 @@ impl<'a> Into<Id3v1TagRef<'a>> for &'a Id3v1Tag {
391394
title: self.title.as_deref(),
392395
artist: self.artist.as_deref(),
393396
album: self.album.as_deref(),
394-
year: self.year.as_deref(),
397+
year: self.year,
395398
comment: self.comment.as_deref(),
396399
track_number: self.track_number,
397400
genre: self.genre,
@@ -405,7 +408,10 @@ impl<'a> Into<Id3v1TagRef<'a>> for &'a Tag {
405408
title: self.get_string(ItemKey::TrackTitle),
406409
artist: self.get_string(ItemKey::TrackArtist),
407410
album: self.get_string(ItemKey::AlbumTitle),
408-
year: self.get_string(ItemKey::Year),
411+
year: self
412+
.get_string(ItemKey::Year)
413+
.and_then(|year| year.parse().ok())
414+
.or_else(|| self.date().map(|date| date.year)),
409415
comment: self.get_string(ItemKey::Comment),
410416
track_number: self
411417
.get_string(ItemKey::TrackNumber)
@@ -461,6 +467,7 @@ mod tests {
461467
use crate::config::WriteOptions;
462468
use crate::id3::v1::Id3v1Tag;
463469
use crate::prelude::*;
470+
use crate::tag::items::Timestamp;
464471
use crate::tag::{Tag, TagType};
465472

466473
#[test_log::test]
@@ -469,7 +476,7 @@ mod tests {
469476
title: Some(String::from("Foo title")),
470477
artist: Some(String::from("Bar artist")),
471478
album: Some(String::from("Baz album")),
472-
year: Some(String::from("1984")),
479+
year: Some(1984),
473480
comment: Some(String::from("Qux comment")),
474481
track_number: Some(1),
475482
genre: Some(32),
@@ -482,7 +489,7 @@ mod tests {
482489
}
483490

484491
#[test_log::test]
485-
fn id3v2_re_read() {
492+
fn id3v1_re_read() {
486493
let tag = crate::tag::utils::test_utils::read_path("tests/tags/assets/test.id3v1");
487494
let parsed_tag = crate::id3::v1::read::parse_id3v1(tag.try_into().unwrap());
488495

@@ -519,4 +526,46 @@ mod tests {
519526
assert_eq!(id3v1_tag.track_number, Some(1));
520527
assert_eq!(id3v1_tag.genre, Some(32));
521528
}
529+
530+
#[test_log::test]
531+
fn year_roundtrip() {
532+
// via set_date(), which uses `ItemKey::RecordingDate`
533+
534+
let mut tag = Tag::new(TagType::Id3v1);
535+
tag.set_date(Timestamp {
536+
year: 1999,
537+
month: Some(10),
538+
day: Some(11),
539+
hour: Some(12),
540+
minute: Some(13),
541+
second: Some(14),
542+
});
543+
544+
let id3v1_tag: Id3v1Tag = tag.into();
545+
546+
assert_eq!(id3v1_tag.year, Some(1999));
547+
assert_eq!(
548+
id3v1_tag.date(),
549+
Some(Timestamp {
550+
year: 1999,
551+
..Timestamp::default()
552+
})
553+
);
554+
555+
// via `ItemKey::Year`
556+
557+
let mut tag = Tag::new(TagType::Id3v1);
558+
tag.insert_text(ItemKey::Year, 1999u16.to_string());
559+
560+
let id3v1_tag: Id3v1Tag = tag.into();
561+
562+
assert_eq!(id3v1_tag.year, Some(1999));
563+
assert_eq!(
564+
id3v1_tag.date(),
565+
Some(Timestamp {
566+
year: 1999,
567+
..Timestamp::default()
568+
})
569+
);
570+
}
522571
}

lofty/src/id3/v1/write.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,23 @@ pub(super) fn encode(tag: &Id3v1TagRef<'_>) -> std::io::Result<Vec<u8>> {
7878
let album = resize_string(tag.album, 30)?;
7979
writer.write_all(&album)?;
8080

81-
let year = resize_string(tag.year, 4)?;
81+
let mut year = [0; 4];
82+
if let Some(year_num) = tag.year {
83+
let mut year_num = std::cmp::min(year_num, 9999);
84+
85+
let mut idx = 3;
86+
loop {
87+
year[idx] = b'0' + (year_num % 10) as u8;
88+
year_num /= 10;
89+
90+
if idx == 0 {
91+
break;
92+
}
93+
94+
idx -= 1;
95+
}
96+
}
97+
8298
writer.write_all(&year)?;
8399

84100
let comment = resize_string(tag.comment, 28)?;

lofty/src/id3/v2/read.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ where
181181
mod tests {
182182
use super::parse_id3v2;
183183
use crate::config::ParseOptions;
184+
use crate::tag::items::Timestamp;
184185

185186
#[test_log::test]
186187
fn zero_size_id3v2() {
@@ -230,7 +231,13 @@ mod tests {
230231
assert_eq!(id3v2.title().as_deref(), Some("Foo title"));
231232
assert_eq!(id3v2.artist().as_deref(), Some("Bar artist"));
232233
assert_eq!(id3v2.comment().as_deref(), Some("Qux comment"));
233-
assert_eq!(id3v2.year(), Some(1984));
234+
assert_eq!(
235+
id3v2.date(),
236+
Some(Timestamp {
237+
year: 1984,
238+
..Default::default()
239+
})
240+
);
234241
assert_eq!(id3v2.track(), Some(1));
235242
assert_eq!(id3v2.genre().as_deref(), Some("Classical"));
236243
}

lofty/src/id3/v2/tag.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -825,21 +825,21 @@ impl Accessor for Id3v2Tag {
825825
let _ = self.remove(&GENRE_ID);
826826
}
827827

828-
fn year(&self) -> Option<u32> {
828+
fn date(&self) -> Option<Timestamp> {
829829
if let Some(Frame::Timestamp(TimestampFrame { timestamp, .. })) =
830830
self.get(&RECORDING_TIME_ID)
831831
{
832-
return Some(u32::from(timestamp.year));
832+
return Some(*timestamp);
833833
}
834834

835835
None
836836
}
837837

838-
fn set_year(&mut self, value: u32) {
838+
fn set_date(&mut self, value: Timestamp) {
839839
self.insert(Frame::text(Cow::Borrowed("TDRC"), value.to_string()));
840840
}
841841

842-
fn remove_year(&mut self) {
842+
fn remove_date(&mut self) {
843843
let _ = self.remove(&RECORDING_TIME_ID);
844844
}
845845

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
use crate::error::{Id3v2Error, Id3v2ErrorKind, Result};
22
use crate::id3::v2::frame::{FrameFlags, FrameRef};
3+
use crate::id3::v2::tag::GenresIter;
34
use crate::id3::v2::util::synchsafe::SynchsafeInteger;
45
use crate::id3::v2::{Frame, FrameId, KeyValueFrame, TextInformationFrame};
56
use crate::tag::items::Timestamp;
67

78
use std::io::Write;
89

9-
use crate::id3::v2::tag::GenresIter;
1010
use byteorder::{BigEndian, WriteBytesExt};
1111

1212
pub(in crate::id3::v2) fn create_items<W>(

0 commit comments

Comments
 (0)