1use crate::config::EngineConfig;
8use crate::error::{EngineError, Result};
9#[cfg(feature = "http")]
10use crate::http::{HttpDownloader, MirrorManager, SegmentedDownload};
11use crate::priority_queue::{DownloadPriority, PriorityQueue};
12use crate::scheduler::{BandwidthLimits, BandwidthScheduler};
13#[cfg(feature = "storage")]
14use crate::storage::SqliteStorage;
15use crate::storage::{Segment, Storage};
16#[cfg(feature = "torrent")]
17use crate::torrent::{MagnetUri, Metainfo, TorrentConfig, TorrentDownloader};
18use crate::types::{
19 DownloadEvent, DownloadId, DownloadOptions, DownloadState, DownloadStatus, GlobalStats,
20};
21#[cfg(any(feature = "http", feature = "torrent"))]
22use crate::types::{DownloadKind, DownloadMetadata, DownloadProgress};
23#[cfg(feature = "torrent")]
24use crate::types::{TorrentFile, TorrentStatusInfo};
25
26#[cfg(all(feature = "http", feature = "recursive-http"))]
27use crate::http::crawl;
28#[cfg(any(feature = "http", feature = "torrent"))]
29use chrono::Utc;
30use parking_lot::RwLock;
31#[cfg(all(feature = "http", feature = "recursive-http"))]
32use serde::{Deserialize, Serialize};
33use std::collections::HashMap;
34#[cfg(all(feature = "http", feature = "recursive-http"))]
35use std::collections::HashSet;
36use std::sync::{Arc, Weak};
37#[cfg(feature = "torrent")]
38use std::time::Duration;
39use tokio::sync::broadcast;
40#[cfg(feature = "http")]
41use url::Url;
42#[cfg(all(feature = "http", feature = "recursive-http"))]
43use uuid::Uuid;
44
45const EVENT_CHANNEL_CAPACITY: usize = 1024;
47#[cfg(all(feature = "http", feature = "recursive-http"))]
48const RECURSIVE_EVENT_CHANNEL_CAPACITY: usize = 256;
49
50struct ManagedDownload {
52 status: DownloadStatus,
53 handle: Option<DownloadHandle>,
54 #[cfg(feature = "http")]
56 cached_segments: Option<Vec<Segment>>,
57 #[cfg(all(feature = "http", feature = "recursive-http"))]
58 redirect_scope: Option<crawl::RedirectScope>,
59 #[cfg(all(feature = "http", feature = "recursive-http"))]
60 recursive_group_id: Option<Uuid>,
61}
62
63#[cfg(all(feature = "http", feature = "recursive-http"))]
64struct RecursiveGroup {
65 child_ids: HashSet<DownloadId>,
66 fail_fast: bool,
67 failed: bool,
68}
69
70#[cfg(all(feature = "http", feature = "recursive-http"))]
71struct RecursiveFailFastAbort {
72 id: DownloadId,
73 old_state: DownloadState,
74 error_message: String,
75 status: DownloadStatus,
76 cancel_token: Option<tokio_util::sync::CancellationToken>,
77}
78
79#[cfg(all(feature = "http", feature = "recursive-http"))]
80#[derive(Debug, Clone, Serialize, Deserialize)]
81struct PersistedDownloadRuntimeMetadata {
82 #[serde(default)]
83 recursive_child: Option<PersistedRecursiveChildState>,
84}
85
86#[cfg(all(feature = "http", feature = "recursive-http"))]
87#[derive(Debug, Clone, Serialize, Deserialize)]
88struct PersistedRecursiveChildState {
89 redirect_scope: crawl::PersistedRedirectScope,
90 recursive_group_id: Option<Uuid>,
91 fail_fast: bool,
92}
93
94#[allow(dead_code)]
96enum DownloadHandle {
97 #[cfg(feature = "http")]
98 Http(HttpDownloadHandle),
99 #[cfg(feature = "torrent")]
100 Torrent(TorrentDownloadHandle),
101}
102
103#[cfg(feature = "http")]
105struct HttpDownloadHandle {
106 cancel_token: tokio_util::sync::CancellationToken,
107 task: tokio::task::JoinHandle<Result<()>>,
108 segmented_download: Arc<RwLock<Option<Arc<SegmentedDownload>>>>,
111}
112
113#[cfg(feature = "torrent")]
115struct TorrentDownloadHandle {
116 downloader: Arc<TorrentDownloader>,
117 task: tokio::task::JoinHandle<Result<()>>,
118 progress_task: tokio::task::JoinHandle<()>,
119}
120
121#[derive(Debug, Default)]
124pub struct BatchResult {
125 pub succeeded: Vec<DownloadId>,
127 pub skipped: Vec<DownloadId>,
130 pub failed: Vec<(DownloadId, EngineError)>,
132}
133
134pub struct DownloadEngine {
136 self_ref: Weak<Self>,
138
139 config: RwLock<EngineConfig>,
141
142 downloads: RwLock<HashMap<DownloadId, ManagedDownload>>,
144 #[cfg(all(feature = "http", feature = "recursive-http"))]
145 recursive_groups: RwLock<HashMap<Uuid, RecursiveGroup>>,
146 #[cfg(all(feature = "http", feature = "recursive-http"))]
147 recursive_jobs: RwLock<HashMap<Uuid, crate::types::TrackedRecursiveJob>>,
148 #[cfg(all(feature = "http", feature = "recursive-http"))]
149 recursive_job_membership: RwLock<HashMap<DownloadId, HashSet<Uuid>>>,
150
151 #[cfg(feature = "http")]
153 http: Arc<HttpDownloader>,
154
155 event_tx: broadcast::Sender<DownloadEvent>,
157 #[cfg(all(feature = "http", feature = "recursive-http"))]
158 recursive_job_event_tx: broadcast::Sender<crate::types::RecursiveJobEvent>,
159
160 priority_queue: Arc<PriorityQueue>,
162
163 scheduler: Arc<RwLock<BandwidthScheduler>>,
165
166 shutdown: tokio_util::sync::CancellationToken,
168
169 storage: Option<Arc<dyn Storage>>,
171}
172
173impl DownloadEngine {
174 fn arc(&self) -> Result<Arc<Self>> {
176 self.self_ref.upgrade().ok_or(EngineError::Shutdown)
177 }
178
179 pub async fn new(config: EngineConfig) -> Result<Arc<Self>> {
186 config.validate()?;
188
189 #[cfg(feature = "storage")]
191 let storage: Option<Arc<dyn Storage>> = if let Some(ref db_path) = config.database_path {
192 match SqliteStorage::new(db_path).await {
193 Ok(s) => Some(Arc::new(s)),
194 Err(e) => {
195 tracing::warn!("Failed to initialize database storage: {}. Downloads will not be persisted.", e);
196 None
197 }
198 }
199 } else {
200 None
201 };
202 #[cfg(not(feature = "storage"))]
203 let storage: Option<Arc<dyn Storage>> = None;
204
205 Self::build(config, storage).await
206 }
207
208 pub async fn with_storage(
233 config: EngineConfig,
234 storage: Arc<dyn Storage>,
235 ) -> Result<Arc<Self>> {
236 config.validate()?;
237
238 if config.database_path.is_some() {
239 tracing::warn!(
240 "Both an injected storage and config.database_path are set; \
241 using the injected storage and ignoring database_path"
242 );
243 }
244
245 Self::build(config, Some(storage)).await
246 }
247
248 async fn build(config: EngineConfig, storage: Option<Arc<dyn Storage>>) -> Result<Arc<Self>> {
251 let (event_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
253 #[cfg(all(feature = "http", feature = "recursive-http"))]
254 let (recursive_job_event_tx, _) = broadcast::channel(RECURSIVE_EVENT_CHANNEL_CAPACITY);
255
256 #[cfg(feature = "http")]
258 let http = Arc::new(HttpDownloader::new(&config)?);
259
260 let priority_queue = PriorityQueue::new(config.max_concurrent_downloads);
262
263 let scheduler = Arc::new(RwLock::new(BandwidthScheduler::new(
265 config.schedule_rules.clone(),
266 BandwidthLimits {
267 download: config.global_download_limit,
268 upload: config.global_upload_limit,
269 },
270 )));
271
272 let engine = Arc::new_cyclic(|weak| Self {
273 self_ref: weak.clone(),
274 config: RwLock::new(config),
275 downloads: RwLock::new(HashMap::new()),
276 #[cfg(all(feature = "http", feature = "recursive-http"))]
277 recursive_groups: RwLock::new(HashMap::new()),
278 #[cfg(all(feature = "http", feature = "recursive-http"))]
279 recursive_jobs: RwLock::new(HashMap::new()),
280 #[cfg(all(feature = "http", feature = "recursive-http"))]
281 recursive_job_membership: RwLock::new(HashMap::new()),
282 #[cfg(feature = "http")]
283 http,
284 event_tx,
285 #[cfg(all(feature = "http", feature = "recursive-http"))]
286 recursive_job_event_tx,
287 priority_queue,
288 scheduler,
289 shutdown: tokio_util::sync::CancellationToken::new(),
290 storage,
291 });
292
293 engine.load_persisted_downloads().await?;
295 #[cfg(all(feature = "http", feature = "recursive-http"))]
296 engine.load_persisted_recursive_jobs().await?;
297
298 Self::start_persistence_task(Arc::clone(&engine));
300
301 Self::start_scheduler_task(Arc::clone(&engine));
303
304 Ok(engine)
305 }
306
307 fn start_persistence_task(engine: Arc<Self>) {
312 if engine.storage.is_none() {
313 return; }
315
316 let shutdown = engine.shutdown.clone();
317 tokio::spawn(async move {
318 const PERSISTENCE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
319 let mut interval = tokio::time::interval(PERSISTENCE_INTERVAL);
320
321 loop {
322 tokio::select! {
323 _ = interval.tick() => {
324 if let Err(e) = engine.persist_active_downloads().await {
325 tracing::warn!("Failed to persist active downloads: {}", e);
326 }
327 }
328 _ = shutdown.cancelled() => {
329 if let Err(e) = engine.persist_active_downloads().await {
331 tracing::warn!("Failed to persist downloads on shutdown: {}", e);
332 }
333 break;
334 }
335 }
336 }
337 });
338 }
339
340 fn start_scheduler_task(engine: Arc<Self>) {
345 let shutdown = engine.shutdown.clone();
346 tokio::spawn(async move {
347 const SCHEDULER_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
348 let mut interval = tokio::time::interval(SCHEDULER_INTERVAL);
349
350 loop {
351 tokio::select! {
352 _ = interval.tick() => {
353 if engine.scheduler.read().update() {
354 let limits = engine.scheduler.read().get_limits();
355 #[cfg(feature = "http")]
356 engine
357 .http
358 .set_bandwidth_limits(limits.download, limits.upload);
359 }
360 }
361 _ = shutdown.cancelled() => {
362 break;
363 }
364 }
365 }
366 });
367 }
368
369 async fn persist_active_downloads(&self) -> Result<()> {
371 let storage = match &self.storage {
372 Some(s) => s,
373 None => return Ok(()),
374 };
375
376 let active_downloads: Vec<(DownloadStatus, Option<Vec<crate::storage::Segment>>)> = {
378 let downloads = self.downloads.read();
379 downloads
380 .values()
381 .filter(|d| d.status.state.is_active())
382 .map(|d| {
383 let segments = match &d.handle {
384 #[cfg(feature = "http")]
385 Some(DownloadHandle::Http(h)) => h
386 .segmented_download
387 .read()
388 .as_ref()
389 .map(|sd| sd.segments_with_progress()),
390 _ => None,
391 };
392 (d.status.clone(), segments)
393 })
394 .collect()
395 };
396
397 for (status, segments_opt) in active_downloads {
399 if let Err(e) = storage.save_download(&status).await {
400 tracing::debug!("Failed to persist download {}: {}", status.id, e);
401 }
402
403 if let Some(segments) = segments_opt {
405 if let Err(e) = storage.save_segments(status.id, &segments).await {
406 tracing::debug!("Failed to persist segments for {}: {}", status.id, e);
407 }
408 }
409 }
410
411 Ok(())
412 }
413
414 async fn load_persisted_downloads(&self) -> Result<()> {
416 let storage = match &self.storage {
417 Some(s) => s,
418 None => return Ok(()), };
420
421 let persisted = storage.load_all().await?;
422 #[cfg(all(feature = "http", feature = "recursive-http"))]
423 let runtime_metadata = storage.load_all_runtime_metadata().await?;
424 #[cfg(all(feature = "http", feature = "recursive-http"))]
425 let mut restored_groups = HashMap::new();
426
427 for status in persisted {
428 let restored_state = match &status.state {
430 DownloadState::Downloading | DownloadState::Connecting => DownloadState::Paused,
431 DownloadState::Seeding => DownloadState::Paused, other => other.clone(),
433 };
434
435 let mut restored_status = status.clone();
436 restored_status.state = restored_state;
437 restored_status.progress.download_speed = 0;
439 restored_status.progress.upload_speed = 0;
440 restored_status.progress.connections = 0;
441
442 #[cfg(all(feature = "http", feature = "recursive-http"))]
443 let (redirect_scope, recursive_group_id, recursive_fail_fast) =
444 if let Some(runtime_json) = runtime_metadata.get(&status.id) {
445 match self.parse_persisted_runtime_metadata(runtime_json) {
446 Ok(runtime) => match runtime.recursive_child {
447 Some(recursive_child) => (
448 Some(crawl::RedirectScope::from_persisted(
449 recursive_child.redirect_scope,
450 )?),
451 recursive_child.recursive_group_id,
452 recursive_child.fail_fast,
453 ),
454 None => (None, None, false),
455 },
456 Err(e) => {
457 tracing::warn!(
458 "Failed to parse runtime metadata for {}: {}",
459 status.id,
460 e
461 );
462 (None, None, false)
463 }
464 }
465 } else {
466 (None, None, false)
467 };
468
469 #[cfg(all(feature = "http", feature = "recursive-http"))]
470 if let Some(group_id) = recursive_group_id {
471 if recursive_fail_fast
472 && !matches!(
473 restored_status.state,
474 DownloadState::Completed | DownloadState::Error { .. }
475 )
476 {
477 restored_groups
478 .entry(group_id)
479 .or_insert_with(|| RecursiveGroup {
480 child_ids: HashSet::new(),
481 fail_fast: true,
482 failed: false,
483 })
484 .child_ids
485 .insert(status.id);
486 }
487 }
488
489 self.downloads.write().insert(
491 status.id,
492 ManagedDownload {
493 status: restored_status,
494 handle: None,
495 #[cfg(feature = "http")]
496 cached_segments: None,
497 #[cfg(all(feature = "http", feature = "recursive-http"))]
498 redirect_scope,
499 #[cfg(all(feature = "http", feature = "recursive-http"))]
500 recursive_group_id,
501 },
502 );
503
504 tracing::info!(
505 "Restored download {} ({}) in state {:?}",
506 status.id,
507 status.metadata.name,
508 status.state
509 );
510 }
511
512 #[cfg(all(feature = "http", feature = "recursive-http"))]
513 {
514 self.recursive_groups.write().extend(restored_groups);
515 }
516
517 Ok(())
518 }
519
520 #[cfg(all(feature = "http", feature = "recursive-http"))]
521 async fn load_persisted_recursive_jobs(&self) -> Result<()> {
522 let storage = match &self.storage {
523 Some(s) => s,
524 None => return Ok(()),
525 };
526
527 let jobs = storage.load_recursive_jobs().await?;
528 if jobs.is_empty() {
529 return Ok(());
530 }
531
532 let mut recursive_jobs = self.recursive_jobs.write();
533 for job in jobs {
534 self.register_recursive_job_membership(&job);
535 recursive_jobs.insert(job.id, job);
536 }
537
538 Ok(())
539 }
540
541 #[cfg(all(feature = "http", feature = "recursive-http"))]
542 fn register_recursive_job_membership(&self, job: &crate::types::TrackedRecursiveJob) {
543 let mut membership = self.recursive_job_membership.write();
544 for child_id in &job.child_ids {
545 membership.entry(*child_id).or_default().insert(job.id);
546 }
547 }
548
549 #[cfg(all(feature = "http", feature = "recursive-http"))]
550 fn unregister_recursive_job_membership(&self, job: &crate::types::TrackedRecursiveJob) {
551 let mut membership = self.recursive_job_membership.write();
552 for child_id in &job.child_ids {
553 let should_remove = if let Some(job_ids) = membership.get_mut(child_id) {
554 job_ids.remove(&job.id);
555 job_ids.is_empty()
556 } else {
557 false
558 };
559 if should_remove {
560 membership.remove(child_id);
561 }
562 }
563 }
564
565 #[cfg(feature = "torrent")]
566 fn build_torrent_status_info(metainfo: &Metainfo) -> TorrentStatusInfo {
567 TorrentStatusInfo {
568 files: metainfo
569 .info
570 .files
571 .iter()
572 .enumerate()
573 .map(|(i, f)| TorrentFile {
574 index: i,
575 path: f.path.clone(),
576 size: f.length,
577 completed: 0,
578 selected: true,
579 })
580 .collect(),
581 piece_length: metainfo.info.piece_length,
582 pieces_count: metainfo.info.pieces.len(),
583 private: metainfo.info.private,
584 }
585 }
586
587 #[cfg(feature = "http")]
589 pub async fn add_http(&self, url: &str, options: DownloadOptions) -> Result<DownloadId> {
590 self.add_http_internal(
591 url,
592 options,
593 #[cfg(all(feature = "http", feature = "recursive-http"))]
594 None,
595 #[cfg(all(feature = "http", feature = "recursive-http"))]
596 None,
597 )
598 .await
599 }
600
601 #[cfg(feature = "http")]
602 async fn add_http_internal(
603 &self,
604 url: &str,
605 options: DownloadOptions,
606 #[cfg(all(feature = "http", feature = "recursive-http"))] redirect_scope: Option<
607 crawl::RedirectScope,
608 >,
609 #[cfg(all(feature = "http", feature = "recursive-http"))] recursive_group_id: Option<Uuid>,
610 ) -> Result<DownloadId> {
611 let parsed_url = Url::parse(url)
613 .map_err(|e| EngineError::invalid_input("url", format!("Invalid URL: {}", e)))?;
614
615 match parsed_url.scheme() {
617 "http" | "https" => {}
618 scheme => {
619 return Err(EngineError::invalid_input(
620 "url",
621 format!("Unsupported scheme: {}", scheme),
622 ));
623 }
624 }
625
626 #[cfg(all(feature = "http", feature = "recursive-http"))]
627 if let Some(group_id) = recursive_group_id {
628 if !self.recursive_groups.read().contains_key(&group_id) {
629 return Err(EngineError::Internal(format!(
630 "recursive group {} missing for child download",
631 group_id
632 )));
633 }
634 }
635
636 let id = DownloadId::new();
638
639 let save_dir = options
641 .save_dir
642 .clone()
643 .unwrap_or_else(|| self.config.read().download_dir.clone());
644
645 let filename = options.filename.clone().or_else(|| {
647 parsed_url
648 .path_segments()
649 .and_then(|mut segments| segments.next_back())
650 .map(|s| s.to_string())
651 .filter(|s| !s.is_empty())
652 });
653
654 let name = filename.clone().unwrap_or_else(|| "download".to_string());
655
656 let status = DownloadStatus {
658 id,
659 kind: DownloadKind::Http,
660 state: DownloadState::Queued,
661 priority: options.priority,
662 progress: DownloadProgress::default(),
663 metadata: DownloadMetadata {
664 name,
665 url: Some(url.to_string()),
666 magnet_uri: None,
667 info_hash: None,
668 save_dir,
669 filename,
670 user_agent: options.user_agent.clone(),
671 referer: options.referer.clone(),
672 headers: options.headers.clone(),
673 cookies: options.cookies.clone().unwrap_or_default(),
674 checksum: options.checksum.clone(),
675 mirrors: options.mirrors.clone(),
676 etag: None,
677 last_modified: None,
678 },
679 torrent_info: None,
680 peers: None,
681 created_at: Utc::now(),
682 completed_at: None,
683 };
684
685 #[cfg(all(feature = "http", feature = "recursive-http"))]
686 let runtime_metadata_json =
687 self.build_persisted_runtime_metadata(redirect_scope.as_ref(), recursive_group_id)?;
688
689 {
691 let mut downloads = self.downloads.write();
692 downloads.insert(
693 id,
694 ManagedDownload {
695 status: status.clone(),
696 handle: None,
697 #[cfg(feature = "http")]
698 cached_segments: None,
699 #[cfg(all(feature = "http", feature = "recursive-http"))]
700 redirect_scope,
701 #[cfg(all(feature = "http", feature = "recursive-http"))]
702 recursive_group_id,
703 },
704 );
705 }
706
707 #[cfg(all(feature = "http", feature = "recursive-http"))]
708 if let Some(group_id) = recursive_group_id {
709 if let Some(group) = self.recursive_groups.write().get_mut(&group_id) {
710 group.child_ids.insert(id);
711 }
712 }
713
714 if let Some(ref storage) = self.storage {
716 if let Err(e) = storage.save_download(&status).await {
717 tracing::warn!("Failed to persist new download {}: {}", id, e);
718 }
719 #[cfg(all(feature = "http", feature = "recursive-http"))]
720 if let Some(runtime_json) = runtime_metadata_json {
721 if let Err(e) = storage.save_runtime_metadata(id, &runtime_json).await {
722 tracing::warn!("Failed to persist runtime metadata for {}: {}", id, e);
723 }
724 }
725 }
726
727 let _ = self.event_tx.send(DownloadEvent::Added { id });
729
730 self.start_download(id, url.to_string(), options, None)
732 .await?;
733
734 Ok(id)
735 }
736
737 #[cfg(all(feature = "http", feature = "recursive-http"))]
743 pub async fn discover_http_recursive(
744 &self,
745 root_url: &str,
746 options: &DownloadOptions,
747 recursive: &crate::types::RecursiveOptions,
748 ) -> Result<crate::types::RecursiveManifest> {
749 crawl::discover(&self.http, root_url, options, recursive).await
750 }
751
752 #[cfg(all(feature = "http", feature = "recursive-http"))]
757 pub async fn add_http_recursive(
758 &self,
759 root_url: &str,
760 options: DownloadOptions,
761 recursive: crate::types::RecursiveOptions,
762 ) -> Result<crate::types::RecursiveJob> {
763 let manifest = self
764 .discover_http_recursive(root_url, &options, &recursive)
765 .await?;
766 self.enqueue_recursive_manifest(manifest, options, &recursive)
767 .await
768 }
769
770 #[cfg(all(feature = "http", feature = "recursive-http"))]
771 async fn enqueue_recursive_manifest(
772 &self,
773 manifest: crate::types::RecursiveManifest,
774 options: DownloadOptions,
775 recursive: &crate::types::RecursiveOptions,
776 ) -> Result<crate::types::RecursiveJob> {
777 let redirect_scope = crawl::RedirectScope::new(&manifest.root_url, recursive)?;
778 let recursive_group_id = if recursive.fail_fast && !manifest.entries.is_empty() {
779 let group_id = Uuid::new_v4();
780 self.recursive_groups.write().insert(
781 group_id,
782 RecursiveGroup {
783 child_ids: HashSet::new(),
784 fail_fast: true,
785 failed: false,
786 },
787 );
788 Some(group_id)
789 } else {
790 None
791 };
792
793 let base_save_dir = options
794 .save_dir
795 .clone()
796 .unwrap_or_else(|| self.config.read().download_dir.clone());
797 let mut child_ids = Vec::with_capacity(manifest.entries.len());
798
799 for entry in &manifest.entries {
800 let mut child_options = options.clone();
801 child_options.save_dir = Some(match entry.relative_path.parent() {
802 Some(parent) if !parent.as_os_str().is_empty() => base_save_dir.join(parent),
803 _ => base_save_dir.clone(),
804 });
805 child_options.filename = entry
806 .relative_path
807 .file_name()
808 .map(|name| name.to_string_lossy().to_string());
809 child_options.checksum = None;
810 child_options.mirrors.clear();
811
812 let child_id = match self
813 .add_http_internal(
814 &entry.url,
815 child_options,
816 Some(redirect_scope.clone()),
817 recursive_group_id,
818 )
819 .await
820 {
821 Ok(child_id) => child_id,
822 Err(err) => {
823 self.rollback_recursive_enqueue(&child_ids, recursive_group_id)
824 .await;
825 return Err(err);
826 }
827 };
828 child_ids.push(child_id);
829 }
830
831 let tracked_job = crate::types::TrackedRecursiveJob {
832 id: Uuid::new_v4(),
833 root_url: manifest.root_url.clone(),
834 child_ids: child_ids.clone(),
835 created_at: Utc::now(),
836 };
837
838 self.register_recursive_job_membership(&tracked_job);
839 self.recursive_jobs
840 .write()
841 .insert(tracked_job.id, tracked_job.clone());
842
843 if let Some(ref storage) = self.storage {
844 if let Err(e) = storage.save_recursive_job(&tracked_job).await {
845 tracing::warn!("Failed to persist recursive job {}: {}", tracked_job.id, e);
846 }
847 }
848
849 let status = self.recursive_job_status(&tracked_job.as_job());
850 let _ = self
851 .recursive_job_event_tx
852 .send(crate::types::RecursiveJobEvent::Added {
853 job: tracked_job,
854 status,
855 });
856
857 Ok(crate::types::RecursiveJob {
858 root_url: manifest.root_url,
859 child_ids,
860 })
861 }
862
863 #[cfg(all(feature = "http", feature = "recursive-http"))]
864 async fn rollback_recursive_enqueue(
865 &self,
866 child_ids: &[DownloadId],
867 recursive_group_id: Option<Uuid>,
868 ) {
869 for child_id in child_ids {
870 if self.status(*child_id).is_some() {
871 let _ = self.cancel(*child_id, false).await;
872 }
873 }
874
875 if let Some(group_id) = recursive_group_id {
876 self.recursive_groups.write().remove(&group_id);
877 }
878 }
879
880 #[cfg(all(feature = "http", feature = "recursive-http"))]
882 pub fn list_recursive_jobs(&self) -> Vec<crate::types::TrackedRecursiveJob> {
883 let mut jobs = self
884 .recursive_jobs
885 .read()
886 .values()
887 .cloned()
888 .collect::<Vec<_>>();
889 jobs.sort_by_key(|job| std::cmp::Reverse(job.created_at));
890 jobs
891 }
892
893 #[cfg(all(feature = "http", feature = "recursive-http"))]
895 pub fn recursive_job(&self, id: Uuid) -> Option<crate::types::TrackedRecursiveJob> {
896 self.recursive_jobs.read().get(&id).cloned()
897 }
898
899 #[cfg(all(feature = "http", feature = "recursive-http"))]
901 pub fn subscribe_recursive_jobs(&self) -> broadcast::Receiver<crate::types::RecursiveJobEvent> {
902 self.recursive_job_event_tx.subscribe()
903 }
904
905 #[cfg(all(feature = "http", feature = "recursive-http"))]
906 fn emit_recursive_job_update(&self, id: Uuid) {
907 if let Some(job) = self.recursive_job(id) {
908 let status = self.recursive_job_status(&job.as_job());
909 let _ = self
910 .recursive_job_event_tx
911 .send(crate::types::RecursiveJobEvent::Updated { job, status });
912 }
913 }
914
915 #[cfg(all(feature = "http", feature = "recursive-http"))]
916 fn emit_recursive_job_updates_for_child(&self, child_id: DownloadId) {
917 let job_ids = self
918 .recursive_job_membership
919 .read()
920 .get(&child_id)
921 .cloned()
922 .unwrap_or_default();
923 for job_id in job_ids {
924 self.emit_recursive_job_update(job_id);
925 }
926 }
927
928 #[cfg(all(feature = "http", feature = "recursive-http"))]
933 pub async fn cancel_recursive_job(&self, id: Uuid, delete_files: bool) -> Result<()> {
934 let job = self
935 .recursive_job(id)
936 .ok_or_else(|| EngineError::NotFound(id.to_string()))?;
937
938 for child_id in job.child_ids {
939 if self.status(child_id).is_some() {
940 self.cancel(child_id, delete_files).await?;
941 }
942 }
943
944 Ok(())
945 }
946
947 #[cfg(all(feature = "http", feature = "recursive-http"))]
949 pub async fn remove_recursive_job(&self, id: Uuid, delete_files: bool) -> Result<()> {
950 let job = self
951 .recursive_job(id)
952 .ok_or_else(|| EngineError::NotFound(id.to_string()))?;
953
954 for child_id in &job.child_ids {
955 if self.status(*child_id).is_some() {
956 self.cancel(*child_id, delete_files).await?;
957 }
958 }
959
960 self.recursive_jobs.write().remove(&id);
961 self.unregister_recursive_job_membership(&job);
962 if let Some(ref storage) = self.storage {
963 if let Err(e) = storage.delete_recursive_job(id).await {
964 tracing::warn!("Failed to delete recursive job {}: {}", id, e);
965 }
966 }
967 let _ = self
968 .recursive_job_event_tx
969 .send(crate::types::RecursiveJobEvent::Removed { id });
970
971 Ok(())
972 }
973
974 #[cfg(all(feature = "http", feature = "recursive-http"))]
976 pub fn recursive_job_status(
977 &self,
978 job: &crate::types::RecursiveJob,
979 ) -> crate::types::RecursiveJobStatus {
980 let mut progress = crate::types::RecursiveJobProgress {
981 total_children: job.child_ids.len(),
982 ..Default::default()
983 };
984 let mut summed_total_size = 0u64;
985 let mut all_total_sizes_known = true;
986
987 for child_id in &job.child_ids {
988 match self.status(*child_id) {
989 Some(status) => {
990 progress.completed_size = progress
991 .completed_size
992 .saturating_add(status.progress.completed_size);
993
994 if let Some(total_size) = status.progress.total_size {
995 summed_total_size = summed_total_size.saturating_add(total_size);
996 } else {
997 all_total_sizes_known = false;
998 }
999
1000 match status.state {
1001 DownloadState::Queued => progress.queued_children += 1,
1002 DownloadState::Connecting
1003 | DownloadState::Downloading
1004 | DownloadState::Seeding => progress.active_children += 1,
1005 DownloadState::Paused => progress.paused_children += 1,
1006 DownloadState::Completed => progress.completed_children += 1,
1007 DownloadState::Error { .. } => progress.failed_children += 1,
1008 }
1009 }
1010 None => {
1011 progress.missing_children += 1;
1012 all_total_sizes_known = false;
1013 }
1014 }
1015 }
1016
1017 progress.total_size = if all_total_sizes_known {
1018 Some(summed_total_size)
1019 } else {
1020 None
1021 };
1022
1023 let state = if progress.total_children == 0 {
1024 crate::types::RecursiveJobState::Empty
1025 } else if progress.completed_children == progress.total_children {
1026 crate::types::RecursiveJobState::Completed
1027 } else if progress.failed_children + progress.missing_children == progress.total_children {
1028 crate::types::RecursiveJobState::Failed
1029 } else if progress.failed_children > 0 || progress.missing_children > 0 {
1030 crate::types::RecursiveJobState::Partial
1031 } else if progress.active_children > 0 {
1032 crate::types::RecursiveJobState::Running
1033 } else if progress.paused_children > 0 {
1034 crate::types::RecursiveJobState::Paused
1035 } else if progress.queued_children > 0 {
1036 crate::types::RecursiveJobState::Queued
1037 } else {
1038 crate::types::RecursiveJobState::Partial
1039 };
1040
1041 crate::types::RecursiveJobStatus {
1042 root_url: job.root_url.clone(),
1043 child_ids: job.child_ids.clone(),
1044 state,
1045 progress,
1046 }
1047 }
1048
1049 #[cfg(all(feature = "http", feature = "recursive-http"))]
1050 fn build_persisted_runtime_metadata(
1051 &self,
1052 redirect_scope: Option<&crawl::RedirectScope>,
1053 recursive_group_id: Option<Uuid>,
1054 ) -> Result<Option<String>> {
1055 let runtime = match redirect_scope {
1056 Some(redirect_scope) => PersistedDownloadRuntimeMetadata {
1057 recursive_child: Some(PersistedRecursiveChildState {
1058 redirect_scope: redirect_scope.to_persisted(),
1059 recursive_group_id,
1060 fail_fast: recursive_group_id
1061 .and_then(|group_id| {
1062 self.recursive_groups
1063 .read()
1064 .get(&group_id)
1065 .map(|g| g.fail_fast)
1066 })
1067 .unwrap_or(false),
1068 }),
1069 },
1070 None => PersistedDownloadRuntimeMetadata {
1071 recursive_child: None,
1072 },
1073 };
1074
1075 if runtime.recursive_child.is_none() {
1076 return Ok(None);
1077 }
1078
1079 serde_json::to_string(&runtime).map(Some).map_err(|e| {
1080 EngineError::Internal(format!("Failed to serialize runtime metadata: {}", e))
1081 })
1082 }
1083
1084 #[cfg(all(feature = "http", feature = "recursive-http"))]
1085 fn parse_persisted_runtime_metadata(
1086 &self,
1087 runtime_json: &str,
1088 ) -> Result<PersistedDownloadRuntimeMetadata> {
1089 serde_json::from_str(runtime_json).map_err(|e| {
1090 EngineError::Internal(format!("Failed to deserialize runtime metadata: {}", e))
1091 })
1092 }
1093
1094 #[cfg(all(feature = "http", feature = "recursive-http"))]
1095 fn remove_recursive_group_member(&self, recursive_group_id: Option<Uuid>, id: DownloadId) {
1096 let Some(group_id) = recursive_group_id else {
1097 return;
1098 };
1099
1100 let mut groups = self.recursive_groups.write();
1101 let should_remove_group = if let Some(group) = groups.get_mut(&group_id) {
1102 group.child_ids.remove(&id);
1103 group.child_ids.is_empty()
1104 } else {
1105 false
1106 };
1107
1108 if should_remove_group {
1109 groups.remove(&group_id);
1110 }
1111 }
1112
1113 #[cfg(all(feature = "http", feature = "recursive-http"))]
1114 async fn trigger_recursive_fail_fast(
1115 &self,
1116 failed_id: DownloadId,
1117 failed_message: &str,
1118 ) -> Result<()> {
1119 let recursive_group_id = {
1120 let downloads = self.downloads.read();
1121 downloads.get(&failed_id).and_then(|d| d.recursive_group_id)
1122 };
1123 let Some(group_id) = recursive_group_id else {
1124 return Ok(());
1125 };
1126
1127 let sibling_ids = {
1128 let mut groups = self.recursive_groups.write();
1129 let Some(group) = groups.get_mut(&group_id) else {
1130 return Ok(());
1131 };
1132
1133 if !group.fail_fast || group.failed {
1134 return Ok(());
1135 }
1136
1137 group.failed = true;
1138 group
1139 .child_ids
1140 .iter()
1141 .copied()
1142 .filter(|id| *id != failed_id)
1143 .collect::<Vec<_>>()
1144 };
1145
1146 let fail_fast_message = format!(
1147 "Aborted because recursive sibling download {} failed: {}",
1148 failed_id, failed_message
1149 );
1150
1151 let impacted = {
1152 let mut downloads = self.downloads.write();
1153 let mut impacted = Vec::new();
1154
1155 for sibling_id in sibling_ids {
1156 let Some(download) = downloads.get_mut(&sibling_id) else {
1157 continue;
1158 };
1159
1160 if !matches!(
1161 download.status.state,
1162 DownloadState::Queued | DownloadState::Connecting | DownloadState::Downloading
1163 ) {
1164 continue;
1165 }
1166
1167 let old_state = download.status.state.clone();
1168 download.status.state = DownloadState::Error {
1169 kind: "RecursiveFailFast".to_string(),
1170 message: fail_fast_message.clone(),
1171 retryable: false,
1172 };
1173
1174 impacted.push(RecursiveFailFastAbort {
1175 id: sibling_id,
1176 old_state,
1177 error_message: fail_fast_message.clone(),
1178 status: download.status.clone(),
1179 cancel_token: match download.handle.as_ref() {
1180 Some(DownloadHandle::Http(handle)) => Some(handle.cancel_token.clone()),
1181 _ => None,
1182 },
1183 });
1184 }
1185
1186 impacted
1187 };
1188
1189 for abort in &impacted {
1190 if let Some(cancel_token) = &abort.cancel_token {
1191 cancel_token.cancel();
1192 }
1193 }
1194
1195 if let Some(ref storage) = self.storage {
1196 for abort in &impacted {
1197 if let Err(e) = storage.save_download(&abort.status).await {
1198 tracing::debug!(
1199 "Failed to persist recursive fail-fast state for {}: {}",
1200 abort.id,
1201 e
1202 );
1203 }
1204 }
1205 }
1206
1207 for abort in impacted {
1208 let new_state = DownloadState::Error {
1209 kind: "RecursiveFailFast".to_string(),
1210 message: abort.error_message.clone(),
1211 retryable: false,
1212 };
1213
1214 let _ = self.event_tx.send(DownloadEvent::StateChanged {
1215 id: abort.id,
1216 old_state: abort.old_state,
1217 new_state,
1218 });
1219 let _ = self.event_tx.send(DownloadEvent::Failed {
1220 id: abort.id,
1221 error: abort.error_message.clone(),
1222 retryable: false,
1223 });
1224 self.emit_recursive_job_updates_for_child(abort.id);
1225 self.remove_recursive_group_member(Some(group_id), abort.id);
1226 }
1227
1228 Ok(())
1229 }
1230
1231 #[cfg(feature = "torrent")]
1233 pub async fn add_torrent(
1234 &self,
1235 torrent_data: &[u8],
1236 options: DownloadOptions,
1237 ) -> Result<DownloadId> {
1238 let metainfo = Metainfo::parse(torrent_data)?;
1240
1241 let id = DownloadId::new();
1243
1244 let save_dir = options
1246 .save_dir
1247 .clone()
1248 .unwrap_or_else(|| self.config.read().download_dir.clone());
1249
1250 let status = DownloadStatus {
1252 id,
1253 kind: DownloadKind::Torrent,
1254 state: DownloadState::Queued,
1255 priority: options.priority,
1256 progress: DownloadProgress::default(),
1257 metadata: DownloadMetadata {
1258 name: metainfo.info.name.clone(),
1259 url: None,
1260 magnet_uri: None,
1261 info_hash: Some(hex::encode(metainfo.info_hash)),
1262 save_dir: save_dir.clone(),
1263 filename: Some(metainfo.info.name.clone()),
1264 user_agent: options.user_agent.clone(),
1265 referer: None,
1266 headers: Vec::new(),
1267 cookies: Vec::new(),
1268 checksum: None,
1269 mirrors: Vec::new(),
1270 etag: None,
1271 last_modified: None,
1272 },
1273 torrent_info: Some(Self::build_torrent_status_info(&metainfo)),
1274 peers: Some(Vec::new()),
1275 created_at: Utc::now(),
1276 completed_at: None,
1277 };
1278
1279 {
1281 let mut downloads = self.downloads.write();
1282 downloads.insert(
1283 id,
1284 ManagedDownload {
1285 status: status.clone(),
1286 handle: None,
1287 #[cfg(feature = "http")]
1288 cached_segments: None,
1289 #[cfg(all(feature = "http", feature = "recursive-http"))]
1290 redirect_scope: None,
1291 #[cfg(all(feature = "http", feature = "recursive-http"))]
1292 recursive_group_id: None,
1293 },
1294 );
1295 }
1296
1297 if let Some(ref storage) = self.storage {
1299 if let Err(e) = storage.save_download(&status).await {
1300 tracing::warn!("Failed to persist new torrent download {}: {}", id, e);
1301 }
1302 if let Err(e) = storage.save_torrent_data(id, torrent_data).await {
1304 tracing::warn!("Failed to persist torrent data for {}: {}", id, e);
1305 }
1306 }
1307
1308 let _ = self.event_tx.send(DownloadEvent::Added { id });
1310
1311 self.start_torrent(id, metainfo, save_dir, options).await?;
1313
1314 Ok(id)
1315 }
1316
1317 #[cfg(feature = "torrent")]
1319 pub async fn add_magnet(
1320 &self,
1321 magnet_uri: &str,
1322 options: DownloadOptions,
1323 ) -> Result<DownloadId> {
1324 let magnet = MagnetUri::parse(magnet_uri)?;
1326
1327 let id = DownloadId::new();
1329
1330 let save_dir = options
1332 .save_dir
1333 .clone()
1334 .unwrap_or_else(|| self.config.read().download_dir.clone());
1335
1336 let status = DownloadStatus {
1338 id,
1339 kind: DownloadKind::Magnet,
1340 state: DownloadState::Queued,
1341 priority: options.priority,
1342 progress: DownloadProgress::default(),
1343 metadata: DownloadMetadata {
1344 name: magnet.name(),
1345 url: None,
1346 magnet_uri: Some(magnet_uri.to_string()),
1347 info_hash: Some(hex::encode(magnet.info_hash)),
1348 save_dir: save_dir.clone(),
1349 filename: magnet.display_name.clone(),
1350 user_agent: options.user_agent.clone(),
1351 referer: None,
1352 headers: Vec::new(),
1353 cookies: Vec::new(),
1354 checksum: None,
1355 mirrors: Vec::new(),
1356 etag: None,
1357 last_modified: None,
1358 },
1359 torrent_info: None, peers: Some(Vec::new()),
1361 created_at: Utc::now(),
1362 completed_at: None,
1363 };
1364
1365 {
1367 let mut downloads = self.downloads.write();
1368 downloads.insert(
1369 id,
1370 ManagedDownload {
1371 status: status.clone(),
1372 handle: None,
1373 #[cfg(feature = "http")]
1374 cached_segments: None,
1375 #[cfg(all(feature = "http", feature = "recursive-http"))]
1376 redirect_scope: None,
1377 #[cfg(all(feature = "http", feature = "recursive-http"))]
1378 recursive_group_id: None,
1379 },
1380 );
1381 }
1382
1383 if let Some(ref storage) = self.storage {
1385 if let Err(e) = storage.save_download(&status).await {
1386 tracing::warn!("Failed to persist new magnet download {}: {}", id, e);
1387 }
1388 }
1389
1390 let _ = self.event_tx.send(DownloadEvent::Added { id });
1392
1393 self.start_magnet(id, magnet, save_dir, options).await?;
1395
1396 Ok(id)
1397 }
1398
1399 #[cfg(feature = "torrent")]
1401 fn build_torrent_runtime_config(&self, options: &DownloadOptions) -> TorrentConfig {
1402 let config = self.config.read();
1403 TorrentConfig {
1404 max_peers: config.max_peers,
1405 listen_port_range: config.torrent.listen_port_range,
1406 enable_dht: config.enable_dht,
1407 enable_pex: config.enable_pex,
1408 enable_lpd: config.enable_lpd,
1409 seed_ratio: options.seed_ratio.or(Some(config.seed_ratio)),
1410 max_upload_speed: options
1411 .max_upload_speed
1412 .or(config.global_upload_limit)
1413 .unwrap_or(0),
1414 max_download_speed: options
1415 .max_download_speed
1416 .or(config.global_download_limit)
1417 .unwrap_or(0),
1418 announce_interval: config.torrent.tracker_update_interval,
1419 request_timeout: Duration::from_secs(config.torrent.peer_timeout),
1420 keepalive_interval: Duration::from_secs(120),
1421 max_pending_requests: config.torrent.max_pending_requests,
1422 dht_bootstrap_nodes: config.torrent.dht_bootstrap_nodes.clone(),
1423 tick_interval_ms: config.torrent.tick_interval_ms,
1424 connect_interval_secs: config.torrent.connect_interval_secs,
1425 choking_interval_secs: config.torrent.choking_interval_secs,
1426 enable_utp: config.torrent.utp.enabled
1427 && config.torrent.utp.policy != crate::config::TransportPolicy::TcpOnly,
1428 }
1429 }
1430
1431 #[cfg(feature = "torrent")]
1433 async fn start_torrent(
1434 &self,
1435 id: DownloadId,
1436 metainfo: Metainfo,
1437 save_dir: std::path::PathBuf,
1438 options: DownloadOptions,
1439 ) -> Result<()> {
1440 let torrent_config = self.build_torrent_runtime_config(&options);
1441 let (webseed_config, encryption_config, transport_policy, tcp_fallback) = {
1442 let config = self.config.read();
1443 let encryption = if config.torrent.encryption.policy
1444 == crate::config::EncryptionPolicy::Preferred
1445 && config.torrent.encryption.allow_plaintext
1446 && config.torrent.encryption.allow_rc4
1447 && config.torrent.encryption.min_padding == 0
1448 && config.torrent.encryption.max_padding == 512
1449 {
1450 crate::config::EncryptionConfig {
1451 policy: crate::config::EncryptionPolicy::Disabled,
1452 ..config.torrent.encryption.clone()
1453 }
1454 } else {
1455 config.torrent.encryption.clone()
1456 };
1457 (
1458 config.torrent.webseed.clone(),
1459 encryption,
1460 config.torrent.utp.policy,
1461 config.torrent.utp.tcp_fallback,
1462 )
1463 };
1464
1465 let downloader = Arc::new(TorrentDownloader::from_torrent(
1466 id,
1467 metainfo,
1468 save_dir,
1469 torrent_config,
1470 self.event_tx.clone(),
1471 )?);
1472 downloader.set_webseed_config(webseed_config);
1473 downloader.set_mse_config(encryption_config);
1474 downloader.set_transport_policy(transport_policy, tcp_fallback);
1475
1476 if let Some(ref selected) = options.selected_files {
1478 downloader.set_selected_files(Some(selected));
1479 }
1480
1481 if let Some(sequential) = options.sequential {
1483 downloader.set_sequential(sequential);
1484 }
1485
1486 let downloader_clone = Arc::clone(&downloader);
1487 let engine = self.arc()?;
1488
1489 self.update_state(id, DownloadState::Connecting)?;
1491
1492 let task = tokio::spawn(async move {
1493 if let Err(e) = Arc::clone(&downloader_clone).start().await {
1495 let error_msg = e.to_string();
1496 engine.update_state(
1497 id,
1498 DownloadState::Error {
1499 kind: format!("{:?}", e),
1500 message: error_msg.clone(),
1501 retryable: e.is_retryable(),
1502 },
1503 )?;
1504 if let Some(ref storage) = engine.storage {
1506 let status = engine.downloads.read().get(&id).map(|d| d.status.clone());
1507 if let Some(status) = status {
1508 if let Err(e) = storage.save_download(&status).await {
1509 tracing::debug!("Failed to persist error state for {}: {}", id, e);
1510 }
1511 }
1512 }
1513 let _ = engine.event_tx.send(DownloadEvent::Failed {
1514 id,
1515 error: error_msg,
1516 retryable: e.is_retryable(),
1517 });
1518 return Ok(());
1519 }
1520
1521 engine.update_state(id, DownloadState::Downloading)?;
1523 let _ = engine.event_tx.send(DownloadEvent::Started { id });
1524
1525 let downloader_ref = Arc::clone(&downloader_clone);
1527 if let Err(e) = downloader_clone.run_peer_loop().await {
1528 let error_msg = e.to_string();
1529 engine.update_state(
1530 id,
1531 DownloadState::Error {
1532 kind: format!("{:?}", e),
1533 message: error_msg.clone(),
1534 retryable: e.is_retryable(),
1535 },
1536 )?;
1537 if let Some(ref storage) = engine.storage {
1539 let status = engine.downloads.read().get(&id).map(|d| d.status.clone());
1540 if let Some(status) = status {
1541 if let Err(e) = storage.save_download(&status).await {
1542 tracing::debug!("Failed to persist error state for {}: {}", id, e);
1543 }
1544 }
1545 }
1546 let _ = engine.event_tx.send(DownloadEvent::Failed {
1547 id,
1548 error: error_msg,
1549 retryable: e.is_retryable(),
1550 });
1551 } else if downloader_ref.is_complete() {
1552 let should_complete = {
1554 let mut downloads = engine.downloads.write();
1555 if let Some(download) = downloads.get_mut(&id) {
1556 if download.status.state == DownloadState::Paused {
1557 false
1558 } else {
1559 download.status.state = DownloadState::Completed;
1560 download.status.completed_at = Some(Utc::now());
1561 true
1562 }
1563 } else {
1564 false
1565 }
1566 };
1567
1568 if should_complete {
1569 if let Some(ref storage) = engine.storage {
1571 let status = engine.downloads.read().get(&id).map(|d| d.status.clone());
1572 if let Some(status) = status {
1573 if let Err(e) = storage.save_download(&status).await {
1574 tracing::debug!(
1575 "Failed to persist completed state for {}: {}",
1576 id,
1577 e
1578 );
1579 }
1580 }
1581 }
1582
1583 let _ = engine.event_tx.send(DownloadEvent::Completed { id });
1584 }
1585 }
1586
1587 Ok(())
1588 });
1589
1590 let progress_task =
1591 Self::spawn_torrent_progress_task(self.arc()?, id, Arc::clone(&downloader));
1592
1593 {
1595 let mut downloads = self.downloads.write();
1596 if let Some(download) = downloads.get_mut(&id) {
1597 download.handle = Some(DownloadHandle::Torrent(TorrentDownloadHandle {
1598 downloader,
1599 task,
1600 progress_task,
1601 }));
1602 }
1603 }
1604
1605 Ok(())
1606 }
1607
1608 #[cfg(feature = "torrent")]
1610 async fn start_magnet(
1611 &self,
1612 id: DownloadId,
1613 magnet: MagnetUri,
1614 save_dir: std::path::PathBuf,
1615 options: DownloadOptions,
1616 ) -> Result<()> {
1617 let torrent_config = self.build_torrent_runtime_config(&options);
1618 let (webseed_config, encryption_config, transport_policy, tcp_fallback) = {
1619 let config = self.config.read();
1620 let encryption = if config.torrent.encryption.policy
1621 == crate::config::EncryptionPolicy::Preferred
1622 && config.torrent.encryption.allow_plaintext
1623 && config.torrent.encryption.allow_rc4
1624 && config.torrent.encryption.min_padding == 0
1625 && config.torrent.encryption.max_padding == 512
1626 {
1627 crate::config::EncryptionConfig {
1628 policy: crate::config::EncryptionPolicy::Disabled,
1629 ..config.torrent.encryption.clone()
1630 }
1631 } else {
1632 config.torrent.encryption.clone()
1633 };
1634 (
1635 config.torrent.webseed.clone(),
1636 encryption,
1637 config.torrent.utp.policy,
1638 config.torrent.utp.tcp_fallback,
1639 )
1640 };
1641
1642 let downloader = Arc::new(TorrentDownloader::from_magnet(
1643 id,
1644 magnet,
1645 save_dir,
1646 torrent_config,
1647 self.event_tx.clone(),
1648 )?);
1649 downloader.set_webseed_config(webseed_config);
1650 downloader.set_mse_config(encryption_config);
1651 downloader.set_transport_policy(transport_policy, tcp_fallback);
1652
1653 if let Some(ref selected) = options.selected_files {
1654 downloader.set_selected_files(Some(selected));
1655 }
1656
1657 if let Some(sequential) = options.sequential {
1659 downloader.set_sequential(sequential);
1660 }
1661
1662 let downloader_clone = Arc::clone(&downloader);
1663 let engine = self.arc()?;
1664
1665 self.update_state(id, DownloadState::Connecting)?;
1667
1668 let task = tokio::spawn(async move {
1669 if let Err(e) = Arc::clone(&downloader_clone).start().await {
1671 let error_msg = e.to_string();
1672 engine.update_state(
1673 id,
1674 DownloadState::Error {
1675 kind: format!("{:?}", e),
1676 message: error_msg.clone(),
1677 retryable: e.is_retryable(),
1678 },
1679 )?;
1680 if let Some(ref storage) = engine.storage {
1682 let status = engine.downloads.read().get(&id).map(|d| d.status.clone());
1683 if let Some(status) = status {
1684 if let Err(e) = storage.save_download(&status).await {
1685 tracing::debug!("Failed to persist error state for {}: {}", id, e);
1686 }
1687 }
1688 }
1689 let _ = engine.event_tx.send(DownloadEvent::Failed {
1690 id,
1691 error: error_msg,
1692 retryable: e.is_retryable(),
1693 });
1694 return Ok(());
1695 }
1696
1697 engine.update_state(id, DownloadState::Downloading)?;
1699 let _ = engine.event_tx.send(DownloadEvent::Started { id });
1700
1701 let downloader_ref = Arc::clone(&downloader_clone);
1703 if let Err(e) = downloader_clone.run_peer_loop().await {
1704 let error_msg = e.to_string();
1705 engine.update_state(
1706 id,
1707 DownloadState::Error {
1708 kind: format!("{:?}", e),
1709 message: error_msg.clone(),
1710 retryable: e.is_retryable(),
1711 },
1712 )?;
1713 if let Some(ref storage) = engine.storage {
1715 let status = engine.downloads.read().get(&id).map(|d| d.status.clone());
1716 if let Some(status) = status {
1717 if let Err(e) = storage.save_download(&status).await {
1718 tracing::debug!("Failed to persist error state for {}: {}", id, e);
1719 }
1720 }
1721 }
1722 let _ = engine.event_tx.send(DownloadEvent::Failed {
1723 id,
1724 error: error_msg,
1725 retryable: e.is_retryable(),
1726 });
1727 } else if downloader_ref.is_complete() {
1728 let should_complete = {
1730 let mut downloads = engine.downloads.write();
1731 if let Some(download) = downloads.get_mut(&id) {
1732 if download.status.state == DownloadState::Paused {
1733 false
1734 } else {
1735 download.status.state = DownloadState::Completed;
1736 download.status.completed_at = Some(Utc::now());
1737 true
1738 }
1739 } else {
1740 false
1741 }
1742 };
1743
1744 if should_complete {
1745 if let Some(ref storage) = engine.storage {
1747 let status = engine.downloads.read().get(&id).map(|d| d.status.clone());
1748 if let Some(status) = status {
1749 if let Err(e) = storage.save_download(&status).await {
1750 tracing::debug!(
1751 "Failed to persist completed state for {}: {}",
1752 id,
1753 e
1754 );
1755 }
1756 }
1757 }
1758
1759 let _ = engine.event_tx.send(DownloadEvent::Completed { id });
1760 }
1761 }
1762
1763 Ok(())
1764 });
1765
1766 let progress_task =
1767 Self::spawn_torrent_progress_task(self.arc()?, id, Arc::clone(&downloader));
1768
1769 {
1771 let mut downloads = self.downloads.write();
1772 if let Some(download) = downloads.get_mut(&id) {
1773 download.handle = Some(DownloadHandle::Torrent(TorrentDownloadHandle {
1774 downloader,
1775 task,
1776 progress_task,
1777 }));
1778 }
1779 }
1780
1781 Ok(())
1782 }
1783
1784 #[cfg(feature = "torrent")]
1785 fn spawn_torrent_progress_task(
1786 engine: Arc<Self>,
1787 id: DownloadId,
1788 downloader: Arc<TorrentDownloader>,
1789 ) -> tokio::task::JoinHandle<()> {
1790 let shutdown = engine.shutdown.clone();
1791 tokio::spawn(async move {
1792 let mut interval = tokio::time::interval(Duration::from_millis(500));
1793 loop {
1794 tokio::select! {
1795 _ = shutdown.cancelled() => break,
1796 _ = interval.tick() => {}
1797 }
1798
1799 let progress = downloader.progress();
1800 let metainfo = downloader.metainfo();
1801 let (send_progress, persist_torrent_data) = {
1802 let mut downloads = engine.downloads.write();
1803 let download = match downloads.get_mut(&id) {
1804 Some(download) => download,
1805 None => break,
1806 };
1807
1808 if matches!(
1809 download.status.state,
1810 DownloadState::Error { .. } | DownloadState::Completed
1811 ) {
1812 break;
1813 }
1814
1815 let mut needs_persist = false;
1816 if let Some(ref metainfo) = metainfo {
1817 if download.status.torrent_info.is_none() {
1818 download.status.torrent_info =
1819 Some(Self::build_torrent_status_info(metainfo));
1820 if download.status.kind == DownloadKind::Magnet {
1822 needs_persist = true;
1823 }
1824 }
1825 if download.status.metadata.name != metainfo.info.name {
1826 download.status.metadata.name = metainfo.info.name.clone();
1827 }
1828 if download.status.metadata.filename.as_deref()
1829 != Some(metainfo.info.name.as_str())
1830 {
1831 download.status.metadata.filename = Some(metainfo.info.name.clone());
1832 }
1833 }
1834
1835 download.status.progress = progress.clone();
1836 (download.status.state.is_active(), needs_persist)
1837 };
1838
1839 if persist_torrent_data {
1841 if let Some(ref storage) = engine.storage {
1842 if let Some(raw_data) = downloader.raw_torrent_data() {
1843 if let Err(e) = storage.save_torrent_data(id, &raw_data).await {
1844 tracing::warn!(
1845 "Failed to persist magnet torrent data for {}: {}",
1846 id,
1847 e
1848 );
1849 }
1850 }
1851 }
1852 }
1853
1854 if send_progress {
1855 let _ = engine
1856 .event_tx
1857 .send(DownloadEvent::Progress { id, progress });
1858 }
1859 }
1860 })
1861 }
1862
1863 #[cfg(feature = "http")]
1865 async fn start_download(
1866 &self,
1867 id: DownloadId,
1868 url: String,
1869 options: DownloadOptions,
1870 saved_segments: Option<Vec<crate::storage::Segment>>,
1871 ) -> Result<()> {
1872 let engine = self.arc()?;
1873 let http = Arc::clone(&self.http);
1874 let priority_queue = Arc::clone(&self.priority_queue);
1875 let priority = options.priority;
1876 let cancel_token = tokio_util::sync::CancellationToken::new();
1877 let cancel_token_clone = cancel_token.clone();
1878
1879 let segmented_ref: Arc<RwLock<Option<Arc<SegmentedDownload>>>> =
1881 Arc::new(RwLock::new(None));
1882 let segmented_ref_for_task = Arc::clone(&segmented_ref);
1883
1884 self.update_state(id, DownloadState::Queued)?;
1886
1887 let task = tokio::spawn(async move {
1888 let _permit = tokio::select! {
1891 permit = priority_queue.acquire(id, priority) => permit,
1892 _ = cancel_token_clone.cancelled() => {
1893 priority_queue.remove(id);
1896 return Ok(());
1897 }
1898 };
1899
1900 if cancel_token_clone.is_cancelled() {
1902 return Ok(());
1903 }
1904
1905 engine.update_state(id, DownloadState::Connecting)?;
1907 engine.update_state(id, DownloadState::Downloading)?;
1908 let _ = engine.event_tx.send(DownloadEvent::Started { id });
1909
1910 #[cfg(feature = "recursive-http")]
1912 let (
1913 save_dir,
1914 filename,
1915 user_agent,
1916 referer,
1917 headers,
1918 cookies,
1919 checksum,
1920 mirrors,
1921 redirect_scope,
1922 recursive_group_id,
1923 ) = {
1924 let downloads = engine.downloads.read();
1925 let download = downloads
1926 .get(&id)
1927 .ok_or_else(|| EngineError::NotFound(id.to_string()))?;
1928 (
1929 download.status.metadata.save_dir.clone(),
1930 download.status.metadata.filename.clone(),
1931 download.status.metadata.user_agent.clone(),
1932 download.status.metadata.referer.clone(),
1933 download.status.metadata.headers.clone(),
1934 download.status.metadata.cookies.clone(),
1935 download.status.metadata.checksum.clone(),
1936 download.status.metadata.mirrors.clone(),
1937 download.redirect_scope.clone(),
1938 download.recursive_group_id,
1939 )
1940 };
1941 #[cfg(not(feature = "recursive-http"))]
1942 let (save_dir, filename, user_agent, referer, headers, cookies, checksum, mirrors) = {
1943 let downloads = engine.downloads.read();
1944 let download = downloads
1945 .get(&id)
1946 .ok_or_else(|| EngineError::NotFound(id.to_string()))?;
1947 (
1948 download.status.metadata.save_dir.clone(),
1949 download.status.metadata.filename.clone(),
1950 download.status.metadata.user_agent.clone(),
1951 download.status.metadata.referer.clone(),
1952 download.status.metadata.headers.clone(),
1953 download.status.metadata.cookies.clone(),
1954 download.status.metadata.checksum.clone(),
1955 download.status.metadata.mirrors.clone(),
1956 )
1957 };
1958
1959 let engine_clone = Arc::clone(&engine);
1961 let progress_callback = Arc::new(move |progress: DownloadProgress| {
1962 {
1964 let mut downloads = engine_clone.downloads.write();
1965 if let Some(download) = downloads.get_mut(&id) {
1966 download.status.progress = progress.clone();
1967 }
1968 }
1969 let _ = engine_clone
1971 .event_tx
1972 .send(DownloadEvent::Progress { id, progress });
1973 #[cfg(feature = "recursive-http")]
1974 engine_clone.emit_recursive_job_updates_for_child(id);
1975 });
1976
1977 let (max_connections, min_segment_size) = {
1979 let config = engine.config.read();
1980 (
1981 options
1982 .max_connections
1983 .unwrap_or(config.max_connections_per_download),
1984 config.min_segment_size,
1985 )
1986 };
1987
1988 let cookies_opt = if cookies.is_empty() {
1990 None
1991 } else {
1992 Some(cookies.as_slice())
1993 };
1994 let mirror_manager = MirrorManager::new(url.clone(), mirrors);
1995 let mut active_url = mirror_manager.current_url().to_string();
1996 let mut saved_segments = saved_segments;
1997 let result = loop {
1998 let attempt_url = active_url.clone();
1999 let attempt_result = http
2000 .download_segmented_with_scope(
2001 &attempt_url,
2002 &save_dir,
2003 filename.as_deref(),
2004 user_agent.as_deref(),
2005 referer.as_deref(),
2006 &headers,
2007 cookies_opt,
2008 checksum.as_ref(),
2009 #[cfg(feature = "recursive-http")]
2010 redirect_scope.clone(),
2011 max_connections,
2012 min_segment_size,
2013 cancel_token_clone.clone(),
2014 saved_segments.take(),
2015 {
2016 let progress_callback = Arc::clone(&progress_callback);
2017 move |progress| progress_callback(progress)
2018 },
2019 Some(Arc::clone(&segmented_ref_for_task)),
2020 )
2021 .await;
2022
2023 match attempt_result {
2024 Ok(result) => break Ok(result),
2025 Err(err) => {
2026 if let Some(next_url) = mirror_manager.failover_from(&attempt_url) {
2027 if next_url != attempt_url {
2028 tracing::warn!(
2029 "Download {} failed on {} ({}). Failing over to {}",
2030 id,
2031 attempt_url,
2032 err,
2033 next_url
2034 );
2035 active_url = next_url.to_string();
2036 saved_segments = None;
2037 continue;
2038 }
2039 }
2040 break Err(err);
2041 }
2042 }
2043 };
2044
2045 match result {
2046 Ok((final_path, _segmented_download)) => {
2047 let should_complete = {
2049 let mut downloads = engine.downloads.write();
2050 if let Some(download) = downloads.get_mut(&id) {
2051 if download.status.state == DownloadState::Paused {
2053 false
2054 } else {
2055 download.status.state = DownloadState::Completed;
2056 download.status.completed_at = Some(Utc::now());
2057 download.status.metadata.filename = final_path
2058 .file_name()
2059 .map(|s| s.to_string_lossy().to_string());
2060 true
2061 }
2062 } else {
2063 false
2064 }
2065 };
2066
2067 if should_complete {
2068 if let Some(ref storage) = engine.storage {
2070 let status = engine.downloads.read().get(&id).map(|d| d.status.clone());
2071 if let Some(status) = status {
2072 if let Err(e) = storage.save_download(&status).await {
2073 tracing::debug!(
2074 "Failed to persist completed state for {}: {}",
2075 id,
2076 e
2077 );
2078 }
2079 }
2080 }
2081
2082 if let Some(ref storage) = engine.storage {
2084 if let Err(e) = storage.delete_segments(id).await {
2085 tracing::debug!("Failed to clean up segments for {}: {}", id, e);
2086 }
2087 }
2088
2089 let _ = engine.event_tx.send(DownloadEvent::Completed { id });
2090 }
2091
2092 #[cfg(feature = "recursive-http")]
2093 engine.emit_recursive_job_updates_for_child(id);
2094 #[cfg(feature = "recursive-http")]
2095 engine.remove_recursive_group_member(recursive_group_id, id);
2096 }
2097 Err(e) if cancel_token_clone.is_cancelled() => {
2098 let _ = e;
2100 }
2101 Err(e) => {
2102 let retryable = e.is_retryable();
2103 let error_msg = e.to_string();
2104
2105 #[cfg(feature = "http")]
2107 {
2108 let segments: Option<Vec<Segment>> = segmented_ref_for_task
2109 .read()
2110 .as_ref()
2111 .map(|sd| sd.segments_with_progress());
2112
2113 if let Some(ref segs) = segments {
2114 {
2116 let mut downloads = engine.downloads.write();
2117 if let Some(download) = downloads.get_mut(&id) {
2118 download.cached_segments = Some(segs.clone());
2119 }
2120 }
2121 if let Some(ref storage) = engine.storage {
2123 if let Err(e) = storage.save_segments(id, segs).await {
2124 tracing::debug!(
2125 "Failed to persist segments on error for {}: {}",
2126 id,
2127 e
2128 );
2129 }
2130 }
2131 }
2132 }
2133
2134 engine.update_state(
2136 id,
2137 DownloadState::Error {
2138 kind: format!("{:?}", e),
2139 message: error_msg.clone(),
2140 retryable,
2141 },
2142 )?;
2143
2144 if let Some(ref storage) = engine.storage {
2146 let status = engine.downloads.read().get(&id).map(|d| d.status.clone());
2147 if let Some(status) = status {
2148 if let Err(e) = storage.save_download(&status).await {
2149 tracing::debug!("Failed to persist error state for {}: {}", id, e);
2150 }
2151 }
2152 }
2153
2154 let _ = engine.event_tx.send(DownloadEvent::Failed {
2155 id,
2156 error: error_msg.clone(),
2157 retryable,
2158 });
2159
2160 #[cfg(feature = "recursive-http")]
2161 {
2162 engine.emit_recursive_job_updates_for_child(id);
2163 engine.trigger_recursive_fail_fast(id, &error_msg).await?;
2164 engine.remove_recursive_group_member(recursive_group_id, id);
2165 }
2166 }
2167 }
2168
2169 Ok(())
2170 });
2171
2172 {
2174 let mut downloads = self.downloads.write();
2175 if let Some(download) = downloads.get_mut(&id) {
2176 download.handle = Some(DownloadHandle::Http(HttpDownloadHandle {
2177 cancel_token,
2178 task,
2179 segmented_download: segmented_ref,
2180 }));
2181 }
2182 }
2183
2184 Ok(())
2185 }
2186
2187 pub async fn pause(&self, id: DownloadId) -> Result<()> {
2189 let (status_to_save, segments_to_save) = {
2190 let mut downloads = self.downloads.write();
2191 let download = downloads
2192 .get_mut(&id)
2193 .ok_or_else(|| EngineError::NotFound(id.to_string()))?;
2194
2195 if !download.status.state.is_active() && download.status.state != DownloadState::Queued
2199 {
2200 return Err(EngineError::InvalidState {
2201 action: "pause",
2202 current_state: format!("{:?}", download.status.state),
2203 });
2204 }
2205
2206 let segments: Option<Vec<Segment>> = match &download.handle {
2208 #[cfg(feature = "http")]
2209 Some(DownloadHandle::Http(h)) => h
2210 .segmented_download
2211 .read()
2212 .as_ref()
2213 .map(|sd| sd.segments_with_progress()),
2214 _ => None,
2215 };
2216
2217 if let Some(handle) = download.handle.take() {
2219 match handle {
2220 #[cfg(feature = "http")]
2221 DownloadHandle::Http(h) => {
2222 h.cancel_token.cancel();
2223 }
2225 #[cfg(feature = "torrent")]
2226 DownloadHandle::Torrent(h) => {
2227 h.downloader.pause();
2228 download.handle = Some(DownloadHandle::Torrent(h));
2229 }
2231 }
2232 }
2233
2234 let old_state = download.status.state.clone();
2236 download.status.state = DownloadState::Paused;
2237
2238 let _ = self.event_tx.send(DownloadEvent::StateChanged {
2240 id,
2241 old_state,
2242 new_state: DownloadState::Paused,
2243 });
2244 let _ = self.event_tx.send(DownloadEvent::Paused { id });
2245
2246 #[cfg(feature = "http")]
2248 {
2249 download.cached_segments = segments.clone();
2250 }
2251
2252 (download.status.clone(), segments)
2253 };
2254
2255 if let Some(ref storage) = self.storage {
2257 if let Err(e) = storage.save_download(&status_to_save).await {
2258 tracing::warn!("Failed to persist paused download {}: {}", id, e);
2259 }
2260 if let Some(segments) = segments_to_save {
2262 if let Err(e) = storage.save_segments(id, &segments).await {
2263 tracing::warn!(
2264 "Failed to persist segments for paused download {}: {}",
2265 id,
2266 e
2267 );
2268 }
2269 }
2270 }
2271
2272 Ok(())
2273 }
2274
2275 pub async fn resume(&self, id: DownloadId) -> Result<()> {
2277 #[allow(unused_variables)]
2279 let (kind, url, options, has_torrent_handle) = {
2280 let downloads = self.downloads.read();
2281 let download = downloads
2282 .get(&id)
2283 .ok_or_else(|| EngineError::NotFound(id.to_string()))?;
2284
2285 if download.status.state != DownloadState::Paused {
2287 return Err(EngineError::InvalidState {
2288 action: "resume",
2289 current_state: format!("{:?}", download.status.state),
2290 });
2291 }
2292
2293 #[cfg(feature = "torrent")]
2294 let has_torrent_handle = matches!(download.handle, Some(DownloadHandle::Torrent(_)));
2295 #[cfg(not(feature = "torrent"))]
2296 let has_torrent_handle = false;
2297
2298 let options = DownloadOptions {
2299 priority: download.status.priority,
2300 save_dir: Some(download.status.metadata.save_dir.clone()),
2301 filename: download.status.metadata.filename.clone(),
2302 user_agent: download.status.metadata.user_agent.clone(),
2303 referer: download.status.metadata.referer.clone(),
2304 headers: download.status.metadata.headers.clone(),
2305 cookies: if download.status.metadata.cookies.is_empty() {
2306 None
2307 } else {
2308 Some(download.status.metadata.cookies.clone())
2309 },
2310 checksum: download.status.metadata.checksum.clone(),
2311 mirrors: download.status.metadata.mirrors.clone(),
2312 ..Default::default()
2313 };
2314
2315 (
2316 download.status.kind,
2317 download.status.metadata.url.clone(),
2318 options,
2319 has_torrent_handle,
2320 )
2321 };
2322
2323 #[allow(unreachable_code)]
2324 {
2325 match kind {
2326 #[cfg(feature = "http")]
2327 DownloadKind::Http => {
2328 let url = url.ok_or_else(|| {
2330 EngineError::Internal("HTTP download missing URL".to_string())
2331 })?;
2332
2333 let mut saved_segments = if let Some(ref storage) = self.storage {
2335 match storage.load_segments(id).await {
2336 Ok(segments) if !segments.is_empty() => {
2337 tracing::debug!(
2338 "Loaded {} saved segments for download {}",
2339 segments.len(),
2340 id
2341 );
2342 Some(segments)
2343 }
2344 Ok(_) => None,
2345 Err(e) => {
2346 tracing::debug!("Failed to load segments for {}: {}", id, e);
2347 None
2348 }
2349 }
2350 } else {
2351 None
2352 };
2353
2354 if saved_segments.is_none() {
2356 let mut downloads = self.downloads.write();
2357 if let Some(download) = downloads.get_mut(&id) {
2358 saved_segments = download.cached_segments.take();
2359 if saved_segments.is_some() {
2360 tracing::debug!("Using cached segments for download {}", id);
2361 }
2362 }
2363 }
2364
2365 self.start_download(id, url, options, saved_segments)
2366 .await?;
2367 }
2368 #[cfg(feature = "torrent")]
2369 DownloadKind::Torrent | DownloadKind::Magnet => {
2370 if has_torrent_handle {
2371 let mut downloads = self.downloads.write();
2373 if let Some(download) = downloads.get_mut(&id) {
2374 if let Some(DownloadHandle::Torrent(ref h)) = download.handle {
2375 h.downloader.resume();
2376 download.status.state = DownloadState::Downloading;
2377 }
2378 }
2379 } else {
2380 let torrent_data = if let Some(ref storage) = self.storage {
2382 storage.load_torrent_data(id).await.unwrap_or(None)
2383 } else {
2384 None
2385 };
2386
2387 if let Some(data) = torrent_data {
2388 let metainfo = Metainfo::parse(&data)?;
2389 let save_dir = {
2390 let downloads = self.downloads.read();
2391 downloads
2392 .get(&id)
2393 .map(|d| d.status.metadata.save_dir.clone())
2394 .unwrap_or_else(|| self.config.read().download_dir.clone())
2395 };
2396 self.start_torrent(id, metainfo, save_dir, options).await?;
2397 } else if let Some(ref magnet_uri) = {
2398 let downloads = self.downloads.read();
2399 downloads
2400 .get(&id)
2401 .and_then(|d| d.status.metadata.magnet_uri.clone())
2402 } {
2403 let magnet = MagnetUri::parse(magnet_uri)?;
2405 let save_dir = {
2406 let downloads = self.downloads.read();
2407 downloads
2408 .get(&id)
2409 .map(|d| d.status.metadata.save_dir.clone())
2410 .unwrap_or_else(|| self.config.read().download_dir.clone())
2411 };
2412 self.start_magnet(id, magnet, save_dir, options).await?;
2413 } else {
2414 return Err(EngineError::Internal(
2415 "Torrent download has no handle and no stored data for recovery"
2416 .to_string(),
2417 ));
2418 }
2419 }
2420 }
2421 #[allow(unreachable_patterns)]
2422 _ => {
2423 return Err(EngineError::Internal(format!(
2424 "Feature not enabled for download kind {:?}",
2425 kind
2426 )));
2427 }
2428 }
2429
2430 let _ = self.event_tx.send(DownloadEvent::Resumed { id });
2431 }
2432
2433 Ok(())
2434 }
2435
2436 pub async fn cancel(&self, id: DownloadId, delete_files: bool) -> Result<()> {
2438 let (handle, save_path, recursive_group_id) = {
2439 let mut downloads = self.downloads.write();
2440 let download = downloads
2441 .remove(&id)
2442 .ok_or_else(|| EngineError::NotFound(id.to_string()))?;
2443
2444 let save_path = if delete_files {
2445 Some(
2446 download.status.metadata.save_dir.join(
2447 download
2448 .status
2449 .metadata
2450 .filename
2451 .as_deref()
2452 .unwrap_or("download"),
2453 ),
2454 )
2455 } else {
2456 None
2457 };
2458
2459 (
2460 download.handle,
2461 save_path,
2462 #[cfg(all(feature = "http", feature = "recursive-http"))]
2463 download.recursive_group_id,
2464 #[cfg(not(all(feature = "http", feature = "recursive-http")))]
2465 None::<uuid::Uuid>,
2466 )
2467 };
2468
2469 if let Some(handle) = handle {
2471 match handle {
2472 #[cfg(feature = "http")]
2473 DownloadHandle::Http(h) => {
2474 h.cancel_token.cancel();
2475 }
2476 #[cfg(feature = "torrent")]
2477 DownloadHandle::Torrent(h) => {
2478 drop(h.downloader.stop());
2479 h.progress_task.abort();
2480 h.task.abort();
2481 }
2482 }
2483 }
2484
2485 if let Some(path) = save_path {
2487 if path.exists() {
2488 if path.is_dir() {
2489 tokio::fs::remove_dir_all(&path).await.ok();
2491 } else {
2492 tokio::fs::remove_file(&path).await.ok();
2494 }
2495 }
2496 let partial_path = path.with_extension("part");
2498 if partial_path.exists() {
2499 tokio::fs::remove_file(&partial_path).await.ok();
2500 }
2501 }
2502
2503 if let Some(ref storage) = self.storage {
2505 if let Err(e) = storage.delete_segments(id).await {
2506 tracing::debug!(
2507 "Failed to clean up segments for cancelled download {}: {}",
2508 id,
2509 e
2510 );
2511 }
2512 if let Err(e) = storage.delete_download(id).await {
2513 tracing::debug!("Failed to delete download record for {}: {}", id, e);
2514 }
2515 }
2516
2517 let _ = self.event_tx.send(DownloadEvent::Removed { id });
2518
2519 #[cfg(all(feature = "http", feature = "recursive-http"))]
2520 self.emit_recursive_job_updates_for_child(id);
2521 #[cfg(all(feature = "http", feature = "recursive-http"))]
2522 self.remove_recursive_group_member(recursive_group_id, id);
2523
2524 Ok(())
2525 }
2526
2527 pub async fn pause_all(&self) -> BatchResult {
2535 let ids: Vec<DownloadId> = {
2536 let downloads = self.downloads.read();
2537 downloads
2538 .iter()
2539 .filter(|(_, d)| {
2540 d.status.state.is_active() || d.status.state == DownloadState::Queued
2541 })
2542 .map(|(id, _)| *id)
2543 .collect()
2544 };
2545
2546 let mut result = BatchResult::default();
2547 for id in ids {
2548 match self.pause(id).await {
2549 Ok(()) => result.succeeded.push(id),
2550 Err(EngineError::InvalidState { .. }) | Err(EngineError::NotFound(_)) => {
2551 result.skipped.push(id);
2552 }
2553 Err(e) => result.failed.push((id, e)),
2554 }
2555 }
2556 result
2557 }
2558
2559 pub async fn resume_all(&self) -> BatchResult {
2564 let ids: Vec<DownloadId> = {
2565 let downloads = self.downloads.read();
2566 downloads
2567 .iter()
2568 .filter(|(_, d)| d.status.state == DownloadState::Paused)
2569 .map(|(id, _)| *id)
2570 .collect()
2571 };
2572
2573 let mut result = BatchResult::default();
2574 for id in ids {
2575 match self.resume(id).await {
2576 Ok(()) => result.succeeded.push(id),
2577 Err(EngineError::InvalidState { .. }) | Err(EngineError::NotFound(_)) => {
2578 result.skipped.push(id);
2579 }
2580 Err(e) => result.failed.push((id, e)),
2581 }
2582 }
2583 result
2584 }
2585
2586 pub async fn cancel_all(&self, delete_files: bool) -> BatchResult {
2592 let ids: Vec<DownloadId> = self.downloads.read().keys().copied().collect();
2593
2594 let mut result = BatchResult::default();
2595 for id in ids {
2596 match self.cancel(id, delete_files).await {
2597 Ok(()) => result.succeeded.push(id),
2598 Err(EngineError::NotFound(_)) => result.skipped.push(id),
2599 Err(e) => result.failed.push((id, e)),
2600 }
2601 }
2602
2603 #[cfg(all(feature = "http", feature = "recursive-http"))]
2605 {
2606 let jobs: Vec<crate::types::TrackedRecursiveJob> = {
2607 let mut recursive_jobs = self.recursive_jobs.write();
2608 recursive_jobs.drain().map(|(_, job)| job).collect()
2609 };
2610 for job in jobs {
2611 self.unregister_recursive_job_membership(&job);
2612 if let Some(ref storage) = self.storage {
2613 if let Err(e) = storage.delete_recursive_job(job.id).await {
2614 tracing::warn!("Failed to delete recursive job {}: {}", job.id, e);
2615 }
2616 }
2617 let _ = self
2618 .recursive_job_event_tx
2619 .send(crate::types::RecursiveJobEvent::Removed { id: job.id });
2620 }
2621 }
2622
2623 result
2624 }
2625
2626 pub fn status(&self, id: DownloadId) -> Option<DownloadStatus> {
2628 self.downloads.read().get(&id).map(|d| d.status.clone())
2629 }
2630
2631 pub fn list(&self) -> Vec<DownloadStatus> {
2633 self.downloads
2634 .read()
2635 .values()
2636 .map(|d| d.status.clone())
2637 .collect()
2638 }
2639
2640 pub fn active(&self) -> Vec<DownloadStatus> {
2642 self.downloads
2643 .read()
2644 .values()
2645 .filter(|d| d.status.state.is_active())
2646 .map(|d| d.status.clone())
2647 .collect()
2648 }
2649
2650 pub fn waiting(&self) -> Vec<DownloadStatus> {
2652 self.downloads
2653 .read()
2654 .values()
2655 .filter(|d| matches!(d.status.state, DownloadState::Queued))
2656 .map(|d| d.status.clone())
2657 .collect()
2658 }
2659
2660 pub fn stopped(&self) -> Vec<DownloadStatus> {
2662 self.downloads
2663 .read()
2664 .values()
2665 .filter(|d| {
2666 matches!(
2667 d.status.state,
2668 DownloadState::Paused | DownloadState::Completed | DownloadState::Error { .. }
2669 )
2670 })
2671 .map(|d| d.status.clone())
2672 .collect()
2673 }
2674
2675 pub fn global_stats(&self) -> GlobalStats {
2677 let downloads = self.downloads.read();
2678 let mut stats = GlobalStats::default();
2679
2680 for download in downloads.values() {
2681 match &download.status.state {
2682 DownloadState::Downloading | DownloadState::Seeding | DownloadState::Connecting => {
2683 stats.num_active += 1;
2684 stats.download_speed += download.status.progress.download_speed;
2685 stats.upload_speed += download.status.progress.upload_speed;
2686 }
2687 DownloadState::Queued => {
2688 stats.num_waiting += 1;
2689 }
2690 DownloadState::Paused | DownloadState::Completed | DownloadState::Error { .. } => {
2691 stats.num_stopped += 1;
2692 }
2693 }
2694 }
2695
2696 stats
2697 }
2698
2699 pub fn subscribe(&self) -> broadcast::Receiver<DownloadEvent> {
2701 self.event_tx.subscribe()
2702 }
2703
2704 pub fn set_config(&self, config: EngineConfig) -> Result<()> {
2706 config.validate()?;
2707
2708 self.priority_queue
2709 .set_max_concurrent(config.max_concurrent_downloads);
2710
2711 let mut scheduler = self.scheduler.write();
2712 scheduler.set_defaults(BandwidthLimits {
2713 download: config.global_download_limit,
2714 upload: config.global_upload_limit,
2715 });
2716 scheduler.set_rules(config.schedule_rules.clone());
2717 let limits = scheduler.get_limits();
2718 drop(scheduler);
2719
2720 #[cfg(feature = "http")]
2721 self.http
2722 .set_bandwidth_limits(limits.download, limits.upload);
2723
2724 *self.config.write() = config;
2725 Ok(())
2726 }
2727
2728 pub fn get_config(&self) -> EngineConfig {
2730 self.config.read().clone()
2731 }
2732
2733 pub fn set_priority(&self, id: DownloadId, priority: DownloadPriority) -> Result<()> {
2741 let status_to_save = {
2743 let mut downloads = self.downloads.write();
2744 let download = downloads
2745 .get_mut(&id)
2746 .ok_or_else(|| EngineError::NotFound(id.to_string()))?;
2747 download.status.priority = priority;
2748 download.status.clone()
2749 };
2750
2751 self.priority_queue.set_priority(id, priority);
2753
2754 if let Some(storage) = self.storage.as_ref().map(Arc::clone) {
2756 tokio::spawn(async move {
2757 if let Err(e) = storage.save_download(&status_to_save).await {
2758 tracing::debug!("Failed to persist priority change for {}: {}", id, e);
2759 }
2760 });
2761 }
2762
2763 Ok(())
2764 }
2765
2766 pub fn get_priority(&self, id: DownloadId) -> Option<DownloadPriority> {
2768 self.downloads.read().get(&id).map(|d| d.status.priority)
2769 }
2770
2771 pub fn get_bandwidth_limits(&self) -> BandwidthLimits {
2773 self.scheduler.read().get_limits()
2774 }
2775
2776 pub fn set_schedule_rules(&self, rules: Vec<crate::scheduler::ScheduleRule>) {
2780 self.config.write().schedule_rules = rules.clone();
2781 let limits = {
2782 let mut scheduler = self.scheduler.write();
2783 scheduler.set_rules(rules);
2784 scheduler.get_limits()
2785 };
2786 #[cfg(feature = "http")]
2787 self.http
2788 .set_bandwidth_limits(limits.download, limits.upload);
2789 }
2790
2791 pub fn get_schedule_rules(&self) -> Vec<crate::scheduler::ScheduleRule> {
2793 self.scheduler.read().rules().to_vec()
2794 }
2795
2796 pub async fn shutdown(&self) -> Result<()> {
2798 self.shutdown.cancel();
2800
2801 let handles: Vec<_> = {
2803 let mut downloads = self.downloads.write();
2804 downloads
2805 .values_mut()
2806 .filter_map(|d| d.handle.take())
2807 .collect()
2808 };
2809
2810 for handle in handles {
2811 match handle {
2812 #[cfg(feature = "http")]
2813 DownloadHandle::Http(h) => {
2814 h.cancel_token.cancel();
2815 let _ = tokio::time::timeout(std::time::Duration::from_secs(5), h.task).await;
2817 }
2818 #[cfg(feature = "torrent")]
2819 DownloadHandle::Torrent(h) => {
2820 drop(h.downloader.stop());
2821 h.progress_task.abort();
2822 let _ = tokio::time::timeout(std::time::Duration::from_secs(5), h.task).await;
2824 }
2825 }
2826 }
2827
2828 Ok(())
2829 }
2830
2831 fn update_state(&self, id: DownloadId, new_state: DownloadState) -> Result<()> {
2833 let old_state = {
2834 let mut downloads = self.downloads.write();
2835 let download = downloads
2836 .get_mut(&id)
2837 .ok_or_else(|| EngineError::NotFound(id.to_string()))?;
2838
2839 let old_state = download.status.state.clone();
2840 download.status.state = new_state.clone();
2841 old_state
2842 };
2843
2844 let _ = self.event_tx.send(DownloadEvent::StateChanged {
2845 id,
2846 old_state,
2847 new_state,
2848 });
2849
2850 #[cfg(all(feature = "http", feature = "recursive-http"))]
2851 self.emit_recursive_job_updates_for_child(id);
2852
2853 Ok(())
2854 }
2855}
2856
2857impl Drop for DownloadEngine {
2858 fn drop(&mut self) {
2859 self.shutdown.cancel();
2861 }
2862}
2863
2864#[cfg(all(test, feature = "http", feature = "recursive-http"))]
2865mod tests {
2866 use super::*;
2867 use std::path::PathBuf;
2868 use tempfile::TempDir;
2869
2870 #[tokio::test]
2871 async fn recursive_enqueue_rolls_back_partial_children_on_error() {
2872 let temp_dir = TempDir::new().expect("temp dir should be created");
2873 let engine = DownloadEngine::new(EngineConfig {
2874 download_dir: temp_dir.path().to_path_buf(),
2875 ..Default::default()
2876 })
2877 .await
2878 .expect("engine should be created");
2879
2880 let manifest = crate::types::RecursiveManifest {
2881 root_url: "https://example.com/pub/".to_string(),
2882 entries: vec![
2883 crate::types::RecursiveEntry {
2884 url: "https://example.com/pub/ok.txt".to_string(),
2885 relative_path: PathBuf::from("ok.txt"),
2886 size_hint: None,
2887 },
2888 crate::types::RecursiveEntry {
2889 url: "ftp://example.com/pub/bad.txt".to_string(),
2890 relative_path: PathBuf::from("bad.txt"),
2891 size_hint: None,
2892 },
2893 ],
2894 };
2895
2896 let err = engine
2897 .enqueue_recursive_manifest(
2898 manifest,
2899 DownloadOptions::default(),
2900 &crate::types::RecursiveOptions {
2901 fail_fast: true,
2902 ..Default::default()
2903 },
2904 )
2905 .await
2906 .expect_err("invalid child URL should fail recursive enqueue");
2907
2908 assert!(matches!(
2909 err,
2910 EngineError::InvalidInput { field: "url", .. }
2911 ));
2912 assert!(
2913 engine.list().is_empty(),
2914 "partial child downloads should be rolled back"
2915 );
2916 assert!(
2917 engine.list_recursive_jobs().is_empty(),
2918 "tracked parent jobs should not be created on enqueue failure"
2919 );
2920 assert!(
2921 engine.recursive_groups.read().is_empty(),
2922 "fail-fast groups should not leak after rollback"
2923 );
2924
2925 engine.shutdown().await.ok();
2926 }
2927}