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