diff --git a/src/tag/id3.rs b/src/tag/id3.rs index 5b8a4a7..fa97340 100644 --- a/src/tag/id3.rs +++ b/src/tag/id3.rs @@ -15,6 +15,7 @@ use id3::{ }; use std::borrow::{Borrow, Cow}; use std::iter; +use std::mem; use std::path::Path; /// ID3 frame ID. @@ -40,13 +41,6 @@ pub struct ID3v2Tag { } impl ID3v2Tag { - #[cfg(test)] - pub fn new() -> Self { - ID3v2Tag { - data: id3::Tag::new(), - } - } - #[cfg(test)] pub fn with_version(version: id3::Version) -> Self { ID3v2Tag { @@ -258,6 +252,47 @@ impl ID3v2Tag { }) .map(|unique_file_identifier| unique_file_identifier.identifier.as_slice()) } + + /// Migrate this tag to the given ID3 version. + pub fn migrate_to(&mut self, new_version: id3::Version) { + let version = self.data.version(); + if version == new_version { + return; + } + + // FIXME: Converting to ID3v2.2 is not supported. + if new_version == id3::Version::Id3v22 { + return; + } + + log::info!("Converting ID3 tag version {version} to {new_version}"); + + let old_data = mem::replace(&mut self.data, id3::Tag::with_version(new_version)); + for frame in old_data.frames() { + let id = frame.id(); + let new_frame = match frame.id_for_version(new_version) { + Some(new_id) if new_id == id => frame.clone(), + Some(new_id) => { + log::info!("Converting ID3 frame {id} to {new_id}"); + let content = frame.content().to_owned(); + id3::Frame::with_content(new_id, content) + } + None => { + log::info!("Removing unsupported ID3 frame {id}"); + continue; + } + }; + let _unused = self.data.add_frame(new_frame); + } + } +} + +impl Default for ID3v2Tag { + fn default() -> Self { + ID3v2Tag { + data: id3::Tag::with_version(id3::Version::Id3v23), + } + } } impl Tag for ID3v2Tag { @@ -401,6 +436,10 @@ impl Tag for ID3v2Tag { self.data.write_to_path(path, self.data.version())?; Ok(()) } + + fn maybe_as_id3v2_mut(&mut self) -> Option<&mut ID3v2Tag> { + Some(self) + } } #[cfg(test)] @@ -430,7 +469,7 @@ mod tests { #[test] fn test_get_set_clear_multivalued_text() { - let mut tag = ID3v2Tag::new(); + let mut tag = ID3v2Tag::default(); assert!(tag.get(TagKey::Arranger).is_none()); assert!(tag.get(TagKey::Engineer).is_none()); assert!(tag.get(TagKey::DjMixer).is_none()); diff --git a/src/tag/mod.rs b/src/tag/mod.rs index 8e16d0d..c99f963 100644 --- a/src/tag/mod.rs +++ b/src/tag/mod.rs @@ -274,6 +274,12 @@ pub trait Tag: Send + Sync { } /// Write the tags to the path. fn write(&mut self, path: &Path) -> crate::Result<()>; + + /// Get mutable reference to the underlying [`id3::ID3v2Tag`] (is this is an ID3v2 tag). + #[cfg(feature = "id3")] + fn maybe_as_id3v2_mut(&mut self) -> Option<&mut id3::ID3v2Tag> { + None + } } /// Return a vector of tags from the file at the given path. diff --git a/src/taggedfile.rs b/src/taggedfile.rs index 0dccf76..8bf0d60 100644 --- a/src/taggedfile.rs +++ b/src/taggedfile.rs @@ -10,12 +10,13 @@ use crate::analyzer::CompoundAnalyzerResult; use crate::release::ReleaseLike; -use crate::tag::{read_tags_from_path, Tag, TagKey}; +use crate::tag::{read_tags_from_path, Tag, TagKey, TagType}; use crate::track::{AnalyzedTrackMetadata, TrackLike}; use std::borrow::Cow; use std::cmp::Ordering; use std::ffi::OsStr; use std::fmt; +use std::mem; use std::path::{Path, PathBuf}; /// A tagged file that contains zero or more tags. @@ -50,6 +51,75 @@ impl TaggedFile { } } + /// Convert tags in this file. + /// + /// Currently, this always converts all ID3v2.x tags to ID3v2.3. + /// + /// # Panics + /// + /// This function may panic with the tag type indicates an ID3v2.x tag but the + /// `Tag::maybe_as_id3v2_mut()` function returns `None`, which constitutes a programming error. + pub fn convert_tags(&mut self) { + #[cfg(feature = "id3")] + { + let (has_id3v22, has_id3v23, has_id3v24) = self.content.iter().fold( + (false, false, false), + |(mut has_id3v22, mut has_id3v23, mut has_id3v24), tag| { + #[allow(clippy::match_wildcard_for_single_variants)] + match tag.tag_type() { + TagType::ID3v22 => has_id3v22 = true, + TagType::ID3v23 => has_id3v23 = true, + TagType::ID3v24 => has_id3v24 = true, + _ => (), + } + (has_id3v22, has_id3v23, has_id3v24) + }, + ); + let capacity = self.content.len(); + let old_content = mem::replace(&mut self.content, Vec::with_capacity(capacity)); + if has_id3v23 { + old_content + .into_iter() + .filter(|tag| { + tag.tag_type() != TagType::ID3v22 && tag.tag_type() != TagType::ID3v24 + }) + .for_each(|tag| self.content.push(tag)); + } else if has_id3v24 { + old_content + .into_iter() + .filter_map(|mut tag| { + if tag.tag_type() == TagType::ID3v24 { + tag.maybe_as_id3v2_mut() + .expect( + "ID3 tags should always return `Some()` for `maybe_as_id3()`", + ) + .migrate_to(id3::Version::Id3v23); + Some(tag) + } else if tag.tag_type() == TagType::ID3v22 { + None + } else { + Some(tag) + } + }) + .for_each(|tag| self.content.push(tag)); + } else if has_id3v22 { + old_content + .into_iter() + .map(|mut tag| { + if tag.tag_type() == TagType::ID3v22 { + tag.maybe_as_id3v2_mut() + .expect( + "ID3 tags should always return `Some()` for `maybe_as_id3()`", + ) + .migrate_to(id3::Version::Id3v23); + } + tag + }) + .for_each(|tag| self.content.push(tag)); + } + } + } + /// Creates a [`TaggedFile`] from the path. /// /// # Errors @@ -638,7 +708,7 @@ mod tests { let track: &MusicBrainzTrack = &release.media.as_ref().unwrap()[0].tracks.as_ref().unwrap()[0]; - let mut tagged_file = TaggedFile::new(vec![Box::new(ID3v2Tag::new())]); + let mut tagged_file = TaggedFile::new(vec![Box::new(ID3v2Tag::default())]); assert!(tagged_file.track_title().is_none()); assert!(tagged_file.track_artist().is_none()); assert!(tagged_file.track_number().is_none()); @@ -659,7 +729,7 @@ mod tests { let release: MusicBrainzRelease = serde_json::from_str(MUSICBRAINZ_RELEASE_JSON).unwrap(); - let tagged_file = TaggedFile::new(vec![Box::new(ID3v2Tag::new())]); + let tagged_file = TaggedFile::new(vec![Box::new(ID3v2Tag::default())]); let tagged_file_collection = TaggedFileCollection::new(vec![tagged_file]); assert!(tagged_file_collection.release_title().is_none()); assert!(tagged_file_collection.release_artist().is_none());