1use 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 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, 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 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 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 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 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
189fn matches_filters(re: Option<&Regex>, sub: &Subscription, item: &TorrentResult) -> bool {
192 if let Some(re) = re
193 && !re.is_match(&item.name)
194 {
195 return false;
196 }
197 if let Some(min) = sub.min_size
198 && (item.size_bytes == 0 || item.size_bytes < min)
199 {
200 return false;
201 }
202 if let Some(max) = sub.max_size
203 && (item.size_bytes == 0 || item.size_bytes > max)
204 {
205 return false;
206 }
207 true
208}
209
210fn 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 )); assert!(!matches_filters(
277 re.as_ref(),
278 &s,
279 &item("Show 01 [1080p]", 6_000_000_000)
280 )); }
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); 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!(
318 subs.add("https://nyaa.si", Some("([".into()), None, None, 60)
319 .is_err()
320 );
321 assert!(subs.add("not a url", None, None, None, 60).is_err());
322 std::fs::remove_dir_all(&dir).ok();
323 }
324}