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: &regex::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}