Skip to main content

lofty/id3/v2/items/
sync_text.rs

1use crate::config::WriteOptions;
2use crate::error::{ErrorKind, Id3v2Error, Id3v2ErrorKind, LoftyError, Result};
3use crate::id3::v2::{FrameFlags, FrameHeader, FrameId};
4use crate::macros::err;
5use crate::util::text::{
6	DecodeTextResult, TextDecodeOptions, TextEncoding, decode_text,
7	utf16_decode_terminated_maybe_bom,
8};
9
10use std::borrow::Cow;
11use std::io::{Cursor, Seek, Write};
12
13use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
14
15const FRAME_ID: FrameId<'static> = FrameId::Valid(Cow::Borrowed("SYLT"));
16
17/// The unit used for [`SynchronizedTextFrame`] timestamps
18#[derive(Copy, Clone, PartialEq, Debug, Eq, Hash)]
19#[repr(u8)]
20pub enum TimestampFormat {
21	/// The unit is MPEG frames
22	MPEG = 1,
23	/// The unit is milliseconds
24	MS = 2,
25}
26
27impl TimestampFormat {
28	/// Get a `TimestampFormat` from a u8, must be 1-2 inclusive
29	pub fn from_u8(byte: u8) -> Option<Self> {
30		match byte {
31			1 => Some(Self::MPEG),
32			2 => Some(Self::MS),
33			_ => None,
34		}
35	}
36}
37
38/// The type of text stored in a [`SynchronizedTextFrame`]
39#[derive(Copy, Clone, PartialEq, Debug, Eq, Hash)]
40#[repr(u8)]
41#[allow(missing_docs)]
42pub enum SyncTextContentType {
43	Other = 0,
44	Lyrics = 1,
45	TextTranscription = 2,
46	PartName = 3,
47	Events = 4,
48	Chord = 5,
49	Trivia = 6,
50	WebpageURL = 7,
51	ImageURL = 8,
52}
53
54impl SyncTextContentType {
55	/// Get a `SyncTextContentType` from a u8, must be 0-8 inclusive
56	pub fn from_u8(byte: u8) -> Option<Self> {
57		match byte {
58			0 => Some(Self::Other),
59			1 => Some(Self::Lyrics),
60			2 => Some(Self::TextTranscription),
61			3 => Some(Self::PartName),
62			4 => Some(Self::Events),
63			5 => Some(Self::Chord),
64			6 => Some(Self::Trivia),
65			7 => Some(Self::WebpageURL),
66			8 => Some(Self::ImageURL),
67			_ => None,
68		}
69	}
70}
71
72/// Represents an ID3v2 synchronized text frame
73#[derive(Clone, Debug, PartialEq, Eq, Hash)]
74pub struct SynchronizedTextFrame<'a> {
75	pub(crate) header: FrameHeader<'a>,
76	/// The text encoding (description/text)
77	pub encoding: TextEncoding,
78	/// ISO-639-2 language code (3 bytes)
79	pub language: [u8; 3],
80	/// The format of the timestamps
81	pub timestamp_format: TimestampFormat,
82	/// The type of content stored
83	pub content_type: SyncTextContentType,
84	/// Unique content description
85	pub description: Option<String>,
86	/// Collection of timestamps and text
87	pub content: Vec<(u32, String)>,
88}
89
90impl SynchronizedTextFrame<'_> {
91	/// Create a new [`SynchronizedTextFrame`]
92	pub fn new(
93		encoding: TextEncoding,
94		language: [u8; 3],
95		timestamp_format: TimestampFormat,
96		content_type: SyncTextContentType,
97		description: Option<String>,
98		content: Vec<(u32, String)>,
99	) -> Self {
100		let header = FrameHeader::new(FRAME_ID, FrameFlags::default());
101		Self {
102			header,
103			encoding,
104			language,
105			timestamp_format,
106			content_type,
107			description,
108			content,
109		}
110	}
111
112	/// Get the ID for the frame
113	pub fn id(&self) -> FrameId<'_> {
114		FRAME_ID
115	}
116
117	/// Get the flags for the frame
118	pub fn flags(&self) -> FrameFlags {
119		self.header.flags
120	}
121
122	/// Set the flags for the frame
123	pub fn set_flags(&mut self, flags: FrameFlags) {
124		self.header.flags = flags;
125	}
126
127	/// Read a [`SynchronizedTextFrame`] from a slice
128	///
129	/// NOTE: This expects the frame header to have already been skipped
130	///
131	/// # Errors
132	///
133	/// This function will return [`BadSyncText`][Id3v2ErrorKind::BadSyncText] if at any point it's unable to parse the data
134	#[allow(clippy::missing_panics_doc)] // Infallible
135	pub fn parse(data: &[u8], frame_flags: FrameFlags) -> Result<Self> {
136		if data.len() < 7 {
137			return Err(Id3v2Error::new(Id3v2ErrorKind::BadFrameLength).into());
138		}
139
140		let encoding = TextEncoding::from_u8(data[0])
141			.ok_or_else(|| LoftyError::new(ErrorKind::TextDecode("Found invalid encoding")))?;
142		let language: [u8; 3] = data[1..4].try_into().unwrap();
143		if language.iter().any(|c| !c.is_ascii_alphabetic()) {
144			return Err(Id3v2Error::new(Id3v2ErrorKind::BadSyncText).into());
145		}
146		let timestamp_format = TimestampFormat::from_u8(data[4])
147			.ok_or_else(|| Id3v2Error::new(Id3v2ErrorKind::BadTimestampFormat))?;
148		let content_type = SyncTextContentType::from_u8(data[5])
149			.ok_or_else(|| Id3v2Error::new(Id3v2ErrorKind::BadSyncText))?;
150
151		let mut cursor = Cursor::new(&data[6..]);
152		let DecodeTextResult {
153			content: description,
154			bom,
155			..
156		} = decode_text(
157			&mut cursor,
158			TextDecodeOptions::new().encoding(encoding).terminated(true),
159		)
160		.map_err(|_| Id3v2Error::new(Id3v2ErrorKind::BadSyncText))?;
161
162		// There are 3 possibilities for UTF-16 encoded frames:
163		//
164		// * The description is the only string with a BOM
165		// * The description is empty (has no BOM)
166		// * All strings have a BOM
167		//
168		// To be safe, we change the encoding to the concrete variant determined from the description.
169		// Otherwise, we just have to hope that the other fields are encoded properly.
170		let endianness: Option<fn([u8; 2]) -> u16> = if encoding == TextEncoding::UTF16 {
171			match bom {
172				[0xFF, 0xFE] => Some(u16::from_le_bytes),
173				[0xFE, 0xFF] => Some(u16::from_be_bytes),
174				_ => None,
175			}
176		} else {
177			None
178		};
179
180		let mut pos = 0;
181		let total = (data.len() - 6) as u64 - cursor.stream_position()?;
182
183		let mut content = Vec::new();
184
185		while pos < total {
186			let text;
187			if let Some(endianness) = endianness {
188				let (decoded, bytes_read) =
189					utf16_decode_terminated_maybe_bom(&mut cursor, endianness)
190						.map_err(|_| Id3v2Error::new(Id3v2ErrorKind::BadSyncText))?;
191				pos += bytes_read as u64;
192				text = decoded;
193			} else {
194				let decoded_text = decode_text(
195					&mut cursor,
196					TextDecodeOptions::new().encoding(encoding).terminated(true),
197				)
198				.map_err(|_| Id3v2Error::new(Id3v2ErrorKind::BadSyncText))?;
199				pos += decoded_text.bytes_read as u64;
200
201				text = decoded_text.content;
202			}
203
204			let time = cursor
205				.read_u32::<BigEndian>()
206				.map_err(|_| Id3v2Error::new(Id3v2ErrorKind::BadSyncText))?;
207			pos += 4;
208
209			content.push((time, text));
210		}
211
212		let header = FrameHeader::new(FRAME_ID, frame_flags);
213		Ok(Self {
214			header,
215			encoding,
216			language,
217			timestamp_format,
218			content_type,
219			description: if description.is_empty() {
220				None
221			} else {
222				Some(description)
223			},
224			content,
225		})
226	}
227
228	/// Convert a [`SynchronizedTextFrame`] to an ID3v2 SYLT frame byte Vec
229	///
230	/// NOTE: This does not include the frame header
231	///
232	/// # Errors
233	///
234	/// * `content`'s length > [`u32::MAX`]
235	/// * `language` is not exactly 3 bytes
236	/// * `language` contains invalid characters (Only `'a'..='z'` and `'A'..='Z'` allowed)
237	/// * [`WriteOptions::lossy_text_encoding()`] is disabled and the content cannot be encoded in the specified [`TextEncoding`].
238	pub fn as_bytes(&self, write_options: WriteOptions) -> Result<Vec<u8>> {
239		let mut data = vec![self.encoding as u8];
240
241		if self.language.len() == 3 && self.language.iter().all(u8::is_ascii_alphabetic) {
242			data.write_all(&self.language)?;
243			data.write_u8(self.timestamp_format as u8)?;
244			data.write_u8(self.content_type as u8)?;
245
246			if let Some(description) = &self.description {
247				data.write_all(&self.encoding.encode(
248					description,
249					true,
250					write_options.lossy_text_encoding,
251				)?)?;
252			} else {
253				data.write_u8(0)?;
254			}
255
256			for (time, text) in &self.content {
257				data.write_all(&self.encoding.encode(
258					text,
259					true,
260					write_options.lossy_text_encoding,
261				)?)?;
262				data.write_u32::<BigEndian>(*time)?;
263			}
264
265			if data.len() as u64 > u64::from(u32::MAX) {
266				err!(TooMuchData);
267			}
268
269			return Ok(data);
270		}
271
272		Err(Id3v2Error::new(Id3v2ErrorKind::BadSyncText).into())
273	}
274}
275
276#[cfg(test)]
277mod tests {
278	use crate::config::WriteOptions;
279	use crate::id3::v2::{
280		FrameFlags, FrameHeader, SyncTextContentType, SynchronizedTextFrame, TimestampFormat,
281	};
282	use crate::util::text::TextEncoding;
283
284	fn expected(encoding: TextEncoding) -> SynchronizedTextFrame<'static> {
285		SynchronizedTextFrame {
286			header: FrameHeader::new(super::FRAME_ID, FrameFlags::default()),
287			encoding,
288			language: *b"eng",
289			timestamp_format: TimestampFormat::MS,
290			content_type: SyncTextContentType::Lyrics,
291			description: Some(String::from("Test Sync Text")),
292			content: vec![
293				(0, String::from("\nLofty")),
294				(10000, String::from("\nIs")),
295				(15000, String::from("\nReading")),
296				(30000, String::from("\nThis")),
297				(1_938_000, String::from("\nCorrectly")),
298			],
299		}
300	}
301
302	#[test_log::test]
303	fn sylt_decode() {
304		let cont = crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test.sylt");
305
306		let parsed_sylt = SynchronizedTextFrame::parse(&cont, FrameFlags::default()).unwrap();
307
308		assert_eq!(parsed_sylt, expected(TextEncoding::Latin1));
309	}
310
311	#[test_log::test]
312	fn sylt_encode() {
313		let encoded = expected(TextEncoding::Latin1)
314			.as_bytes(WriteOptions::default())
315			.unwrap();
316
317		let expected_bytes =
318			crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test.sylt");
319
320		assert_eq!(encoded, expected_bytes);
321	}
322
323	#[test_log::test]
324	fn sylt_decode_utf16() {
325		let cont =
326			crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test_utf16.sylt");
327
328		let parsed_sylt = SynchronizedTextFrame::parse(&cont, FrameFlags::default()).unwrap();
329
330		assert_eq!(parsed_sylt, expected(TextEncoding::UTF16));
331	}
332
333	#[test_log::test]
334	fn sylt_encode_utf_16() {
335		let encoded = expected(TextEncoding::UTF16)
336			.as_bytes(WriteOptions::default())
337			.unwrap();
338
339		let expected_bytes =
340			crate::tag::utils::test_utils::read_path("tests/tags/assets/id3v2/test_utf16.sylt");
341
342		assert_eq!(encoded, expected_bytes);
343	}
344}