#![doc = include_str!("../README.md")]
#![deny(missing_docs)]
#![deny(rustdoc::broken_intra_doc_links)]
mod serialization;
use chrono::Duration;
use id3::{Error, ErrorKind, Tag, TagLike, Version};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[cfg(feature = "rssblue")]
use uuid::Uuid;
#[derive(Debug, PartialEq, Serialize)]
pub struct Link {
#[serde(serialize_with = "serialization::url_to_string")]
pub url: url::Url,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
}
#[derive(Debug, PartialEq)]
pub enum Image {
Url(url::Url),
}
#[cfg(feature = "rssblue")]
#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]
pub enum RemoteEntity {
#[serde(rename = "feed")]
Feed {
guid: Uuid,
},
#[serde(rename = "item")]
Item {
feed_guid: Uuid,
guid: String,
},
}
#[derive(Debug, PartialEq, Serialize)]
pub struct Chapter {
#[serde(serialize_with = "serialization::duration_to_float")]
pub start: Duration,
#[serde(
serialize_with = "serialization::duration_option_to_float_option",
skip_serializing_if = "Option::is_none"
)]
pub end: Option<Duration>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<Image>,
#[serde(skip_serializing_if = "Option::is_none")]
pub link: Option<Link>,
pub hidden: bool,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg(feature = "rssblue")]
pub remote_entity: Option<RemoteEntity>,
}
impl Default for Chapter {
fn default() -> Self {
Self {
start: Duration::zero(),
end: None,
title: None,
image: None,
link: None,
hidden: false,
#[cfg(feature = "rssblue")]
remote_entity: None,
}
}
}
impl From<PodcastNamespaceChapter> for Chapter {
fn from(podcast_namespace_chapter: PodcastNamespaceChapter) -> Self {
Self {
start: podcast_namespace_chapter.start_time,
end: podcast_namespace_chapter.end_time,
title: podcast_namespace_chapter.title,
image: podcast_namespace_chapter.img.map(Image::Url),
link: podcast_namespace_chapter
.url
.map(|url| Link { url, title: None }),
hidden: !podcast_namespace_chapter.toc.unwrap_or(true),
#[cfg(feature = "rssblue")]
remote_entity: podcast_namespace_chapter.remote_entity,
}
}
}
#[derive(Debug, PartialEq, Deserialize, Serialize)]
pub struct PodcastNamespaceChapters {
version: String,
chapters: Vec<PodcastNamespaceChapter>,
}
impl From<&[Chapter]> for PodcastNamespaceChapters {
fn from(chapters: &[Chapter]) -> Self {
Self {
version: "1.2.0".to_string(),
chapters: chapters.iter().map(|c| c.into()).collect(),
}
}
}
#[derive(Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PodcastNamespaceChapter {
#[serde(
deserialize_with = "serialization::float_to_duration",
serialize_with = "serialization::duration_to_float"
)]
start_time: Duration,
#[serde(
default,
deserialize_with = "serialization::float_to_duration_option",
serialize_with = "serialization::duration_option_to_float_option",
skip_serializing_if = "Option::is_none"
)]
end_time: Option<Duration>,
#[serde(default)]
title: Option<String>,
#[serde(
default,
deserialize_with = "serialization::string_to_url",
serialize_with = "serialization::url_option_to_string",
skip_serializing_if = "Option::is_none"
)]
img: Option<url::Url>,
#[serde(
default,
deserialize_with = "serialization::string_to_url",
serialize_with = "serialization::url_option_to_string",
skip_serializing_if = "Option::is_none"
)]
url: Option<url::Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
toc: Option<bool>,
#[cfg(feature = "rssblue")]
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "rssblue:remoteEntity"
)]
remote_entity: Option<RemoteEntity>,
}
impl<'a> From<&'a Chapter> for PodcastNamespaceChapter {
fn from(chapter: &'a Chapter) -> Self {
Self {
start_time: chapter.start,
end_time: chapter.end,
title: chapter.title.clone(),
img: match &chapter.image {
Some(Image::Url(url)) => Some(url.clone()),
_ => None,
},
url: chapter.link.as_ref().map(|link| link.url.clone()),
toc: if chapter.hidden { Some(false) } else { None },
#[cfg(feature = "rssblue")]
remote_entity: chapter.remote_entity.clone(),
}
}
}
pub fn from_json<R: std::io::Read>(reader: R) -> Result<Vec<Chapter>, String> {
let podcast_namespace_chapters: PodcastNamespaceChapters =
serde_json::from_reader(reader).map_err(|e| e.to_string())?;
Ok(podcast_namespace_chapters
.chapters
.into_iter()
.map(|c| c.into())
.collect())
}
pub fn to_json(chapters: &[Chapter]) -> Result<String, String> {
let podcast_namespace_chapters: PodcastNamespaceChapters = chapters.into();
serde_json::to_string_pretty(&podcast_namespace_chapters).map_err(|e| e.to_string())
}
#[derive(Debug, Clone)]
enum TimestampType {
MmSs,
HhMmSs,
MmSsParentheses,
HhMmSsParentheses,
}
impl TimestampType {
fn regex_pattern(&self) -> &str {
match self {
Self::MmSs => r"^(?P<minutes>[0-5]\d):(?P<seconds>[0-5]\d)",
Self::HhMmSs => r"^(?P<hours>\d{2}):(?P<minutes>[0-5]\d):(?P<seconds>[0-5]\d)",
Self::MmSsParentheses => r"^\((?P<minutes>[0-5]\d):(?P<seconds>[0-5]\d)\)",
Self::HhMmSsParentheses => {
r"^\((?P<hours>\d{2}):(?P<minutes>[0-5]\d):(?P<seconds>[0-5]\d)\)"
}
}
}
fn line_regex_pattern(&self) -> String {
format!("{}[.!?\\- ]+(?P<text>.+)$", self.regex_pattern())
}
fn from_line(line: &str) -> Option<Self> {
if let Some(first_char) = line.chars().next() {
if first_char == '(' || first_char.is_numeric() {
return [
Self::MmSs,
Self::HhMmSs,
Self::MmSsParentheses,
Self::HhMmSsParentheses,
]
.iter()
.find(|&temp_timestamp_type| {
regex::Regex::new(temp_timestamp_type.line_regex_pattern().as_str())
.map(|re| re.captures(line).is_some())
.unwrap_or(false)
})
.cloned();
}
}
None
}
}
pub fn from_description(description: &str) -> Result<Vec<Chapter>, String> {
let mut chapters = Vec::new();
let mut timestamp_type: Option<TimestampType> = None;
let parse_line = |line: &str, timestamp_type: &TimestampType| -> Option<Chapter> {
let re = regex::Regex::new(timestamp_type.line_regex_pattern().as_str())
.map_err(|e| e.to_string())
.ok()?;
if let Some(captures) = re.captures(line) {
let start = parse_timestamp(&captures).ok()?;
let text = captures.name("text").unwrap().as_str();
Some(Chapter {
start,
end: None,
title: Some(text.trim().to_string()),
image: None,
link: None,
hidden: false,
#[cfg(feature = "rssblue")]
remote_entity: None,
})
} else {
None
}
};
for line in description.lines().map(|line| line.trim()) {
if timestamp_type.is_none() {
timestamp_type = TimestampType::from_line(line);
}
if let Some(timestamp_type) = timestamp_type.as_ref() {
if let Some(chapter) = parse_line(line, timestamp_type) {
chapters.push(chapter);
} else {
break;
}
}
}
Ok(chapters)
}
pub fn to_description(chapters: &[Chapter]) -> Result<String, String> {
let mut description = String::new();
let at_least_an_hour = chapters
.iter()
.any(|chapter| chapter.start >= Duration::hours(1));
let timestamp_type = if at_least_an_hour {
TimestampType::HhMmSs
} else {
TimestampType::MmSs
};
for chapter in chapters {
let start = chapter.start;
let title = chapter.title.as_ref().ok_or("Chapter title is missing")?;
let line = format!(
"{} {}",
duration_to_timestamp(start, timestamp_type.clone()),
title
);
description.push_str(&line);
description.push('\n');
}
Ok(description)
}
fn parse_timestamp(captures: ®ex::Captures) -> Result<Duration, String> {
let parse_i64 = |capture: Option<regex::Match>| -> Result<i64, String> {
capture
.map(|m| m.as_str().parse::<i64>().map_err(|e| e.to_string()))
.unwrap_or(Ok(0))
};
let hours = parse_i64(captures.name("hours"))?;
let minutes = parse_i64(captures.name("minutes"))?;
let seconds = parse_i64(captures.name("seconds"))?;
Ok(Duration::hours(hours) + Duration::minutes(minutes) + Duration::seconds(seconds))
}
fn duration_to_timestamp(duration: Duration, timestamp_type: TimestampType) -> String {
let hours = duration.num_hours();
let minutes = duration.num_minutes() - hours * 60;
let seconds = duration.num_seconds() - minutes * 60 - hours * 3600;
match timestamp_type {
TimestampType::MmSs => format!("{minutes:02}:{seconds:02}"),
TimestampType::HhMmSs => format!("{hours:02}:{minutes:02}:{seconds:02}"),
TimestampType::MmSsParentheses => format!("({minutes:02}:{seconds:02})"),
TimestampType::HhMmSsParentheses => format!("({hours:02}:{minutes:02}:{seconds:02})"),
}
}
pub fn from_mp3_file<P: AsRef<Path>>(path: P) -> Result<Vec<Chapter>, String> {
let tag = Tag::read_from_path(&path).map_err(|e| {
format!(
"Error reading ID3 tag from `{}`: {}",
path.as_ref().display(),
e
)
})?;
let mut chapters = Vec::new();
for id3_chapter in tag.chapters() {
let start = Duration::milliseconds(id3_chapter.start_time as i64);
let temp_end = Duration::milliseconds(id3_chapter.end_time as i64);
let end = if temp_end == start {
None
} else {
Some(temp_end)
};
let mut title = None;
let mut link = None;
for subframe in &id3_chapter.frames {
match subframe.content() {
id3::Content::Text(text) => {
title = Some(text.clone());
}
id3::Content::Link(url) => {
link = Some(Link {
url: url::Url::parse(url).map_err(|e| e.to_string())?,
title: None,
});
}
id3::Content::ExtendedLink(extended_link) => {
link = Some(Link {
url: url::Url::parse(&extended_link.link).map_err(|e| e.to_string())?,
title: match extended_link.description.trim() {
"" => None,
description => Some(description.to_string()),
},
});
}
_ => {}
}
}
chapters.push(Chapter {
title,
link,
start,
end,
..Default::default()
});
}
chapters.sort_by(|a, b| a.start.cmp(&b.start));
Ok(chapters)
}
pub fn to_mp3_file<P: AsRef<Path>>(
src_path: P,
dst_path: P,
chapters: &[Chapter],
) -> Result<(), String> {
std::fs::copy(&src_path, &dst_path).map_err(|e| {
format!(
"Error copying `{}` to `{}`: {}",
src_path.as_ref().display(),
dst_path.as_ref().display(),
e
)
})?;
let mut tag = match Tag::read_from_path(&src_path) {
Ok(mut tag) => {
tag.remove_all_chapters();
tag
}
Err(Error {
kind: ErrorKind::NoTag,
..
}) => Tag::new(),
Err(err) => {
return Err(format!(
"Error reading ID3 tag from `{}`: {}",
src_path.as_ref().display(),
err
))
}
};
for (i, chapter) in chapters.iter().enumerate() {
let mut id3_chapter = id3::frame::Chapter {
element_id: format!("chp{}", i + 1),
start_time: chapter.start.num_milliseconds() as u32,
end_time: if let Some(end) = chapter.end {
end.num_milliseconds() as u32
} else {
chapter.start.num_milliseconds() as u32
},
start_offset: 0,
end_offset: 0,
frames: Vec::new(),
};
if let Some(title) = &chapter.title {
let frame = id3::frame::Frame::with_content("TIT2", id3::Content::Text(title.clone()));
id3_chapter.frames.push(frame);
}
if let Some(link) = &chapter.link {
let link_title = link.title.as_ref().map_or("", |t| t.as_str());
let frame = id3::frame::Frame::with_content(
"WXXX",
id3::Content::ExtendedLink(id3::frame::ExtendedLink {
link: link.url.to_string(),
description: link_title.to_string(),
}),
);
id3_chapter.frames.push(frame);
}
tag.add_frame(id3::frame::Frame::with_content(
"CHAP",
id3::Content::Chapter(id3_chapter),
));
}
tag.write_to_path(&dst_path, Version::Id3v24).map_err(|e| {
format!(
"Error writing ID3 tag to `{}`: {}",
dst_path.as_ref().display(),
e
)
})?;
Ok(())
}