1use 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
27pub 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
70struct Meta {
71 user_paused: bool,
72 #[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 pub rss: Arc<Subscriptions>,
99 pub library: Arc<Library>,
101}
102
103impl Daemon {
104 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 {
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 pub async fn add_magnet(&self, magnet: &str, paused: bool) -> Result<TorrentView> {
161 let magnet = magnet.trim();
162 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 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 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; }
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 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 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 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 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 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 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 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 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
457async 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
470fn 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
484fn local_minutes() -> u32 {
487 use chrono::Timelike;
488 chrono::Local::now().num_seconds_from_midnight() / 60
489}
490
491fn 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 };
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
518fn 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 assert_eq!(
627 derive_status(&stats(TorrentStatsState::Paused), &meta(false)),
628 Status::Queued
629 );
630 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 assert_eq!(
641 derive_status(&stats(TorrentStatsState::Live), &meta_queued()),
642 Status::Queued
643 );
644 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)); assert_eq!(active_limits(&sched, 23 * 60 + 30), (Some(200), None)); 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}