Skip to main content

aria2_core/engine/
bt_web_seed.rs

1//! BitTorrent Web-seed (HTTP/HTTPS fallback for piece downloads)
2//!
3//! This module implements BEP 19 / BEP 17 Web Seed support, allowing
4//! aria2-rust to download torrent pieces via HTTP Range requests when
5//! the peer swarm is insufficient or unavailable.
6//!
7//! # Architecture
8//!
9//! - [`WebSeedClient`] - Single HTTP endpoint for downloading pieces
10//! - [`WebSeedManager`] - Manages multiple web-seed URLs with fallback logic
11//! - [`WebSeedStats`] - Speed statistics for web-seed downloads
12//! - [`parse_url_list()`] - Extracts `url-list` from torrent metadata
13
14use std::collections::HashSet;
15use std::sync::Arc;
16use std::sync::atomic::{AtomicU64, Ordering};
17use std::time::{Duration, Instant};
18use tracing::{debug, warn};
19
20/// Speed statistics for web-seed downloads.
21///
22/// Tracks download speed separately from peer downloads.
23#[derive(Debug, Default)]
24pub struct WebSeedStats {
25    /// Total bytes downloaded from web seeds
26    pub total_bytes: AtomicU64,
27    /// Download start time (for speed calculation)
28    pub start_time: Option<Instant>,
29    /// Bytes downloaded in the current second (for real-time speed)
30    pub current_second_bytes: AtomicU64,
31    /// Timestamp of the current second window
32    pub current_second_start: Option<Instant>,
33}
34
35impl WebSeedStats {
36    /// Create a new WebSeedStats instance.
37    pub fn new() -> Self {
38        Self {
39            total_bytes: AtomicU64::new(0),
40            start_time: Some(Instant::now()),
41            current_second_bytes: AtomicU64::new(0),
42            current_second_start: Some(Instant::now()),
43        }
44    }
45
46    /// Record bytes downloaded from a web seed.
47    pub fn record_bytes(&self, bytes: u64) {
48        self.total_bytes.fetch_add(bytes, Ordering::Relaxed);
49        self.current_second_bytes
50            .fetch_add(bytes, Ordering::Relaxed);
51    }
52
53    /// Get total bytes downloaded from web seeds.
54    pub fn total_bytes_downloaded(&self) -> u64 {
55        self.total_bytes.load(Ordering::Relaxed)
56    }
57
58    /// Get average download speed in bytes/sec.
59    pub fn average_speed(&self) -> u64 {
60        if let Some(start) = self.start_time {
61            let elapsed = start.elapsed().as_secs();
62            return self
63                .total_bytes
64                .load(Ordering::Relaxed)
65                .checked_div(elapsed)
66                .unwrap_or(0);
67        }
68        0
69    }
70
71    /// Get current download speed in bytes/sec (real-time).
72    pub fn current_speed(&self) -> u64 {
73        if let Some(start) = self.current_second_start {
74            let elapsed = start.elapsed();
75            if elapsed >= Duration::from_secs(1) {
76                // Reset the window
77                let bytes = self.current_second_bytes.swap(0, Ordering::Relaxed);
78                let secs = elapsed.as_secs_f64();
79                return (bytes as f64 / secs) as u64;
80            }
81            // Within the same second, estimate based on current rate
82            let bytes = self.current_second_bytes.load(Ordering::Relaxed);
83            let secs = elapsed.as_secs_f64();
84            if secs > 0.0 {
85                return (bytes as f64 / secs) as u64;
86            }
87        }
88        0
89    }
90}
91
92/// HTTP client for downloading individual BT pieces from a single web-seed URL.
93///
94/// Uses HTTP Range requests (`Range: bytes={start}-{end}`) to fetch specific
95/// byte ranges corresponding to torrent pieces.
96pub struct WebSeedClient {
97    /// Base URL of the web-seed (e.g., "http://example.com/files/")
98    base_url: String,
99    /// Reusable reqwest HTTP client with connection pooling
100    client: reqwest::Client,
101    /// Pieces currently being requested (for concurrency control).
102    /// Uses std::sync::Mutex because the lock is only held for short synchronous
103    /// operations (insert/remove/check) and never across .await points.
104    active_requests: Arc<std::sync::Mutex<HashSet<u32>>>,
105    /// Statistics for this web seed
106    stats: Arc<WebSeedStats>,
107}
108
109impl WebSeedClient {
110    /// Create a new WebSeedClient for the given base URL.
111    ///
112    /// # Arguments
113    ///
114    /// * `base_url` - The root URL for HTTP piece requests
115    ///
116    /// # Example
117    ///
118    /// ```
119    /// use aria2_core::engine::bt_web_seed::WebSeedClient;
120    /// let client = WebSeedClient::new("http://cdn.example.com/torrent/");
121    /// ```
122    pub fn new(base_url: &str) -> Self {
123        debug!(url = base_url, "Creating WebSeedClient");
124
125        // Build client with sensible defaults for large file downloads
126        let client = reqwest::Client::builder()
127            .timeout(std::time::Duration::from_secs(120))
128            .connect_timeout(std::time::Duration::from_secs(10))
129            .pool_max_idle_per_host(4)
130            .build()
131            .unwrap_or_else(|_| reqwest::Client::new());
132
133        Self {
134            base_url: base_url.to_string(),
135            client,
136            active_requests: Arc::new(std::sync::Mutex::new(HashSet::new())),
137            stats: Arc::new(WebSeedStats::new()),
138        }
139    }
140
141    /// Create a WebSeedClient with shared stats (for aggregated statistics).
142    pub fn with_shared_stats(base_url: &str, stats: Arc<WebSeedStats>) -> Self {
143        debug!(url = base_url, "Creating WebSeedClient with shared stats");
144
145        let client = reqwest::Client::builder()
146            .timeout(std::time::Duration::from_secs(120))
147            .connect_timeout(std::time::Duration::from_secs(10))
148            .pool_max_idle_per_host(4)
149            .build()
150            .unwrap_or_else(|_| reqwest::Client::new());
151
152        Self {
153            base_url: base_url.to_string(),
154            client,
155            active_requests: Arc::new(std::sync::Mutex::new(HashSet::new())),
156            stats,
157        }
158    }
159
160    /// Check if a piece can be requested (not already active).
161    pub fn can_request(&self, piece_index: u32) -> bool {
162        let active = self
163            .active_requests
164            .lock()
165            .unwrap_or_else(|e| e.into_inner());
166        !active.contains(&piece_index)
167    }
168
169    /// Mark a piece as being requested.
170    pub fn mark_requesting(&self, piece_index: u32) {
171        let mut active = self
172            .active_requests
173            .lock()
174            .unwrap_or_else(|e| e.into_inner());
175        active.insert(piece_index);
176    }
177
178    /// Mark a piece as no longer being requested.
179    pub fn clear_request(&self, piece_index: u32) {
180        let mut active = self
181            .active_requests
182            .lock()
183            .unwrap_or_else(|e| e.into_inner());
184        active.remove(&piece_index);
185    }
186
187    /// Get the number of active requests.
188    pub fn active_request_count(&self) -> usize {
189        let active = self
190            .active_requests
191            .lock()
192            .unwrap_or_else(|e| e.into_inner());
193        active.len()
194    }
195
196    /// Get reference to the stats.
197    pub fn stats(&self) -> &WebSeedStats {
198        &self.stats
199    }
200
201    /// Download a specific piece range via HTTP GET with Range header.
202    ///
203    /// Constructs an HTTP request to fetch bytes `[piece_offset, piece_offset+length)`
204    /// from the web-seed server using the `Range` header.
205    ///
206    /// # Arguments
207    ///
208    /// * `piece_index` - Logical index of the piece (for logging)
209    /// * `piece_length` - Total length of this piece (unused in request but for context)
210    /// * `piece_offset` - Byte offset within the full file where this piece starts
211    /// * `length` - Number of bytes to download
212    ///
213    /// # Returns
214    ///
215    /// * `Ok(Vec<u8>)` - Raw piece data on success (HTTP 206 Partial Content or 200 OK)
216    /// * `Err(String)` - Network error or non-success HTTP status
217    pub async fn download_piece(
218        &self,
219        piece_index: u32,
220        _piece_length: u64,
221        piece_offset: u64,
222        length: u64,
223    ) -> Result<Vec<u8>, String> {
224        let range_end = piece_offset + length.saturating_sub(1);
225        let range_header = format!("bytes={}-{}", piece_offset, range_end);
226
227        debug!(
228            piece_index,
229            offset = piece_offset,
230            length,
231            url = self.base_url,
232            range = %range_header,
233            "Web-seed HTTP Range request"
234        );
235
236        let response = self
237            .client
238            .get(&self.base_url)
239            .header("Range", &range_header)
240            .header("User-Agent", "aria2-rust/1.0")
241            .send()
242            .await
243            .map_err(|e| format!("HTTP request failed: {}", e))?;
244
245        let status = response.status().as_u16();
246
247        // Accept 200 OK or 206 Partial Content
248        if status != 200 && status != 206 {
249            return Err(format!("Unexpected HTTP status {} from web-seed", status));
250        }
251
252        let data = response
253            .bytes()
254            .await
255            .map_err(|e| format!("Failed to read response body: {}", e))?
256            .to_vec();
257
258        // Record statistics
259        self.stats.record_bytes(data.len() as u64);
260
261        if data.len() != length as usize {
262            warn!(
263                expected = length,
264                actual = data.len(),
265                piece_index,
266                "Web-seed response size mismatch"
267            );
268        }
269
270        Ok(data)
271    }
272
273    /// Request a piece from this web seed with concurrency control.
274    ///
275    /// This method:
276    /// 1. Checks if the piece is already being requested
277    /// 2. Marks the piece as active
278    /// 3. Downloads the piece
279    /// 4. Clears the active flag
280    ///
281    /// # Arguments
282    ///
283    /// * `piece_index` - Index of the piece to download
284    /// * `piece_length` - Length of each piece
285    /// * `total_length` - Total file length (for calculating the last piece size)
286    ///
287    /// # Returns
288    ///
289    /// * `Ok(Vec<u8>)` - Piece data
290    /// * `Err(String)` - Error or "already active"
291    pub async fn request_piece(
292        &self,
293        piece_index: u32,
294        piece_length: u32,
295        total_length: u64,
296    ) -> Result<Vec<u8>, String> {
297        // Check if already requesting
298        if !self.can_request(piece_index) {
299            return Err(format!("Piece {} already being requested", piece_index));
300        }
301
302        // Mark as active
303        self.mark_requesting(piece_index);
304
305        // Calculate offset and length
306        let piece_offset = piece_index as u64 * piece_length as u64;
307        let remaining = total_length.saturating_sub(piece_offset);
308        let actual_length = std::cmp::min(piece_length as u64, remaining);
309
310        // Download
311        let result = self
312            .download_piece(
313                piece_index,
314                piece_length as u64,
315                piece_offset,
316                actual_length,
317            )
318            .await;
319
320        // Clear active flag
321        self.clear_request(piece_index);
322
323        result
324    }
325
326    /// Check whether this web-seed appears to be available.
327    ///
328    /// Currently returns `true` unconditionally; a future implementation
329    /// could perform a lightweight HEAD request or health check.
330    pub fn is_available(&self) -> bool {
331        true
332    }
333
334    /// Get the base URL of this web-seed (for display/logging).
335    pub fn url(&self) -> &str {
336        &self.base_url
337    }
338}
339
340/// Manages multiple web-seed endpoints with automatic fallback.
341///
342/// When downloading a piece, tries each configured web-seed URL in order
343/// until one succeeds. If all fail, returns an aggregated error.
344pub struct WebSeedManager {
345    /// Ordered list of web-seed clients
346    clients: Vec<WebSeedClient>,
347    /// Shared statistics across all web seeds
348    stats: Arc<WebSeedStats>,
349    /// Piece length for calculating offsets
350    piece_length: u32,
351    /// Total file length
352    total_length: u64,
353}
354
355impl WebSeedManager {
356    /// Create a new WebSeedManager from a list of web-seed URLs.
357    ///
358    /// # Arguments
359    ///
360    /// * `urls` - List of HTTP(S) URLs serving the torrent content
361    /// * `piece_length` - Length of each piece in the torrent
362    /// * `total_length` - Total file length
363    ///
364    /// # Example
365    ///
366    /// ```
367    /// use aria2_core::engine::bt_web_seed::WebSeedManager;
368    /// let manager = WebSeedManager::new(
369    ///     vec![
370    ///         "http://mirror1.example.com/file.bin".to_string(),
371    ///         "http://mirror2.example.com/file.bin".to_string(),
372    ///     ],
373    ///     16384,  // piece_length
374    ///     1048576 // total_length
375    /// );
376    /// ```
377    pub fn new(urls: Vec<String>, piece_length: u32, total_length: u64) -> Self {
378        debug!(
379            count = urls.len(),
380            "Creating WebSeedManager with {} seed(s)",
381            urls.len()
382        );
383
384        let stats = Arc::new(WebSeedStats::new());
385
386        let clients = urls
387            .into_iter()
388            .map(|url| WebSeedClient::with_shared_stats(&url, stats.clone()))
389            .collect();
390
391        Self {
392            clients,
393            stats,
394            piece_length,
395            total_length,
396        }
397    }
398
399    /// Get the shared statistics.
400    pub fn stats(&self) -> &WebSeedStats {
401        &self.stats
402    }
403
404    /// Request a piece from any available web seed.
405    ///
406    /// This method uses the new `request_piece` API with concurrency control.
407    ///
408    /// # Arguments
409    ///
410    /// * `piece_index` - Index of the piece to download
411    ///
412    /// # Returns
413    ///
414    /// * `Ok(Vec<u8>)` - Piece data from first successful web-seed
415    /// * `Err(String)` - All web-seeds failed
416    pub async fn request_piece(&self, piece_index: u32) -> Result<Vec<u8>, String> {
417        if self.clients.is_empty() {
418            return Err("No web-seeds configured".to_string());
419        }
420
421        let mut last_error = String::new();
422
423        for (i, client) in self.clients.iter().enumerate() {
424            if !client.is_available() || !client.can_request(piece_index) {
425                debug!(
426                    index = i,
427                    url = client.url(),
428                    "Skipping unavailable or busy web-seed"
429                );
430                continue;
431            }
432
433            match client
434                .request_piece(piece_index, self.piece_length, self.total_length)
435                .await
436            {
437                Ok(data) => {
438                    debug!(
439                        piece_index,
440                        seed_index = i,
441                        url = client.url(),
442                        size = data.len(),
443                        "Piece downloaded from web-seed"
444                    );
445                    return Ok(data);
446                }
447                Err(e) => {
448                    warn!(
449                        piece_index,
450                        seed_index = i,
451                        url = client.url(),
452                        error = %e,
453                        "Web-seed download failed, trying next"
454                    );
455                    last_error = format!("seed[{}]={}: {}", i, client.url(), e);
456                }
457            }
458        }
459
460        Err(format!("All web-seeds failed: {}", last_error))
461    }
462
463    /// Attempt to download a piece from any available web-seed.
464    ///
465    /// Tries each web-seed in order; returns data from the first successful
466    /// response. Collects errors from all failed attempts if all fail.
467    ///
468    /// # Arguments
469    ///
470    /// * `piece_index` - Logical index of the piece
471    /// * `piece_length` - Total length of this piece
472    /// * `piece_offset` - Byte offset within the file
473    /// * `length` - Number of bytes to download
474    ///
475    /// # Returns
476    ///
477    /// * `Ok(Vec<u8>)` - Piece data from first successful web-seed
478    /// * `Err(String)` - All web-seeds failed (contains error details)
479    pub async fn try_download_piece(
480        &self,
481        piece_index: u32,
482        piece_length: u64,
483        piece_offset: u64,
484        length: u64,
485    ) -> Result<Vec<u8>, String> {
486        if self.clients.is_empty() {
487            return Err("No web-seeds configured".to_string());
488        }
489
490        let mut last_error = String::new();
491
492        for (i, client) in self.clients.iter().enumerate() {
493            if !client.is_available() {
494                debug!(
495                    index = i,
496                    url = client.url(),
497                    "Skipping unavailable web-seed"
498                );
499                continue;
500            }
501
502            match client
503                .download_piece(piece_index, piece_length, piece_offset, length)
504                .await
505            {
506                Ok(data) => {
507                    debug!(
508                        piece_index,
509                        seed_index = i,
510                        url = client.url(),
511                        size = data.len(),
512                        "Piece downloaded from web-seed"
513                    );
514                    return Ok(data);
515                }
516                Err(e) => {
517                    warn!(
518                        piece_index,
519                        seed_index = i,
520                        url = client.url(),
521                        error = %e,
522                        "Web-seed download failed, trying next"
523                    );
524                    last_error = format!("seed[{}]={}: {}", i, client.url(), e);
525                }
526            }
527        }
528
529        Err(format!("All web-seeds failed: {}", last_error))
530    }
531
532    /// Get the number of configured web-seed URLs.
533    pub fn len(&self) -> usize {
534        self.clients.len()
535    }
536
537    /// Check if any web-seeds are configured.
538    pub fn is_empty(&self) -> bool {
539        self.clients.is_empty()
540    }
541
542    /// Get reference to the underlying web-seed clients.
543    pub fn clients(&self) -> &[WebSeedClient] {
544        &self.clients
545    }
546}
547
548/// Parse the `url-list` field from torrent metadata.
549///
550/// The BEP 19 / RFC 6986 `url-list` key can be either:
551/// - A single string (one URL)
552/// - A list of strings (multiple fallback URLs)
553///
554/// Returns an empty vector if the key is missing or malformed.
555///
556/// # Arguments
557///
558/// * `meta` - Parsed torrent metadata structure
559///
560/// # Returns
561///
562/// * `Vec<String>` - List of extracted web-seed URLs
563///
564/// # Example
565///
566/// ```ignore
567/// let urls = parse_url_list(&torrent_meta);
568/// if !urls.is_empty() {
569///     let manager = WebSeedManager::new(urls, piece_length, total_length);
570/// }
571/// ```
572pub fn parse_url_list(
573    meta: &aria2_protocol::bittorrent::torrent::parser::TorrentMeta,
574) -> Vec<String> {
575    meta.web_seeds.clone()
576}
577
578/// Parse url-list directly from raw bencoded torrent data.
579///
580/// This is the working implementation that decodes the raw torrent bytes
581/// and extracts the `url-list` key at the top level of the bencode dictionary.
582///
583/// # Arguments
584///
585/// * `torrent_bytes` - Raw bencoded torrent file contents
586///
587/// # Returns
588///
589/// * `Vec<String>` - Extracted web-seed URLs (empty if missing/malformed)
590pub fn parse_url_list_from_bytes(torrent_bytes: &[u8]) -> Vec<String> {
591    use aria2_protocol::bittorrent::bencode::codec::BencodeValue;
592
593    let (root, _) = match BencodeValue::decode(torrent_bytes) {
594        Ok(result) => result,
595        Err(_) => return Vec::new(),
596    };
597
598    match root.dict_get(b"url-list") {
599        Some(BencodeValue::Bytes(url_bytes)) => {
600            // Single URL string
601            match std::str::from_utf8(url_bytes) {
602                Ok(url) => vec![url.to_string()],
603                Err(_) => Vec::new(),
604            }
605        }
606        Some(BencodeValue::List(items)) => {
607            // List of URL strings
608            items
609                .iter()
610                .filter_map(|item| item.as_str())
611                .map(|s| s.to_string())
612                .collect()
613        }
614        _ => Vec::new(), // Missing or wrong type
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use aria2_protocol::bittorrent::bencode::codec::BencodeValue;
622    use std::collections::BTreeMap;
623
624    // ==================== parse_url_list tests ====================
625
626    #[test]
627    fn test_parse_url_list_single() {
628        let mut root = BTreeMap::new();
629        root.insert(
630            b"announce".to_vec(),
631            BencodeValue::Bytes(b"http://tracker.example.com/announce".to_vec()),
632        );
633        root.insert(
634            b"url-list".to_vec(),
635            BencodeValue::Bytes(b"http://webseed.example.com/file.bin".to_vec()),
636        );
637
638        // Add minimal info dict
639        let mut info = BTreeMap::new();
640        info.insert(b"name".to_vec(), BencodeValue::Bytes(b"test".to_vec()));
641        info.insert(b"length".to_vec(), BencodeValue::Int(1024));
642        info.insert(b"piece length".to_vec(), BencodeValue::Int(512));
643        info.insert(b"pieces".to_vec(), BencodeValue::Bytes(vec![0u8; 40]));
644        root.insert(b"info".to_vec(), BencodeValue::Dict(info));
645
646        let encoded = BencodeValue::Dict(root).encode();
647        let urls = parse_url_list_from_bytes(&encoded);
648
649        assert_eq!(urls.len(), 1);
650        assert_eq!(urls[0], "http://webseed.example.com/file.bin");
651    }
652
653    #[test]
654    fn test_parse_url_list_multiple() {
655        let mut root = BTreeMap::new();
656        root.insert(
657            b"announce".to_vec(),
658            BencodeValue::Bytes(b"http://tracker.example.com/announce".to_vec()),
659        );
660        root.insert(
661            b"url-list".to_vec(),
662            BencodeValue::List(vec![
663                BencodeValue::Bytes(b"http://seed1.example.com/file.bin".to_vec()),
664                BencodeValue::Bytes(b"http://seed2.example.com/file.bin".to_vec()),
665                BencodeValue::Bytes(b"https://seed3.example.com/file.bin".to_vec()),
666            ]),
667        );
668
669        let mut info = BTreeMap::new();
670        info.insert(b"name".to_vec(), BencodeValue::Bytes(b"test".to_vec()));
671        info.insert(b"length".to_vec(), BencodeValue::Int(2048));
672        info.insert(b"piece length".to_vec(), BencodeValue::Int(512));
673        info.insert(b"pieces".to_vec(), BencodeValue::Bytes(vec![0u8; 80]));
674        root.insert(b"info".to_vec(), BencodeValue::Dict(info));
675
676        let encoded = BencodeValue::Dict(root).encode();
677        let urls = parse_url_list_from_bytes(&encoded);
678
679        assert_eq!(urls.len(), 3);
680        assert_eq!(urls[0], "http://seed1.example.com/file.bin");
681        assert_eq!(urls[1], "http://seed2.example.com/file.bin");
682        assert_eq!(urls[2], "https://seed3.example.com/file.bin");
683    }
684
685    #[test]
686    fn test_parse_url_list_missing() {
687        let mut root = BTreeMap::new();
688        root.insert(
689            b"announce".to_vec(),
690            BencodeValue::Bytes(b"http://tracker.example.com/announce".to_vec()),
691        );
692        // No url-list key present
693
694        let mut info = BTreeMap::new();
695        info.insert(b"name".to_vec(), BencodeValue::Bytes(b"test".to_vec()));
696        info.insert(b"length".to_vec(), BencodeValue::Int(512));
697        info.insert(b"piece length".to_vec(), BencodeValue::Int(256));
698        info.insert(b"pieces".to_vec(), BencodeValue::Bytes(vec![0u8; 20]));
699        root.insert(b"info".to_vec(), BencodeValue::Dict(info));
700
701        let encoded = BencodeValue::Dict(root).encode();
702        let urls = parse_url_list_from_bytes(&encoded);
703
704        assert!(urls.is_empty());
705    }
706
707    // ==================== Range header construction tests ====================
708
709    #[test]
710    fn test_range_request_format() {
711        // Verify the Range header format matches HTTP spec (RFC 7233)
712        let _client = WebSeedClient::new("http://example.com/file.bin");
713
714        // Test: piece starting at offset 0, length 16384
715        // Expected Range: bytes=0-16383
716        let offset = 0u64;
717        let length = 16384u64;
718        let range_end = offset + length.saturating_sub(1);
719        let expected = format!("bytes={}-{}", offset, range_end);
720
721        assert_eq!(expected, "bytes=0-16383");
722
723        // Test: piece starting at offset 524288, length 262144
724        let offset2 = 524288u64;
725        let length2 = 262144u64;
726        let range_end2 = offset2 + length2.saturating_sub(1);
727        let expected2 = format!("bytes={}-{}", offset2, range_end2);
728
729        assert_eq!(expected2, "bytes=524288-786431");
730    }
731
732    #[test]
733    fn test_web_seed_manager_fallback() {
734        // Verify manager creation with multiple seeds
735        let urls = vec![
736            "http://seed1.example.com/file.iso".to_string(),
737            "http://seed2.example.com/file.iso".to_string(),
738        ];
739
740        let manager = WebSeedManager::new(urls, 16384, 1048576);
741
742        assert_eq!(manager.len(), 2);
743        assert!(!manager.is_empty());
744        assert_eq!(manager.clients().len(), 2);
745
746        // Verify each client has correct URL
747        assert_eq!(
748            manager.clients()[0].url(),
749            "http://seed1.example.com/file.iso"
750        );
751        assert_eq!(
752            manager.clients()[1].url(),
753            "http://seed2.example.com/file.iso"
754        );
755    }
756
757    #[test]
758    fn test_web_seed_client_creation() {
759        let client = WebSeedClient::new("https://cdn.example.com/releases/v1.tar.gz");
760
761        assert_eq!(client.url(), "https://cdn.example.com/releases/v1.tar.gz");
762        assert!(client.is_available());
763    }
764
765    #[test]
766    fn test_web_seed_manager_empty() {
767        let manager = WebSeedManager::new(Vec::new(), 16384, 1048576);
768
769        assert_eq!(manager.len(), 0);
770        assert!(manager.is_empty());
771    }
772
773    #[test]
774    fn test_parse_url_list_invalid_utf8() {
775        let mut root = BTreeMap::new();
776        root.insert(
777            b"url-list".to_vec(),
778            BencodeValue::Bytes(vec![0xFF, 0xFE]), // Invalid UTF-8
779        );
780
781        let encoded = BencodeValue::Dict(root).encode();
782        let urls = parse_url_list_from_bytes(&encoded);
783
784        // Should return empty (skip invalid UTF-8 URLs)
785        assert!(urls.is_empty());
786    }
787
788    #[test]
789    fn test_parse_url_list_mixed_valid_invalid() {
790        let mut root = BTreeMap::new();
791        root.insert(
792            b"url-list".to_vec(),
793            BencodeValue::List(vec![
794                BencodeValue::Bytes(b"http://valid.example.com/file.bin".to_vec()),
795                BencodeValue::Int(42), // Invalid: not a string
796                BencodeValue::Bytes(b"http://also-valid.example.com/file.bin".to_vec()),
797            ]),
798        );
799
800        let encoded = BencodeValue::Dict(root).encode();
801        let urls = parse_url_list_from_bytes(&encoded);
802
803        // Should skip non-string entries, return only valid URLs
804        assert_eq!(urls.len(), 2);
805        assert_eq!(urls[0], "http://valid.example.com/file.bin");
806        assert_eq!(urls[1], "http://also-valid.example.com/file.bin");
807    }
808
809    // ==================== WebSeedStats tests ====================
810
811    #[test]
812    fn test_web_seed_stats() {
813        let stats = WebSeedStats::new();
814
815        // Record some bytes
816        stats.record_bytes(1000);
817        stats.record_bytes(500);
818
819        assert_eq!(stats.total_bytes_downloaded(), 1500);
820    }
821
822    #[test]
823    fn test_web_seed_stats_average_speed() {
824        let stats = WebSeedStats::new();
825        stats.record_bytes(10000);
826
827        // Speed depends on elapsed time, just verify it doesn't panic
828        let _speed = stats.average_speed();
829    }
830
831    // ==================== Concurrency control tests ====================
832
833    #[test]
834    fn test_active_requests_tracking() {
835        let client = WebSeedClient::new("http://example.com/file.bin");
836
837        // Initially, all pieces can be requested
838        assert!(client.can_request(0));
839        assert!(client.can_request(1));
840        assert!(client.can_request(2));
841
842        // Mark piece 0 as active
843        client.mark_requesting(0);
844        assert!(!client.can_request(0)); // Now piece 0 is busy
845        assert!(client.can_request(1)); // Others still available
846        assert_eq!(client.active_request_count(), 1);
847
848        // Mark piece 1 as active
849        client.mark_requesting(1);
850        assert!(!client.can_request(0));
851        assert!(!client.can_request(1));
852        assert!(client.can_request(2));
853        assert_eq!(client.active_request_count(), 2);
854
855        // Clear piece 0
856        client.clear_request(0);
857        assert!(client.can_request(0)); // Piece 0 available again
858        assert!(!client.can_request(1)); // Piece 1 still busy
859        assert_eq!(client.active_request_count(), 1);
860    }
861
862    #[test]
863    fn test_web_seed_manager_stats() {
864        let urls = vec![
865            "http://seed1.example.com/file.bin".to_string(),
866            "http://seed2.example.com/file.bin".to_string(),
867        ];
868
869        let manager = WebSeedManager::new(urls, 16384, 1048576);
870
871        // Stats should be accessible
872        let stats = manager.stats();
873        assert_eq!(stats.total_bytes_downloaded(), 0);
874    }
875}