Skip to main content

gosh_dl/
engine.rs

1//! Download Engine - Main coordinator
2//!
3//! The `DownloadEngine` is the primary entry point for the library.
4//! It manages all downloads, coordinates between HTTP and BitTorrent
5//! engines, handles persistence, and emits events.
6
7use 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
45/// Maximum number of events to buffer
46const EVENT_CHANNEL_CAPACITY: usize = 1024;
47#[cfg(all(feature = "http", feature = "recursive-http"))]
48const RECURSIVE_EVENT_CHANNEL_CAPACITY: usize = 256;
49
50/// Internal representation of a managed download
51struct ManagedDownload {
52    status: DownloadStatus,
53    handle: Option<DownloadHandle>,
54    /// Cached HTTP segment state for in-memory pause/resume (no storage needed)
55    #[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/// Handle to control a running download
95#[allow(dead_code)]
96enum DownloadHandle {
97    #[cfg(feature = "http")]
98    Http(HttpDownloadHandle),
99    #[cfg(feature = "torrent")]
100    Torrent(TorrentDownloadHandle),
101}
102
103/// Handle for an HTTP download task
104#[cfg(feature = "http")]
105struct HttpDownloadHandle {
106    cancel_token: tokio_util::sync::CancellationToken,
107    task: tokio::task::JoinHandle<Result<()>>,
108    /// Reference to segmented download for persistence (if using segmented download).
109    /// Wrapped in RwLock so it can be populated from inside the spawned task.
110    segmented_download: Arc<RwLock<Option<Arc<SegmentedDownload>>>>,
111}
112
113/// Handle for a torrent download
114#[cfg(feature = "torrent")]
115struct TorrentDownloadHandle {
116    downloader: Arc<TorrentDownloader>,
117    task: tokio::task::JoinHandle<Result<()>>,
118    progress_task: tokio::task::JoinHandle<()>,
119}
120
121/// Per-download outcomes of a batch operation such as
122/// [`DownloadEngine::pause_all`] or [`DownloadEngine::resume_all`].
123#[derive(Debug, Default)]
124pub struct BatchResult {
125    /// Downloads the operation was applied to successfully.
126    pub succeeded: Vec<DownloadId>,
127    /// Downloads skipped because their state changed between snapshot and
128    /// action (e.g. a download completed while `pause_all` was running).
129    pub skipped: Vec<DownloadId>,
130    /// Downloads where the operation failed with a real error.
131    pub failed: Vec<(DownloadId, EngineError)>,
132}
133
134/// The main download engine
135pub struct DownloadEngine {
136    /// Weak self-reference for spawning background tasks from `&self` methods
137    self_ref: Weak<Self>,
138
139    /// Configuration
140    config: RwLock<EngineConfig>,
141
142    /// All managed downloads
143    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    /// HTTP downloader
152    #[cfg(feature = "http")]
153    http: Arc<HttpDownloader>,
154
155    /// Event broadcaster
156    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 for limiting and ordering concurrent downloads
161    priority_queue: Arc<PriorityQueue>,
162
163    /// Bandwidth scheduler for time-based limits
164    scheduler: Arc<RwLock<BandwidthScheduler>>,
165
166    /// Shutdown flag
167    shutdown: tokio_util::sync::CancellationToken,
168
169    /// Persistent storage for download state
170    storage: Option<Arc<dyn Storage>>,
171}
172
173impl DownloadEngine {
174    /// Obtain a strong `Arc<Self>` reference for spawning background tasks.
175    fn arc(&self) -> Result<Arc<Self>> {
176        self.self_ref.upgrade().ok_or(EngineError::Shutdown)
177    }
178
179    /// Create a new download engine with the given configuration
180    ///
181    /// When the `storage` feature is enabled and `config.database_path` is
182    /// set, download state is persisted to the built-in SQLite storage. To
183    /// use a custom [`Storage`] implementation instead, see
184    /// [`with_storage`](Self::with_storage).
185    pub async fn new(config: EngineConfig) -> Result<Arc<Self>> {
186        // Validate configuration
187        config.validate()?;
188
189        // Initialize persistent storage
190        #[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    /// Create a new download engine that persists state to the given
209    /// [`Storage`] implementation.
210    ///
211    /// This allows applications that maintain their own metadata store
212    /// (custom database, sidecar files, ...) to get full pause/resume and
213    /// crash-recovery support without the built-in SQLite storage or the
214    /// `storage` feature. Previously persisted downloads are loaded from
215    /// the given storage during construction.
216    ///
217    /// `config.database_path` is ignored when this constructor is used; the
218    /// injected storage always wins.
219    ///
220    /// # Example
221    ///
222    /// ```no_run
223    /// use std::sync::Arc;
224    /// use gosh_dl::{DownloadEngine, EngineConfig, MemoryStorage};
225    ///
226    /// # async fn example() -> gosh_dl::Result<()> {
227    /// let storage = Arc::new(MemoryStorage::new());
228    /// let engine = DownloadEngine::with_storage(EngineConfig::default(), storage).await?;
229    /// # Ok(())
230    /// # }
231    /// ```
232    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    /// Shared construction path for [`new`](Self::new) and
249    /// [`with_storage`](Self::with_storage). Expects a validated config.
250    async fn build(config: EngineConfig, storage: Option<Arc<dyn Storage>>) -> Result<Arc<Self>> {
251        // Create event channel
252        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        // Create HTTP downloader
257        #[cfg(feature = "http")]
258        let http = Arc::new(HttpDownloader::new(&config)?);
259
260        // Create priority queue for concurrent download limiting
261        let priority_queue = PriorityQueue::new(config.max_concurrent_downloads);
262
263        // Create bandwidth scheduler with configured rules
264        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        // Load persisted downloads from database
294        engine.load_persisted_downloads().await?;
295        #[cfg(all(feature = "http", feature = "recursive-http"))]
296        engine.load_persisted_recursive_jobs().await?;
297
298        // Start background persistence task
299        Self::start_persistence_task(Arc::clone(&engine));
300
301        // Start bandwidth scheduler update task
302        Self::start_scheduler_task(Arc::clone(&engine));
303
304        Ok(engine)
305    }
306
307    /// Start background task that periodically persists active download states.
308    ///
309    /// This ensures that if the process crashes, downloads can be resumed
310    /// from approximately where they left off.
311    fn start_persistence_task(engine: Arc<Self>) {
312        if engine.storage.is_none() {
313            return; // No storage configured
314        }
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                        // Final persistence on shutdown
330                        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    /// Start background task that updates bandwidth limits based on schedule.
341    ///
342    /// This checks the schedule rules every minute and updates the current
343    /// bandwidth limits if they have changed.
344    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    /// Persist all active (non-completed, non-error) downloads to storage.
370    async fn persist_active_downloads(&self) -> Result<()> {
371        let storage = match &self.storage {
372            Some(s) => s,
373            None => return Ok(()),
374        };
375
376        // Collect active downloads and their segment info
377        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        // Save each active download and its segments
398        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            // Save segments if this is a segmented HTTP download
404            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    /// Load persisted downloads from database on startup
415    async fn load_persisted_downloads(&self) -> Result<()> {
416        let storage = match &self.storage {
417            Some(s) => s,
418            None => return Ok(()), // No storage configured
419        };
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            // For active/downloading states, mark as paused (crashed mid-download)
429            let restored_state = match &status.state {
430                DownloadState::Downloading | DownloadState::Connecting => DownloadState::Paused,
431                DownloadState::Seeding => DownloadState::Paused, // Torrents that were seeding
432                other => other.clone(),
433            };
434
435            let mut restored_status = status.clone();
436            restored_status.state = restored_state;
437            // Reset speeds (they're stale)
438            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            // Insert into downloads map
490            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    /// Add an HTTP/HTTPS download
588    #[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        // Validate URL
612        let parsed_url = Url::parse(url)
613            .map_err(|e| EngineError::invalid_input("url", format!("Invalid URL: {}", e)))?;
614
615        // Only allow http and https
616        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        // Generate download ID
637        let id = DownloadId::new();
638
639        // Determine save directory
640        let save_dir = options
641            .save_dir
642            .clone()
643            .unwrap_or_else(|| self.config.read().download_dir.clone());
644
645        // Extract filename from URL or options
646        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        // Create download status
657        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        // Insert into downloads map
690        {
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        // Persist to database
715        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        // Emit event
728        let _ = self.event_tx.send(DownloadEvent::Added { id });
729
730        // Start the download (no saved segments for new downloads)
731        self.start_download(id, url.to_string(), options, None)
732            .await?;
733
734        Ok(id)
735    }
736
737    /// Discover files reachable from an HTTP/HTTPS directory-like root URL.
738    ///
739    /// This method is feature-gated behind `recursive-http` and currently
740    /// validates recursive inputs before delegating to the in-progress crawler
741    /// implementation.
742    #[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    /// Expand a recursive HTTP/HTTPS discovery root into child HTTP downloads.
753    ///
754    /// Each child is intended to become a normal HTTP download so the existing
755    /// queueing, retry, and persistence paths remain unchanged.
756    #[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    /// List tracked recursive jobs restored from storage or created in-process.
881    #[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    /// Look up a tracked recursive job by ID.
894    #[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    /// Subscribe to recursive parent job events.
900    #[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    /// Cancel all currently present child downloads for a tracked recursive job.
929    ///
930    /// This leaves the tracked recursive job record intact so callers can still
931    /// inspect aggregate state/history after the children have been removed.
932    #[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    /// Remove a tracked recursive job record and cancel any remaining children.
948    #[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    /// Derive aggregate status for a recursive job from its child downloads.
975    #[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    /// Add a BitTorrent download from torrent file data
1232    #[cfg(feature = "torrent")]
1233    pub async fn add_torrent(
1234        &self,
1235        torrent_data: &[u8],
1236        options: DownloadOptions,
1237    ) -> Result<DownloadId> {
1238        // Parse torrent file
1239        let metainfo = Metainfo::parse(torrent_data)?;
1240
1241        // Generate download ID
1242        let id = DownloadId::new();
1243
1244        // Determine save directory
1245        let save_dir = options
1246            .save_dir
1247            .clone()
1248            .unwrap_or_else(|| self.config.read().download_dir.clone());
1249
1250        // Create download status
1251        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        // Insert into downloads map
1280        {
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        // Persist to database
1298        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            // Save raw torrent data for crash recovery
1303            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        // Emit event
1309        let _ = self.event_tx.send(DownloadEvent::Added { id });
1310
1311        // Start the torrent download
1312        self.start_torrent(id, metainfo, save_dir, options).await?;
1313
1314        Ok(id)
1315    }
1316
1317    /// Add a BitTorrent download from a magnet URI
1318    #[cfg(feature = "torrent")]
1319    pub async fn add_magnet(
1320        &self,
1321        magnet_uri: &str,
1322        options: DownloadOptions,
1323    ) -> Result<DownloadId> {
1324        // Parse magnet URI
1325        let magnet = MagnetUri::parse(magnet_uri)?;
1326
1327        // Generate download ID
1328        let id = DownloadId::new();
1329
1330        // Determine save directory
1331        let save_dir = options
1332            .save_dir
1333            .clone()
1334            .unwrap_or_else(|| self.config.read().download_dir.clone());
1335
1336        // Create download status
1337        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, // Will be populated when metadata is received
1360            peers: Some(Vec::new()),
1361            created_at: Utc::now(),
1362            completed_at: None,
1363        };
1364
1365        // Insert into downloads map
1366        {
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        // Persist to database
1384        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        // Emit event
1391        let _ = self.event_tx.send(DownloadEvent::Added { id });
1392
1393        // Start the magnet download
1394        self.start_magnet(id, magnet, save_dir, options).await?;
1395
1396        Ok(id)
1397    }
1398
1399    /// Start a torrent download task
1400    #[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    /// Start a torrent download task
1432    #[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        // Apply selected files for partial download
1477        if let Some(ref selected) = options.selected_files {
1478            downloader.set_selected_files(Some(selected));
1479        }
1480
1481        // Apply sequential download mode if requested
1482        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        // Update state
1490        self.update_state(id, DownloadState::Connecting)?;
1491
1492        let task = tokio::spawn(async move {
1493            // Start the download (announces to trackers, verifies existing pieces)
1494            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                // Persist error state to storage
1505                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            // Update state to downloading
1522            engine.update_state(id, DownloadState::Downloading)?;
1523            let _ = engine.event_tx.send(DownloadEvent::Started { id });
1524
1525            // Run the peer connection loop
1526            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                // Persist error state to storage
1538                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                // Torrent completed successfully
1553                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                    // Persist completed state to storage
1570                    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        // Store the handle
1594        {
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    /// Start a magnet download task
1609    #[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        // Apply sequential download mode if requested
1658        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        // Update state
1666        self.update_state(id, DownloadState::Connecting)?;
1667
1668        let task = tokio::spawn(async move {
1669            // Start the download (announces to trackers)
1670            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                // Persist error state to storage
1681                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            // Update state - for magnets, we're initially fetching metadata
1698            engine.update_state(id, DownloadState::Downloading)?;
1699            let _ = engine.event_tx.send(DownloadEvent::Started { id });
1700
1701            // Run the peer connection loop (handles both downloading and metadata fetching for magnets)
1702            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                // Persist error state to storage
1714                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                // Magnet download completed successfully
1729                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                    // Persist completed state to storage
1746                    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        // Store the handle
1770        {
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                            // Magnet metadata just arrived — persist torrent data
1821                            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                // Persist torrent data for magnet crash recovery (outside the lock)
1840                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    /// Start a download task
1864    #[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        // Create shared reference for segmented download (populated by download_segmented)
1880        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        // Update state to connecting
1885        self.update_state(id, DownloadState::Queued)?;
1886
1887        let task = tokio::spawn(async move {
1888            // Acquire priority queue permit for concurrent limit, bailing out
1889            // promptly if the download is paused/cancelled while still queued.
1890            let _permit = tokio::select! {
1891                permit = priority_queue.acquire(id, priority) => permit,
1892                _ = cancel_token_clone.cancelled() => {
1893                    // The dropped acquire future leaves its entry in the
1894                    // waiting heap; remove it so it can't win a permit later.
1895                    priority_queue.remove(id);
1896                    return Ok(());
1897                }
1898            };
1899
1900            // Check if cancelled before starting
1901            if cancel_token_clone.is_cancelled() {
1902                return Ok(());
1903            }
1904
1905            // Update state to connecting then downloading
1906            engine.update_state(id, DownloadState::Connecting)?;
1907            engine.update_state(id, DownloadState::Downloading)?;
1908            let _ = engine.event_tx.send(DownloadEvent::Started { id });
1909
1910            // Get save path and options
1911            #[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            // Create progress callback
1960            let engine_clone = Arc::clone(&engine);
1961            let progress_callback = Arc::new(move |progress: DownloadProgress| {
1962                // Update progress in download status
1963                {
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                // Emit progress event
1970                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            // Get config for segmented downloads
1978            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            // Perform the download (uses segmented if server supports it)
1989            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                    // Update status to completed (but not if paused - race condition)
2048                    let should_complete = {
2049                        let mut downloads = engine.downloads.write();
2050                        if let Some(download) = downloads.get_mut(&id) {
2051                            // Don't overwrite Paused state - user paused before completion
2052                            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                        // Persist completed state to storage
2069                        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                        // Clean up saved segments from storage
2083                        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                    // Cancelled, already handled
2099                    let _ = e;
2100                }
2101                Err(e) => {
2102                    let retryable = e.is_retryable();
2103                    let error_msg = e.to_string();
2104
2105                    // Save segment progress so retries can resume from where we left off
2106                    #[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                            // Cache in memory for storage-less resume
2115                            {
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                            // Persist to database
2122                            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                    // Update status to error
2135                    engine.update_state(
2136                        id,
2137                        DownloadState::Error {
2138                            kind: format!("{:?}", e),
2139                            message: error_msg.clone(),
2140                            retryable,
2141                        },
2142                    )?;
2143
2144                    // Persist error state to storage
2145                    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        // Store the handle
2173        {
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    /// Pause a download
2188    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            // Check if can be paused. Queued downloads are pausable too:
2196            // their task is parked waiting for a permit and bails out when
2197            // the cancel token fires.
2198            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            // Extract segments before taking the handle (for HTTP resume)
2207            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            // Cancel the task
2218            if let Some(handle) = download.handle.take() {
2219                match handle {
2220                    #[cfg(feature = "http")]
2221                    DownloadHandle::Http(h) => {
2222                        h.cancel_token.cancel();
2223                        // Don't await the task here to avoid blocking
2224                    }
2225                    #[cfg(feature = "torrent")]
2226                    DownloadHandle::Torrent(h) => {
2227                        h.downloader.pause();
2228                        download.handle = Some(DownloadHandle::Torrent(h));
2229                        // Don't await the task
2230                    }
2231                }
2232            }
2233
2234            // Update state
2235            let old_state = download.status.state.clone();
2236            download.status.state = DownloadState::Paused;
2237
2238            // Emit events
2239            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            // Cache segments in memory for storage-less pause/resume
2247            #[cfg(feature = "http")]
2248            {
2249                download.cached_segments = segments.clone();
2250            }
2251
2252            (download.status.clone(), segments)
2253        };
2254
2255        // Persist to database
2256        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            // Save HTTP segments for resume
2261            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    /// Resume a paused download
2276    pub async fn resume(&self, id: DownloadId) -> Result<()> {
2277        // Get download info and determine type
2278        #[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            // Check if can be resumed
2286            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                    // HTTP: restart download with saved segments
2329                    let url = url.ok_or_else(|| {
2330                        EngineError::Internal("HTTP download missing URL".to_string())
2331                    })?;
2332
2333                    // Load saved segments from storage if available
2334                    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                    // Fall back to in-memory cached segments (for storage-less pause/resume)
2355                    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                        // Live handle exists — just unpause
2372                        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                        // No live handle (crash recovery) — try reconstructing from stored data
2381                        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                            // Fall back to magnet URI (will re-fetch metadata from peers)
2404                            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    /// Cancel a download and optionally delete files
2437    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        // Cancel the task if running
2470        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        // Delete files and segments if requested
2486        if let Some(path) = save_path {
2487            if path.exists() {
2488                if path.is_dir() {
2489                    // Multi-file torrent: remove entire directory
2490                    tokio::fs::remove_dir_all(&path).await.ok();
2491                } else {
2492                    // Single file: remove the file
2493                    tokio::fs::remove_file(&path).await.ok();
2494                }
2495            }
2496            // Also try to remove partial file
2497            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        // Clean up saved segments and download record from storage
2504        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    /// Pause every active or queued download.
2528    ///
2529    /// Queued downloads are paused as well so that freed slots do not
2530    /// immediately promote them back into running state. Downloads that
2531    /// change state while the batch is in flight are reported as skipped.
2532    /// Per-download `StateChanged`/`Paused` events are emitted as usual;
2533    /// there is no separate batch event.
2534    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    /// Resume every paused download.
2560    ///
2561    /// Downloads that change state while the batch is in flight are reported
2562    /// as skipped. Per-download `Resumed` events are emitted as usual.
2563    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    /// Cancel every download, optionally deleting downloaded files.
2587    ///
2588    /// Tracked recursive job records are removed as well, since all of their
2589    /// children are gone after this call. Per-download `Removed` events are
2590    /// emitted as usual.
2591    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        // All children are gone; drop the now-empty recursive job records.
2604        #[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    /// Get the status of a download
2627    pub fn status(&self, id: DownloadId) -> Option<DownloadStatus> {
2628        self.downloads.read().get(&id).map(|d| d.status.clone())
2629    }
2630
2631    /// List all downloads
2632    pub fn list(&self) -> Vec<DownloadStatus> {
2633        self.downloads
2634            .read()
2635            .values()
2636            .map(|d| d.status.clone())
2637            .collect()
2638    }
2639
2640    /// Get active downloads
2641    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    /// Get waiting/queued downloads
2651    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    /// Get stopped downloads (paused, completed, error)
2661    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    /// Get global statistics
2676    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    /// Subscribe to download events
2700    pub fn subscribe(&self) -> broadcast::Receiver<DownloadEvent> {
2701        self.event_tx.subscribe()
2702    }
2703
2704    /// Update engine configuration
2705    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    /// Get current configuration
2729    pub fn get_config(&self) -> EngineConfig {
2730        self.config.read().clone()
2731    }
2732
2733    /// Set the priority of a download
2734    ///
2735    /// This affects the order in which downloads acquire slots when queued.
2736    /// If the download is already active, the priority is updated but
2737    /// won't affect scheduling until the download is paused and resumed.
2738    ///
2739    /// The priority change is persisted to storage immediately (non-blocking).
2740    pub fn set_priority(&self, id: DownloadId, priority: DownloadPriority) -> Result<()> {
2741        // Update in downloads map and get status for persistence
2742        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        // Update in priority queue (affects scheduling if waiting)
2752        self.priority_queue.set_priority(id, priority);
2753
2754        // Persist in background (fire-and-forget)
2755        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    /// Get the current priority of a download
2767    pub fn get_priority(&self, id: DownloadId) -> Option<DownloadPriority> {
2768        self.downloads.read().get(&id).map(|d| d.status.priority)
2769    }
2770
2771    /// Get current bandwidth limits (accounting for schedule rules)
2772    pub fn get_bandwidth_limits(&self) -> BandwidthLimits {
2773        self.scheduler.read().get_limits()
2774    }
2775
2776    /// Update the bandwidth schedule rules
2777    ///
2778    /// The new rules take effect immediately after evaluation.
2779    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    /// Get the current schedule rules
2792    pub fn get_schedule_rules(&self) -> Vec<crate::scheduler::ScheduleRule> {
2793        self.scheduler.read().rules().to_vec()
2794    }
2795
2796    /// Graceful shutdown
2797    pub async fn shutdown(&self) -> Result<()> {
2798        // Signal shutdown
2799        self.shutdown.cancel();
2800
2801        // Cancel all active downloads
2802        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                    // Wait for task to finish (with timeout)
2816                    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                    // Wait for task to finish (with timeout)
2823                    let _ = tokio::time::timeout(std::time::Duration::from_secs(5), h.task).await;
2824                }
2825            }
2826        }
2827
2828        Ok(())
2829    }
2830
2831    /// Helper to update download state
2832    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        // Signal shutdown on drop
2860        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}