Skip to main content

torq_core/
rss.rs

1//! RSS subscriptions: a feed URL plus filters, polled on a jittered cadence;
2//! matching items are added to the download queue automatically.
3
4use std::path::PathBuf;
5use std::sync::Arc;
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use anyhow::{Context, Result};
9use parking_lot::Mutex;
10use regex::Regex;
11use serde::{Deserialize, Serialize};
12use tracing::{debug, info, warn};
13
14use crate::daemon::Daemon;
15use torq_sources::rss_src::{RssDef, RssSource};
16use torq_sources::{Source, TorrentResult};
17
18fn default_interval() -> u64 {
19    300
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct Subscription {
24    pub id: u64,
25    pub url: String,
26    #[serde(default)]
27    pub title_re: Option<String>,
28    #[serde(default)]
29    pub min_size: Option<u64>,
30    #[serde(default)]
31    pub max_size: Option<u64>,
32    #[serde(default = "default_interval")]
33    pub interval_secs: u64,
34    #[serde(skip)]
35    pub next_poll: u64,
36    #[serde(skip)]
37    pub seen: Vec<String>,
38}
39
40#[derive(Default, Clone, Serialize, Deserialize)]
41struct State {
42    subs: Vec<Subscription>,
43    next_id: u64,
44}
45
46pub struct Subscriptions {
47    path: PathBuf,
48    inner: Mutex<State>,
49}
50
51impl Subscriptions {
52    pub fn load(path: PathBuf) -> Arc<Self> {
53        let state = std::fs::read_to_string(&path)
54            .ok()
55            .and_then(|raw| serde_json::from_str::<State>(&raw).ok())
56            .unwrap_or_default();
57        let next_id = state.subs.iter().map(|s| s.id).max().map_or(1, |m| m + 1);
58        Arc::new(Self {
59            path,
60            inner: Mutex::new(State { next_id, ..state }),
61        })
62    }
63
64    fn save(&self) {
65        let state = self.inner.lock().clone();
66        if let Err(e) = std::fs::write(&self.path, serde_json::to_vec(&state).unwrap_or_default()) {
67            warn!(path = %self.path.display(), "subscriptions write failed: {e}");
68        }
69    }
70
71    pub fn list(&self) -> Vec<Subscription> {
72        self.inner.lock().subs.clone()
73    }
74
75    /// Add a subscription; validates URL and filter regex up front.
76    pub fn add(
77        &self,
78        url: &str,
79        title_re: Option<String>,
80        min_size: Option<u64>,
81        max_size: Option<u64>,
82        interval_secs: u64,
83    ) -> Result<Subscription> {
84        url::Url::parse(url).context("invalid feed URL")?;
85        if let Some(re) = &title_re {
86            Regex::new(re).context("invalid title regex")?;
87        }
88        let mut state = self.inner.lock();
89        let sub = Subscription {
90            id: state.next_id,
91            url: url.to_string(),
92            title_re,
93            min_size,
94            max_size,
95            interval_secs,
96            next_poll: 0, // poll on the next tick
97            seen: Vec::new(),
98        };
99        state.next_id += 1;
100        state.subs.push(sub.clone());
101        drop(state);
102        self.save();
103        Ok(sub)
104    }
105
106    /// Remove by id; returns false when no such subscription exists.
107    pub fn remove(&self, id: u64) -> bool {
108        let mut state = self.inner.lock();
109        let before = state.subs.len();
110        state.subs.retain(|s| s.id != id);
111        let removed = state.subs.len() != before;
112        drop(state);
113        if removed {
114            self.save();
115        }
116        removed
117    }
118
119    /// Poll every subscription whose next-poll time has passed.
120    pub async fn poll_due(&self, client: &reqwest::Client, daemon: &Daemon) {
121        let now = now();
122        let due: Vec<Subscription> = self
123            .inner
124            .lock()
125            .subs
126            .iter()
127            .filter(|s| s.next_poll <= now)
128            .cloned()
129            .collect();
130        for sub in due {
131            self.poll_one(&sub, client, daemon).await;
132        }
133    }
134
135    async fn poll_one(&self, sub: &Subscription, client: &reqwest::Client, daemon: &Daemon) {
136        let source = RssSource::new(RssDef {
137            id: format!("sub:{}", sub.id),
138            label: "subscription".into(),
139            hosts: vec![sub.url.clone()],
140            ..Default::default()
141        });
142        let items = match source.search("", client).await {
143            Ok(items) => items,
144            Err(e) => {
145                debug!(sub = sub.id, %sub.url, "poll failed: {e:#}");
146                return;
147            }
148        };
149        let re = sub.title_re.as_ref().and_then(|r| Regex::new(r).ok());
150
151        // Decide what to add and update bookkeeping under the lock; the
152        // network calls happen after it is released.
153        let to_add: Vec<TorrentResult> = {
154            let mut state = self.inner.lock();
155            let Some(s) = state.subs.iter_mut().find(|s| s.id == sub.id) else {
156                return;
157            };
158            let mut to_add = Vec::new();
159            for item in items {
160                if s.seen.iter().any(|h| h == &item.info_hash) {
161                    continue;
162                }
163                s.seen.push(item.info_hash.clone());
164                if matches_filters(re.as_ref(), s, &item) {
165                    to_add.push(item);
166                }
167            }
168            s.seen.truncate(500);
169            // Stagger polls so a burst of subscriptions doesn't all fire at once.
170            s.next_poll = now() + s.interval_secs + jitter(&s.url);
171            to_add
172        };
173        self.save();
174
175        let mut added = 0usize;
176        for item in to_add {
177            match daemon.add_magnet(&item.magnet, false).await {
178                Ok(_) => {
179                    added += 1;
180                    info!(sub = sub.id, hash = %item.info_hash, name = %item.name, "autodownloaded");
181                }
182                Err(e) => warn!(sub = sub.id, hash = %item.info_hash, "autodownload failed: {e:#}"),
183            }
184        }
185        debug!(sub = sub.id, added, "polled {}", sub.url);
186    }
187}
188
189/// Filter rules: optional title regex, optional size window. Items with
190/// unknown size (0) fail any size rule — they cannot be verified.
191fn matches_filters(re: Option<&Regex>, sub: &Subscription, item: &TorrentResult) -> bool {
192    if let Some(re) = re {
193        if !re.is_match(&item.name) {
194            return false;
195        }
196    }
197    if let Some(min) = sub.min_size {
198        if item.size_bytes == 0 || item.size_bytes < min {
199            return false;
200        }
201    }
202    if let Some(max) = sub.max_size {
203        if item.size_bytes == 0 || item.size_bytes > max {
204            return false;
205        }
206    }
207    true
208}
209
210/// Deterministic sub-minute stagger derived from the feed URL.
211fn jitter(url: &str) -> u64 {
212    (url.bytes().fold(0u64, |a, b| a.wrapping_add(b as u64)) % 60) + 1
213}
214
215fn now() -> u64 {
216    SystemTime::now()
217        .duration_since(UNIX_EPOCH)
218        .map(|d| d.as_secs())
219        .unwrap_or(0)
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    fn sub(title_re: Option<String>, min: Option<u64>, max: Option<u64>) -> Subscription {
227        Subscription {
228            id: 1,
229            url: "https://x".into(),
230            title_re,
231            min_size: min,
232            max_size: max,
233            interval_secs: 60,
234            next_poll: 0,
235            seen: vec![],
236        }
237    }
238
239    fn item(name: &str, size: u64) -> TorrentResult {
240        TorrentResult {
241            info_hash: "hash".into(),
242            name: name.into(),
243            size_bytes: size,
244            seeders: 0,
245            leechers: 0,
246            num_files: None,
247            source: "test".into(),
248            magnet: "magnet:?xt=urn:btih:hash".into(),
249            added: None,
250        }
251    }
252
253    #[test]
254    fn filters_match_regex_and_size() {
255        let s = sub(
256            Some(r"1080p".into()),
257            Some(1_000_000_000),
258            Some(5_000_000_000),
259        );
260        let re = Regex::new(s.title_re.as_ref().unwrap()).ok();
261        assert!(matches_filters(
262            re.as_ref(),
263            &s,
264            &item("Show 01 [1080p]", 2_000_000_000)
265        ));
266        assert!(!matches_filters(
267            re.as_ref(),
268            &s,
269            &item("Show 01 [720p]", 2_000_000_000)
270        ));
271        assert!(!matches_filters(
272            re.as_ref(),
273            &s,
274            &item("Show 01 [1080p]", 500_000_000)
275        )); // below min
276        assert!(!matches_filters(
277            re.as_ref(),
278            &s,
279            &item("Show 01 [1080p]", 6_000_000_000)
280        )); // above max
281    }
282
283    #[test]
284    fn unknown_size_fails_size_rules() {
285        let s = sub(None, Some(100), None);
286        assert!(!matches_filters(None, &s, &item("x", 0)));
287        assert!(matches_filters(None, &s, &item("x", 200)));
288    }
289
290    #[test]
291    fn persistence_roundtrip() {
292        let dir = std::env::temp_dir().join(format!("torq-rss-test-{}", std::process::id()));
293        std::fs::create_dir_all(&dir).unwrap();
294        let path = dir.join("subs.json");
295        let subs = Subscriptions::load(path.clone());
296        subs.add(
297            "https://nyaa.si/?page=rss&q=test",
298            Some("1080".into()),
299            None,
300            None,
301            60,
302        )
303        .unwrap();
304        subs.remove(0); // no-op, wrong id
305        let reloaded = Subscriptions::load(path.clone());
306        let list = reloaded.list();
307        assert_eq!(list.len(), 1);
308        assert_eq!(list[0].title_re.as_deref(), Some("1080"));
309        std::fs::remove_dir_all(&dir).ok();
310    }
311
312    #[test]
313    fn add_rejects_bad_regex() {
314        let dir = std::env::temp_dir().join(format!("torq-rss-test2-{}", std::process::id()));
315        std::fs::create_dir_all(&dir).unwrap();
316        let subs = Subscriptions::load(dir.join("subs.json"));
317        assert!(subs
318            .add("https://nyaa.si", Some("([".into()), None, None, 60)
319            .is_err());
320        assert!(subs.add("not a url", None, None, None, 60).is_err());
321        std::fs::remove_dir_all(&dir).ok();
322    }
323}