1 // utils.rs
2 //
3 // Copyright 2018 Jordan Petridis <jpetridis@gnome.org>
4 //
5 // This program is free software: you can redistribute it and/or modify
6 // it under the terms of the GNU General Public License as published by
7 // the Free Software Foundation, either version 3 of the License, or
8 // (at your option) any later version.
9 //
10 // This program is distributed in the hope that it will be useful,
11 // but WITHOUT ANY WARRANTY; without even the implied warranty of
12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 // GNU General Public License for more details.
14 //
15 // You should have received a copy of the GNU General Public License
16 // along with this program. If not, see <http://www.gnu.org/licenses/>.
17 //
18 // SPDX-License-Identifier: GPL-3.0-or-later
19
20 use gdk_pixbuf::Pixbuf;
21 use gio::prelude::ActionMapExt;
22 use glib::clone;
23 use glib::Sender;
24 use glib::Variant;
25 use glib::{self, object::WeakRef};
26 use glib::{IsA, Object};
27 use gtk::prelude::*;
28 use gtk::Widget;
29
30 use anyhow::{anyhow, Result};
31 use chrono::prelude::*;
32 use crossbeam_channel::{bounded, unbounded};
33 use fragile::Fragile;
34 use regex::Regex;
35 use serde_json::Value;
36 use url::Url;
37
38 use podcasts_data::dbqueries;
39 use podcasts_data::downloader;
40 use podcasts_data::errors::DownloadError;
41 use podcasts_data::opml;
42 use podcasts_data::pipeline::pipeline;
43 use podcasts_data::utils::checkup;
44 use podcasts_data::Source;
45
46 use std::collections::{HashMap, HashSet};
47 use std::sync::{Arc, Mutex, RwLock};
48 use std::time::Duration;
49
50 use crate::app::Action;
51
52 use crate::i18n::i18n;
53
54 /// Copied from the gtk-macros crate
55 ///
56 /// Send an event through a glib::Sender
57 ///
58 /// - Before:
59 ///
60 /// Example:
61 ///
62 /// ```no_run
63 /// sender.send(Action::DoThing).expect("Failed to send DoThing through the glib channel?");
64 /// ```
65 ///
66 /// - After:
67 ///
68 /// Example:
69 ///
70 /// ```no_run
71 /// send!(self.sender, Action::DoThing);
72 /// ```
73 #[macro_export]
74 macro_rules! send {
75 ($sender:expr, $action:expr) => {
76 if let Err(err) = $sender.send($action) {
77 panic!(
78 "Failed to send \"{}\" action due to {}",
79 stringify!($action),
80 err
81 );
82 }
83 };
84 }
85
86 /// Creates an action named `name` in the action map `T with the handler `F`
make_action<T, F>(thing: &T, name: &str, action: F) where T: ActionMapExt, F: Fn(&gio::SimpleAction, Option<&Variant>) + 'static,87 pub fn make_action<T, F>(thing: &T, name: &str, action: F)
88 where
89 T: ActionMapExt,
90 F: Fn(&gio::SimpleAction, Option<&Variant>) + 'static,
91 {
92 // Create a stateless, parameterless action
93 let act = gio::SimpleAction::new(name, None);
94 // Connect the handler
95 act.connect_activate(action);
96 // Add it to the map
97 thing.add_action(&act);
98 }
99
100 /// Lazy evaluates and loads widgets to the parent `container` widget.
101 ///
102 /// Accepts an `IntoIterator`, `data`, as the source from which each widget
103 /// will be constructed. An `FnMut` function that returns the desired
104 /// widget should be passed as the widget `constructor`. You can also specify
105 /// a `callback` that will be executed when the iteration finish.
106 ///
107 /// ```no_run
108 /// # struct Message;
109 /// # struct MessageWidget(gtk::Label);
110 ///
111 /// # impl MessageWidget {
112 /// # fn new(_: Message) -> Self {
113 /// # MessageWidget(gtk::Label::new("A message"))
114 /// # }
115 /// # }
116 ///
117 /// let messages: Vec<Message> = Vec::new();
118 /// let list = gtk::ListBox::new();
119 /// let constructor = |m| MessageWidget::new(m).0;
120 /// lazy_load(messages, list, constructor, || {});
121 /// ```
122 ///
123 /// If you have already constructed the widgets and only want to
124 /// load them to the parent you can pass a closure that returns it's
125 /// own argument to the constructor.
126 ///
127 /// ```no_run
128 /// # use std::collections::binary_heap::BinaryHeap;
129 /// let widgets: BinaryHeap<gtk::Button> = BinaryHeap::new();
130 /// let list = gtk::ListBox::new();
131 /// lazy_load(widgets, list, |w| w, || {});
132 /// ```
lazy_load<T, C, F, W, U>( data: T, container: WeakRef<C>, mut constructor: F, callback: U, ) where T: IntoIterator + 'static, T::Item: 'static, C: IsA<Object> + ContainerExt + 'static, F: FnMut(T::Item) -> W + 'static, W: IsA<Widget> + WidgetExt, U: Fn() + 'static,133 pub(crate) fn lazy_load<T, C, F, W, U>(
134 data: T,
135 container: WeakRef<C>,
136 mut constructor: F,
137 callback: U,
138 ) where
139 T: IntoIterator + 'static,
140 T::Item: 'static,
141 C: IsA<Object> + ContainerExt + 'static,
142 F: FnMut(T::Item) -> W + 'static,
143 W: IsA<Widget> + WidgetExt,
144 U: Fn() + 'static,
145 {
146 let func = move |x| {
147 let container = match container.upgrade() {
148 Some(c) => c,
149 None => return,
150 };
151
152 let widget = constructor(x);
153 container.add(&widget);
154 widget.show();
155 };
156 lazy_load_full(data, func, callback);
157 }
158
159 /// Iterate over `data` and execute `func` using a `gtk::idle_add()`,
160 /// when the iteration finishes, it executes `finish_callback`.
161 ///
162 /// This is a more flexible version of `lazy_load` with less constrains.
163 /// If you just want to lazy add `widgets` to a `container` check if
164 /// `lazy_load` fits your needs first.
165 #[allow(clippy::redundant_closure)]
lazy_load_full<T, F, U>(data: T, mut func: F, finish_callback: U) where T: IntoIterator + 'static, T::Item: 'static, F: FnMut(T::Item) + 'static, U: Fn() + 'static,166 pub(crate) fn lazy_load_full<T, F, U>(data: T, mut func: F, finish_callback: U)
167 where
168 T: IntoIterator + 'static,
169 T::Item: 'static,
170 F: FnMut(T::Item) + 'static,
171 U: Fn() + 'static,
172 {
173 let mut data = data.into_iter();
174 glib::idle_add_local(move || {
175 data.next()
176 .map(|x| func(x))
177 .map(|_| glib::Continue(true))
178 .unwrap_or_else(|| {
179 finish_callback();
180 glib::Continue(false)
181 })
182 });
183 }
184
185 // Kudos to Julian Sparber
186 // https://blogs.gnome.org/jsparber/2018/04/29/animate-a-scrolledwindow/
187 #[allow(clippy::float_cmp)]
smooth_scroll_to(view: >k::ScrolledWindow, target: >k::Adjustment)188 pub(crate) fn smooth_scroll_to(view: >k::ScrolledWindow, target: >k::Adjustment) {
189 let adj = view.vadjustment();
190 if let Some(clock) = view.frame_clock() {
191 let duration = 200;
192 let start = adj.value();
193 let end = target.value();
194 let start_time = clock.frame_time();
195 let end_time = start_time + 1000 * duration;
196
197 view.add_tick_callback(move |_, clock| {
198 let now = clock.frame_time();
199 // FIXME: `adj.get_value != end` is a float comparison...
200 if now < end_time && adj.value().abs() != end.abs() {
201 let mut t = (now - start_time) as f64 / (end_time - start_time) as f64;
202 t = ease_out_cubic(t);
203 adj.set_value(start + t * (end - start));
204 Continue(true)
205 } else {
206 adj.set_value(end);
207 Continue(false)
208 }
209 });
210 }
211 }
212
213 // From clutter-easing.c, based on Robert Penner's
214 // infamous easing equations, MIT license.
ease_out_cubic(t: f64) -> f64215 fn ease_out_cubic(t: f64) -> f64 {
216 let p = t - 1f64;
217 p * p * p + 1f64
218 }
219
220 lazy_static! {
221 static ref IGNORESHOWS: Arc<Mutex<HashSet<i32>>> = Arc::new(Mutex::new(HashSet::new()));
222 }
223
ignore_show(id: i32) -> Result<bool>224 pub(crate) fn ignore_show(id: i32) -> Result<bool> {
225 IGNORESHOWS
226 .lock()
227 .map(|mut guard| guard.insert(id))
228 .map_err(|err| anyhow!("{}", err))
229 }
230
unignore_show(id: i32) -> Result<bool>231 pub(crate) fn unignore_show(id: i32) -> Result<bool> {
232 IGNORESHOWS
233 .lock()
234 .map(|mut guard| guard.remove(&id))
235 .map_err(|err| anyhow!("{}", err))
236 }
237
get_ignored_shows() -> Result<Vec<i32>>238 pub(crate) fn get_ignored_shows() -> Result<Vec<i32>> {
239 IGNORESHOWS
240 .lock()
241 .map(|guard| guard.iter().cloned().collect::<Vec<_>>())
242 .map_err(|err| anyhow!("{}", err))
243 }
244
cleanup(cleanup_date: DateTime<Utc>)245 pub(crate) fn cleanup(cleanup_date: DateTime<Utc>) {
246 checkup(cleanup_date)
247 .map_err(|err| error!("Check up failed: {}", err))
248 .ok();
249 }
250
251 /// Schedule feed refresh
252 /// If `source` is None, Refreshes all sources in the database.
253 /// Current implementation ignores update request if another update is already running
schedule_refresh(source: Option<Vec<Source>>, sender: Sender<Action>)254 pub(crate) fn schedule_refresh(source: Option<Vec<Source>>, sender: Sender<Action>) {
255 // If we try to update the whole db,
256 // Exit early if `source` table is empty
257 if source.is_none() {
258 match dbqueries::is_source_populated(&[]) {
259 Ok(false) => {
260 info!("No source of feeds where found, returning");
261 return;
262 }
263 Err(err) => debug_assert!(false, "{}", err),
264 _ => (),
265 };
266 }
267
268 send!(sender, Action::UpdateFeed(source));
269 }
270
271 /// Update the rss feed(s) originating from `source`.
272 /// If `source` is None, Fetches all the `Source` entries in the database and updates them.
273 /// Do not call this function directly unless you are sure no other updates are running.
274 /// Use `schedule_refresh()` instead
refresh_feed(source: Option<Vec<Source>>, sender: Sender<Action>)275 pub(crate) fn refresh_feed(source: Option<Vec<Source>>, sender: Sender<Action>) {
276 let (up_sender, up_receiver) = bounded(1);
277 send!(sender, Action::ShowUpdateNotif(up_receiver));
278
279 if let Some(s) = source {
280 // Refresh only specified feeds
281 tokio::spawn(async move {
282 pipeline(s).await;
283 up_sender
284 .send(true)
285 .expect("Channel was dropped unexpectedly");
286 })
287 } else {
288 // Refresh all the feeds
289 tokio::spawn(async move {
290 let sources = dbqueries::get_sources().map(|s| s.into_iter()).unwrap();
291 pipeline(sources).await;
292 up_sender
293 .send(true)
294 .expect("Channel was dropped unexpectedly");
295 })
296 };
297 }
298
299 lazy_static! {
300 static ref CACHED_PIXBUFS: RwLock<HashMap<(i32, u32), Mutex<Fragile<Pixbuf>>>> =
301 RwLock::new(HashMap::new());
302 static ref COVER_DL_REGISTRY: RwLock<HashSet<i32>> = RwLock::new(HashSet::new());
303 static ref THREADPOOL: rayon::ThreadPool = rayon::ThreadPoolBuilder::new().build().unwrap();
304 static ref CACHE_VALID_DURATION: chrono::Duration = chrono::Duration::weeks(4);
305 }
306
307 // Since gdk_pixbuf::Pixbuf is reference counted and every episode,
308 // use the cover of the Podcast Feed/Show, We can only create a Pixbuf
309 // cover per show and pass around the Rc pointer.
310 //
311 // GObjects do not implement Send trait, so SendCell is a way around that.
312 // Also lazy_static requires Sync trait, so that's what the mutexes are.
313 // TODO: maybe use something that would just scale to requested size?
314 // todo Unit test.
set_image_from_path(image: >k::Image, show_id: i32, size: u32) -> Result<()>315 pub(crate) fn set_image_from_path(image: >k::Image, show_id: i32, size: u32) -> Result<()> {
316 if let Ok(hashmap) = CACHED_PIXBUFS.read() {
317 if let Ok(pd) = dbqueries::get_podcast_cover_from_id(show_id) {
318 // If the image is still valid, check if the requested (cover + size) is already in the
319 // cache and if so do an early return after that.
320 if pd.is_cached_image_valid(&CACHE_VALID_DURATION) {
321 if let Some(guard) = hashmap.get(&(show_id, size)) {
322 guard
323 .lock()
324 .map_err(|err| anyhow!("Fragile Mutex: {}", err))
325 .and_then(|fragile| {
326 fragile
327 .try_get()
328 .map(|px| image.set_from_pixbuf(Some(px)))
329 .map_err(From::from)
330 })?;
331
332 return Ok(());
333 }
334 }
335 }
336 }
337
338 // Check if there's an active download about this show cover.
339 // If there is, a callback will be set so this function will be called again.
340 // If the download succeeds, there should be a quick return from the pixbuf cache_image
341 // If it fails another download will be scheduled.
342 if let Ok(guard) = COVER_DL_REGISTRY.read() {
343 if guard.contains(&show_id) {
344 let callback = clone!(@weak image => @default-return glib::Continue(false), move || {
345 let _ = set_image_from_path(&image, show_id, size);
346 glib::Continue(false)
347 });
348 glib::timeout_add_local(Duration::from_millis(250), callback);
349 return Ok(());
350 }
351 }
352
353 let (sender, receiver) = unbounded();
354 if let Ok(mut guard) = COVER_DL_REGISTRY.write() {
355 // Add the id to the hashmap from the main thread to avoid queuing more than one downloads.
356 guard.insert(show_id);
357 drop(guard);
358
359 THREADPOOL.spawn(move || {
360 // This operation is polling and will block the thread till the download is finished
361 if let Ok(pd) = dbqueries::get_podcast_cover_from_id(show_id) {
362 sender
363 .send(downloader::cache_image(&pd, true))
364 .expect("channel was dropped unexpectedly");
365 }
366
367 if let Ok(mut guard) = COVER_DL_REGISTRY.write() {
368 guard.remove(&show_id);
369 }
370 });
371 }
372
373 let image = image.clone();
374 let s = size as i32;
375 glib::timeout_add_local(Duration::from_millis(25), move || {
376 use crossbeam_channel::TryRecvError;
377
378 match receiver.try_recv() {
379 Err(TryRecvError::Empty) => glib::Continue(true),
380 Err(TryRecvError::Disconnected) => glib::Continue(false),
381 Ok(path) => {
382 match path {
383 Ok(path) => {
384 if let Ok(px) = Pixbuf::from_file_at_scale(&path, s, s, true) {
385 if let Ok(mut hashmap) = CACHED_PIXBUFS.write() {
386 hashmap
387 .insert((show_id, size), Mutex::new(Fragile::new(px.clone())));
388 image.set_from_pixbuf(Some(&px));
389 }
390 }
391 }
392 Err(DownloadError::NoImageLocation) => {
393 image.set_from_icon_name(
394 Some("image-x-generic-symbolic"),
395 gtk::IconSize::__Unknown(s),
396 );
397 }
398 _ => {}
399 }
400 if let Ok(pd) = dbqueries::get_podcast_from_id(show_id) {
401 if let Err(err) = pd.update_image_cache_values() {
402 error!(
403 "Failed to update the image's cache values for podcast {}: {}",
404 pd.title(),
405 err
406 )
407 }
408 }
409 glib::Continue(false)
410 }
411 }
412 });
413 Ok(())
414 }
415
416 // FIXME: the signature should be `fn foo(s: Url) -> Result<Url>`
itunes_to_rss(url: &str) -> Result<String>417 pub(crate) async fn itunes_to_rss(url: &str) -> Result<String> {
418 let id = itunes_id_from_url(url).ok_or_else(|| anyhow!("Failed to find an iTunes ID."))?;
419 itunes_lookup_id(id).await
420 }
421
itunes_id_from_url(url: &str) -> Option<u32>422 fn itunes_id_from_url(url: &str) -> Option<u32> {
423 lazy_static! {
424 static ref RE: Regex = Regex::new(r"/id([0-9]+)").unwrap();
425 }
426
427 // Get the itunes id from the url
428 let itunes_id = RE.captures_iter(url).next()?.get(1)?.as_str();
429 // Parse it to a u32, this *should* never fail
430 itunes_id.parse::<u32>().ok()
431 }
432
itunes_lookup_id(id: u32) -> Result<String>433 async fn itunes_lookup_id(id: u32) -> Result<String> {
434 let url = format!("https://itunes.apple.com/lookup?id={}&entity=podcast", id);
435 let req: Value = reqwest::get(&url).await?.json().await?;
436 let rssurl = || -> Option<&str> { req.get("results")?.get(0)?.get("feedUrl")?.as_str() };
437 rssurl()
438 .map(From::from)
439 .ok_or_else(|| anyhow!("Failed to get url from itunes response"))
440 }
441
442 /// Convert soundcloud page links to rss feed links.
443 /// Works for users and playlists.
soundcloud_to_rss(url: &Url) -> Result<Url>444 pub(crate) async fn soundcloud_to_rss(url: &Url) -> Result<Url> {
445 // Turn: https://soundcloud.com/chapo-trap-house
446 // into: https://feeds.soundcloud.com/users/soundcloud:users:211911700/sounds.rss
447 let (user_id, playlist_id) = soundcloud_lookup_id(url)
448 .await
449 .ok_or_else(|| anyhow!("Failed to find a soundcloud ID."))?;
450 if playlist_id != 0 {
451 let url = format!(
452 "https://feeds.soundcloud.com/playlists/soundcloud:playlists:{}/sounds.rss",
453 playlist_id
454 );
455 Ok(Url::parse(&url)?)
456 } else if user_id != 0 {
457 let url = format!(
458 "https://feeds.soundcloud.com/users/soundcloud:users:{}/sounds.rss",
459 user_id
460 );
461 Ok(Url::parse(&url)?)
462 } else {
463 Err(anyhow!("No valid id's in soundcloud page."))
464 }
465 }
466
467 /// Extract (user, playlist) id's from a soundcloud page.
468 /// The id's are 0 if none was found.
469 /// If fetching the html page fails an Error is returned.
soundcloud_lookup_id(url: &Url) -> Option<(u64, u64)>470 async fn soundcloud_lookup_id(url: &Url) -> Option<(u64, u64)> {
471 lazy_static! {
472 static ref RE_U: Regex = Regex::new(r"soundcloud://users:([0-9]+)").unwrap();
473 }
474 lazy_static! {
475 static ref RE_P: Regex = Regex::new(r"soundcloud://playlists:([0-9]+)").unwrap();
476 }
477 let url_str = url.to_string();
478 let response_text = reqwest::get(&url_str).await.ok()?.text().await.ok()?;
479 let user_id = RE_U
480 .captures_iter(&response_text)
481 .next()
482 .and_then(|r| r.get(1).map(|u| u.as_str()));
483 let playlist_id = RE_P
484 .captures_iter(&response_text)
485 .next()
486 .and_then(|r| r.get(1).map(|u| u.as_str()));
487 // Parse it to a u64, this *should* never fail
488 Some((
489 user_id.and_then(|id| id.parse::<u64>().ok()).unwrap_or(0),
490 playlist_id
491 .and_then(|id| id.parse::<u64>().ok())
492 .unwrap_or(0),
493 ))
494 }
495
on_import_clicked(window: >k::ApplicationWindow, sender: &Sender<Action>)496 pub(crate) fn on_import_clicked(window: >k::ApplicationWindow, sender: &Sender<Action>) {
497 use gtk::{FileChooserAction, FileChooserNative, FileFilter, ResponseType};
498
499 // Create the FileChooser Dialog
500 let dialog = FileChooserNative::new(
501 Some(i18n("Select the file from which to you want to import shows.").as_str()),
502 Some(window),
503 FileChooserAction::Open,
504 Some(i18n("_Import").as_str()),
505 None,
506 );
507
508 // Do not show hidden(.thing) files
509 dialog.set_show_hidden(false);
510
511 // Set a filter to show only xml files
512 let filter = FileFilter::new();
513 FileFilter::set_name(&filter, Some(i18n("OPML file").as_str()));
514 filter.add_mime_type("application/xml");
515 filter.add_mime_type("text/xml");
516 filter.add_mime_type("text/x-opml");
517 dialog.add_filter(&filter);
518
519 let resp = dialog.run();
520 debug!("Dialog Response {}", resp);
521 if resp == ResponseType::Accept {
522 if let Some(filename) = dialog.filename() {
523 debug!("File selected: {:?}", filename);
524
525 rayon::spawn(clone!(@strong sender => move || {
526 // Parse the file and import the feeds
527 if let Ok(sources) = opml::import_from_file(filename) {
528 // Refresh the successfully parsed feeds to index them
529 schedule_refresh(Some(sources), sender)
530 } else {
531 let text = i18n("Failed to parse the imported file");
532 send!(sender, Action::ErrorNotification(text));
533 }
534 }))
535 } else {
536 let text = i18n("Selected file could not be accessed.");
537 send!(sender, Action::ErrorNotification(text))
538 }
539 }
540 }
541
on_export_clicked(window: >k::ApplicationWindow, sender: &Sender<Action>)542 pub(crate) fn on_export_clicked(window: >k::ApplicationWindow, sender: &Sender<Action>) {
543 use gtk::{FileChooserAction, FileChooserNative, FileFilter, ResponseType};
544
545 // Create the FileChooser Dialog
546 let dialog = FileChooserNative::new(
547 Some(i18n("Export shows to…").as_str()),
548 Some(window),
549 FileChooserAction::Save,
550 Some(i18n("_Export").as_str()),
551 Some(i18n("_Cancel").as_str()),
552 );
553
554 // Translators: This is the string of the suggested name for the exported opml file
555 dialog.set_current_name(&format!("{}.opml", i18n("gnome-podcasts-exported-shows")));
556
557 // Do not show hidden(.thing) files
558 dialog.set_show_hidden(false);
559
560 // Set a filter to show only xml files
561 let filter = FileFilter::new();
562 FileFilter::set_name(&filter, Some(i18n("OPML file").as_str()));
563 filter.add_mime_type("application/xml");
564 filter.add_mime_type("text/xml");
565 filter.add_mime_type("text/x-opml");
566 dialog.add_filter(&filter);
567
568 let resp = dialog.run();
569 debug!("Dialog Response {}", resp);
570 if resp == ResponseType::Accept {
571 if let Some(filename) = dialog.filename() {
572 debug!("File selected: {:?}", filename);
573
574 rayon::spawn(clone!(@strong sender => move || {
575 if opml::export_from_db(filename, i18n("GNOME Podcasts Subscriptions").as_str()).is_err() {
576 let text = i18n("Failed to export podcasts");
577 send!(sender, Action::ErrorNotification(text));
578 }
579 }))
580 } else {
581 let text = i18n("Selected file could not be accessed.");
582 send!(sender, Action::ErrorNotification(text));
583 }
584 }
585 }
586
587 #[cfg(test)]
588 mod tests {
589 use super::*;
590 use anyhow::Result;
591 use podcasts_data::database::truncate_db;
592 use podcasts_data::dbqueries;
593 use podcasts_data::pipeline::pipeline;
594 use podcasts_data::utils::get_download_folder;
595 use podcasts_data::{Save, Source};
596 use std::fs;
597 use std::path::PathBuf;
598 // use podcasts_data::Source;
599 // use podcasts_data::dbqueries;
600
601 // #[test]
602 // This test inserts an rss feed to your `XDG_DATA/podcasts/podcasts.db` so we make it explicit
603 // to run it.
604 // #[ignore]
605 // Disabled till https://gitlab.gnome.org/World/podcasts/issues/56
606 // fn test_set_image_from_path() {
607 // let url = "https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff";
608 // Create and index a source
609 // let source = Source::from_url(url).unwrap();
610 // Copy it's id
611 // let sid = source.id();
612 // pipeline::run(vec![source], true).unwrap();
613
614 // Get the Podcast
615 // let img = gtk::Image::new();
616 // let pd = dbqueries::get_podcast_from_source_id(sid).unwrap().into();
617 // let pxbuf = set_image_from_path(&img, Arc::new(pd), 256);
618 // assert!(pxbuf.is_ok());
619 // }
620
621 #[tokio::test]
test_itunes_to_rss() -> Result<()>622 async fn test_itunes_to_rss() -> Result<()> {
623 let itunes_url = "https://itunes.apple.com/podcast/id1195206601";
624 let rss_url = String::from(
625 "https://feeds.acast.com/public/shows/f5b64019-68c3-57d4-b70b-043e63e5cbf6",
626 );
627 assert_eq!(rss_url, itunes_to_rss(itunes_url).await?);
628
629 let itunes_url = "https://itunes.apple.com/podcast/id000000000000000";
630 assert!(itunes_to_rss(itunes_url).await.is_err());
631 Ok(())
632 }
633
634 #[test]
test_itunes_id() -> Result<()>635 fn test_itunes_id() -> Result<()> {
636 let id = 1195206601;
637 let itunes_url = "https://itunes.apple.com/podcast/id1195206601";
638 assert_eq!(id, itunes_id_from_url(itunes_url).unwrap());
639 Ok(())
640 }
641
642 #[tokio::test]
test_itunes_lookup_id() -> Result<()>643 async fn test_itunes_lookup_id() -> Result<()> {
644 let id = 1195206601;
645 let rss_url = "https://feeds.acast.com/public/shows/f5b64019-68c3-57d4-b70b-043e63e5cbf6";
646 assert_eq!(rss_url, itunes_lookup_id(id).await?);
647
648 let id = 000000000;
649 assert!(itunes_lookup_id(id).await.is_err());
650 Ok(())
651 }
652
653 #[tokio::test]
test_soundcloud_to_rss() -> Result<()>654 async fn test_soundcloud_to_rss() -> Result<()> {
655 let soundcloud_url = Url::parse("https://soundcloud.com/chapo-trap-house")?;
656 let rss_url = String::from(
657 "https://feeds.soundcloud.com/users/soundcloud:users:211911700/sounds.rss",
658 );
659 assert_eq!(
660 Url::parse(&rss_url)?,
661 soundcloud_to_rss(&soundcloud_url).await?
662 );
663
664 let soundcloud_url =
665 Url::parse("https://soundcloud.com/id000000000000000ajlsfhlsfhwoerzuweioh")?;
666 assert!(soundcloud_to_rss(&soundcloud_url).await.is_err());
667 Ok(())
668 }
669
670 #[tokio::test]
test_soundcloud_playlist_to_rss() -> Result<()>671 async fn test_soundcloud_playlist_to_rss() -> Result<()> {
672 // valid playlist
673 let soundcloud_url =
674 Url::parse("https://soundcloud.com/languagetransfer/sets/introduction-to-italian")?;
675 let rss_url = String::from(
676 "https://feeds.soundcloud.com/playlists/soundcloud:playlists:220248349/sounds.rss",
677 );
678 assert_eq!(
679 Url::parse(&rss_url)?,
680 soundcloud_to_rss(&soundcloud_url).await?
681 );
682
683 // invalid playlist link
684 let soundcloud_url =
685 Url::parse("https://soundcloud.com/languagetransfer/sets/does-not-exist")?;
686 assert!(soundcloud_to_rss(&soundcloud_url).await.is_err());
687
688 // user page with a playlist pinned at the top, should return user rss not playlist
689 let soundcloud_url = Url::parse("https://soundcloud.com/yung-chomsky")?;
690 let rss_url = String::from(
691 "https://feeds.soundcloud.com/users/soundcloud:users:418603470/sounds.rss",
692 );
693 assert_eq!(
694 Url::parse(&rss_url)?,
695 soundcloud_to_rss(&soundcloud_url).await?
696 );
697
698 // playlist without rss entries
699 let soundcloud_url =
700 Url::parse("https://soundcloud.com/yung-chomsky/sets/music-for-podcasts-volume-1")?;
701 let rss_url = String::from(
702 "https://feeds.soundcloud.com/playlists/soundcloud:playlists:1165448311/sounds.rss",
703 );
704 assert_eq!(
705 Url::parse(&rss_url)?,
706 soundcloud_to_rss(&soundcloud_url).await?
707 );
708 Ok(())
709 }
710
711 #[test]
712 #[ignore]
should_refresh_cached_image_when_the_image_uri_changes() -> Result<()>713 fn should_refresh_cached_image_when_the_image_uri_changes() -> Result<()> {
714 truncate_db()?;
715 let mut original_feed = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
716 original_feed.push("resources/test/feeds/2018-01-20-LinuxUnplugged.xml");
717 let original_url = format!(
718 "{}{}",
719 "file:/",
720 fs::canonicalize(original_feed)?.to_str().unwrap()
721 );
722 println!("Made it here! (1)");
723 let mut source = Source::from_url(&original_url)?;
724 println!("Made it here! (2)");
725 source.set_http_etag(None);
726 source.set_last_modified(None);
727 let sid = source.save()?.id();
728 println!("Made it here! (3)");
729 let rt = tokio::runtime::Runtime::new()?;
730 rt.block_on(pipeline(vec![source]));
731 println!("Made it here! (4)");
732 println!("The source id is {}!", sid);
733 dbqueries::get_sources().unwrap().iter().for_each(|s| {
734 println!("{}:{}", s.id(), s.uri());
735 });
736
737 let original = dbqueries::get_podcast_from_source_id(sid)?;
738 println!("Made it here! (5)");
739 let original_image_uri = original.image_uri();
740 let original_image_uri_hash = original.image_uri_hash();
741 let original_image_cached = original.image_cached();
742 let download_folder = get_download_folder(&original.title())?;
743 let image_path = download_folder + "/cover.jpeg";
744 let original_image_file_size = fs::metadata(&image_path)?.len(); // 693,343
745 println!("Made it here! (6)");
746
747 // Update the URI and refresh the feed
748 let mut new_feed = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
749 new_feed.push("resources/test/feeds/2020-12-19-LinuxUnplugged.xml");
750 let mut source = dbqueries::get_source_from_id(sid)?;
751 let new_url = format!(
752 "{}{}",
753 "file:/",
754 fs::canonicalize(new_feed)?.to_str().unwrap()
755 );
756 source.set_uri(new_url);
757 source.set_http_etag(None);
758 source.set_last_modified(None);
759 source.save()?;
760 println!("Made it here! (7)");
761 let rt = tokio::runtime::Runtime::new()?;
762 rt.block_on(pipeline(vec![source]));
763
764 println!("Made it here! (8)");
765 let new = dbqueries::get_podcast_from_source_id(sid)?;
766 let new_image_uri = new.image_uri();
767 let new_image_uri_hash = new.image_uri_hash();
768 let new_image_cached = new.image_cached();
769 let new_image_file_size = fs::metadata(&image_path)?.len();
770
771 println!("Made it here! (9)");
772 assert_eq!(original.title(), new.title());
773 assert_ne!(original_image_uri, new_image_uri);
774 assert_ne!(original_image_uri_hash, new_image_uri_hash);
775 assert_ne!(original_image_cached, new_image_cached);
776 assert_ne!(original_image_file_size, new_image_file_size);
777
778 fs::remove_file(image_path)?;
779 Ok(())
780 }
781 }
782