use super::{ BTreeMap, BtFileInfo, BtPeerInfo, BtRuntimeState, BtTrackerInfo, DhtNodeModel, Digest, MagnetBootstrapModel, MagnetUriModel, PieceId, PieceMap, PieceState, RequestGroup, RpcError, TorrentMetadataModel, hex_string, merge_bt_dht_nodes, parse_torrent_metadata, }; /// Extracts an uppercase BitTorrent info hash from a magnet URI. pub(in crate::dispatcher) fn rpc_bt_info_hash(uri: &str) -> Option { let lower = uri.to_ascii_lowercase(); let marker = "xt=urn:btih:"; let start = lower.find(marker)?; let raw = &uri[start + marker.len()..]; let token = raw.split('&').next().unwrap_or(raw); let normalized: String = token .chars() .filter(|ch| ch.is_ascii_alphanumeric()) .map(|ch| ch.to_ascii_uppercase()) .collect(); if normalized.is_empty() { None } else { Some(normalized) } } /// Converts parsed torrent metadata into the core BitTorrent runtime model. pub(in crate::dispatcher) fn build_bt_runtime_state( metadata: &TorrentMetadataModel, ) -> BtRuntimeState { let trackers: Vec = metadata .trackers .iter() .map(|tracker| BtTrackerInfo { url: tracker.url.clone(), tier: tracker.tier, id: tracker.id.clone(), seeders: tracker.seeders, leechers: tracker.leechers, }) .collect(); let files = metadata .info .files .iter() .map(|file| BtFileInfo { path: file.path.clone(), length: file.length, piece_offset: file.piece_offset, selected: file.selected, }) .collect(); let peers = metadata .peers .iter() .map(|peer| BtPeerInfo { peer_id: peer.peer_id.map(|peer_id| hex_string(&peer_id)), ip: peer.ip.clone(), port: peer.port, client_name: peer.client_name.clone(), interested: peer.interested, choked: peer.choked, download_speed: 0, upload_speed: 0, seeder: false, }) .collect(); let info_hash = metadata .info .hash .as_ref() .map(|hash| hash.info_hash_hex.to_ascii_uppercase()) .unwrap_or_default(); let magnet_uri = (!info_hash.is_empty()).then(|| { MagnetUriModel { info_hash: info_hash.clone(), display_name: Some(metadata.info.name.clone()), trackers: trackers.iter().map(|tracker| tracker.url.clone()).collect(), web_seeds: Vec::new(), exact_topic: None, } .to_uri() }); BtRuntimeState { info_hash, name: Some(metadata.info.name.clone()), magnet_uri, metadata_only: false, metadata_size: None, metadata_extension_ids: BTreeMap::new(), metadata_piece_payloads: BTreeMap::new(), creation_date: metadata.creation_date.clone(), comment: metadata.comment.clone(), dht_nodes: initial_bt_dht_nodes(&metadata.dht_nodes), files, trackers, peers, } } /// Seeds BitTorrent runtime state from a magnet URI before metadata arrives. pub(in crate::dispatcher) fn build_bt_runtime_state_from_magnet( uri: &str, magnet: &MagnetBootstrapModel, ) -> BtRuntimeState { let magnet_uri = Some(uri.to_owned()); let trackers = magnet .trackers .iter() .map(|tracker| BtTrackerInfo { url: tracker.url.clone(), tier: tracker.tier, id: tracker.id.clone(), seeders: tracker.seeders, leechers: tracker.leechers, }) .collect(); let peer_hints = magnet .peer_hints .iter() .map(|peer| BtPeerInfo { peer_id: peer .peer_id .map(|peer_id: [u8; 20]| hex_string(&peer_id[..])), ip: peer.ip.clone(), port: peer.port, client_name: peer.client_name.clone(), interested: peer.interested, choked: peer.choked, download_speed: 0, upload_speed: 0, seeder: false, }) .collect(); let hinted_dht_nodes = magnet .peer_hint_nodes .iter() .map(DhtNodeModel::to_spec) .collect::>(); BtRuntimeState { info_hash: magnet.info_hash_hex.to_ascii_uppercase(), name: magnet.uri.display_name.clone(), magnet_uri, metadata_only: true, metadata_size: None, metadata_extension_ids: BTreeMap::new(), metadata_piece_payloads: BTreeMap::new(), creation_date: None, comment: None, dht_nodes: initial_bt_dht_nodes(&hinted_dht_nodes), files: Vec::new(), trackers, peers: peer_hints, } } /// Returns the built-in fallback DHT router list. pub(in crate::dispatcher) fn default_bt_dht_nodes() -> Vec { vec![ "router.bittorrent.com:6881".to_owned(), "dht.transmissionbt.com:6881".to_owned(), "router.utorrent.com:6881".to_owned(), ] } /// Chooses explicit DHT nodes when present and otherwise falls back to router defaults. pub(in crate::dispatcher) fn initial_bt_dht_nodes(explicit_nodes: &[String]) -> Vec { if explicit_nodes.is_empty() { default_bt_dht_nodes() } else { explicit_nodes.to_vec() } } /// Returns the BEP 9 metadata piece size used by aria2-compatible peers. pub(in crate::dispatcher) const BT_METADATA_PIECE_LENGTH: u64 = 16 * 1024; /// Returns the number of metadata pieces needed for one BEP 9 payload size. pub(in crate::dispatcher) fn bt_metadata_piece_count(metadata_size: u32) -> u32 { if metadata_size == 0 { return 0; } metadata_size.div_ceil(BT_METADATA_PIECE_LENGTH as u32) } /// Returns the byte length of one metadata piece within the BEP 9 payload. pub(in crate::dispatcher) fn bt_metadata_piece_span(metadata_size: u32, piece_index: u32) -> usize { let piece_start = u64::from(piece_index).saturating_mul(BT_METADATA_PIECE_LENGTH); let metadata_size = u64::from(metadata_size); if piece_start >= metadata_size { return 0; } let remaining = metadata_size.saturating_sub(piece_start); usize::try_from(remaining.min(BT_METADATA_PIECE_LENGTH)).unwrap_or(usize::MAX) } /// Returns a stable runtime key for one BT peer endpoint. pub(in crate::dispatcher) fn bt_peer_metadata_key(peer: &BtPeerInfo) -> String { format!("{}:{}", peer.ip, peer.port) } /// Merges BT tracker snapshots by URL while preserving any existing runtime rows. pub(in crate::dispatcher) fn merge_bt_trackers( existing: &mut Vec, incoming: Vec, ) { for tracker in incoming { if existing.iter().any(|current| current.url == tracker.url) { continue; } existing.push(tracker); } } /// Merges BT peer snapshots while preserving existing runtime rows. pub(in crate::dispatcher) fn merge_bt_peers( existing: &mut Vec, incoming: Vec, ) { for peer in incoming { if let Some(current) = existing.iter_mut().find(|candidate| { candidate.peer_id.as_deref() == peer.peer_id.as_deref() || (candidate.ip == peer.ip && candidate.port == peer.port) }) { *current = peer; } else { existing.push(peer); } } } /// Promotes a metadata-only magnet runtime into a full torrent-backed BT session when ready. pub(in crate::dispatcher) fn try_promote_bt_metadata( group: &mut RequestGroup, ) -> Result { let Some(bt) = group.bt().cloned() else { return Err(RpcError::unsupported( "metadata promotion requires bt runtime state", )); }; if !bt.metadata_only { return Ok(false); } let Some(metadata_size) = bt.metadata_size.filter(|size| *size > 0) else { return Ok(false); }; let piece_count = bt_metadata_piece_count(metadata_size); if piece_count == 0 { return Ok(false); } let mut metadata_bytes = Vec::with_capacity(usize::try_from(metadata_size).unwrap_or(usize::MAX)); for piece in 0..piece_count { let Some(payload) = bt.metadata_piece_payloads.get(&piece) else { return Ok(false); }; let expected_len = bt_metadata_piece_span(metadata_size, piece); if payload.len() < expected_len { return Ok(false); } metadata_bytes.extend_from_slice(&payload[..expected_len]); } metadata_bytes.truncate(usize::try_from(metadata_size).unwrap_or(usize::MAX)); let metadata = parse_torrent_metadata(&metadata_bytes).map_err(|error| { RpcError::unsupported(&format!("invalid magnet metadata payload: {error}")) })?; let parsed_info_hash = metadata .info .hash .as_ref() .map(|hash| hash.info_hash_hex.to_ascii_uppercase()) .unwrap_or_default(); if parsed_info_hash.is_empty() { return Err(RpcError::unsupported( "promoted torrent metadata did not expose an info hash", )); } if !bt.info_hash.is_empty() && !parsed_info_hash.eq_ignore_ascii_case(&bt.info_hash) { return Err(RpcError::unsupported(&format!( "magnet metadata info hash mismatch: expected {}, got {parsed_info_hash}", bt.info_hash ))); } let mut promoted = build_bt_runtime_state(&metadata); promoted.magnet_uri = bt.magnet_uri.clone(); promoted.metadata_size = Some(metadata_size); promoted.metadata_extension_ids = bt.metadata_extension_ids.clone(); promoted.metadata_piece_payloads = bt.metadata_piece_payloads.clone(); promoted.dht_nodes = bt.dht_nodes.clone(); merge_bt_dht_nodes(&mut promoted.dht_nodes, metadata.dht_nodes.iter().cloned()); merge_bt_trackers(&mut promoted.trackers, bt.trackers.clone()); let metadata_peers = std::mem::take(&mut promoted.peers); promoted.peers = bt.peers.clone(); merge_bt_peers(&mut promoted.peers, metadata_peers); group.set_bt(promoted); group.set_total_length(metadata.total_length()); group.set_piece_length(metadata.info.piece_length.max(1)); group.set_completed_length(0); group.clear_piece_availability(); group.clear_segment_assignments(); *group.piece_map_mut() = PieceMap::new(); for piece in &metadata.pieces { group.set_piece_state(PieceId(piece.index), PieceState::Pending); } Ok(true) } /// Computes the effective total length visible to BitTorrent runtime reporting. pub(in crate::dispatcher) fn bt_runtime_total_length(group: &RequestGroup) -> u64 { group.total_length().max( group .bt() .map(BtRuntimeState::selected_or_all_total_length) .unwrap_or_default(), ) } /// Computes the number of pieces required to cover a torrent payload. pub(in crate::dispatcher) fn bt_piece_count(total_length: u64, piece_length: u64) -> usize { if total_length == 0 { 0 } else { total_length.div_ceil(piece_length.max(1)) as usize } } /// Computes the byte span represented by a single BitTorrent piece. pub(in crate::dispatcher) fn bt_piece_span_bytes( piece: PieceId, piece_length: u64, total_length: u64, ) -> u64 { let piece_length = piece_length.max(1); if total_length == 0 { return piece_length; } let start = u64::from(piece.0).saturating_mul(piece_length); total_length.saturating_sub(start).min(piece_length) } /// Sums the verified length implied by the request group's piece map. pub(in crate::dispatcher) fn bt_verified_length( group: &RequestGroup, piece_length: u64, total_length: u64, ) -> u64 { group .piece_map() .iter() .filter(|(_, state)| **state == PieceState::Verified) .map(|(piece, _)| bt_piece_span_bytes(*piece, piece_length, total_length)) .sum() } /// Returns whether a BT peer row is usable for an outbound peer-wire exchange. pub(in crate::dispatcher) fn bt_peer_is_connectable(peer: &BtPeerInfo) -> bool { !peer.ip.trim().is_empty() && peer.port != 0 }