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 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 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 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 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 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 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 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
469async 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
482fn 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
496fn local_minutes() -> u32 {
499 use chrono::Timelike;
500 chrono::Local::now().num_seconds_from_midnight() / 60
501}
502
503fn 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 };
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
530fn 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 assert_eq!(
639 derive_status(&stats(TorrentStatsState::Paused), &meta(false)),
640 Status::Queued
641 );
642 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 assert_eq!(
653 derive_status(&stats(TorrentStatsState::Live), &meta_queued()),
654 Status::Queued
655 );
656 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)); assert_eq!(active_limits(&sched, 23 * 60 + 30), (Some(200), None)); 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}