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
57impl TorrentView {
58 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#[derive(Debug, Clone, Serialize, Deserialize)]
83struct Meta {
84 user_paused: bool,
85 #[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 pub rss: Arc<Subscriptions>,
112 pub library: Arc<Library>,
114}
115
116impl Daemon {
117 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 {
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 pub async fn add_magnet(&self, magnet: &str, paused: bool) -> Result<TorrentView> {
174 let magnet = magnet.trim();
175 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 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 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; }
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 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 pub fn max_active(&self) -> usize {
285 self.max_active
286 }
287
288 pub fn views(&self) -> Vec<TorrentView> {
289 let resp = self
290 .engine
291 .api()
292 .api_torrent_list_ext(ApiTorrentListOpts { with_stats: true });
293 let meta = self.meta.lock();
294 resp.torrents
295 .iter()
296 .filter_map(|t| {
297 let m = meta.get(&t.info_hash).cloned().unwrap_or_default();
298 view_from(t, &m)
299 })
300 .collect()
301 }
302
303 pub fn view(&self, id: usize) -> Option<TorrentView> {
304 self.views().into_iter().find(|v| v.id == id)
305 }
306
307 fn locate(&self, id: &TorrentIdOrHash) -> Result<(usize, String)> {
309 let handle = self.engine.api().mgr_handle(*id).map_err(|e| {
310 anyhow::anyhow!(if e.to_string().contains("not found") {
311 "torrent not found"
312 } else {
313 "torrent lookup failed"
314 })
315 })?;
316 Ok((handle.id(), handle.info_hash().as_string()))
317 }
318
319 fn active_count(&self) -> usize {
320 self.views()
321 .iter()
322 .filter(|v| v.status == Status::Downloading)
323 .count()
324 }
325
326 async fn try_promote(&self) {
330 let active = self.active_count();
331 if active >= self.max_active {
332 return;
333 }
334 let mut queued: Vec<TorrentView> = self
335 .views()
336 .into_iter()
337 .filter(|v| v.status == Status::Queued)
338 .collect();
339 queued.sort_by_key(|v| v.added_at);
340 for v in queued.into_iter().take(self.max_active - active) {
341 if let Err(e) = self.engine.resume(TorrentIdOrHash::Id(v.id)).await {
342 warn!(id = v.id, "promote failed: {e:#}");
343 } else {
344 if let Some(entry) = self.meta.lock().get_mut(&v.info_hash) {
345 entry.queued = false;
346 }
347 info!(id = v.id, "promoted from queue");
348 }
349 }
350 self.save_meta();
351 }
352
353 fn meta_file(&self) -> PathBuf {
356 self.state_dir.join("queue.json")
357 }
358
359 fn load_meta(&self) {
360 let path = self.meta_file();
361 let Ok(raw) = std::fs::read_to_string(&path) else {
362 return;
363 };
364 match serde_json::from_str::<HashMap<String, Meta>>(&raw) {
365 Ok(loaded) => {
366 *self.meta.lock() = loaded;
367 }
368 Err(e) => warn!(path = %path.display(), "ignoring unparsable queue.json: {e}"),
369 }
370 }
371
372 fn save_meta(&self) {
373 let map = self.meta.lock().clone();
374 let raw = match serde_json::to_vec(&map) {
375 Ok(v) => v,
376 Err(e) => {
377 warn!("queue.json serialization failed: {e}");
378 return;
379 }
380 };
381 let path = self.meta_file();
382 let tmp = path.with_extension("json.tmp");
383 if let Err(e) = std::fs::write(&tmp, raw).and_then(|()| std::fs::rename(&tmp, &path)) {
384 warn!(path = %path.display(), "queue.json write failed: {e:#}");
385 }
386 }
387
388 async fn reconcile(&self) {
393 let resp = self
394 .engine
395 .api()
396 .api_torrent_list_ext(ApiTorrentListOpts { with_stats: true });
397 let mut completed = Vec::new();
398 let mut failed = Vec::new();
399 let mut updated = Vec::new();
400
401 {
402 let mut meta = self.meta.lock();
403 for t in &resp.torrents {
404 let Some(stats) = t.stats.as_ref() else {
405 continue;
406 };
407 let Some(id) = t.id else { continue };
408 let entry = meta.entry(t.info_hash.clone()).or_default();
409 let status = derive_status(stats, entry);
410 let prev = entry.last_status;
411 entry.last_status = Some(status);
412 match (prev, status) {
413 (Some(_), Status::Completed) => completed.push(id),
414 (Some(_), Status::Failed) => {
415 failed.push((id, stats.error.clone().unwrap_or_default()))
416 }
417 (Some(p), s) if p != s => updated.push((id, status)),
418 _ => {}
419 }
420 }
421 }
422
423 for id in &completed {
424 let _ = self.events.send(Event::TorrentCompleted { id: *id });
425 }
426 for (id, error) in &failed {
427 let _ = self.events.send(Event::TorrentFailed {
428 id: *id,
429 error: error.clone(),
430 });
431 }
432 for (id, _) in &updated {
433 let _ = self.events.send(Event::TorrentUpdated { id: *id });
434 }
435
436 for t in &resp.torrents {
439 let Some(id) = t.id else { continue };
440 let Some(stats) = t.stats.as_ref() else {
441 continue;
442 };
443 let meta = self.meta.lock().get(&t.info_hash).cloned();
444 let Some(meta) = meta else { continue };
445 if (meta.user_paused || meta.queued)
446 && !matches!(
447 stats.state,
448 TorrentStatsState::Paused | TorrentStatsState::Error
449 )
450 && let Err(e) = self.engine.pause(TorrentIdOrHash::Id(id)).await
451 {
452 debug!(id, "deferred pause not yet possible: {e}");
453 }
454 }
455
456 self.try_promote().await;
459
460 if !completed.is_empty() {
461 debug!("{} torrent(s) completed", completed.len());
462 }
463 }
464}
465
466async fn tick_loop(daemon: Arc<Daemon>) {
467 let mut interval = tokio::time::interval(Duration::from_secs(1));
468 loop {
469 interval.tick().await;
470 daemon.reconcile().await;
471 }
472}
473
474async fn rss_poll_loop(daemon: Arc<Daemon>, socks_proxy: Option<String>) {
476 let Ok(client) = torq_sources::types::http_client(socks_proxy.as_deref()) else {
477 warn!("rss polling disabled: no HTTP client");
478 return;
479 };
480 let mut interval = tokio::time::interval(Duration::from_secs(30));
481 loop {
482 interval.tick().await;
483 daemon.rss.poll_due(&client, &daemon).await;
484 }
485}
486
487fn spawn_scheduler(daemon: Arc<Daemon>, schedule: Vec<crate::config::ScheduleEntry>) {
491 tokio::spawn(async move {
492 let mut tick = tokio::time::interval(Duration::from_secs(60));
493 loop {
494 tick.tick().await;
495 let (up, down) = active_limits(&schedule, local_minutes());
496 daemon.engine.set_limits(up, down);
497 }
498 });
499}
500
501fn local_minutes() -> u32 {
504 use chrono::Timelike;
505 chrono::Local::now().num_seconds_from_midnight() / 60
506}
507
508fn active_limits(
511 schedule: &[crate::config::ScheduleEntry],
512 now: u32,
513) -> (Option<u32>, Option<u32>) {
514 for e in schedule {
515 let (Some(s), Some(end)) = (parse_hhmm(&e.start), parse_hhmm(&e.end)) else {
516 continue;
517 };
518 let active = if s < end {
519 now >= s && now < end
520 } else {
521 now >= s || now < end };
523 if active {
524 return (e.upload_bps, e.download_bps);
525 }
526 }
527 (None, None)
528}
529
530fn parse_hhmm(s: &str) -> Option<u32> {
531 let (h, m) = s.split_once(':')?;
532 Some(h.parse::<u32>().ok()? * 60 + m.parse::<u32>().ok()?)
533}
534
535fn derive_status(stats: &TorrentStats, meta: &Meta) -> Status {
538 if stats.error.is_some() && !stats.finished {
539 return Status::Failed;
540 }
541 if stats.finished {
542 return Status::Completed;
543 }
544 if meta.user_paused {
545 return Status::Paused;
546 }
547 if meta.queued {
548 return Status::Queued;
549 }
550 match stats.state {
551 TorrentStatsState::Paused => Status::Queued,
552 _ => Status::Downloading,
553 }
554}
555
556fn view_from(details: &TorrentDetailsResponse, meta: &Meta) -> Option<TorrentView> {
557 let id = details.id?;
558 let stats = details.stats.as_ref()?;
559 let (download_mbps, upload_mbps, peers) = match &stats.live {
560 Some(live) => (
561 Some(live.download_speed.mbps as f32),
562 Some(live.upload_speed.mbps as f32),
563 live.snapshot.peer_stats.live,
564 ),
565 None => (None, None, 0),
566 };
567 Some(TorrentView {
568 id,
569 info_hash: details.info_hash.clone(),
570 name: details
571 .name
572 .clone()
573 .unwrap_or_else(|| details.info_hash.clone()),
574 status: derive_status(stats, meta),
575 progress: if stats.total_bytes > 0 {
576 stats.progress_bytes as f32 / stats.total_bytes as f32
577 } else {
578 0.0
579 },
580 total_bytes: stats.total_bytes,
581 downloaded_bytes: stats.progress_bytes,
582 upload_mbps,
583 download_mbps,
584 peers,
585 error: stats.error.clone(),
586 added_at: meta.added_at,
587 })
588}
589
590fn now_secs() -> i64 {
591 SystemTime::now()
592 .duration_since(UNIX_EPOCH)
593 .map(|d| d.as_secs() as i64)
594 .unwrap_or(0)
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600
601 fn stats(state: TorrentStatsState) -> TorrentStats {
602 TorrentStats {
603 state,
604 file_progress: vec![],
605 error: None,
606 progress_bytes: 0,
607 uploaded_bytes: 0,
608 total_bytes: 100,
609 finished: false,
610 live: None,
611 }
612 }
613
614 fn meta(user_paused: bool) -> Meta {
615 Meta {
616 user_paused,
617 queued: false,
618 added_at: 1,
619 last_status: None,
620 }
621 }
622
623 fn meta_queued() -> Meta {
624 Meta {
625 user_paused: false,
626 queued: true,
627 added_at: 1,
628 last_status: None,
629 }
630 }
631
632 #[test]
633 fn status_precedence() {
634 assert_eq!(
635 derive_status(&stats(TorrentStatsState::Live), &meta(false)),
636 Status::Downloading
637 );
638 assert_eq!(
639 derive_status(&stats(TorrentStatsState::Initializing), &meta(false)),
640 Status::Downloading
641 );
642 assert_eq!(
644 derive_status(&stats(TorrentStatsState::Paused), &meta(false)),
645 Status::Queued
646 );
647 assert_eq!(
649 derive_status(&stats(TorrentStatsState::Paused), &meta(true)),
650 Status::Paused
651 );
652 assert_eq!(
653 derive_status(&stats(TorrentStatsState::Live), &meta(true)),
654 Status::Paused
655 );
656 assert_eq!(
658 derive_status(&stats(TorrentStatsState::Live), &meta_queued()),
659 Status::Queued
660 );
661 let mut m = meta_queued();
663 m.user_paused = true;
664 assert_eq!(
665 derive_status(&stats(TorrentStatsState::Live), &m),
666 Status::Paused
667 );
668 }
669
670 #[test]
671 fn finished_beats_paused_and_error_clears() {
672 let mut s = stats(TorrentStatsState::Paused);
673 s.finished = true;
674 assert_eq!(derive_status(&s, &meta(true)), Status::Completed);
675
676 s.error = Some("boom".into());
677 s.finished = false;
678 assert_eq!(derive_status(&s, &meta(false)), Status::Failed);
679 }
680
681 #[test]
682 fn view_maps_progress_and_speeds() {
683 let mut s = stats(TorrentStatsState::Live);
684 s.progress_bytes = 50;
685 s.total_bytes = 200;
686 let details = TorrentDetailsResponse {
687 id: Some(7),
688 info_hash: "abc".into(),
689 name: Some("x".into()),
690 output_folder: "/tmp".into(),
691 files: None,
692 stats: Some(s),
693 };
694 let v = view_from(&details, &meta(false)).unwrap();
695 assert_eq!(v.id, 7);
696 assert_eq!(v.status, Status::Downloading);
697 assert!((v.progress - 0.25).abs() < f32::EPSILON);
698 assert_eq!(v.downloaded_bytes, 50);
699 }
700
701 #[test]
702 fn schedule_windows_and_midnight() {
703 use crate::config::ScheduleEntry;
704 let entry = |start: &str, end: &str, up: u32| ScheduleEntry {
705 start: start.into(),
706 end: end.into(),
707 upload_bps: Some(up),
708 download_bps: None,
709 };
710 let sched = vec![entry("08:00", "12:00", 100), entry("23:00", "02:00", 200)];
711 assert_eq!(active_limits(&sched, 9 * 60), (Some(100), None));
712 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));
715 assert_eq!(active_limits(&sched, 3 * 60), (None, None));
716 assert_eq!(parse_hhmm("08:30"), Some(510));
717 assert_eq!(parse_hhmm("x"), None);
718 }
719}