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: &gtk::ScrolledWindow, target: &gtk::Adjustment)188 pub(crate) fn smooth_scroll_to(view: &gtk::ScrolledWindow, target: &gtk::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: &gtk::Image, show_id: i32, size: u32) -> Result<()>315 pub(crate) fn set_image_from_path(image: &gtk::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: &gtk::ApplicationWindow, sender: &Sender<Action>)496 pub(crate) fn on_import_clicked(window: &gtk::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: &gtk::ApplicationWindow, sender: &Sender<Action>)542 pub(crate) fn on_export_clicked(window: &gtk::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