chapters/lib.rs
1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3#![deny(rustdoc::broken_intra_doc_links)]
4
5mod serialization;
6
7use chrono::Duration;
8use id3::{Error, ErrorKind, Tag, TagLike, Version};
9use serde::{Deserialize, Serialize};
10use std::path::Path;
11#[cfg(feature = "rssblue")]
12use uuid::Uuid;
13
14/// Represents a web link for the [chapter](crate::Chapter).
15#[derive(Debug, PartialEq, Serialize)]
16pub struct Link {
17 /// The URL of the link.
18 #[serde(serialize_with = "serialization::url_to_string")]
19 pub url: url::Url,
20 /// The title of the link.
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub title: Option<String>,
23}
24
25/// Represents a [chapter](crate::Chapter) image.
26#[derive(Debug, PartialEq)]
27pub enum Image {
28 /// The URL of the image.
29 Url(url::Url),
30 // TODO: some ways of encoding chapters (e.g., ID3 tags in MP3 files) allow to embed images directly in the file.
31 // Data(Vec<u8>),
32}
33
34/// Represents a remote item as defined in the [Podcast namespace
35/// specification](https://podcastindex.org/namespace/1.0#remote-item). Used internally by RSS
36/// Blue.
37#[cfg(feature = "rssblue")]
38#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]
39pub enum RemoteEntity {
40 /// Represents a podcast feed.
41 #[serde(rename = "feed")]
42 Feed {
43 /// [Podcast GUID](https://podcastindex.org/namespace/1.0#guid)
44 guid: Uuid,
45 },
46 /// Represents a podcast item.
47 #[serde(rename = "item")]
48 Item {
49 /// [Podcast GUID](https://podcastindex.org/namespace/1.0#guid)
50 feed_guid: Uuid,
51 /// Item GUID, see <https://www.rssboard.org/rss-specification>.
52 guid: String,
53 },
54}
55
56/// Chapters follow mostly the [Podcast namespace specification](https://github.com/Podcastindex-org/podcast-namespace/blob/main/chapters/jsonChapters.md).
57#[derive(Debug, PartialEq, Serialize)]
58pub struct Chapter {
59 /// The starting time of the chapter.
60 #[serde(serialize_with = "serialization::duration_to_float")]
61 pub start: Duration,
62 /// The end time of the chapter.
63 #[serde(
64 serialize_with = "serialization::duration_option_to_float_option",
65 skip_serializing_if = "Option::is_none"
66 )]
67 pub end: Option<Duration>,
68 /// The title of this chapter.
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub title: Option<String>,
71 /// The image to use as chapter art.
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub image: Option<Image>,
74 /// Web page or supporting document that's related to the topic of this chapter.
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub link: Option<Link>,
77 /// If this property is set to true, this chapter should not display visibly to the user in either the table of contents or as a jump-to point in the user interface. In the original spec, the inverse of this is called `toc`.
78 pub hidden: bool,
79 // TODO: This object defines an optional location that is tied to this chapter.
80 // pub location: Option<()>,
81 /// Remote entity used internally by RSS Blue.
82 #[serde(skip_serializing_if = "Option::is_none")]
83 #[cfg(feature = "rssblue")]
84 pub remote_entity: Option<RemoteEntity>,
85}
86
87impl Default for Chapter {
88 fn default() -> Self {
89 Self {
90 start: Duration::zero(),
91 end: None,
92 title: None,
93 image: None,
94 link: None,
95 hidden: false,
96 #[cfg(feature = "rssblue")]
97 remote_entity: None,
98 }
99 }
100}
101
102impl From<PodcastNamespaceChapter> for Chapter {
103 fn from(podcast_namespace_chapter: PodcastNamespaceChapter) -> Self {
104 Self {
105 start: podcast_namespace_chapter.start_time,
106 end: podcast_namespace_chapter.end_time,
107 title: podcast_namespace_chapter.title,
108 image: podcast_namespace_chapter.img.map(Image::Url),
109 link: podcast_namespace_chapter
110 .url
111 .map(|url| Link { url, title: None }),
112 hidden: !podcast_namespace_chapter.toc.unwrap_or(true),
113 #[cfg(feature = "rssblue")]
114 remote_entity: podcast_namespace_chapter.remote_entity,
115 }
116 }
117}
118
119/// Chapters of the [Podcast namespace](https://github.com/Podcastindex-org/podcast-namespace/blob/main/chapters/jsonChapters.md).
120#[derive(Debug, PartialEq, Deserialize, Serialize)]
121pub struct PodcastNamespaceChapters {
122 version: String,
123 chapters: Vec<PodcastNamespaceChapter>,
124}
125
126impl From<&[Chapter]> for PodcastNamespaceChapters {
127 fn from(chapters: &[Chapter]) -> Self {
128 Self {
129 version: "1.2.0".to_string(),
130 chapters: chapters.iter().map(|c| c.into()).collect(),
131 }
132 }
133}
134
135/// Individual chapter inside [Chapters](crate::PodcastNamespaceChapters).
136#[derive(Debug, PartialEq, Deserialize, Serialize)]
137#[serde(rename_all = "camelCase")]
138pub struct PodcastNamespaceChapter {
139 /// The starting time of the chapter.
140 #[serde(
141 deserialize_with = "serialization::float_to_duration",
142 serialize_with = "serialization::duration_to_float"
143 )]
144 start_time: Duration,
145 /// The end time of the chapter.
146 #[serde(
147 default,
148 deserialize_with = "serialization::float_to_duration_option",
149 serialize_with = "serialization::duration_option_to_float_option",
150 skip_serializing_if = "Option::is_none"
151 )]
152 end_time: Option<Duration>,
153 /// The title of this chapter.
154 #[serde(default)]
155 title: Option<String>,
156 /// The url of an image to use as chapter art.
157 #[serde(
158 default,
159 deserialize_with = "serialization::string_to_url",
160 serialize_with = "serialization::url_option_to_string",
161 skip_serializing_if = "Option::is_none"
162 )]
163 img: Option<url::Url>,
164 /// The url of a web page or supporting document that's related to the topic of this chapter.
165 #[serde(
166 default,
167 deserialize_with = "serialization::string_to_url",
168 serialize_with = "serialization::url_option_to_string",
169 skip_serializing_if = "Option::is_none"
170 )]
171 url: Option<url::Url>,
172 /// If this property is present and set to false, this chapter should not display visibly to the user in either the table of contents or as a jump-to point in the user interface.
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 toc: Option<bool>,
175 // TODO: This object defines an optional location that is tied to this chapter.
176 // pub location: Option<()>,
177 #[cfg(feature = "rssblue")]
178 #[serde(
179 default,
180 skip_serializing_if = "Option::is_none",
181 rename = "rssblue:remoteEntity"
182 )]
183 remote_entity: Option<RemoteEntity>,
184}
185
186impl<'a> From<&'a Chapter> for PodcastNamespaceChapter {
187 fn from(chapter: &'a Chapter) -> Self {
188 Self {
189 start_time: chapter.start,
190 end_time: chapter.end,
191 title: chapter.title.clone(),
192 img: match &chapter.image {
193 Some(Image::Url(url)) => Some(url.clone()),
194 _ => None,
195 },
196 url: chapter.link.as_ref().map(|link| link.url.clone()),
197 toc: if chapter.hidden { Some(false) } else { None },
198 #[cfg(feature = "rssblue")]
199 remote_entity: chapter.remote_entity.clone(),
200 }
201 }
202}
203
204/// Reads [chapters](crate::Chapter) from a [JSON chapters file](https://github.com/Podcastindex-org/podcast-namespace/blob/main/chapters/jsonChapters.md).
205///
206/// # Example:
207/// ```rust
208/// # use chapters::{Chapter, Image, Link};
209/// # use chrono::Duration;
210/// # use pretty_assertions::assert_eq;
211/// #
212/// # fn main() {
213/// let json = r#"{
214/// "version": "1.2.0",
215/// "chapters": [
216/// {
217/// "startTime": 0,
218/// "endTime": 30.5,
219/// "title": "Chapter 1",
220/// "img": "https://example.com/chapter-1.jpg",
221/// "url": "https://example.com/chapter-1"
222/// },
223/// {
224/// "startTime": 30.5,
225/// "title": "Chapter 2"
226/// },
227/// {
228/// "startTime": 55,
229/// "title": "Hidden chapter",
230/// "toc": false
231/// },
232/// {
233/// "startTime": 60,
234/// "endTime": 90,
235/// "title": "Chapter 3",
236/// "img": "https://example.com/chapter-3.jpg"
237/// }
238/// ]
239/// }"#;
240///
241/// let chapters = chapters::from_json(json.as_bytes()).unwrap();
242///
243/// assert_eq!(
244/// chapters,
245/// vec![
246/// Chapter {
247/// start: Duration::seconds(0),
248/// end: Some(Duration::seconds(30) + Duration::milliseconds(500)),
249/// title: Some("Chapter 1".to_string()),
250/// image: Some(Image::Url(
251/// url::Url::parse("https://example.com/chapter-1.jpg").unwrap()
252/// )),
253/// link: Some(Link {
254/// url: url::Url::parse("https://example.com/chapter-1").unwrap(),
255/// title: None,
256/// }),
257/// ..Default::default()
258/// },
259/// Chapter {
260/// start: Duration::seconds(30) + Duration::milliseconds(500),
261/// end: None,
262/// title: Some("Chapter 2".to_string()),
263/// ..Default::default()
264/// },
265/// Chapter {
266/// start: Duration::seconds(55),
267/// end: None,
268/// title: Some("Hidden chapter".to_string()),
269/// hidden: true,
270/// ..Default::default()
271/// },
272/// Chapter {
273/// start: Duration::seconds(60),
274/// end: Some(Duration::seconds(90)),
275/// title: Some("Chapter 3".to_string()),
276/// image: Some(Image::Url(
277/// url::Url::parse("https://example.com/chapter-3.jpg").unwrap()
278/// )),
279/// ..Default::default()
280/// },
281/// ]
282/// );
283/// # }
284/// ```
285pub fn from_json<R: std::io::Read>(reader: R) -> Result<Vec<Chapter>, String> {
286 let podcast_namespace_chapters: PodcastNamespaceChapters =
287 serde_json::from_reader(reader).map_err(|e| e.to_string())?;
288 Ok(podcast_namespace_chapters
289 .chapters
290 .into_iter()
291 .map(|c| c.into())
292 .collect())
293}
294
295/// Writes [chapters](crate::Chapter) to a [JSON chapters file](https://github.com/Podcastindex-org/podcast-namespace/blob/main/chapters/jsonChapters.md).
296///
297/// # Example:
298/// ```rust
299/// # use chapters::{Chapter, Image, Link};
300/// # use chrono::Duration;
301/// # use pretty_assertions::assert_eq;
302/// #
303/// # fn main() {
304/// let chapters = vec![
305/// Chapter {
306/// start: Duration::zero(),
307/// title: Some("Chapter 1".to_string()),
308/// ..Default::default()
309/// },
310/// Chapter {
311/// start: Duration::seconds(45) + Duration::milliseconds(900),
312/// title: Some("Chapter 2".to_string()),
313/// link: Some(Link {
314/// url: "https://example.com".parse().unwrap(),
315/// title: Some("Example".to_string()),
316/// }),
317/// ..Default::default()
318/// },
319/// Chapter {
320/// start: Duration::minutes(1)+Duration::seconds(5),
321/// title: Some("Hidden chapter".to_string()),
322/// hidden: true,
323/// ..Default::default()
324/// },
325/// Chapter {
326/// start: Duration::minutes(2)+Duration::seconds(10)+Duration::milliseconds(500),
327/// title: Some("Chapter 3".to_string()),
328/// image: Some(Image::Url("https://example.com/image.png".parse().unwrap())),
329/// ..Default::default()
330/// },
331/// ];
332///
333/// let json_chapters = chapters::to_json(&chapters).expect("Failed to serialize chapters");
334///
335/// assert_eq!(json_chapters, r#"{
336/// "version": "1.2.0",
337/// "chapters": [
338/// {
339/// "startTime": 0,
340/// "title": "Chapter 1"
341/// },
342/// {
343/// "startTime": 45.9,
344/// "title": "Chapter 2",
345/// "url": "https://example.com/"
346/// },
347/// {
348/// "startTime": 65,
349/// "title": "Hidden chapter",
350/// "toc": false
351/// },
352/// {
353/// "startTime": 130.5,
354/// "title": "Chapter 3",
355/// "img": "https://example.com/image.png"
356/// }
357/// ]
358/// }"#);
359/// # }
360/// ```
361pub fn to_json(chapters: &[Chapter]) -> Result<String, String> {
362 let podcast_namespace_chapters: PodcastNamespaceChapters = chapters.into();
363 serde_json::to_string_pretty(&podcast_namespace_chapters).map_err(|e| e.to_string())
364}
365
366/// Timestamp format used in episode descriptions.
367#[derive(Debug, Clone)]
368enum TimestampType {
369 /// MM:SS format, e.g., "12:34"
370 MmSs,
371 /// HH:MM:SS format, e.g., "01:23:45"
372 HhMmSs,
373 /// MM:SS format within parentheses, e.g., "(12:34)"
374 MmSsParentheses,
375 /// HH:MM:SS format within parentheses, e.g., "(01:23:45)"
376 HhMmSsParentheses,
377}
378
379impl TimestampType {
380 fn regex_pattern(&self) -> &str {
381 match self {
382 Self::MmSs => r"^(?P<minutes>[0-5]\d):(?P<seconds>[0-5]\d)",
383 Self::HhMmSs => r"^(?P<hours>\d{2}):(?P<minutes>[0-5]\d):(?P<seconds>[0-5]\d)",
384 Self::MmSsParentheses => r"^\((?P<minutes>[0-5]\d):(?P<seconds>[0-5]\d)\)",
385 Self::HhMmSsParentheses => {
386 r"^\((?P<hours>\d{2}):(?P<minutes>[0-5]\d):(?P<seconds>[0-5]\d)\)"
387 }
388 }
389 }
390
391 fn line_regex_pattern(&self) -> String {
392 // Combines the timestamp regex pattern with space (or a punctuation mark) and a pattern for text following the timestamp.
393 format!("{}[.!?\\- ]+(?P<text>.+)$", self.regex_pattern())
394 }
395
396 fn from_line(line: &str) -> Option<Self> {
397 if let Some(first_char) = line.chars().next() {
398 // regex can be expensive, so we first check if the line at least starts with the right character.
399 if first_char == '(' || first_char.is_numeric() {
400 return [
401 Self::MmSs,
402 Self::HhMmSs,
403 Self::MmSsParentheses,
404 Self::HhMmSsParentheses,
405 ]
406 .iter()
407 .find(|&temp_timestamp_type| {
408 regex::Regex::new(temp_timestamp_type.line_regex_pattern().as_str())
409 .map(|re| re.captures(line).is_some())
410 .unwrap_or(false)
411 })
412 .cloned();
413 }
414 }
415 None
416 }
417}
418
419/// Reads [chapters](crate::Chapter) from [episode description](https://help.spotifyforpodcasters.com/hc/en-us/articles/13194991130779-Enabling-podcast-chapters-) (show notes).
420///
421/// # Example:
422/// ```rust
423/// # use pretty_assertions::assert_eq;
424/// #
425/// # fn main() {
426/// let description = r#"
427/// In this episode, we explore a hot new trend in fitness: "The Movement"!
428///
429/// 00:00 - The Movement
430/// 05:04 - Baboons
431/// 09:58 - Steve Jobs
432/// "#;
433///
434/// let chapters = chapters::from_description(description).expect("Failed to parse chapters");
435///
436/// assert_eq!(chapters.len(), 3);
437/// assert_eq!(chapters[1].title, Some(String::from("Baboons")));
438/// # }
439/// ```
440pub fn from_description(description: &str) -> Result<Vec<Chapter>, String> {
441 let mut chapters = Vec::new();
442 let mut timestamp_type: Option<TimestampType> = None;
443
444 let parse_line = |line: &str, timestamp_type: &TimestampType| -> Option<Chapter> {
445 let re = regex::Regex::new(timestamp_type.line_regex_pattern().as_str())
446 .map_err(|e| e.to_string())
447 .ok()?;
448
449 if let Some(captures) = re.captures(line) {
450 let start = parse_timestamp(&captures).ok()?;
451 let text = captures.name("text").unwrap().as_str();
452 Some(Chapter {
453 start,
454 end: None,
455 title: Some(text.trim().to_string()),
456 image: None,
457 link: None,
458 hidden: false,
459 #[cfg(feature = "rssblue")]
460 remote_entity: None,
461 })
462 } else {
463 None
464 }
465 };
466
467 for line in description.lines().map(|line| line.trim()) {
468 if timestamp_type.is_none() {
469 timestamp_type = TimestampType::from_line(line);
470 }
471
472 if let Some(timestamp_type) = timestamp_type.as_ref() {
473 if let Some(chapter) = parse_line(line, timestamp_type) {
474 chapters.push(chapter);
475 } else {
476 break;
477 }
478 }
479 }
480
481 Ok(chapters)
482}
483
484/// Writes [chapters](crate::Chapter) to [episode description](https://help.spotifyforpodcasters.com/hc/en-us/articles/13194991130779-Enabling-podcast-chapters-) (show notes).
485///
486/// Only the start time and title are used.
487///
488/// # Example:
489/// ```rust
490/// # use chapters::{Chapter, Link};
491/// # use chrono::Duration;
492/// # use pretty_assertions::assert_eq;
493/// #
494/// # fn main() {
495/// let chapters = vec![
496/// Chapter {
497/// start: Duration::zero(),
498/// title: Some("The Movement".to_string()),
499/// link: Some(Link {
500/// url: url::Url::parse("https://example.com/the-movement").unwrap(),
501/// title: None,
502/// }),
503/// ..Default::default()
504/// },
505/// Chapter {
506/// start: Duration::minutes(5) + Duration::seconds(4),
507/// title: Some("Baboons".to_string()),
508/// ..Default::default()
509/// },
510/// Chapter {
511/// start: Duration::minutes(9) + Duration::seconds(58),
512/// title: Some("Steve Jobs".to_string()),
513/// ..Default::default()
514/// },
515/// ];
516///
517/// let description = chapters::to_description(&chapters).expect("Failed to write chapters");
518/// assert_eq!(
519/// description,
520/// r#"00:00 The Movement
521/// 05:04 Baboons
522/// 09:58 Steve Jobs
523/// "#
524/// );
525/// # }
526/// ```
527pub fn to_description(chapters: &[Chapter]) -> Result<String, String> {
528 let mut description = String::new();
529
530 let at_least_an_hour = chapters
531 .iter()
532 .any(|chapter| chapter.start >= Duration::hours(1));
533 let timestamp_type = if at_least_an_hour {
534 TimestampType::HhMmSs
535 } else {
536 TimestampType::MmSs
537 };
538
539 for chapter in chapters {
540 let start = chapter.start;
541 let title = chapter.title.as_ref().ok_or("Chapter title is missing")?;
542 let line = format!(
543 "{} {}",
544 duration_to_timestamp(start, timestamp_type.clone()),
545 title
546 );
547 description.push_str(&line);
548 description.push('\n');
549 }
550
551 Ok(description)
552}
553
554fn parse_timestamp(captures: ®ex::Captures) -> Result<Duration, String> {
555 let parse_i64 = |capture: Option<regex::Match>| -> Result<i64, String> {
556 capture
557 .map(|m| m.as_str().parse::<i64>().map_err(|e| e.to_string()))
558 .unwrap_or(Ok(0))
559 };
560
561 let hours = parse_i64(captures.name("hours"))?;
562 let minutes = parse_i64(captures.name("minutes"))?;
563 let seconds = parse_i64(captures.name("seconds"))?;
564
565 Ok(Duration::hours(hours) + Duration::minutes(minutes) + Duration::seconds(seconds))
566}
567
568fn duration_to_timestamp(duration: Duration, timestamp_type: TimestampType) -> String {
569 let hours = duration.num_hours();
570 let minutes = duration.num_minutes() - hours * 60;
571 let seconds = duration.num_seconds() - minutes * 60 - hours * 3600;
572
573 match timestamp_type {
574 TimestampType::MmSs => format!("{minutes:02}:{seconds:02}"),
575 TimestampType::HhMmSs => format!("{hours:02}:{minutes:02}:{seconds:02}"),
576 TimestampType::MmSsParentheses => format!("({minutes:02}:{seconds:02})"),
577 TimestampType::HhMmSsParentheses => format!("({hours:02}:{minutes:02}:{seconds:02})"),
578 }
579}
580
581/// Reads [chapters](crate::Chapter) from MP3 file's [ID3](https://en.wikipedia.org/wiki/ID3) tag frames.
582///
583/// # Example:
584/// ```rust
585/// # use chapters::{Chapter, Link};
586/// # use pretty_assertions::assert_eq;
587/// #
588/// # fn main() {
589/// # struct Test {
590/// # file_path: &'static str,
591/// # expected_chapters: Vec<Chapter>,
592/// # }
593/// #
594/// # let tests = vec![
595/// # Test {
596/// # file_path: "tests/data/id3-chapters.jfk-rice-university-speech.mp3",
597/// # expected_chapters: vec![
598/// # Chapter {
599/// # start: chrono::Duration::seconds(0),
600/// # title: Some(String::from("Introduction")),
601/// # ..Default::default()
602/// # },
603/// # Chapter {
604/// # start: chrono::Duration::seconds(9),
605/// # title: Some(String::from("Thanks")),
606/// # ..Default::default()
607/// # },
608/// # Chapter {
609/// # start: chrono::Duration::seconds(42),
610/// # title: Some(String::from("Status quo")),
611/// # ..Default::default()
612/// # },
613/// # Chapter {
614/// # start: chrono::Duration::minutes(5) + chrono::Duration::seconds(8),
615/// # title: Some(String::from("On being first")),
616/// # link: Some(Link{
617/// # url: url::Url::parse("https://www.osti.gov/opennet/manhattan-project-history/Events/1945/trinity.htm").unwrap(),
618/// # title: Some(String::from("The Trinity Test")),
619/// # }),
620/// # ..Default::default()
621/// # },
622/// # Chapter {
623/// # start: chrono::Duration::minutes(8) + chrono::Duration::seconds(8),
624/// # title: Some(String::from("Why we're going to the Moon")),
625/// # link: Some(Link{
626/// # url: url::Url::parse("https://www.nasa.gov/mission_pages/apollo/missions/apollo11.html").unwrap(),
627/// # title: None,
628/// # }),
629/// # ..Default::default()
630/// # },
631/// # Chapter {
632/// # start: chrono::Duration::minutes(16) + chrono::Duration::seconds(24),
633/// # title: Some(String::from("Conclusion")),
634/// # ..Default::default()
635/// # },
636/// # ],
637/// # },
638/// # Test {
639/// # file_path: "tests/data/id3-chapters.jfk-rice-university-speech.no-frames.mp3",
640/// # expected_chapters: vec![],
641/// # },
642/// # ];
643/// #
644/// # for test in tests {
645/// # let path = std::path::Path::new(test.file_path);
646/// let chapters = chapters::from_mp3_file(path).expect("Failed to parse chapters");
647/// #
648/// # assert_eq!(chapters, test.expected_chapters);
649/// # }
650/// # }
651pub fn from_mp3_file<P: AsRef<Path>>(path: P) -> Result<Vec<Chapter>, String> {
652 let tag = Tag::read_from_path(&path).map_err(|e| {
653 format!(
654 "Error reading ID3 tag from `{}`: {}",
655 path.as_ref().display(),
656 e
657 )
658 })?;
659 let mut chapters = Vec::new();
660
661 for id3_chapter in tag.chapters() {
662 let start = Duration::milliseconds(id3_chapter.start_time as i64);
663
664 let temp_end = Duration::milliseconds(id3_chapter.end_time as i64);
665 // Some programs might encode chapters as instants, i.e., with the start and end time being the same.
666 let end = if temp_end == start {
667 None
668 } else {
669 Some(temp_end)
670 };
671
672 let mut title = None;
673 let mut link = None;
674
675 for subframe in &id3_chapter.frames {
676 match subframe.content() {
677 id3::Content::Text(text) => {
678 title = Some(text.clone());
679 }
680 // TODO: Check if anyone uses this method as opposed to `ExtendedLink`.
681 id3::Content::Link(url) => {
682 link = Some(Link {
683 url: url::Url::parse(url).map_err(|e| e.to_string())?,
684 title: None,
685 });
686 }
687 id3::Content::ExtendedLink(extended_link) => {
688 link = Some(Link {
689 url: url::Url::parse(&extended_link.link).map_err(|e| e.to_string())?,
690 title: match extended_link.description.trim() {
691 "" => None,
692 description => Some(description.to_string()),
693 },
694 });
695 }
696 _ => {}
697 }
698 }
699
700 chapters.push(Chapter {
701 title,
702 link,
703 start,
704 end,
705 ..Default::default()
706 });
707 }
708
709 // Order chapters by start time.
710 chapters.sort_by(|a, b| a.start.cmp(&b.start));
711
712 Ok(chapters)
713}
714
715/// Writes [chapters](crate::Chapter) to MP3 file's [ID3](https://en.wikipedia.org/wiki/ID3) tag frames.
716///
717/// If the file already has chapters, they will be replaced.
718///
719/// # Example:
720/// ```rust
721/// # use chapters::{Chapter, Link};
722/// # use chrono::Duration;
723/// # use pretty_assertions::assert_eq;
724/// #
725/// # fn main() {
726/// # let dst_filepath_str = "tests/data/id3-chapters.jfk-rice-university-speech.frames-added.mp3";
727/// # let dst_filepath = std::path::Path::new(&dst_filepath_str);
728/// #
729/// # struct Test {
730/// # src_filepath_str: &'static str,
731/// # }
732/// #
733/// # let tests = vec![
734/// # Test {
735/// # src_filepath_str: "tests/data/id3-chapters.jfk-rice-university-speech.mp3",
736/// # },
737/// # Test {
738/// # src_filepath_str: "tests/data/id3-chapters.jfk-rice-university-speech.no-frames.mp3",
739/// # },
740/// # ];
741/// #
742/// # for test in tests {
743/// # let src_filepath = std::path::Path::new(&test.src_filepath_str);
744/// let chapters = vec![
745/// Chapter {
746/// start: Duration::seconds(0),
747/// title: Some("Introduction".to_string()),
748/// link: Some(Link{
749/// url: url::Url::parse("https://www.rice.edu").unwrap(),
750/// title: None,
751/// }),
752/// ..Default::default()
753/// },
754/// Chapter {
755/// start: Duration::seconds(42),
756/// title: Some("Status quo".to_string()),
757/// ..Default::default()
758/// },
759/// Chapter {
760/// start: chrono::Duration::minutes(5) + chrono::Duration::seconds(8),
761/// title: Some(String::from("On being first")),
762/// link: Some(Link{
763/// url: url::Url::parse("https://www.osti.gov/opennet/manhattan-project-history/Events/1945/trinity.htm").unwrap(),
764/// title: Some(String::from("The Trinity Test")),
765/// }),
766/// ..Default::default()
767/// },
768/// ];
769///
770/// chapters::to_mp3_file(src_filepath, dst_filepath, &chapters).expect("Failed to write chapters");
771/// #
772/// # let chapters_read = chapters::from_mp3_file(dst_filepath).expect("Failed to read chapters");
773/// # assert_eq!(chapters, chapters_read);
774/// #
775/// # // Cleanup
776/// # std::fs::remove_file(dst_filepath).unwrap();
777/// # }
778/// # }
779/// ```
780pub fn to_mp3_file<P: AsRef<Path>>(
781 src_path: P,
782 dst_path: P,
783 chapters: &[Chapter],
784) -> Result<(), String> {
785 std::fs::copy(&src_path, &dst_path).map_err(|e| {
786 format!(
787 "Error copying `{}` to `{}`: {}",
788 src_path.as_ref().display(),
789 dst_path.as_ref().display(),
790 e
791 )
792 })?;
793
794 let mut tag = match Tag::read_from_path(&src_path) {
795 Ok(mut tag) => {
796 tag.remove_all_chapters();
797 tag
798 }
799 Err(Error {
800 kind: ErrorKind::NoTag,
801 ..
802 }) => Tag::new(),
803 Err(err) => {
804 return Err(format!(
805 "Error reading ID3 tag from `{}`: {}",
806 src_path.as_ref().display(),
807 err
808 ))
809 }
810 };
811
812 for (i, chapter) in chapters.iter().enumerate() {
813 let mut id3_chapter = id3::frame::Chapter {
814 element_id: format!("chp{}", i + 1),
815 start_time: chapter.start.num_milliseconds() as u32,
816 end_time: if let Some(end) = chapter.end {
817 end.num_milliseconds() as u32
818 } else {
819 chapter.start.num_milliseconds() as u32
820 },
821 start_offset: 0,
822 end_offset: 0,
823 frames: Vec::new(),
824 };
825
826 if let Some(title) = &chapter.title {
827 let frame = id3::frame::Frame::with_content("TIT2", id3::Content::Text(title.clone()));
828 id3_chapter.frames.push(frame);
829 }
830
831 if let Some(link) = &chapter.link {
832 // title or "" if None
833 let link_title = link.title.as_ref().map_or("", |t| t.as_str());
834 let frame = id3::frame::Frame::with_content(
835 "WXXX",
836 id3::Content::ExtendedLink(id3::frame::ExtendedLink {
837 link: link.url.to_string(),
838 description: link_title.to_string(),
839 }),
840 );
841 id3_chapter.frames.push(frame);
842 }
843
844 tag.add_frame(id3::frame::Frame::with_content(
845 "CHAP",
846 id3::Content::Chapter(id3_chapter),
847 ));
848 }
849
850 tag.write_to_path(&dst_path, Version::Id3v24).map_err(|e| {
851 format!(
852 "Error writing ID3 tag to `{}`: {}",
853 dst_path.as_ref().display(),
854 e
855 )
856 })?;
857
858 Ok(())
859}