Skip to main content

news_flash/feed_api_implementations/fever/
mod.rs

1pub mod config;
2pub mod metadata;
3
4use self::config::AccountConfig;
5use self::metadata::FeverMetadata;
6use crate::feed_api::{FeedApi, FeedApiError, FeedApiResult, FeedHeaderMap, Portal};
7use crate::models::{
8    self, ArticleID, Category, CategoryID, CategoryMapping, DirectLogin, FatArticle, FavIcon, Feed, FeedID, FeedMapping, FeedUpdateResult, Headline,
9    LoginData, Marked, NEWSFLASH_TOPLEVEL, PasswordLogin, PluginCapabilities, Read, SyncResult, TagID, Url,
10};
11use crate::util;
12use crate::util::favicons::EXPIRES_AFTER_DAYS;
13use async_trait::async_trait;
14
15use base64::Engine;
16use base64::engine::general_purpose::STANDARD as base64_std;
17use chrono::{Duration, Utc};
18use fever_api::FeverApi;
19use fever_api::error::ApiError as FeverApiError;
20use fever_api::models::{Feed as FeverFeed, FeedsGroups, Group as FeverCategory, Item as FeverEntry, ItemStatus};
21use futures::future;
22use reqwest::Client;
23use reqwest::header::{HeaderMap, HeaderValue};
24use std::collections::HashMap;
25use std::collections::HashSet;
26use std::convert::TryInto;
27use std::sync::Arc;
28
29impl From<FeverApiError> for FeedApiError {
30    fn from(error: FeverApiError) -> FeedApiError {
31        match error {
32            FeverApiError::Url(e) => FeedApiError::Url(e),
33            FeverApiError::Json { source, json } => FeedApiError::Json { source, json },
34            FeverApiError::Http(e) => FeedApiError::Network(e),
35            FeverApiError::Fever(fever_error) => FeedApiError::Api {
36                message: format!("Fever Error (code {})\nMessage: {}", fever_error.error_code, fever_error.error_message),
37            },
38            FeverApiError::Input => FeedApiError::Api {
39                message: FeverApiError::Input.to_string(),
40            },
41            FeverApiError::Token => FeedApiError::Api {
42                message: FeverApiError::Token.to_string(),
43            },
44            FeverApiError::Parse => FeedApiError::Api {
45                message: FeverApiError::Parse.to_string(),
46            },
47            FeverApiError::Unauthorized => FeedApiError::Auth,
48            FeverApiError::Unknown => FeedApiError::Unknown,
49        }
50    }
51}
52
53pub struct Fever {
54    api: Option<Arc<FeverApi>>,
55    portal: Arc<Box<dyn Portal>>,
56    logged_in: bool,
57    config: AccountConfig,
58}
59
60impl Fever {
61    fn convert_category(category: FeverCategory, sort_index: Option<i32>) -> (Category, CategoryMapping) {
62        let FeverCategory { id, title } = category;
63        let category_id = CategoryID::new(&id.to_string());
64
65        let category = Category {
66            category_id: category_id.clone(),
67            label: title,
68        };
69        let category_mapping = CategoryMapping {
70            parent_id: NEWSFLASH_TOPLEVEL.clone(),
71            category_id,
72            sort_index,
73        };
74
75        (category, category_mapping)
76    }
77
78    fn convert_category_vec(mut categories: Vec<FeverCategory>) -> (Vec<Category>, Vec<CategoryMapping>) {
79        categories
80            .drain(..)
81            .enumerate()
82            .map(|(i, c)| Self::convert_category(c, Some(i as i32)))
83            .unzip()
84    }
85
86    fn convert_feed(feed: FeverFeed) -> Feed {
87        let FeverFeed {
88            id,
89            favicon_id: _,
90            title,
91            url,
92            site_url,
93            is_spark: _,
94            last_updated_on_time: _,
95        } = feed;
96
97        Feed {
98            feed_id: FeedID::new(&id.to_string()),
99            label: title,
100            website: site_url.and_then(|url| Url::parse(&url).ok()),
101            feed_url: Url::parse(&url).ok(),
102            icon_url: None,
103            error_count: 0,
104            error_message: None,
105        }
106    }
107
108    fn convert_feed_vec(mut feeds: Vec<FeverFeed>, feeds_groups: Vec<FeedsGroups>) -> (Vec<Feed>, Vec<FeedMapping>) {
109        let mut group_mapping = HashMap::new();
110        for group in feeds_groups {
111            for feed_id in group.feed_ids {
112                group_mapping.insert(feed_id, group.group_id);
113            }
114        }
115
116        let mut mappings: Vec<FeedMapping> = Vec::new();
117        let feeds = feeds
118            .drain(..)
119            .enumerate()
120            .map(|(i, f)| {
121                mappings.push(FeedMapping {
122                    feed_id: FeedID::new(&f.id.to_string()),
123                    category_id: group_mapping
124                        .get(&f.id)
125                        .map(|id| CategoryID::new(&id.to_string()))
126                        .unwrap_or_else(|| NEWSFLASH_TOPLEVEL.clone()),
127                    sort_index: Some(i as i32),
128                });
129
130                Self::convert_feed(f)
131            })
132            .collect();
133
134        (feeds, mappings)
135    }
136
137    fn convert_entry(entry: FeverEntry, portal: Arc<Box<dyn Portal>>) -> FatArticle {
138        let FeverEntry {
139            id,
140            feed_id,
141            title,
142            author,
143            html,
144            url,
145            is_saved,
146            is_read,
147            created_on_time,
148        } = entry;
149
150        let article_id = ArticleID::new(&id.to_string());
151        let article_exists_locally = portal.get_article_exists(&article_id).unwrap_or(false);
152        let plain_text = if article_exists_locally {
153            None
154        } else {
155            Some(util::html2text::html2text(&html))
156        };
157        let summary = plain_text.as_deref().map(util::html2text::text2summary);
158        let thumbnail_url = crate::util::thumbnail::extract_thumbnail(&html);
159
160        FatArticle {
161            article_id,
162            title: match escaper::decode_html(&title) {
163                Ok(title) => Some(title),
164                Err(error) => {
165                    tracing::warn!(?error.kind, %error.position, "Failed to decode html");
166                    Some(title)
167                }
168            },
169            author: if author.is_empty() { None } else { Some(author) },
170            feed_id: FeedID::new(&feed_id.to_string()),
171            url: Url::parse(&url).ok(),
172            date: util::timestamp_to_datetime(created_on_time),
173            synced: Utc::now(),
174            updated: None,
175            summary,
176            html: Some(html),
177            scraped_content: None,
178            direction: None,
179            unread: if is_read { models::Read::Read } else { models::Read::Unread },
180            marked: if is_saved { models::Marked::Marked } else { models::Marked::Unmarked },
181            plain_text,
182            thumbnail_url,
183        }
184    }
185
186    fn convert_entry_vec(entries: Vec<FeverEntry>, feed_ids: &HashSet<FeedID>, portal: Arc<Box<dyn Portal>>) -> Vec<FatArticle> {
187        entries
188            .into_iter()
189            .filter_map(|e| {
190                let feed_ids = feed_ids.clone();
191                let portal = portal.clone();
192
193                let feed_id = FeedID::new(&e.feed_id.to_string());
194                if feed_ids.contains(&feed_id) || e.is_saved {
195                    Some(Self::convert_entry(e, portal))
196                } else {
197                    None
198                }
199            })
200            .collect()
201    }
202
203    pub async fn get_articles(
204        &self,
205        api: &Arc<FeverApi>,
206        item_ids: Vec<u64>,
207        client: &Client,
208        feeds: &[Feed],
209    ) -> Result<Vec<FatArticle>, FeverApiError> {
210        let batch_size: usize = 50;
211        let mut tasks = Vec::new();
212        let feed_ids: HashSet<FeedID> = feeds.iter().map(|f| f.feed_id.clone()).collect();
213        let item_id_chunks = item_ids.chunks(batch_size);
214
215        for chunk in item_id_chunks {
216            let feed_ids = feed_ids.clone();
217            let client = client.clone();
218            let chunk = chunk.to_vec();
219            let api = api.clone();
220            let portal = self.portal.clone();
221
222            let task = tokio::spawn(async move {
223                let entries = api.get_items_with(chunk.to_vec(), &client).await?;
224                let converted_articles = Self::convert_entry_vec(entries.items, &feed_ids, portal);
225                Ok::<Vec<FatArticle>, FeedApiError>(converted_articles)
226            });
227
228            tasks.push(task);
229        }
230
231        let articles = future::join_all(tasks)
232            .await
233            .into_iter()
234            .filter_map(|res| if let Ok(Ok(v)) = res { Some(v) } else { None })
235            .flatten()
236            .collect();
237        Ok(articles)
238    }
239
240    fn ids_to_fever_ids(ids: &[ArticleID]) -> Vec<u64> {
241        ids.iter().filter_map(|article_id| article_id.to_string().parse::<u64>().ok()).collect()
242    }
243}
244
245#[async_trait]
246impl FeedApi for Fever {
247    fn features(&self) -> FeedApiResult<PluginCapabilities> {
248        Ok(PluginCapabilities::SUPPORT_CATEGORIES)
249    }
250
251    fn has_user_configured(&self) -> FeedApiResult<bool> {
252        Ok(self.api.is_some())
253    }
254
255    async fn is_reachable(&self, client: &Client) -> FeedApiResult<bool> {
256        if let Some(url) = self.config.get_url() {
257            let res = client.head(&url).send().await?;
258            Ok(res.status().is_success())
259        } else {
260            Err(FeedApiError::Login)
261        }
262    }
263
264    async fn is_logged_in(&self, _client: &Client) -> FeedApiResult<bool> {
265        Ok(self.logged_in)
266    }
267
268    async fn user_name(&self) -> Option<String> {
269        self.config.get_user_name()
270    }
271
272    async fn get_login_data(&self) -> Option<LoginData> {
273        if self.has_user_configured().unwrap_or(false) {
274            let user = self.config.get_user_name();
275            let password = self.config.get_password();
276
277            if let (Some(user), Some(password)) = (user, password) {
278                return Some(LoginData::Direct(DirectLogin::Password(PasswordLogin {
279                    id: FeverMetadata::get_id(),
280                    url: self.config.get_url(),
281                    user,
282                    password,
283                    basic_auth: None, // fever authentication already uses multi-form
284                })));
285            }
286        }
287
288        None
289    }
290
291    async fn login(&mut self, data: LoginData, client: &Client) -> FeedApiResult<()> {
292        if let LoginData::Direct(DirectLogin::Password(data)) = data
293            && let Some(url_string) = data.url.clone()
294        {
295            let url = Url::parse(&url_string)?;
296            let api = if let Some(basic_auth) = &data.basic_auth {
297                FeverApi::new_with_http_auth(&url, &data.user, &data.password, &basic_auth.user, basic_auth.password.as_deref())
298            } else {
299                FeverApi::new(&url, &data.user, &data.password)
300            };
301
302            self.config.set_url(&url_string);
303            self.config.set_password(&data.password);
304            self.config.set_user_name(&data.user);
305            self.config
306                .set_http_user_name(data.basic_auth.as_ref().map(|auth| auth.user.clone()).as_deref());
307            self.config.set_http_password(data.basic_auth.and_then(|auth| auth.password).as_deref());
308            self.config.write()?;
309            let valid = api.valid_credentials(client).await?;
310            if valid {
311                self.api = Some(Arc::new(api));
312                self.logged_in = true;
313                return Ok(());
314            }
315        }
316
317        self.logged_in = false;
318        self.api = None;
319        Err(FeedApiError::Login)
320    }
321
322    async fn logout(&mut self, _client: &Client) -> FeedApiResult<()> {
323        self.config.delete()?;
324        Ok(())
325    }
326
327    async fn initial_sync(&self, client: &Client, _custom_header: FeedHeaderMap) -> FeedApiResult<SyncResult> {
328        if let Some(api) = &self.api {
329            let categories = api.get_groups(client);
330            let feeds = api.get_feeds(client);
331
332            // starred articles
333            let starred_ids = api.get_saved_items(client);
334
335            // unread articles
336            let unread_ids = api.get_unread_items(client);
337
338            // latest read articles
339            // NOTE: currently there is no existing function to get the latest articles
340            //       instead we crawl some arbitrary articles
341            let entries = api.get_items(client);
342
343            let (categories, feeds, starred_ids, unread_ids, entries) = futures::try_join!(categories, feeds, starred_ids, unread_ids, entries)?;
344
345            let (categories, category_mappings) = Self::convert_category_vec(categories.groups);
346            let (feeds, feed_mappings) = Self::convert_feed_vec(feeds.feeds, feeds.feeds_groups);
347            let feed_ids: HashSet<FeedID> = feeds.iter().map(|f| f.feed_id.clone()).collect();
348
349            let mut articles: Vec<FatArticle> = Vec::new();
350
351            let mut starred = self.get_articles(api, starred_ids.saved_item_ids, client, &feeds).await?;
352            articles.append(&mut starred);
353
354            let mut unread = self.get_articles(api, unread_ids.unread_item_ids, client, &feeds).await?;
355            articles.append(&mut unread);
356
357            let mut read_articles = Self::convert_entry_vec(entries.items, &feed_ids, self.portal.clone());
358            articles.append(&mut read_articles);
359
360            return Ok(SyncResult {
361                feeds: util::vec_to_option(feeds),
362                categories: util::vec_to_option(categories),
363                feed_mappings: util::vec_to_option(feed_mappings),
364                category_mappings: util::vec_to_option(category_mappings),
365                tags: None,
366                taggings: None,
367                headlines: None,
368                articles: util::vec_to_option(articles),
369                enclosures: None,
370            });
371        }
372        Err(FeedApiError::Login)
373    }
374
375    async fn sync(&self, client: &Client, _custom_header: FeedHeaderMap) -> FeedApiResult<SyncResult> {
376        if let Some(api) = &self.api {
377            let categories = api.get_groups(client);
378            let feeds = api.get_feeds(client);
379
380            let fever_unread_ids = api.get_unread_items(client);
381            let fever_marked_ids = api.get_saved_items(client);
382
383            let (categories, feeds, fever_unread_ids, fever_marked_ids) = futures::try_join!(categories, feeds, fever_unread_ids, fever_marked_ids)?;
384
385            let (categories, category_mappings) = Self::convert_category_vec(categories.groups);
386            let (feeds, feed_mappings) = Self::convert_feed_vec(feeds.feeds, feeds.feeds_groups);
387            let feed_ids: HashSet<FeedID> = feeds.iter().map(|f| f.feed_id.clone()).collect();
388
389            let mut articles: Vec<FatArticle> = Vec::new();
390            let mut headlines: Vec<Headline> = Vec::new();
391
392            // get local unread
393            let local_unread_ids = self.portal.get_article_ids_unread_all()?;
394            let local_unread_ids = Self::ids_to_fever_ids(&local_unread_ids);
395            let local_unread_ids = local_unread_ids.into_iter().collect();
396
397            // get local marked
398            let local_marked_ids = self.portal.get_article_ids_marked_all()?;
399            let local_marked_ids = Self::ids_to_fever_ids(&local_marked_ids);
400            let local_marked_ids = local_marked_ids.into_iter().collect();
401
402            // unread article ids
403            let fever_unread_ids: HashSet<u64> = fever_unread_ids.unread_item_ids.into_iter().collect();
404
405            // marked (saved/starred) article ids
406            let fever_marked_ids: HashSet<u64> = fever_marked_ids.saved_item_ids.into_iter().collect();
407
408            let missing_unread_ids = fever_unread_ids.difference(&local_unread_ids).cloned().collect();
409            let missing_marked_ids = fever_marked_ids.difference(&local_marked_ids).cloned().collect();
410
411            // sync new unread articles
412            let missing_unread_articles = self.get_articles(api, missing_unread_ids, client, &feeds);
413
414            // sync new marked articles
415            let missing_marked_articles = self.get_articles(api, missing_marked_ids, client, &feeds);
416
417            // latest articles
418            let entries = api.get_items(client);
419
420            let (mut missing_unread_articles, mut missing_marked_articles, latest_entries) =
421                futures::try_join!(missing_unread_articles, missing_marked_articles, entries)?;
422
423            let mut latest_articles = Self::convert_entry_vec(latest_entries.items, &feed_ids, self.portal.clone());
424
425            articles.append(&mut missing_unread_articles);
426            articles.append(&mut missing_marked_articles);
427            articles.append(&mut latest_articles);
428
429            // mark remotely read article as read
430            let mut should_mark_read_headlines = local_unread_ids
431                .difference(&fever_unread_ids)
432                .cloned()
433                .map(|id| Headline {
434                    article_id: ArticleID::new(&id.to_string()),
435                    unread: Read::Read,
436                    marked: if fever_marked_ids.contains(&id) {
437                        Marked::Marked
438                    } else {
439                        Marked::Unmarked
440                    },
441                })
442                .collect();
443            headlines.append(&mut should_mark_read_headlines);
444
445            // mark remotly starred articles locally
446            let mut mark_headlines = fever_marked_ids
447                .iter()
448                .map(|id| Headline {
449                    article_id: ArticleID::new(&id.to_string()),
450                    marked: Marked::Marked,
451                    unread: if fever_unread_ids.contains(id) { Read::Unread } else { Read::Read },
452                })
453                .collect();
454            headlines.append(&mut mark_headlines);
455
456            // unmark remotly unstarred articles locally
457            let mut missing_unmarked_headlines = local_marked_ids
458                .difference(&fever_marked_ids)
459                .cloned()
460                .map(|id| Headline {
461                    article_id: ArticleID::new(&id.to_string()),
462                    marked: Marked::Unmarked,
463                    unread: if fever_unread_ids.contains(&id) { Read::Unread } else { Read::Read },
464                })
465                .collect();
466            headlines.append(&mut missing_unmarked_headlines);
467
468            return Ok(SyncResult {
469                feeds: util::vec_to_option(feeds),
470                categories: util::vec_to_option(categories),
471                feed_mappings: util::vec_to_option(feed_mappings),
472                category_mappings: util::vec_to_option(category_mappings),
473                tags: None,
474                taggings: None,
475                headlines: util::vec_to_option(headlines),
476                articles: util::vec_to_option(articles),
477                enclosures: None,
478            });
479        }
480        Err(FeedApiError::Login)
481    }
482
483    async fn fetch_feed(&self, _feed_id: &FeedID, _client: &Client, _custom_header: HeaderMap<HeaderValue>) -> FeedApiResult<FeedUpdateResult> {
484        Err(FeedApiError::Unsupported)
485    }
486
487    async fn set_article_read(&self, articles: &[ArticleID], read: Read, client: &Client) -> FeedApiResult<()> {
488        if let Some(api) = &self.api {
489            let entries = Self::ids_to_fever_ids(articles);
490            let status = match read {
491                models::Read::Read => ItemStatus::Read,
492                models::Read::Unread => ItemStatus::Unread,
493            };
494            for entry in entries {
495                api.mark_item(status, entry, client).await?;
496            }
497
498            return Ok(());
499        }
500        Err(FeedApiError::Login)
501    }
502
503    async fn set_article_marked(&self, articles: &[ArticleID], marked: Marked, client: &Client) -> FeedApiResult<()> {
504        if let Some(api) = &self.api {
505            for article in articles {
506                if let Ok(entry_id) = article.as_str().parse::<i64>() {
507                    match marked {
508                        models::Marked::Marked => api.mark_item(ItemStatus::Saved, entry_id.try_into().unwrap(), client).await?,
509                        models::Marked::Unmarked => api.mark_item(ItemStatus::Unsaved, entry_id.try_into().unwrap(), client).await?,
510                    }
511                };
512            }
513
514            return Ok(());
515        }
516        Err(FeedApiError::Login)
517    }
518
519    async fn set_feed_read(&self, feeds: &[FeedID], _articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
520        if let Some(api) = &self.api {
521            let last_sync = self.portal.get_config().read().await.get_last_sync().timestamp();
522
523            for feed in feeds {
524                let id = feed.to_string().parse::<i64>().unwrap();
525                api.mark_feed(ItemStatus::Read, id, last_sync, client).await?;
526            }
527            return Ok(());
528        }
529        Err(FeedApiError::Login)
530    }
531
532    async fn set_category_read(&self, categories: &[CategoryID], _articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
533        if let Some(api) = &self.api {
534            let last_sync = self.portal.get_config().read().await.get_last_sync().timestamp();
535
536            for category in categories {
537                let id = category.to_string().parse::<i64>().unwrap();
538                api.mark_group(ItemStatus::Read, id, last_sync, client).await?;
539            }
540            return Ok(());
541        }
542        Err(FeedApiError::Login)
543    }
544
545    async fn set_tag_read(&self, _tags: &[TagID], _articles: &[ArticleID], _client: &Client) -> FeedApiResult<()> {
546        Err(FeedApiError::Unsupported)
547    }
548
549    async fn set_all_read(&self, _articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
550        if let Some(api) = &self.api {
551            let last_sync = self.portal.get_config().read().await.get_last_sync();
552            api.mark_group(ItemStatus::Read, 0, last_sync.timestamp(), client).await?;
553            return Ok(());
554        }
555        Err(FeedApiError::Login)
556    }
557
558    async fn add_feed(
559        &self,
560        _url: &Url,
561        _title: Option<String>,
562        _category_id: Option<CategoryID>,
563        _client: &Client,
564    ) -> FeedApiResult<(Feed, Option<Category>)> {
565        Err(FeedApiError::Unsupported)
566    }
567
568    async fn remove_feed(&self, _id: &FeedID, _client: &Client) -> FeedApiResult<()> {
569        Err(FeedApiError::Unsupported)
570    }
571
572    async fn move_feed(&self, _feed_id: &FeedID, _from: &CategoryID, _to: &CategoryID, _client: &Client) -> FeedApiResult<()> {
573        Err(FeedApiError::Unsupported)
574    }
575
576    async fn rename_feed(&self, _feed_id: &FeedID, _new_title: &str, _client: &Client) -> FeedApiResult<FeedID> {
577        Err(FeedApiError::Unsupported)
578    }
579
580    async fn edit_feed_url(&self, _feed_id: &FeedID, _new_url: &str, _client: &Client) -> FeedApiResult<()> {
581        Err(FeedApiError::Unsupported)
582    }
583
584    async fn add_category<'a>(&self, _title: &str, _parent: Option<&'a CategoryID>, _client: &Client) -> FeedApiResult<CategoryID> {
585        Err(FeedApiError::Unsupported)
586    }
587
588    async fn remove_category(&self, _id: &CategoryID, _remove_children: bool, _client: &Client) -> FeedApiResult<()> {
589        Err(FeedApiError::Unsupported)
590    }
591
592    async fn rename_category(&self, _id: &CategoryID, _new_title: &str, _client: &Client) -> FeedApiResult<CategoryID> {
593        Err(FeedApiError::Unsupported)
594    }
595
596    async fn move_category(&self, _id: &CategoryID, _parent: &CategoryID, _client: &Client) -> FeedApiResult<()> {
597        Err(FeedApiError::Unsupported)
598    }
599
600    async fn import_opml(&self, _opml: &str, _client: &Client) -> FeedApiResult<()> {
601        Err(FeedApiError::Unsupported)
602    }
603
604    async fn add_tag(&self, _title: &str, _client: &Client) -> FeedApiResult<TagID> {
605        Err(FeedApiError::Unsupported)
606    }
607
608    async fn remove_tag(&self, _id: &TagID, _client: &Client) -> FeedApiResult<()> {
609        Err(FeedApiError::Unsupported)
610    }
611
612    async fn rename_tag(&self, _id: &TagID, _new_title: &str, _client: &Client) -> FeedApiResult<TagID> {
613        Err(FeedApiError::Unsupported)
614    }
615
616    async fn tag_article(&self, _article_id: &ArticleID, _tag_id: &TagID, _client: &Client) -> FeedApiResult<()> {
617        Err(FeedApiError::Unsupported)
618    }
619
620    async fn untag_article(&self, _article_id: &ArticleID, _tag_id: &TagID, _client: &Client) -> FeedApiResult<()> {
621        Err(FeedApiError::Unsupported)
622    }
623
624    async fn get_favicon(&self, feed_id: &FeedID, client: &Client, _custom_header: HeaderMap<HeaderValue>) -> FeedApiResult<FavIcon> {
625        if let Some(api) = &self.api {
626            let fever_feed_id = feed_id.to_string().parse::<u64>().map_err(|e| FeedApiError::Api {
627                message: format!("Failed to parse feed id {e}"),
628            })?;
629
630            let feeds = api.get_feeds(client).await?;
631
632            let mut favicon_id = 0;
633
634            for feed in feeds.feeds {
635                if feed.id == fever_feed_id {
636                    favicon_id = feed.favicon_id;
637                }
638            }
639
640            let favicon_set = api.get_favicons(client).await?.favicons;
641
642            if let Some(favicon) = favicon_set.get(&favicon_id)
643                && let Some(start) = favicon.data.find(',')
644            {
645                let data = base64_std.decode(&favicon.data[start + 1..]).map_err(|_| FeedApiError::Encryption)?;
646
647                let favicon = FavIcon {
648                    feed_id: feed_id.clone(),
649                    expires: Utc::now() + Duration::try_days(EXPIRES_AFTER_DAYS).unwrap(),
650                    format: Some(favicon.mime_type.clone()),
651                    etag: None,
652                    source_url: None,
653                    data: Some(data),
654                };
655
656                return Ok(favicon);
657            }
658        }
659
660        Err(FeedApiError::Login)
661    }
662}