1pub mod db;
4#[allow(clippy::module_name_repetitions)]
5pub mod episode;
6#[allow(clippy::module_inception)]
8mod podcast;
9
10use crate::config::v2::server::PodcastSettings;
11use crate::taskpool::TaskPool;
12use crate::types::{Msg, PCMsg};
13use db::Database;
14use episode::{Episode, EpisodeNoId};
15#[allow(clippy::module_name_repetitions)]
16pub use podcast::{Podcast, PodcastNoId};
17
18use anyhow::{bail, Context, Result};
19use bytes::Buf;
20use chrono::{DateTime, Utc};
21use lazy_static::lazy_static;
22use opml::{Body, Head, Outline, OPML};
23use regex::Regex;
24use reqwest::ClientBuilder;
25use rfc822_sanitizer::parse_from_rfc2822_with_fallback;
26use rss::{Channel, Item};
27use sanitize_filename::{sanitize_with_options, Options};
28use std::fs::File;
29use std::io::Write as _;
30use std::path::{Path, PathBuf};
31use std::sync::mpsc::{self, Sender};
32use std::time::Duration;
33
34pub const PODCAST_UNPLAYED_TOTALS_LENGTH: usize = 25;
37
38pub const EPISODE_DURATION_LENGTH: usize = 45;
41
42pub const EPISODE_PUBDATE_LENGTH: usize = 60;
45
46lazy_static! {
47 static ref RE_DURATION: Regex = Regex::new(r"(\d+)(?::(\d+))?(?::(\d+))?").expect("Regex error");
50
51 static ref RE_ARTICLES: Regex = Regex::new(r"^(a|an|the) ").expect("Regex error");
54}
55
56pub trait Menuable {
59 fn get_id(&self) -> i64;
60 fn get_title(&self, length: usize) -> String;
61 fn is_played(&self) -> bool;
62}
63
64#[derive(Debug, Clone, Eq, PartialEq)]
65#[allow(clippy::module_name_repetitions)]
66pub struct PodcastFeed {
67 pub id: Option<i64>,
68 pub url: String,
69 pub title: Option<String>,
70}
71
72impl PodcastFeed {
73 #[must_use]
74 pub const fn new(id: Option<i64>, url: String, title: Option<String>) -> Self {
75 Self { id, url, title }
76 }
77}
78
79pub fn check_feed(feed: PodcastFeed, max_retries: usize, tp: &TaskPool, tx_to_main: Sender<Msg>) {
83 tp.execute(async move {
84 let _ = tx_to_main.send(Msg::Podcast(PCMsg::FetchPodcastStart(feed.url.clone())));
85 match get_feed_data(&feed.url, max_retries).await {
86 Ok(pod) => match feed.id {
87 Some(id) => {
88 let _ = tx_to_main.send(Msg::Podcast(PCMsg::SyncData((id, pod))));
89 }
90 None => {
91 let _ = tx_to_main.send(Msg::Podcast(PCMsg::NewData(pod)));
92 }
93 },
94 Err(err) => {
95 error!("get_feed_data had a Error: {:#?}", err);
96 let _ = tx_to_main.send(Msg::Podcast(PCMsg::Error(feed)));
97 }
98 }
99 });
100}
101
102async fn get_feed_data(url: &str, mut max_retries: usize) -> Result<PodcastNoId> {
105 let agent = ClientBuilder::new()
106 .connect_timeout(Duration::from_secs(5))
107 .build()?;
108
109 let resp: reqwest::Response = loop {
110 let response = agent.get(url).send().await;
111 if let Ok(resp) = response {
112 break resp;
113 }
114 max_retries -= 1;
115 if max_retries == 0 {
116 bail!("No response from feed");
117 }
118 };
119
120 let channel = Channel::read_from(resp.bytes().await?.reader())?;
121 Ok(parse_feed_data(channel, url))
122}
123
124fn parse_feed_data(channel: Channel, url: &str) -> PodcastNoId {
130 let title = channel.title().to_string();
131 let url = url.to_string();
132 let description = Some(channel.description().to_string());
133 let last_checked = Utc::now();
134
135 let mut author = None;
136 let mut explicit = None;
137 let mut image_url = None;
138 if let Some(itunes) = channel.itunes_ext() {
139 author = itunes.author().map(std::string::ToString::to_string);
140 explicit = itunes.explicit().and_then(|s| {
141 let ss = s.to_lowercase();
142 match &ss[..] {
143 "yes" | "explicit" | "true" => Some(true),
144 "no" | "clean" | "false" => Some(false),
145 _ => None,
146 }
147 });
148 image_url = itunes.image().map(std::string::ToString::to_string);
149 }
150
151 let mut episodes = Vec::new();
152 let items = channel.into_items();
153 if !items.is_empty() {
154 for item in &items {
155 episodes.push(parse_episode_data(item));
156 }
157 }
158
159 PodcastNoId {
160 title,
161 url,
162 description,
163 author,
164 explicit,
165 last_checked,
166 episodes,
167 image_url,
168 }
169}
170
171fn parse_episode_data(item: &Item) -> EpisodeNoId {
177 let title = item.title().unwrap_or("").to_string();
178 let url = match item.enclosure() {
179 Some(enc) => enc.url().to_string(),
180 None => String::new(),
181 };
182 let guid = match item.guid() {
183 Some(guid) => guid.value().to_string(),
184 None => String::new(),
185 };
186 let description = item.description().unwrap_or("").to_string();
187 let pubdate = item
188 .pub_date()
189 .and_then(|pd| parse_from_rfc2822_with_fallback(pd).ok())
190 .map(std::convert::Into::into);
191
192 let mut duration = None;
193 let mut image_url = None;
194 if let Some(itunes) = item.itunes_ext() {
195 duration = duration_to_int(itunes.duration()).map(i64::from);
196 image_url = itunes.image().map(std::string::ToString::to_string);
197 }
198
199 EpisodeNoId {
200 title,
201 url,
202 guid,
203 description,
204 pubdate,
205 duration,
206 image_url,
207 }
208}
209
210fn duration_to_int(duration: Option<&str>) -> Option<i32> {
215 let duration = duration?;
216 let captures = RE_DURATION.captures(duration)?;
217
218 let mut times = [None; 3];
232 let mut counter = 0;
233 for c in captures.iter().skip(1).flatten() {
235 let intval = c.as_str().parse().ok()?;
236 times[counter] = Some(intval);
237 counter += 1;
238 }
239
240 match counter {
241 3 => Some(times[0].unwrap() * 60 * 60 + times[1].unwrap() * 60 + times[2].unwrap()),
243 2 => Some(times[0].unwrap() * 60 + times[1].unwrap()),
245 1 => times[0],
247 _ => None,
248 }
249}
250
251pub fn import_from_opml(db_path: &Path, config: &PodcastSettings, file: &Path) -> Result<()> {
254 let xml = std::fs::read_to_string(file)
255 .with_context(|| format!("Could not open OPML file: {}", file.display()))?;
256
257 let mut podcast_list = import_opml_feeds(&xml).with_context(|| {
258 "Could not properly parse OPML file -- file may be formatted improperly or corrupted."
259 })?;
260
261 if podcast_list.is_empty() {
262 println!("No podcasts to import.");
263 return Ok(());
264 }
265
266 let db_inst = db::Database::new(db_path)?;
267
268 let old_podcasts = db_inst.get_podcasts()?;
275
276 podcast_list.retain(|pod| {
278 for op in &old_podcasts {
279 if pod.url == op.url {
280 return false;
281 }
282 }
283 true
284 });
285 if podcast_list.is_empty() {
290 println!("No podcasts to import.");
291 return Ok(());
292 }
293
294 println!("Importing {} podcasts...", podcast_list.len());
295
296 let taskpool = TaskPool::new(usize::from(config.concurrent_downloads_max.get()));
297 let (tx_to_main, rx_to_main) = mpsc::channel();
298
299 for pod in &podcast_list {
300 check_feed(
301 pod.clone(),
302 usize::from(config.max_download_retries),
303 &taskpool,
304 tx_to_main.clone(),
305 );
306 }
307
308 let mut msg_counter: usize = 0;
309 let mut failure = false;
310 while let Some(message) = rx_to_main.iter().next() {
311 match message {
312 Msg::Podcast(PCMsg::NewData(pod)) => {
313 msg_counter += 1;
314 let title = &pod.title;
315 let db_result = db_inst.insert_podcast(&pod);
316 match db_result {
317 Ok(_) => {
318 println!("Added {title}");
319 }
320 Err(err) => {
321 failure = true;
322 error!("Error adding {title}, err: {err}");
323 }
324 }
325 }
326
327 Msg::Podcast(PCMsg::Error(feed)) => {
328 msg_counter += 1;
329 failure = true;
330 error!("Error retrieving RSS feed: {}", feed.url);
331 }
332
333 Msg::Podcast(PCMsg::SyncData((_id, _pod))) => {
334 msg_counter += 1;
335 }
336 _ => {}
337 }
338
339 if msg_counter >= podcast_list.len() {
340 break;
341 }
342 }
343
344 if failure {
345 bail!("Process finished with errors.");
346 }
347 println!("Import successful.");
348
349 Ok(())
350}
351
352pub fn export_to_opml(db_path: &Path, file: &Path) -> Result<()> {
355 let db_inst = Database::new(db_path)?;
356 let podcast_list = db_inst.get_podcasts()?;
357 let opml = export_opml_feeds(&podcast_list);
358
359 let xml = opml.to_string().context("Could not create OPML format")?;
360
361 let mut dst = File::create(file)
362 .with_context(|| format!("Could not create output file: {}", file.display()))?;
363 dst.write_all(xml.as_bytes()).with_context(|| {
364 format!(
365 "Could not copy OPML data to output file: {}",
366 file.display()
367 )
368 })?;
369 Ok(())
370}
371
372fn import_opml_feeds(xml: &str) -> Result<Vec<PodcastFeed>> {
375 let opml = OPML::from_str(xml)?;
376 let mut feeds = Vec::new();
377 for pod in opml.body.outlines {
378 if pod.xml_url.is_some() {
379 let title = pod.title.filter(|t| !t.is_empty()).or({
383 if pod.text.is_empty() {
384 None
385 } else {
386 Some(pod.text)
387 }
388 });
389 feeds.push(PodcastFeed::new(None, pod.xml_url.unwrap(), title));
390 }
391 }
392 Ok(feeds)
393}
394
395fn export_opml_feeds(podcasts: &[Podcast]) -> OPML {
397 let date = Utc::now();
398 let mut opml = OPML {
399 head: Some(Head {
400 title: Some("Termusic Podcast Feeds".to_string()),
401 date_created: Some(date.to_rfc2822()),
402 ..Head::default()
403 }),
404 ..Default::default()
405 };
406
407 let mut outlines = Vec::new();
408
409 for pod in podcasts {
410 outlines.push(Outline {
412 text: pod.title.clone(),
413 r#type: Some("rss".to_string()),
414 xml_url: Some(pod.url.clone()),
415 title: Some(pod.title.clone()),
416 ..Outline::default()
417 });
418 }
419
420 opml.body = Body { outlines };
421 opml
422}
423
424#[derive(Debug, Clone, Eq, PartialEq)]
426pub struct EpData {
427 pub id: i64,
428 pub pod_id: i64,
429 pub title: String,
430 pub url: String,
431 pub pubdate: Option<DateTime<Utc>>,
432 pub file_path: Option<PathBuf>,
433}
434
435pub fn download_list(
442 episodes: Vec<EpData>,
443 dest: &Path,
444 max_retries: usize,
445 tp: &TaskPool,
446 tx_to_main: &Sender<Msg>,
447) {
448 for ep in episodes {
450 let tx = tx_to_main.clone();
451 let dest2 = dest.to_path_buf();
452 tp.execute(async move {
453 let _ = tx.send(Msg::Podcast(PCMsg::DLStart(ep.clone())));
454 let result = download_file(ep, dest2, max_retries).await;
455 let _ = tx.send(Msg::Podcast(result));
456 });
457 }
458}
459
460async fn download_file(
463 mut ep_data: EpData,
464 destination_path: PathBuf,
465 mut max_retries: usize,
466) -> PCMsg {
467 let agent = ClientBuilder::new()
468 .connect_timeout(Duration::from_secs(10))
469 .build()
470 .expect("reqwest client build failed");
471
472 let response: reqwest::Response = loop {
473 let response = agent.get(&ep_data.url).send().await;
474 if let Ok(resp) = response {
475 break resp;
476 }
477 max_retries -= 1;
478 if max_retries == 0 {
479 return PCMsg::DLResponseError(ep_data);
480 }
481 };
482
483 let ext = if let Some(content_type) = response
485 .headers()
486 .get("content-type")
487 .and_then(|v| v.to_str().ok())
488 {
489 match content_type {
490 "audio/x-m4a" | "audio/mp4" => "m4a",
491 "audio/x-matroska" => "mka",
492 "audio/flac" => "flac",
493 "video/quicktime" => "mov",
494 "video/mp4" => "mp4",
495 "video/x-m4v" => "m4v",
496 "video/x-matroska" => "mkv",
497 "video/webm" => "webm",
498 _ => "mp3",
501 }
502 } else {
503 error!("The response doesn't contain a content type, using \"mp3\" as fallback!");
504 "mp3"
505 };
506
507 let mut file_name = sanitize_with_options(
508 &ep_data.title,
509 Options {
510 truncate: true,
511 windows: true, replacement: "",
513 },
514 );
515
516 if let Some(pubdate) = ep_data.pubdate {
517 file_name = format!("{file_name}_{}", pubdate.format("%Y%m%d_%H%M%S"));
518 }
519
520 let mut file_path = destination_path;
521 file_path.push(format!("{file_name}.{ext}"));
522
523 let Ok(mut dst) = File::create(&file_path) else {
524 return PCMsg::DLFileCreateError(ep_data);
525 };
526
527 ep_data.file_path = Some(file_path);
528
529 let Ok(bytes) = response.bytes().await else {
530 return PCMsg::DLFileCreateError(ep_data);
531 };
532
533 match std::io::copy(&mut bytes.reader(), &mut dst) {
534 Ok(_) => PCMsg::DLComplete(ep_data),
535 Err(_) => PCMsg::DLFileWriteError(ep_data),
536 }
537}