Skip to content

Commit 2497325

Browse files
committed
APE: Properly handle multi-value text items
1 parent f3fcfd0 commit 2497325

3 files changed

Lines changed: 205 additions & 3 deletions

File tree

CHANGELOG.md

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

77
## [Unreleased]
88

9+
### Added
10+
11+
- **APE** ([issue](https://github.com/Serial-ATA/lofty-rs/issues/631)) ([PR](https://github.com/Serial-ATA/lofty-rs/pull/633)):
12+
- `ApeTag::push()` which will append values to existing items
13+
- `ApeItem::text_values()` which returns an iterator over all text values in the item
14+
15+
### Changed
16+
17+
- **APE**: The `Tag` -> `ApeTag` conversion will now preserve multi-value items ([issue](https://github.com/Serial-ATA/lofty-rs/issues/631)) ([PR](https://github.com/Serial-ATA/lofty-rs/pull/633))
18+
919
## [0.23.3] - 2026-03-14
1020

1121
### Added

lofty/src/ape/tag/item.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,33 @@ impl ApeItem {
6161
&self.value
6262
}
6363

64+
/// Get all text values of this item
65+
///
66+
/// This will return `None` for [`ItemValue::Binary`].
67+
///
68+
/// ```rust
69+
/// use lofty::ape::ApeItem;
70+
/// use lofty::tag::ItemValue;
71+
///
72+
/// # fn main() -> lofty::error::Result<()> {
73+
/// let item = ApeItem::new(
74+
/// String::from("Artist"),
75+
/// ItemValue::Text(String::from("Serial\0ATA")),
76+
/// )?;
77+
///
78+
/// let mut values = item.text_values().expect("should be text");
79+
/// assert_eq!(values.next(), Some("Serial"));
80+
/// assert_eq!(values.next(), Some("ATA"));
81+
/// assert!(values.next().is_none());
82+
/// # Ok(()) }
83+
/// ```
84+
pub fn text_values(&self) -> Option<impl Iterator<Item = &str>> {
85+
match self.value() {
86+
ItemValue::Text(text) | ItemValue::Locator(text) => Some(text.split('\0')),
87+
ItemValue::Binary(_) => None,
88+
}
89+
}
90+
6491
// Used internally, has no correctness checks
6592
pub(crate) fn text(key: &str, value: String) -> Self {
6693
Self {

lofty/src/ape/tag/mod.rs

Lines changed: 168 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,14 +136,110 @@ impl ApeTag {
136136
.find(|i| i.key().eq_ignore_ascii_case(key))
137137
}
138138

139-
/// Insert an [`ApeItem`]
139+
fn get_mut(&mut self, key: &str) -> Option<&mut ApeItem> {
140+
self.items
141+
.iter_mut()
142+
.find(|i| i.key().eq_ignore_ascii_case(key))
143+
}
144+
145+
/// Inserts an item
146+
///
147+
/// This is the same as [`ApeTag::push()`], except it will remove any items with the same key.
148+
///
149+
/// # Examples
140150
///
141-
/// This will remove any item with the same key prior to insertion
151+
/// ```rust
152+
/// use lofty::ape::{ApeItem, ApeTag};
153+
/// use lofty::tag::ItemValue;
154+
///
155+
/// # fn main() -> lofty::error::Result<()> {
156+
/// let mut tag = ApeTag::default();
157+
/// tag.insert(ApeItem::new(
158+
/// String::from("Title"),
159+
/// ItemValue::Text(String::from("Title 1")),
160+
/// )?);
161+
/// tag.insert(ApeItem::new(
162+
/// String::from("Title"),
163+
/// ItemValue::Text(String::from("Title 2")),
164+
/// )?);
165+
///
166+
/// // We only retain the last title inserted
167+
/// let mut titles = tag.get("Title").expect("should exist");
168+
/// let mut values = titles.text_values().expect("should be text");
169+
/// assert_eq!(values.next(), Some("Title 2"));
170+
/// assert!(values.next().is_none());
171+
/// # Ok(()) }
172+
/// ```
142173
pub fn insert(&mut self, value: ApeItem) {
143174
self.remove(value.key());
144175
self.items.push(value);
145176
}
146177

178+
/// Appends an item
179+
///
180+
/// If an item with the same key already exists, this will:
181+
///
182+
/// * Append the value to the existing item if they are the same **text-based** [`ItemValue`] variant
183+
/// * Or replace the existing item if they are different [`ItemValue`]s or binary
184+
///
185+
/// # Examples
186+
///
187+
/// ```rust
188+
/// use lofty::ape::{ApeItem, ApeTag};
189+
/// use lofty::tag::ItemValue;
190+
///
191+
/// # fn main() -> lofty::error::Result<()> {
192+
/// use lofty::tag::ItemValue;
193+
/// let mut tag = ApeTag::default();
194+
/// tag.push(ApeItem::new(
195+
/// String::from("Title"),
196+
/// ItemValue::Text(String::from("Title 1")),
197+
/// )?);
198+
/// tag.push(ApeItem::new(
199+
/// String::from("Title"),
200+
/// ItemValue::Text(String::from("Title 2")),
201+
/// )?);
202+
///
203+
/// // We retain both titles
204+
/// {
205+
/// let mut titles = tag.get("Title").expect("should exist");
206+
/// let mut values = titles.text_values().expect("should be text");
207+
/// assert_eq!(values.next(), Some("Title 1"));
208+
/// assert_eq!(values.next(), Some("Title 2"));
209+
/// assert!(values.next().is_none());
210+
/// }
211+
///
212+
/// // With different value types
213+
/// tag.push(ApeItem::new(
214+
/// String::from("Artist"),
215+
/// ItemValue::Text(String::from("Text artist")),
216+
/// )?);
217+
/// tag.push(ApeItem::new(
218+
/// String::from("Artist"),
219+
/// ItemValue::Binary(b"Binary artist".to_vec()),
220+
/// )?);
221+
///
222+
/// let artist = tag.get("Artist").expect("should exist");
223+
/// assert!(matches!(artist.value(), ItemValue::Binary(_)));
224+
/// # Ok(()) }
225+
/// ```
226+
pub fn push(&mut self, value: ApeItem) {
227+
if let Some(existing) = self.get_mut(value.key()) {
228+
match (&mut existing.value, &value.value) {
229+
(ItemValue::Text(existing_text), ItemValue::Text(new_text))
230+
| (ItemValue::Locator(existing_text), ItemValue::Locator(new_text)) => {
231+
existing_text.push('\0');
232+
existing_text.push_str(new_text);
233+
},
234+
_ => {
235+
let _ = std::mem::replace(existing, value);
236+
},
237+
}
238+
return;
239+
}
240+
self.items.push(value);
241+
}
242+
147243
/// Remove an [`ApeItem`] by key
148244
///
149245
/// NOTE: Like [`ApeTag::get`], this is not case-sensitive
@@ -193,7 +289,7 @@ impl ApeTag {
193289
},
194290
_ => {
195291
if let Ok(item) = item.try_into() {
196-
self.insert(item);
292+
self.push(item);
197293
}
198294
},
199295
}
@@ -470,6 +566,23 @@ impl SplitTag for ApeTag {
470566
},
471567
(k, _) => {
472568
let item = std::mem::replace(item, ApeItem::EMPTY);
569+
570+
// Multi-value string
571+
if let Some(text) = item.value.text() {
572+
if text.contains('\0') {
573+
for value in text.split('\0') {
574+
let item_value = match &item.value {
575+
ItemValue::Text(_) => ItemValue::Text(value.to_string()),
576+
ItemValue::Locator(_) => ItemValue::Locator(value.to_string()),
577+
_ => unreachable!(),
578+
};
579+
580+
tag.items.push(TagItem::new(k, item_value))
581+
}
582+
return false; // Item consumed
583+
}
584+
}
585+
473586
tag.items.push(TagItem::new(k, item.value));
474587
false // Item consumed
475588
},
@@ -973,4 +1086,56 @@ mod tests {
9731086
assert!(ape.disk_total().is_none());
9741087
assert_eq!(ape.track(), Some(2));
9751088
}
1089+
1090+
#[test_log::test]
1091+
fn split_text_values() {
1092+
let mut ape = ApeTag::new();
1093+
ape.push(
1094+
ApeItem::new(
1095+
String::from("Artists"),
1096+
ItemValue::Text(String::from("Serial-ATA")),
1097+
)
1098+
.unwrap(),
1099+
);
1100+
ape.push(
1101+
ApeItem::new(
1102+
String::from("Artists"),
1103+
ItemValue::Text(String::from("Lofty")),
1104+
)
1105+
.unwrap(),
1106+
);
1107+
1108+
let artists = ape.get("Artists").unwrap();
1109+
assert_eq!(artists.value.text(), Some("Serial-ATA\0Lofty"));
1110+
1111+
let tag: Tag = ape.into();
1112+
assert_eq!(tag.len(), 2);
1113+
1114+
let mut artists = tag.get_strings(ItemKey::TrackArtists);
1115+
assert_eq!(artists.next().unwrap(), "Serial-ATA");
1116+
assert_eq!(artists.next().unwrap(), "Lofty");
1117+
assert!(artists.next().is_none());
1118+
}
1119+
1120+
#[test_log::test]
1121+
fn merge_text_values() {
1122+
let mut tag = Tag::new(TagType::Ape);
1123+
tag.push(TagItem::new(
1124+
ItemKey::TrackArtists,
1125+
ItemValue::Text(String::from("Serial-ATA")),
1126+
));
1127+
tag.push(TagItem::new(
1128+
ItemKey::TrackArtists,
1129+
ItemValue::Text(String::from("Lofty")),
1130+
));
1131+
1132+
let ape: ApeTag = tag.into();
1133+
assert_eq!(ape.len(), 1);
1134+
1135+
let artists = ape.get("Artists").unwrap();
1136+
let mut values = artists.text_values().unwrap();
1137+
assert_eq!(values.next().unwrap(), "Serial-ATA");
1138+
assert_eq!(values.next().unwrap(), "Lofty");
1139+
assert!(values.next().is_none());
1140+
}
9761141
}

0 commit comments

Comments
 (0)