Skip to main content

torq_core/
daemon.rs

1//! Download queue, daemon state, and event broadcast.
2//!
3//! The queue is torlink's model: a fixed number of active slots; torrents
4//! beyond the cap are engine-paused and auto-promoted as slots free. Status is
5//! derived per torrent: user pause, engine pause (queued), error, completed.
6//! Our own metadata (user_paused, added_at) persists to `queue.json`; the
7//! engine session persists the torrents themselves.
8
9use std::collections::HashMap;
10use std::path::PathBuf;
11use std::sync::Arc;
12use std::time::{Duration, SystemTime, UNIX_EPOCH};
13
14use anyhow::{Context, Result};
15use librqbit::api::{ApiTorrentListOpts, TorrentDetailsResponse, TorrentIdOrHash};
16use librqbit::{AddTorrentResponse, TorrentStats, TorrentStatsState};
17use parking_lot::Mutex;
18use serde::{Deserialize, Serialize};
19use tokio::sync::broadcast;
20use tracing::{debug, info, warn};
21
22use crate::config::Config;
23use crate::engine::Engine;
24use crate::library::Library;
25use crate::rss::Subscriptions;
26
27/// How many torrents download at once (torlink parity; tunable later).
28pub const DEFAULT_MAX_ACTIVE: usize = 3;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "lowercase")]
32pub enum Status {
33    Downloading,
34    Queued,
35    Paused,
36    Completed,
37    Failed,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct TorrentView {
42    pub id: usize,
43    pub info_hash: String,
44    pub name: String,
45    pub status: Status,
46    /// 0..=1, 0 while metadata is still being fetched.
47    pub progress: f32,
48    pub total_bytes: u64,
49    pub downloaded_bytes: u64,
50    pub upload_mbps: Option<f32>,
51    pub download_mbps: Option<f32>,
52    pub peers: usize,
53    pub error: Option<String>,
54    pub added_at: i64,
55}
56
57#[derive(Debug, Clone, Serialize)]
58#[serde(tag = "type", rename_all = "snake_case")]
59pub enum Event {
60    TorrentAdded { id: usize },
61    TorrentUpdated { id: usize },
62    TorrentCompleted { id: usize },
63    TorrentFailed { id: usize, error: String },
64    TorrentRemoved { id: usize },
65}
66
67/// Per-torrent daemon metadata, keyed by info hash (stable across restarts,
68/// unlike engine torrent ids). Only `user_paused`/`queued`/`added_at` persist.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70struct Meta {
71    user_paused: bool,
72    /// Waiting for an active slot (over the cap at add time).
73    #[serde(default)]
74    queued: bool,
75    added_at: i64,
76    #[serde(skip)]
77    last_status: Option<Status>,
78}
79
80impl Default for Meta {
81    fn default() -> Self {
82        Self {
83            user_paused: false,
84            queued: false,
85            added_at: now_secs(),
86            last_status: None,
87        }
88    }
89}
90
91pub struct Daemon {
92    engine: Arc<Engine>,
93    max_active: usize,
94    state_dir: PathBuf,
95    meta: Mutex<HashMap<String, Meta>>,
96    events: broadcast::Sender<Event>,
97    /// RSS subscriptions; polled by a background task.
98    pub rss: Arc<Subscriptions>,
99    /// Cross-seed index of .torrent files whose data is already on disk.
100    pub library: Arc<Library>,
101}
102
103impl Daemon {
104    /// Start the daemon: hydrate metadata for restored torrents, promote any
105    /// queued into free slots, and spawn the transition + RSS poll + schedule
106    /// ticks.
107    pub async fn start(config: &Config, engine: Arc<Engine>) -> Result<Arc<Self>> {
108        let (events, _) = broadcast::channel(512);
109        let rss = Subscriptions::load(config.state_dir.join("subscriptions.json"));
110        let library = Library::new(config.library_dirs.clone());
111        let daemon = Arc::new(Self {
112            engine,
113            max_active: DEFAULT_MAX_ACTIVE,
114            state_dir: config.state_dir.clone(),
115            meta: Mutex::new(HashMap::new()),
116            events,
117            rss,
118            library,
119        });
120
121        daemon.load_meta();
122        // Metadata for torrents restored by the session (no prior meta file).
123        {
124            let mut meta = daemon.meta.lock();
125            let resp = daemon
126                .engine
127                .api()
128                .api_torrent_list_ext(ApiTorrentListOpts { with_stats: true });
129            for t in &resp.torrents {
130                meta.entry(t.info_hash.clone()).or_default();
131            }
132        }
133        daemon.save_meta();
134        daemon.try_promote().await;
135
136        tokio::spawn(tick_loop(daemon.clone()));
137        tokio::spawn(rss_poll_loop(daemon.clone(), config.socks_proxy.clone()));
138        if !config.library_dirs.is_empty() {
139            let lib = daemon.library.clone();
140            tokio::spawn(async move {
141                let _ = lib.scan();
142            });
143        }
144        if !config.schedule.is_empty() {
145            spawn_scheduler(daemon.clone(), config.schedule.clone());
146        }
147        Ok(daemon)
148    }
149
150    pub fn engine(&self) -> &Arc<Engine> {
151        &self.engine
152    }
153
154    pub fn subscribe(&self) -> broadcast::Receiver<Event> {
155        self.events.subscribe()
156    }
157
158    // -- mutations ----------------------------------------------------------
159
160    pub async fn add_magnet(&self, magnet: &str, paused: bool) -> Result<TorrentView> {
161        let magnet = magnet.trim();
162        // Cross-seed: if the hash exists in the library, download "into" the
163        // existing data dir so the piece check finds it instead of fetching.
164        let resp = match self.cross_seed_dir(magnet) {
165            Some(dir) => self.engine.add_magnet_with_output(magnet, dir).await?,
166            None => self.engine.add_magnet(magnet).await?,
167        };
168        self.finish_add(resp, paused).await
169    }
170
171    /// Library data dir for a magnet's infohash, if indexed.
172    fn cross_seed_dir(&self, magnet: &str) -> Option<PathBuf> {
173        let hash = magnet
174            .split("urn:btih:")
175            .nth(1)
176            .and_then(|s| s.split('&').next())
177            .and_then(torq_sources::util::canonicalize_hash)?;
178        let entry = self.library.lookup(&hash)?;
179        info!(%hash, dir = %entry.data_dir.display(), "cross-seed: data already in library");
180        Some(entry.data_dir)
181    }
182
183    pub async fn add_torrent_bytes(&self, bytes: Vec<u8>, paused: bool) -> Result<TorrentView> {
184        let resp = self.engine.add_torrent_bytes(bytes).await?;
185        self.finish_add(resp, paused).await
186    }
187
188    async fn finish_add(&self, resp: AddTorrentResponse, paused: bool) -> Result<TorrentView> {
189        let (id, info_hash) = match &resp {
190            AddTorrentResponse::Added(id, h) | AddTorrentResponse::AlreadyManaged(id, h) => {
191                (*id, h.info_hash().as_string())
192            }
193            AddTorrentResponse::ListOnly(_) => anyhow::bail!("torrent added in list-only mode"),
194        };
195
196        // Record intent only; librqbit refuses to pause a torrent that is still
197        // initializing, so the reconcile tick applies pause/queue once the
198        // torrent is ready. An explicit `paused` add reads back as "paused"
199        // immediately via the meta flag. Re-adding an existing torrent must
200        // never clobber the user's pause state.
201        //
202        // NOTE: active_count() takes the meta lock (via views()), so it must
203        // run before we hold the guard — parking_lot is not reentrant.
204        let is_new = matches!(resp, AddTorrentResponse::Added(_, _));
205        let queued = is_new && !paused && self.active_count() > self.max_active;
206        if queued {
207            debug!(id, "over active cap, queueing");
208        }
209        {
210            let mut meta = self.meta.lock();
211            let entry = meta.entry(info_hash.clone()).or_default();
212            if is_new {
213                entry.user_paused = paused;
214                entry.queued = queued;
215                entry.last_status = None; // force a transition broadcast on next tick
216            }
217        }
218
219        self.save_meta();
220        let _ = self.events.send(Event::TorrentAdded { id });
221        debug!(id, "finish_add: returning view");
222        self.view(id).context("torrent disappeared after add")
223    }
224
225    pub async fn pause(&self, id: TorrentIdOrHash) -> Result<()> {
226        let (id_num, hash) = self.locate(&id)?;
227        self.meta
228            .lock()
229            .get_mut(&hash)
230            .expect("meta exists")
231            .user_paused = true;
232        self.save_meta();
233        // Best-effort: pausing a still-initializing torrent fails in librqbit;
234        // the reconcile tick retries until it takes.
235        if let Err(e) = self.engine.pause(TorrentIdOrHash::Id(id_num)).await {
236            debug!(id = id_num, "deferred pause not yet possible: {e}");
237        }
238        let _ = self.events.send(Event::TorrentUpdated { id: id_num });
239        Ok(())
240    }
241
242    pub async fn resume(&self, id: TorrentIdOrHash) -> Result<()> {
243        let (id_num, hash) = self.locate(&id)?;
244        {
245            let mut meta = self.meta.lock();
246            let entry = meta.get_mut(&hash).expect("meta exists");
247            entry.user_paused = false;
248            entry.queued = false;
249        }
250        self.save_meta();
251        self.try_promote().await;
252        let _ = self.events.send(Event::TorrentUpdated { id: id_num });
253        Ok(())
254    }
255
256    pub async fn remove(&self, id: TorrentIdOrHash, delete_files: bool) -> Result<()> {
257        let (id_num, hash) = self.locate(&id)?;
258        self.engine
259            .remove(TorrentIdOrHash::Id(id_num), delete_files)
260            .await?;
261        self.meta.lock().remove(&hash);
262        self.save_meta();
263        let _ = self.events.send(Event::TorrentRemoved { id: id_num });
264        self.try_promote().await;
265        Ok(())
266    }
267
268    // -- reads ---------------------------------------------------------------
269
270    pub fn views(&self) -> Vec<TorrentView> {
271        let resp = self
272            .engine
273            .api()
274            .api_torrent_list_ext(ApiTorrentListOpts { with_stats: true });
275        let meta = self.meta.lock();
276        resp.torrents
277            .iter()
278            .filter_map(|t| {
279                let m = meta.get(&t.info_hash).cloned().unwrap_or_default();
280                view_from(t, &m)
281            })
282            .collect()
283    }
284
285    pub fn view(&self, id: usize) -> Option<TorrentView> {
286        self.views().into_iter().find(|v| v.id == id)
287    }
288
289    /// Resolve a torrent by numeric id or info hash to (id, info_hash).
290    fn locate(&self, id: &TorrentIdOrHash) -> Result<(usize, String)> {
291        let handle = self.engine.api().mgr_handle(*id).map_err(|e| {
292            anyhow::anyhow!(if e.to_string().contains("not found") {
293                "torrent not found"
294            } else {
295                "torrent lookup failed"
296            })
297        })?;
298        Ok((handle.id(), handle.info_hash().as_string()))
299    }
300
301    fn active_count(&self) -> usize {
302        self.views()
303            .iter()
304            .filter(|v| v.status == Status::Downloading)
305            .count()
306    }
307
308    // -- queue promotion ------------------------------------------------------
309
310    /// Resume queued torrents into free slots, oldest first.
311    async fn try_promote(&self) {
312        let active = self.active_count();
313        if active >= self.max_active {
314            return;
315        }
316        let mut queued: Vec<TorrentView> = self
317            .views()
318            .into_iter()
319            .filter(|v| v.status == Status::Queued)
320            .collect();
321        queued.sort_by_key(|v| v.added_at);
322        for v in queued.into_iter().take(self.max_active - active) {
323            if let Err(e) = self.engine.resume(TorrentIdOrHash::Id(v.id)).await {
324                warn!(id = v.id, "promote failed: {e:#}");
325            } else {
326                if let Some(entry) = self.meta.lock().get_mut(&v.info_hash) {
327                    entry.queued = false;
328                }
329                info!(id = v.id, "promoted from queue");
330            }
331        }
332        self.save_meta();
333    }
334
335    // -- persistence ----------------------------------------------------------
336
337    fn meta_file(&self) -> PathBuf {
338        self.state_dir.join("queue.json")
339    }
340
341    fn load_meta(&self) {
342        let path = self.meta_file();
343        let Ok(raw) = std::fs::read_to_string(&path) else {
344            return;
345        };
346        match serde_json::from_str::<HashMap<String, Meta>>(&raw) {
347            Ok(loaded) => {
348                *self.meta.lock() = loaded;
349            }
350            Err(e) => warn!(path = %path.display(), "ignoring unparsable queue.json: {e}"),
351        }
352    }
353
354    fn save_meta(&self) {
355        let map = self.meta.lock().clone();
356        let raw = match serde_json::to_vec(&map) {
357            Ok(v) => v,
358            Err(e) => {
359                warn!("queue.json serialization failed: {e}");
360                return;
361            }
362        };
363        let path = self.meta_file();
364        let tmp = path.with_extension("json.tmp");
365        if let Err(e) = std::fs::write(&tmp, raw).and_then(|()| std::fs::rename(&tmp, &path)) {
366            warn!(path = %path.display(), "queue.json write failed: {e:#}");
367        }
368    }
369
370    // -- transition tick --------------------------------------------------------
371
372    /// One pass over session state: detect status transitions, broadcast them,
373    /// promote completions. Runs every second; does nothing when nothing changed.
374    async fn reconcile(&self) {
375        let resp = self
376            .engine
377            .api()
378            .api_torrent_list_ext(ApiTorrentListOpts { with_stats: true });
379        let mut completed = Vec::new();
380        let mut failed = Vec::new();
381        let mut updated = Vec::new();
382
383        {
384            let mut meta = self.meta.lock();
385            for t in &resp.torrents {
386                let Some(stats) = t.stats.as_ref() else {
387                    continue;
388                };
389                let Some(id) = t.id else { continue };
390                let entry = meta.entry(t.info_hash.clone()).or_default();
391                let status = derive_status(stats, entry);
392                let prev = entry.last_status;
393                entry.last_status = Some(status);
394                match (prev, status) {
395                    (Some(_), Status::Completed) => completed.push(id),
396                    (Some(_), Status::Failed) => {
397                        failed.push((id, stats.error.clone().unwrap_or_default()))
398                    }
399                    (Some(p), s) if p != s => updated.push((id, status)),
400                    _ => {}
401                }
402            }
403        }
404
405        for id in &completed {
406            let _ = self.events.send(Event::TorrentCompleted { id: *id });
407        }
408        for (id, error) in &failed {
409            let _ = self.events.send(Event::TorrentFailed {
410                id: *id,
411                error: error.clone(),
412            });
413        }
414        for (id, _) in &updated {
415            let _ = self.events.send(Event::TorrentUpdated { id: *id });
416        }
417
418        // Enforce recorded intent the engine hasn't applied yet: pausing a
419        // torrent mid-initialization fails in librqbit, so retry each tick.
420        for t in &resp.torrents {
421            let Some(id) = t.id else { continue };
422            let Some(stats) = t.stats.as_ref() else {
423                continue;
424            };
425            let meta = self.meta.lock().get(&t.info_hash).cloned();
426            let Some(meta) = meta else { continue };
427            if (meta.user_paused || meta.queued)
428                && !matches!(
429                    stats.state,
430                    TorrentStatsState::Paused | TorrentStatsState::Error
431                )
432            {
433                if let Err(e) = self.engine.pause(TorrentIdOrHash::Id(id)).await {
434                    debug!(id, "deferred pause not yet possible: {e}");
435                }
436            }
437        }
438
439        // Fill any free slots (also covers torrents restored paused by the
440        // session with no recorded intent).
441        self.try_promote().await;
442
443        if !completed.is_empty() {
444            debug!("{} torrent(s) completed", completed.len());
445        }
446    }
447}
448
449async fn tick_loop(daemon: Arc<Daemon>) {
450    let mut interval = tokio::time::interval(Duration::from_secs(1));
451    loop {
452        interval.tick().await;
453        daemon.reconcile().await;
454    }
455}
456
457/// Poll due RSS subscriptions every 30s; each subscription staggers itself.
458async fn rss_poll_loop(daemon: Arc<Daemon>, socks_proxy: Option<String>) {
459    let Ok(client) = torq_sources::types::http_client(socks_proxy.as_deref()) else {
460        warn!("rss polling disabled: no HTTP client");
461        return;
462    };
463    let mut interval = tokio::time::interval(Duration::from_secs(30));
464    loop {
465        interval.tick().await;
466        daemon.rss.poll_due(&client, &daemon).await;
467    }
468}
469
470/// Apply the active bandwidth schedule every minute; outside any window the
471/// flat `upload_bps`/`download_bps` limits from config apply (already set on
472/// the session at startup).
473fn spawn_scheduler(daemon: Arc<Daemon>, schedule: Vec<crate::config::ScheduleEntry>) {
474    tokio::spawn(async move {
475        let mut tick = tokio::time::interval(Duration::from_secs(60));
476        loop {
477            tick.tick().await;
478            let (up, down) = active_limits(&schedule, local_minutes());
479            daemon.engine.set_limits(up, down);
480        }
481    });
482}
483
484/// Minutes since local midnight (wall-clock): schedules are defined in local
485/// time, so a UTC clock would shift the window with timezone/DST.
486fn local_minutes() -> u32 {
487    use chrono::Timelike;
488    chrono::Local::now().num_seconds_from_midnight() / 60
489}
490
491/// Limits for the current minute: the first matching schedule window, else
492/// None (meaning "revert to the flat config limits", which the engine holds).
493fn active_limits(
494    schedule: &[crate::config::ScheduleEntry],
495    now: u32,
496) -> (Option<u32>, Option<u32>) {
497    for e in schedule {
498        let (Some(s), Some(end)) = (parse_hhmm(&e.start), parse_hhmm(&e.end)) else {
499            continue;
500        };
501        let active = if s < end {
502            now >= s && now < end
503        } else {
504            now >= s || now < end // overnight window
505        };
506        if active {
507            return (e.upload_bps, e.download_bps);
508        }
509    }
510    (None, None)
511}
512
513fn parse_hhmm(s: &str) -> Option<u32> {
514    let (h, m) = s.split_once(':')?;
515    Some(h.parse::<u32>().ok()? * 60 + m.parse::<u32>().ok()?)
516}
517
518/// Status precedence: error → completed → user-paused → engine-paused (queued)
519/// → downloading. A completed torrent that the user paused stays completed.
520fn derive_status(stats: &TorrentStats, meta: &Meta) -> Status {
521    if stats.error.is_some() && !stats.finished {
522        return Status::Failed;
523    }
524    if stats.finished {
525        return Status::Completed;
526    }
527    if meta.user_paused {
528        return Status::Paused;
529    }
530    if meta.queued {
531        return Status::Queued;
532    }
533    match stats.state {
534        TorrentStatsState::Paused => Status::Queued,
535        _ => Status::Downloading,
536    }
537}
538
539fn view_from(details: &TorrentDetailsResponse, meta: &Meta) -> Option<TorrentView> {
540    let id = details.id?;
541    let stats = details.stats.as_ref()?;
542    let (download_mbps, upload_mbps, peers) = match &stats.live {
543        Some(live) => (
544            Some(live.download_speed.mbps as f32),
545            Some(live.upload_speed.mbps as f32),
546            live.snapshot.peer_stats.live,
547        ),
548        None => (None, None, 0),
549    };
550    Some(TorrentView {
551        id,
552        info_hash: details.info_hash.clone(),
553        name: details
554            .name
555            .clone()
556            .unwrap_or_else(|| details.info_hash.clone()),
557        status: derive_status(stats, meta),
558        progress: if stats.total_bytes > 0 {
559            stats.progress_bytes as f32 / stats.total_bytes as f32
560        } else {
561            0.0
562        },
563        total_bytes: stats.total_bytes,
564        downloaded_bytes: stats.progress_bytes,
565        upload_mbps,
566        download_mbps,
567        peers,
568        error: stats.error.clone(),
569        added_at: meta.added_at,
570    })
571}
572
573fn now_secs() -> i64 {
574    SystemTime::now()
575        .duration_since(UNIX_EPOCH)
576        .map(|d| d.as_secs() as i64)
577        .unwrap_or(0)
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583
584    fn stats(state: TorrentStatsState) -> TorrentStats {
585        TorrentStats {
586            state,
587            file_progress: vec![],
588            error: None,
589            progress_bytes: 0,
590            uploaded_bytes: 0,
591            total_bytes: 100,
592            finished: false,
593            live: None,
594        }
595    }
596
597    fn meta(user_paused: bool) -> Meta {
598        Meta {
599            user_paused,
600            queued: false,
601            added_at: 1,
602            last_status: None,
603        }
604    }
605
606    fn meta_queued() -> Meta {
607        Meta {
608            user_paused: false,
609            queued: true,
610            added_at: 1,
611            last_status: None,
612        }
613    }
614
615    #[test]
616    fn status_precedence() {
617        assert_eq!(
618            derive_status(&stats(TorrentStatsState::Live), &meta(false)),
619            Status::Downloading
620        );
621        assert_eq!(
622            derive_status(&stats(TorrentStatsState::Initializing), &meta(false)),
623            Status::Downloading
624        );
625        // engine-paused = queued (waiting for a slot)
626        assert_eq!(
627            derive_status(&stats(TorrentStatsState::Paused), &meta(false)),
628            Status::Queued
629        );
630        // user-paused wins over engine pause
631        assert_eq!(
632            derive_status(&stats(TorrentStatsState::Paused), &meta(true)),
633            Status::Paused
634        );
635        assert_eq!(
636            derive_status(&stats(TorrentStatsState::Live), &meta(true)),
637            Status::Paused
638        );
639        // over-cap intent reads as queued even while the engine catches up
640        assert_eq!(
641            derive_status(&stats(TorrentStatsState::Live), &meta_queued()),
642            Status::Queued
643        );
644        // user pause wins over queued intent
645        let mut m = meta_queued();
646        m.user_paused = true;
647        assert_eq!(
648            derive_status(&stats(TorrentStatsState::Live), &m),
649            Status::Paused
650        );
651    }
652
653    #[test]
654    fn finished_beats_paused_and_error_clears() {
655        let mut s = stats(TorrentStatsState::Paused);
656        s.finished = true;
657        assert_eq!(derive_status(&s, &meta(true)), Status::Completed);
658
659        s.error = Some("boom".into());
660        s.finished = false;
661        assert_eq!(derive_status(&s, &meta(false)), Status::Failed);
662    }
663
664    #[test]
665    fn view_maps_progress_and_speeds() {
666        let mut s = stats(TorrentStatsState::Live);
667        s.progress_bytes = 50;
668        s.total_bytes = 200;
669        let details = TorrentDetailsResponse {
670            id: Some(7),
671            info_hash: "abc".into(),
672            name: Some("x".into()),
673            output_folder: "/tmp".into(),
674            files: None,
675            stats: Some(s),
676        };
677        let v = view_from(&details, &meta(false)).unwrap();
678        assert_eq!(v.id, 7);
679        assert_eq!(v.status, Status::Downloading);
680        assert!((v.progress - 0.25).abs() < f32::EPSILON);
681        assert_eq!(v.downloaded_bytes, 50);
682    }
683
684    #[test]
685    fn schedule_windows_and_midnight() {
686        use crate::config::ScheduleEntry;
687        let entry = |start: &str, end: &str, up: u32| ScheduleEntry {
688            start: start.into(),
689            end: end.into(),
690            upload_bps: Some(up),
691            download_bps: None,
692        };
693        let sched = vec![entry("08:00", "12:00", 100), entry("23:00", "02:00", 200)];
694        assert_eq!(active_limits(&sched, 9 * 60), (Some(100), None));
695        assert_eq!(active_limits(&sched, 12 * 60), (None, None)); // end exclusive
696        assert_eq!(active_limits(&sched, 23 * 60 + 30), (Some(200), None)); // overnight
697        assert_eq!(active_limits(&sched, 60), (Some(200), None));
698        assert_eq!(active_limits(&sched, 3 * 60), (None, None));
699        assert_eq!(parse_hhmm("08:30"), Some(510));
700        assert_eq!(parse_hhmm("x"), None);
701    }
702}