use crate::{AudioError, AudioResult};
use std::collections::HashMap;
#[derive(Clone, Debug, Default)]
pub struct Id3Tag {
pub version: Id3Version,
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub year: Option<String>,
pub comment: Option<String>,
pub track: Option<u8>,
pub genre: Option<String>,
pub frames: HashMap<String, Vec<u8>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Id3Version {
#[default]
None,
V1,
V11,
V22,
V23,
V24,
}
impl Id3Tag {
pub fn parse_v1(data: &[u8]) -> AudioResult<Self> {
if data.len() < 128 {
return Err(AudioError::InvalidData("ID3v1 tag too short".into()));
}
if &data[0..3] != b"TAG" {
return Err(AudioError::InvalidData("Invalid ID3v1 tag".into()));
}
let mut tag = Self::default();
tag.title = parse_string(&data[3..33]);
tag.artist = parse_string(&data[33..63]);
tag.album = parse_string(&data[63..93]);
tag.year = parse_string(&data[93..97]);
if data[125] == 0 && data[126] != 0 {
tag.comment = parse_string(&data[97..125]);
tag.track = Some(data[126]);
tag.version = Id3Version::V11;
} else {
tag.comment = parse_string(&data[97..127]);
tag.version = Id3Version::V1;
}
let genre_id = data[127];
tag.genre = get_genre(genre_id);
Ok(tag)
}
pub fn parse_v2(data: &[u8]) -> AudioResult<(Self, usize)> {
if data.len() < 10 {
return Err(AudioError::InvalidData("ID3v2 header too short".into()));
}
if &data[0..3] != b"ID3" {
return Err(AudioError::InvalidData("Invalid ID3v2 tag".into()));
}
let major_version = data[3];
let _minor_version = data[4];
let flags = data[5];
let size = synchsafe_to_u32(&data[6..10]) as usize;
let version = match major_version {
2 => Id3Version::V22,
3 => Id3Version::V23,
4 => Id3Version::V24,
_ => {
return Err(AudioError::UnsupportedFormat(format!(
"ID3v2.{major_version}"
)))
}
};
let mut tag = Self {
version,
..Default::default()
};
let mut offset = 10;
if (flags & 0x40) != 0 {
if data.len() < offset + 4 {
return Err(AudioError::InvalidData("Extended header too short".into()));
}
let ext_size = synchsafe_to_u32(&data[offset..offset + 4]) as usize;
offset += ext_size;
}
while offset + 10 < 10 + size && offset < data.len() {
if data[offset] == 0 {
break;
}
let frame_id = match major_version {
2 => {
if offset + 6 > data.len() {
break;
}
String::from_utf8_lossy(&data[offset..offset + 3]).to_string()
}
_ => {
if offset + 10 > data.len() {
break;
}
String::from_utf8_lossy(&data[offset..offset + 4]).to_string()
}
};
let (frame_size, frame_flags, header_size) = if major_version == 2 {
let size = u32::from(data[offset + 3]) << 16
| u32::from(data[offset + 4]) << 8
| u32::from(data[offset + 5]);
(size as usize, 0u16, 6)
} else if major_version == 4 {
let size = synchsafe_to_u32(&data[offset + 4..offset + 8]) as usize;
let flags = u16::from(data[offset + 8]) << 8 | u16::from(data[offset + 9]);
(size, flags, 10)
} else {
let size = u32::from(data[offset + 4]) << 24
| u32::from(data[offset + 5]) << 16
| u32::from(data[offset + 6]) << 8
| u32::from(data[offset + 7]);
let flags = u16::from(data[offset + 8]) << 8 | u16::from(data[offset + 9]);
(size as usize, flags, 10)
};
offset += header_size;
if offset + frame_size > data.len() {
break;
}
let frame_data = &data[offset..offset + frame_size];
tag.parse_frame(&frame_id, frame_data, frame_flags)?;
offset += frame_size;
}
Ok((tag, 10 + size))
}
fn parse_frame(&mut self, id: &str, data: &[u8], _flags: u16) -> AudioResult<()> {
if data.is_empty() {
return Ok(());
}
let encoding = data[0];
let text_data = &data[1..];
match id {
"TIT2" | "TT2" => {
self.title = decode_text(text_data, encoding);
}
"TPE1" | "TP1" => {
self.artist = decode_text(text_data, encoding);
}
"TALB" | "TAL" => {
self.album = decode_text(text_data, encoding);
}
"TYER" | "TYE" | "TDRC" => {
self.year = decode_text(text_data, encoding);
}
"COMM" | "COM" => {
if text_data.len() > 4 {
self.comment = decode_text(&text_data[4..], encoding);
}
}
"TRCK" | "TRK" => {
if let Some(track_str) = decode_text(text_data, encoding) {
if let Some(num_str) = track_str.split('/').next() {
if let Ok(track) = num_str.trim().parse::<u8>() {
self.track = Some(track);
}
}
}
}
"TCON" | "TCO" => {
self.genre = decode_text(text_data, encoding);
}
_ => {
self.frames.insert(id.to_string(), data.to_vec());
}
}
Ok(())
}
pub fn get_tag_size(data: &[u8]) -> AudioResult<usize> {
if data.len() < 10 {
return Ok(0);
}
if &data[0..3] != b"ID3" {
return Ok(0);
}
let size = synchsafe_to_u32(&data[6..10]) as usize;
Ok(10 + size)
}
}
fn parse_string(data: &[u8]) -> Option<String> {
let end = data.iter().position(|&b| b == 0).unwrap_or(data.len());
let trimmed = &data[..end];
if trimmed.is_empty() {
return None;
}
let s = String::from_utf8_lossy(trimmed).trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
fn decode_text(data: &[u8], encoding: u8) -> Option<String> {
if data.is_empty() {
return None;
}
let s = match encoding {
0 => {
let end = data.iter().position(|&b| b == 0).unwrap_or(data.len());
String::from_utf8_lossy(&data[..end]).to_string()
}
1 => {
decode_utf16(data)
}
2 => {
decode_utf16_be(data)
}
3 => {
let end = data.iter().position(|&b| b == 0).unwrap_or(data.len());
String::from_utf8_lossy(&data[..end]).to_string()
}
_ => String::from_utf8_lossy(data).to_string(),
};
let trimmed = s.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
fn decode_utf16(data: &[u8]) -> String {
if data.len() < 2 {
return String::new();
}
let le = data[0] == 0xFF && data[1] == 0xFE;
let be = data[0] == 0xFE && data[1] == 0xFF;
let start = if le || be { 2 } else { 0 };
if le {
decode_utf16_le(&data[start..])
} else {
decode_utf16_be(&data[start..])
}
}
fn decode_utf16_le(data: &[u8]) -> String {
let mut chars = Vec::new();
for chunk in data.chunks_exact(2) {
let code = u16::from_le_bytes([chunk[0], chunk[1]]);
if code == 0 {
break;
}
chars.push(code);
}
String::from_utf16_lossy(&chars)
}
fn decode_utf16_be(data: &[u8]) -> String {
let mut chars = Vec::new();
for chunk in data.chunks_exact(2) {
let code = u16::from_be_bytes([chunk[0], chunk[1]]);
if code == 0 {
break;
}
chars.push(code);
}
String::from_utf16_lossy(&chars)
}
fn synchsafe_to_u32(data: &[u8]) -> u32 {
debug_assert!(data.len() >= 4);
u32::from(data[0] & 0x7F) << 21
| u32::from(data[1] & 0x7F) << 14
| u32::from(data[2] & 0x7F) << 7
| u32::from(data[3] & 0x7F)
}
fn get_genre(id: u8) -> Option<String> {
GENRES.get(id as usize).map(|&s| s.to_string())
}
const GENRES: &[&str] = &[
"Blues",
"Classic Rock",
"Country",
"Dance",
"Disco",
"Funk",
"Grunge",
"Hip-Hop",
"Jazz",
"Metal",
"New Age",
"Oldies",
"Other",
"Pop",
"R&B",
"Rap",
"Reggae",
"Rock",
"Techno",
"Industrial",
"Alternative",
"Ska",
"Death Metal",
"Pranks",
"Soundtrack",
"Euro-Techno",
"Ambient",
"Trip-Hop",
"Vocal",
"Jazz+Funk",
"Fusion",
"Trance",
"Classical",
"Instrumental",
"Acid",
"House",
"Game",
"Sound Clip",
"Gospel",
"Noise",
"AlternRock",
"Bass",
"Soul",
"Punk",
"Space",
"Meditative",
"Instrumental Pop",
"Instrumental Rock",
"Ethnic",
"Gothic",
"Darkwave",
"Techno-Industrial",
"Electronic",
"Pop-Folk",
"Eurodance",
"Dream",
"Southern Rock",
"Comedy",
"Cult",
"Gangsta",
"Top 40",
"Christian Rap",
"Pop/Funk",
"Jungle",
"Native American",
"Cabaret",
"New Wave",
"Psychedelic",
"Rave",
"Showtunes",
"Trailer",
"Lo-Fi",
"Tribal",
"Acid Punk",
"Acid Jazz",
"Polka",
"Retro",
"Musical",
"Rock & Roll",
"Hard Rock",
];