Skip to main content

irssc/
feed_cache.rs

1use std::collections::HashSet;
2use std::fs::File;
3use std::io::{Read, Write};
4use std::path::PathBuf;
5
6use feed_rs::model::Feed;
7use log::{debug, warn};
8use miette::{IntoDiagnostic, WrapErr};
9use serde::{Deserialize, Serialize};
10
11/// Represents a cached entry from a feed for comparison purposes.
12/// We use a subset of entry data to efficiently detect new entries.
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
14pub(crate) struct CachedEntry {
15    /// Unique identifier for the entry (URL or title as fallback)
16    pub(crate) id: String,
17    /// Title of the entry (for display purposes)
18    pub(crate) title: String,
19}
20
21impl CachedEntry {
22    /// Create a cached entry from a feed entry
23    pub(crate) fn from_feed_entry(entry: &feed_rs::model::Entry) -> Option<Self> {
24        // Try to use the entry's ID first, fall back to title, then to a generic ID
25        let id = if !entry.id.is_empty() {
26            entry.id.clone()
27        } else if let Some(title) = &entry.title {
28            title.content.clone()
29        } else {
30            format!("entry_{:p}", entry)
31        };
32
33        let title = entry
34            .title
35            .as_ref()
36            .map(|t| t.content.clone())
37            .unwrap_or_else(|| "Untitled".to_string());
38
39        Some(CachedEntry { id, title })
40    }
41}
42
43/// Represents a complete cached feed
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub(crate) struct CachedFeed {
46    /// URL of the feed
47    pub(crate) url: String,
48    /// Title of the feed
49    pub(crate) title: String,
50    /// Set of all entries currently in the feed
51    pub(crate) entries: HashSet<CachedEntry>,
52    /// IRC server name this feed belongs to (not persisted)
53    #[serde(skip)]
54    pub(crate) server_name: String,
55}
56
57impl CachedFeed {
58    /// Create a cached feed from a freshly fetched feed
59    pub(crate) fn from_feed(url: &str, feed: &Feed, server_name: &str) -> Self {
60        let title = feed
61            .title
62            .as_ref()
63            .map(|t| t.content.clone())
64            .unwrap_or_else(|| "Untitled".to_string());
65
66        let entries = feed
67            .entries
68            .iter()
69            .filter_map(CachedEntry::from_feed_entry)
70            .collect();
71
72        CachedFeed {
73            url: url.to_string(),
74            title,
75            entries,
76            server_name: server_name.to_string(),
77        }
78    }
79
80    /// Get the file path where this feed cache should be stored
81    fn get_cache_path(feed_url: &str, server_name: &str) -> miette::Result<PathBuf> {
82        let base_path = if let Ok(base_path) = std::env::var("IRSSC_DATA_DIR") {
83            let p = PathBuf::from(base_path);
84            if !p.is_dir() {
85                warn!("IRSSC_DATA_DIR was set to a non-existing directory!");
86            }
87            p
88        } else {
89            PathBuf::from(".")
90        };
91
92        // Create a safe filename from the URL
93        let hash = format!("{:x}", fxhash::hash64(feed_url));
94        let filename = format!("feed_cache_{}.toml", hash);
95        Ok(base_path.join("storage").join(server_name).join(filename))
96    }
97
98    /// Load a cached feed from disk, or return None if it doesn't exist
99    pub(crate) async fn load(
100        feed_url: &str,
101        server_name: &str,
102    ) -> miette::Result<Option<CachedFeed>> {
103        let cache_path = Self::get_cache_path(feed_url, server_name)?;
104
105        if !cache_path.exists() {
106            return Ok(None);
107        }
108
109        let mut f = File::open(&cache_path).into_diagnostic().wrap_err(format!(
110            "Could not open feed cache at path {}",
111            cache_path.display()
112        ))?;
113
114        let fsize = f.metadata().map(|m| m.len()).unwrap_or(1024);
115        let mut readbuffer = String::with_capacity(fsize as usize);
116        f.read_to_string(&mut readbuffer).into_diagnostic()?;
117
118        let mut cached_feed: CachedFeed =
119            toml::from_str(&readbuffer)
120                .into_diagnostic()
121                .wrap_err(format!(
122                    "Could not parse feed cache from {}",
123                    cache_path.display()
124                ))?;
125        cached_feed.server_name = server_name.to_string();
126
127        Ok(Some(cached_feed))
128    }
129
130    /// Save a cached feed to disk
131    pub(crate) async fn save(&self) -> miette::Result<()> {
132        let cache_path = Self::get_cache_path(&self.url, &self.server_name)?;
133
134        // Ensure the storage subdirectory exists
135        if let Some(parent) = cache_path.parent() {
136            tokio::fs::create_dir_all(parent)
137                .await
138                .into_diagnostic()
139                .wrap_err(format!(
140                    "Could not create cache directory {}",
141                    parent.display()
142                ))?;
143        }
144
145        let mut f = File::create(&cache_path)
146            .into_diagnostic()
147            .wrap_err(format!(
148                "Could not create feed cache file at {}",
149                cache_path.display()
150            ))?;
151
152        let s = toml::to_string_pretty(self).into_diagnostic()?;
153        f.write_all(s.as_bytes()).into_diagnostic()?;
154
155        debug!(
156            "Saved feed cache for {} to {}",
157            self.url,
158            cache_path.display()
159        );
160        Ok(())
161    }
162
163    /// Find new entries between old and new feeds (returns only the new entries from the feed)
164    pub(crate) fn get_new_entries_from_feed<'a>(
165        &self,
166        new_feed: &'a Feed,
167    ) -> Vec<&'a feed_rs::model::Entry> {
168        new_feed
169            .entries
170            .iter()
171            .filter(|entry| {
172                if let Some(cached) = CachedEntry::from_feed_entry(entry) {
173                    !self.entries.contains(&cached)
174                } else {
175                    false
176                }
177            })
178            .collect()
179    }
180}