Skip to main content

torq_core/
engine.rs

1//! Thin wrapper over the librqbit engine session.
2//!
3//! The daemon's only job here: construct the session from [`Config`], expose
4//! add/remove/pause/resume, and hand out the `Api` facade (serializable DTOs)
5//! for the REST layer. Queue semantics and persistence-on-top live in the
6//! daemon module (next phase).
7
8use std::num::NonZeroU32;
9use std::path::PathBuf;
10use std::sync::Arc;
11use std::time::Duration;
12
13use anyhow::{Context, Result};
14use librqbit::api::{Api, TorrentIdOrHash};
15use librqbit::limits::LimitsConfig;
16use librqbit::{
17    AddTorrent, AddTorrentOptions, AddTorrentResponse, Session, SessionOptions,
18    SessionPersistenceConfig,
19};
20use url::Url;
21
22/// Default add options: resume semantics. librqbit refuses to open existing
23/// files unless `overwrite` is set; a downloader must be able to resume an
24/// interrupted download (the piece check validates what's on disk).
25fn default_add_options() -> AddTorrentOptions {
26    AddTorrentOptions {
27        overwrite: true,
28        ..Default::default()
29    }
30}
31
32use crate::config::Config;
33
34pub struct Engine {
35    api: Api,
36    session: Arc<Session>,
37    pub download_dir: PathBuf,
38}
39
40impl Engine {
41    /// Start a librqbit session shaped by `config`, restoring any persisted
42    /// torrents from the previous run.
43    pub async fn start(config: &Config) -> Result<Arc<Self>> {
44        let opts = SessionOptions {
45            fastresume: true,
46            dht_config: Some(librqbit::dht::PersistentDhtConfig {
47                dump_interval: Some(std::time::Duration::from_secs(300)),
48                config_filename: Some(config.state_dir.join("dht.json")),
49            }),
50            persistence: Some(SessionPersistenceConfig::Json {
51                folder: Some(config.state_dir.join("session")),
52            }),
53            socks_proxy_url: config.socks_proxy.clone(),
54            ratelimits: LimitsConfig {
55                upload_bps: config.upload_bps.and_then(NonZeroU32::new),
56                download_bps: config.download_bps.and_then(NonZeroU32::new),
57            },
58            trackers: config
59                .trackers
60                .iter()
61                .filter_map(|t| Url::parse(t).ok())
62                .collect(),
63            ..Default::default()
64        };
65
66        let session = Session::new_with_opts(config.download_dir.clone(), opts)
67            .await
68            .context("starting librqbit session")?;
69
70        Ok(Arc::new(Self {
71            api: Api::new(session.clone(), None),
72            session,
73            download_dir: config.download_dir.clone(),
74        }))
75    }
76
77    pub fn api(&self) -> &Api {
78        &self.api
79    }
80
81    pub fn session(&self) -> &Arc<Session> {
82        &self.session
83    }
84
85    /// Add a magnet link or bare 40-char infohash. The caller matches on
86    /// [`AddTorrentResponse`] for the torrent id/handle. Bounded: a magnet
87    /// whose metadata never resolves (no reachable peers) errors after 30s
88    /// instead of hanging the request forever (librqbit has no resolve timeout).
89    pub async fn add_magnet(&self, magnet: &str) -> Result<AddTorrentResponse> {
90        let fut = self.session.add_torrent(
91            AddTorrent::from_url(magnet.to_string()),
92            Some(default_add_options()),
93        );
94        tokio::time::timeout(Duration::from_secs(30), fut)
95            .await
96            .context("metadata resolution timed out (no reachable peers?)")?
97            .context("adding torrent")
98    }
99
100    /// Apply session-wide rate limits live (None = unlimited).
101    pub fn set_limits(&self, upload_bps: Option<u32>, download_bps: Option<u32>) {
102        self.session
103            .ratelimits
104            .set_upload_bps(upload_bps.and_then(NonZeroU32::new));
105        self.session
106            .ratelimits
107            .set_download_bps(download_bps.and_then(NonZeroU32::new));
108    }
109
110    /// Add a magnet, downloading into `output_folder` (cross-seed: point it at
111    /// existing library data so the piece check finds it instead of fetching).
112    pub async fn add_magnet_with_output(
113        &self,
114        magnet: &str,
115        output_folder: PathBuf,
116    ) -> Result<AddTorrentResponse> {
117        let opts = AddTorrentOptions {
118            output_folder: Some(output_folder.to_string_lossy().into_owned()),
119            ..default_add_options()
120        };
121        let fut = self
122            .session
123            .add_torrent(AddTorrent::from_url(magnet.to_string()), Some(opts));
124        tokio::time::timeout(Duration::from_secs(30), fut)
125            .await
126            .context("metadata resolution timed out (no reachable peers?)")?
127            .context("adding torrent")
128    }
129
130    /// Add an in-memory .torrent file (bytes from disk, HTTP, or watch folder).
131    pub async fn add_torrent_bytes(&self, bytes: Vec<u8>) -> Result<AddTorrentResponse> {
132        let fut = self
133            .session
134            .add_torrent(AddTorrent::from_bytes(bytes), Some(default_add_options()));
135        tokio::time::timeout(Duration::from_secs(30), fut)
136            .await
137            .context("metadata resolution timed out")?
138            .context("adding torrent")
139    }
140
141    /// Delete a torrent. `delete_files` removes its files from disk.
142    pub async fn remove(&self, id: TorrentIdOrHash, delete_files: bool) -> Result<()> {
143        self.session
144            .delete(id, delete_files)
145            .await
146            .context("removing torrent")
147    }
148
149    pub async fn pause(&self, id: TorrentIdOrHash) -> Result<()> {
150        let handle = self.api.mgr_handle(id)?;
151        self.session.pause(&handle).await.context("pausing torrent")
152    }
153
154    pub async fn resume(&self, id: TorrentIdOrHash) -> Result<()> {
155        let handle = self.api.mgr_handle(id)?;
156        self.session
157            .unpause(&handle)
158            .await
159            .context("resuming torrent")
160    }
161
162    /// Snapshot of every torrent in the session (id, name, hashes, state).
163    pub fn list(&self) -> librqbit::api::TorrentListResponse {
164        self.api.api_torrent_list()
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn parse_id_accepts_hash_and_numeric_id() {
174        let hash = "cab507494d02ebb1178b38f2e9d7be299c86b862";
175        assert!(matches!(
176            TorrentIdOrHash::parse(hash).unwrap(),
177            TorrentIdOrHash::Hash(_)
178        ));
179        let id = TorrentIdOrHash::parse("3").unwrap();
180        assert!(matches!(id, TorrentIdOrHash::Id(_)));
181        assert!(TorrentIdOrHash::parse("not-an-id").is_err());
182    }
183}