pub(crate) mod item;
pub(crate) mod utils;
use crate::error::{LoftyError, Result};
use crate::file::FileType;
use crate::macros::err;
use crate::picture::{Picture, PictureType};
use crate::probe::Probe;
use crate::traits::{Accessor, SplitAndMergeTag, TagExt};
use item::{ItemKey, ItemValue, TagItem};
use std::borrow::Cow;
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::Path;
macro_rules! impl_accessor {
($($item_key:ident => $name:tt),+) => {
paste::paste! {
$(
fn $name(&self) -> Option<Cow<'_, str>> {
if let Some(ItemValue::Text(txt)) = self.get_item_ref(&ItemKey::$item_key).map(TagItem::value) {
return Some(Cow::Borrowed(txt))
}
None
}
fn [<set_ $name>](&mut self, value: String) {
if value.is_empty() {
self.[<remove_ $name>]();
return;
}
self.insert_item(TagItem::new(ItemKey::$item_key, ItemValue::Text(value)));
}
fn [<remove_ $name>](&mut self) {
self.retain_items(|i| i.item_key != ItemKey::$item_key)
}
)+
}
}
}
#[derive(Clone)]
pub struct Tag {
tag_type: TagType,
pub(crate) pictures: Vec<Picture>,
pub(crate) items: Vec<TagItem>,
}
impl Accessor for Tag {
impl_accessor!(
TrackArtist => artist,
TrackTitle => title,
AlbumTitle => album,
Genre => genre,
Comment => comment
);
fn track(&self) -> Option<u32> {
if let Some(i) = self.get_string(&ItemKey::TrackNumber) {
return i.parse::<u32>().ok();
}
None
}
fn set_track(&mut self, value: u32) {
self.insert_text(ItemKey::TrackNumber, value.to_string());
}
fn remove_track(&mut self) {
self.remove_key(&ItemKey::TrackNumber);
}
fn track_total(&self) -> Option<u32> {
if let Some(i) = self.get_string(&ItemKey::TrackTotal) {
return i.parse::<u32>().ok();
}
None
}
fn set_track_total(&mut self, value: u32) {
self.insert_text(ItemKey::TrackTotal, value.to_string());
}
fn remove_track_total(&mut self) {
self.remove_key(&ItemKey::TrackTotal);
}
fn disk(&self) -> Option<u32> {
if let Some(i) = self.get_string(&ItemKey::DiscNumber) {
return i.parse::<u32>().ok();
}
None
}
fn set_disk(&mut self, value: u32) {
self.insert_text(ItemKey::DiscNumber, value.to_string());
}
fn remove_disk(&mut self) {
self.remove_key(&ItemKey::DiscNumber);
}
fn disk_total(&self) -> Option<u32> {
if let Some(i) = self.get_string(&ItemKey::DiscTotal) {
return i.parse::<u32>().ok();
}
None
}
fn set_disk_total(&mut self, value: u32) {
self.insert_text(ItemKey::DiscTotal, value.to_string());
}
fn remove_disk_total(&mut self) {
self.remove_key(&ItemKey::DiscTotal);
}
fn year(&self) -> Option<u32> {
if let Some(item) = self
.get_string(&ItemKey::Year)
.map_or_else(|| self.get_string(&ItemKey::RecordingDate), Some)
{
return item.chars().take(4).collect::<String>().parse::<u32>().ok();
}
None
}
fn set_year(&mut self, value: u32) {
if let Some(item) = self.get_string(&ItemKey::RecordingDate) {
if item.len() >= 4 {
let (_, remaining) = item.split_at(4);
self.insert_text(ItemKey::RecordingDate, format!("{value}{remaining}"));
return;
}
}
if ItemKey::Year.map_key(self.tag_type, false).is_some() {
self.insert_text(ItemKey::Year, value.to_string());
} else {
self.insert_text(ItemKey::RecordingDate, value.to_string());
}
}
fn remove_year(&mut self) {
self.remove_key(&ItemKey::Year);
self.remove_key(&ItemKey::RecordingDate);
}
}
impl Tag {
#[must_use]
pub const fn new(tag_type: TagType) -> Self {
Self {
tag_type,
pictures: Vec::new(),
items: Vec::new(),
}
}
pub fn re_map(&mut self, tag_type: TagType) {
self.retain_items(|i| i.re_map(tag_type));
self.tag_type = tag_type
}
pub fn tag_type(&self) -> TagType {
self.tag_type
}
pub fn item_count(&self) -> u32 {
self.items.len() as u32
}
pub fn picture_count(&self) -> u32 {
self.pictures.len() as u32
}
pub fn items(&self) -> impl Iterator<Item = &TagItem> + Clone {
self.items.iter()
}
pub fn get_item_ref(&self, item_key: &ItemKey) -> Option<&TagItem> {
self.items.iter().find(|i| &i.item_key == item_key)
}
pub fn get_string(&self, item_key: &ItemKey) -> Option<&str> {
if let Some(ItemValue::Text(ret)) = self.get_item_ref(item_key).map(TagItem::value) {
return Some(ret);
}
None
}
pub fn get_binary(&self, item_key: &ItemKey, convert: bool) -> Option<&[u8]> {
if let Some(item) = self.get_item_ref(item_key) {
match item.value() {
ItemValue::Text(text) | ItemValue::Locator(text) if convert => {
return Some(text.as_bytes())
},
ItemValue::Binary(binary) => return Some(binary),
_ => {},
}
}
None
}
pub fn insert_item(&mut self, item: TagItem) -> bool {
if item.re_map(self.tag_type) {
self.insert_item_unchecked(item);
return true;
}
false
}
pub fn insert_item_unchecked(&mut self, item: TagItem) {
self.retain_items(|i| i.item_key != item.item_key);
self.items.push(item);
}
pub fn push_item(&mut self, item: TagItem) -> bool {
if item.re_map(self.tag_type) {
self.items.push(item);
return true;
}
false
}
pub fn push_item_unchecked(&mut self, item: TagItem) {
self.items.push(item);
}
pub fn insert_text(&mut self, item_key: ItemKey, text: String) -> bool {
self.insert_item(TagItem::new(item_key, ItemValue::Text(text)))
}
pub fn take(&mut self, key: &ItemKey) -> impl Iterator<Item = TagItem> + '_ {
let mut split_idx = 0_usize;
for read_idx in 0..self.items.len() {
if self.items[read_idx].key() == key {
self.items.swap(split_idx, read_idx);
split_idx += 1;
}
}
self.items.drain(..split_idx)
}
pub fn take_strings(&mut self, key: &ItemKey) -> impl Iterator<Item = String> + '_ {
self.take(key).filter_map(|i| i.item_value.into_string())
}
pub fn get_items<'a>(&'a self, key: &'a ItemKey) -> impl Iterator<Item = &'a TagItem> + Clone {
self.items.iter().filter(move |i| i.key() == key)
}
pub fn get_strings<'a>(&'a self, key: &'a ItemKey) -> impl Iterator<Item = &'a str> + Clone {
self.items.iter().filter_map(move |i| {
if i.key() == key {
i.value().text()
} else {
None
}
})
}
pub fn get_locators<'a>(&'a self, key: &'a ItemKey) -> impl Iterator<Item = &'a str> + Clone {
self.items.iter().filter_map(move |i| {
if i.key() == key {
i.value().locator()
} else {
None
}
})
}
pub fn get_bytes<'a>(&'a self, key: &'a ItemKey) -> impl Iterator<Item = &'a [u8]> + Clone {
self.items.iter().filter_map(move |i| {
if i.key() == key {
i.value().binary()
} else {
None
}
})
}
pub fn remove_key(&mut self, key: &ItemKey) {
self.items.retain(|i| i.key() != key)
}
pub fn retain_items<F>(&mut self, f: F)
where
F: FnMut(&TagItem) -> bool,
{
self.items.retain(f)
}
pub fn pictures(&self) -> &[Picture] {
&self.pictures
}
pub fn get_picture_type(&self, picture_type: PictureType) -> Option<&Picture> {
self.pictures
.iter()
.find(|picture| picture.pic_type() == picture_type)
}
pub fn push_picture(&mut self, picture: Picture) {
self.pictures.push(picture)
}
pub fn remove_picture_type(&mut self, picture_type: PictureType) {
self.pictures.retain(|p| p.pic_type != picture_type)
}
pub fn set_picture(&mut self, index: usize, picture: Picture) {
if index >= self.pictures.len() {
self.push_picture(picture);
} else {
self.pictures[index] = picture;
}
}
pub fn remove_picture(&mut self, index: usize) -> Picture {
self.pictures.remove(index)
}
}
impl TagExt for Tag {
type Err = LoftyError;
type RefKey<'a> = &'a ItemKey;
fn len(&self) -> usize {
self.items.len() + self.pictures.len()
}
fn contains<'a>(&'a self, key: Self::RefKey<'a>) -> bool {
self.items.iter().any(|item| item.key() == key)
}
fn is_empty(&self) -> bool {
self.items.is_empty() && self.pictures.is_empty()
}
fn save_to_path<P: AsRef<Path>>(&self, path: P) -> std::result::Result<(), Self::Err> {
self.save_to(&mut OpenOptions::new().read(true).write(true).open(path)?)
}
fn save_to(&self, file: &mut File) -> std::result::Result<(), Self::Err> {
let probe = Probe::new(file).guess_file_type()?;
match probe.file_type() {
Some(file_type) => {
if file_type.supports_tag_type(self.tag_type()) {
utils::write_tag(self, probe.into_inner(), file_type)
} else {
err!(UnsupportedTag);
}
},
None => err!(UnknownFormat),
}
}
fn dump_to<W: Write>(&self, writer: &mut W) -> Result<()> {
utils::dump_tag(self, writer)
}
fn remove_from_path<P: AsRef<Path>>(&self, path: P) -> std::result::Result<(), Self::Err> {
self.tag_type.remove_from_path(path)
}
fn remove_from(&self, file: &mut File) -> std::result::Result<(), Self::Err> {
self.tag_type.remove_from(file)
}
fn clear(&mut self) {
self.items.clear();
self.pictures.clear();
}
}
impl SplitAndMergeTag for Tag {
fn split_tag(&mut self) -> Self {
std::mem::replace(self, Self::new(self.tag_type))
}
fn merge_tag(&mut self, tag: Self) {
*self = tag;
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum TagType {
APE,
ID3v1,
ID3v2,
MP4ilst,
VorbisComments,
RIFFInfo,
AIFFText,
}
impl TagType {
pub fn remove_from_path(&self, path: impl AsRef<Path>) -> Result<()> {
let mut file = OpenOptions::new().read(true).write(true).open(path)?;
self.remove_from(&mut file)
}
#[allow(clippy::shadow_unrelated)]
pub fn remove_from(&self, file: &mut File) -> Result<()> {
let probe = Probe::new(file).guess_file_type()?;
let file_type = match probe.file_type() {
Some(f_ty) => f_ty,
None => err!(UnknownFormat),
};
let special_exceptions =
(file_type == FileType::APE || file_type == FileType::FLAC) && *self == TagType::ID3v2;
if !special_exceptions && !file_type.supports_tag_type(*self) {
err!(UnsupportedTag);
}
let file = probe.into_inner();
utils::write_tag(&Tag::new(*self), file, file_type)
}
}
#[cfg(test)]
mod tests {
use crate::tag::utils::test_utils::read_path;
use crate::{Accessor, Picture, PictureType, Tag, TagExt, TagType};
use std::io::{Seek, Write};
use std::process::Command;
#[test]
fn issue_37() {
let file_contents = read_path("tests/files/assets/issue_37.ogg");
let mut temp_file = tempfile::NamedTempFile::new().unwrap();
temp_file.write_all(&file_contents).unwrap();
temp_file.rewind().unwrap();
let mut tag = Tag::new(TagType::VorbisComments);
let mut picture =
Picture::from_reader(&mut &*read_path("tests/files/assets/issue_37.jpg")).unwrap();
picture.set_pic_type(PictureType::CoverFront);
tag.push_picture(picture);
tag.save_to(temp_file.as_file_mut()).unwrap();
let cmd_output = Command::new("ffprobe")
.arg(temp_file.path().to_str().unwrap())
.output()
.unwrap();
assert!(cmd_output.status.success());
let stderr = String::from_utf8(cmd_output.stderr).unwrap();
assert!(!stderr.contains("CRC mismatch!"));
assert!(
!stderr.contains("Header processing failed: Invalid data found when processing input")
);
}
#[test]
fn issue_130_huge_picture() {
let file_contents = read_path("tests/files/assets/minimal/full_test.opus");
let mut temp_file = tempfile::NamedTempFile::new().unwrap();
temp_file.write_all(&file_contents).unwrap();
temp_file.rewind().unwrap();
let mut tag = Tag::new(TagType::VorbisComments);
let mut picture =
Picture::from_reader(&mut &*read_path("tests/files/assets/issue_37.jpg")).unwrap();
picture.set_pic_type(PictureType::CoverFront);
tag.push_picture(picture);
tag.save_to(temp_file.as_file_mut()).unwrap();
let cmd_output = Command::new("opusinfo")
.arg(temp_file.path().to_str().unwrap())
.output()
.unwrap();
assert!(cmd_output.status.success());
let stderr = String::from_utf8(cmd_output.stderr).unwrap();
assert!(!stderr.contains("WARNING:"));
}
#[test]
fn insert_empty() {
let mut tag = Tag::new(TagType::ID3v2);
tag.set_title(String::from("Foo title"));
assert_eq!(tag.title().as_deref(), Some("Foo title"));
tag.set_title(String::new());
assert_eq!(tag.title(), None);
}
}