From ab5ae527aad4e1bbb9dc1a22b78cb67e901d151c Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Mon, 27 Nov 2023 18:53:20 +0000 Subject: [PATCH 01/51] A bunch of initial changes to DHT --- crates/dht/src/bprotocol.rs | 2 +- crates/dht/src/dht.rs | 33 ++++++++++-- crates/dht/src/routing_table.rs | 91 +++++++++++++++++++++++++++------ 3 files changed, 103 insertions(+), 23 deletions(-) diff --git a/crates/dht/src/bprotocol.rs b/crates/dht/src/bprotocol.rs index 4488fec..d0f92a4 100644 --- a/crates/dht/src/bprotocol.rs +++ b/crates/dht/src/bprotocol.rs @@ -309,7 +309,7 @@ pub struct GetPeersRequest { #[derive(Debug, Serialize, Deserialize)] pub struct PingRequest { - id: Id20, + pub id: Id20, } #[derive(Debug, Serialize, Deserialize)] diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index f2331e2..779c214 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -9,7 +9,7 @@ use std::{ use crate::{ bprotocol::{ self, CompactNodeInfo, CompactPeerInfo, FindNodeRequest, GetPeersRequest, Message, - MessageKind, Node, + MessageKind, Node, PingRequest, }, routing_table::{InsertResult, RoutingTable}, }; @@ -108,6 +108,12 @@ impl DhtState { target, }), }, + Request::Ping => Message { + transaction_id: ByteString::from(transaction_id_buf.as_ref()), + version: None, + ip: None, + kind: MessageKind::PingRequest(PingRequest { id: self.id }), + }, }; self.outstanding_requests .insert((transaction_id, addr), request); @@ -169,6 +175,7 @@ impl DhtState { Request::GetPeers(id) => { self.on_found_peers_or_nodes(response.id, addr, id, response) } + Request::Ping => Ok(()), } } MessageKind::PingRequest(_) => { @@ -205,6 +212,7 @@ impl DhtState { None }; let compact_node_info = generate_compact_nodes(req.info_hash); + self.routing_table.mark_last_query(&req.id); let message = Message { transaction_id: msg.transaction_id, version: None, @@ -221,6 +229,7 @@ impl DhtState { } MessageKind::FindNodeRequest(req) => { let compact_node_info = generate_compact_nodes(req.target); + self.routing_table.mark_last_query(&req.id); let message = Message { transaction_id: msg.transaction_id, version: None, @@ -320,6 +329,19 @@ impl DhtState { Ok(()) } + fn routing_table_add_node(&mut self, id: Id20, addr: SocketAddr) -> InsertResult { + let mut questionable_nodes = Vec::new(); + let res = self.routing_table.add_node(id, addr, |addr| { + questionable_nodes.push(addr); + true + }); + for addr in questionable_nodes { + let req = self.create_request(Request::Ping, addr); + let _ = self.sender.send((req, addr)); + } + res + } + fn on_found_nodes( &mut self, source: Id20, @@ -336,7 +358,7 @@ impl DhtState { .collect::>(); // On newly discovered nodes, ask them for peers that we are interested in. - match self.routing_table.add_node(source, source_addr) { + match self.routing_table_add_node(source, source_addr) { InsertResult::ReplacedBad(_) | InsertResult::Added => { for info_hash in &searching_for_peers { self.send_find_peers_if_not_yet(*info_hash, source, source_addr)?; @@ -345,7 +367,7 @@ impl DhtState { _ => {} }; for node in nodes.nodes { - match self.routing_table.add_node(node.id, node.addr.into()) { + match self.routing_table_add_node(node.id, node.addr.into()) { InsertResult::ReplacedBad(_) | InsertResult::Added => { for info_hash in &searching_for_peers { self.send_find_peers_if_not_yet(*info_hash, node.id, node.addr.into())?; @@ -366,7 +388,7 @@ impl DhtState { target: Id20, data: bprotocol::Response, ) -> anyhow::Result<()> { - self.routing_table.add_node(source, source_addr); + self.routing_table_add_node(source, source_addr); self.routing_table.mark_response(&source); let bsender = match self.get_peers_subscribers.get(&target) { @@ -398,7 +420,7 @@ impl DhtState { }; if let Some(nodes) = data.nodes { for node in nodes.nodes { - self.routing_table.add_node(node.id, node.addr.into()); + self.routing_table_add_node(node.id, node.addr.into()); self.send_find_peers_if_not_yet(target, node.id, node.addr.into())?; } }; @@ -488,6 +510,7 @@ async fn run_framer( enum Request { GetPeers(Id20), FindNode(Id20), + Ping, } #[derive(Clone)] diff --git a/crates/dht/src/routing_table.rs b/crates/dht/src/routing_table.rs index 5118c81..6e45ab9 100644 --- a/crates/dht/src/routing_table.rs +++ b/crates/dht/src/routing_table.rs @@ -280,9 +280,15 @@ impl BucketTree { } } - pub fn add_node(&mut self, self_id: &Id20, id: Id20, addr: SocketAddr) -> InsertResult { + pub fn add_node( + &mut self, + self_id: &Id20, + id: Id20, + addr: SocketAddr, + on_questionable_node: impl FnMut(SocketAddr) -> bool, + ) -> InsertResult { let idx = self.get_leaf(&id); - self.insert_into_leaf(idx, self_id, id, addr) + self.insert_into_leaf(idx, self_id, id, addr, on_questionable_node) } fn insert_into_leaf( &mut self, @@ -290,6 +296,7 @@ impl BucketTree { self_id: &Id20, id: Id20, addr: SocketAddr, + mut on_questionable_node: impl FnMut(SocketAddr) -> bool, ) -> InsertResult { // The loop here is for this case: // in case we split a node into two, and it degenerates into all the leaves @@ -313,6 +320,7 @@ impl BucketTree { addr, last_request: None, last_response: None, + last_query: None, outstanding_queries_in_a_row: 0, }; @@ -322,6 +330,16 @@ impl BucketTree { return InsertResult::Added; } + // Ping first questionable node + if let Some(questionable_node) = nodes + .iter_mut() + .find(|r| matches!(r.status(), NodeStatus::Questionable)) + { + if on_questionable_node(questionable_node.addr) { + questionable_node.mark_outgoing_request(); + } + } + // Try replace a bad node if let Some(bad_node) = nodes .iter_mut() @@ -400,6 +418,8 @@ pub struct RoutingTableNode { #[serde(skip)] last_response: Option, #[serde(skip)] + last_query: Option, + #[serde(skip)] outstanding_queries_in_a_row: usize, } @@ -418,19 +438,36 @@ impl RoutingTableNode { self.addr } pub fn status(&self) -> NodeStatus { - // TODO: this is just a stub with simpler logic - let last_request = match self.last_request { - Some(v) => v, - None => return NodeStatus::Unknown, - }; - if self.outstanding_queries_in_a_row > 0 && last_request.elapsed() > Duration::from_secs(10) - { - return NodeStatus::Bad; + const RESPONSE_TIMEOUT: Duration = Duration::from_secs(10); + const INACTIVITY_TIMEOUT: Duration = Duration::from_secs(15 * 60); + + match (self.last_request, self.last_response, self.last_query) { + (None, _, _) => NodeStatus::Unknown, + // Nodes become bad when they fail to respond to multiple queries in a row. + (Some(last_request), _, _) + if last_request.elapsed() > RESPONSE_TIMEOUT + && self.outstanding_queries_in_a_row >= 2 => + { + NodeStatus::Bad + } + + // A good node is a node has responded to one of our queries within the last 15 minutes. + // A node is also good if it has ever responded to one of our queries and has sent + // us a query within the last 15 minutes. + (Some(_), Some(last_activity), _) | (Some(_), Some(_), Some(last_activity)) + if last_activity.elapsed() < INACTIVITY_TIMEOUT => + { + NodeStatus::Good + } + + // After 15 minutes of inactivity, a node becomes questionable + (_, _, Some(last_activity)) | (_, Some(last_activity), _) + if last_activity.elapsed() > INACTIVITY_TIMEOUT => + { + NodeStatus::Questionable + } + (Some(_), _, _) => NodeStatus::Unknown, } - if self.last_response.is_some() { - return NodeStatus::Good; - } - NodeStatus::Questionable } pub fn mark_outgoing_request(&mut self) { @@ -438,6 +475,10 @@ impl RoutingTableNode { self.outstanding_queries_in_a_row += 1; } + pub fn mark_last_query(&mut self) { + self.last_query = Some(Instant::now()); + } + pub fn mark_response(&mut self) { let now = Instant::now(); self.last_response = Some(now); @@ -479,8 +520,15 @@ impl RoutingTable { result } - pub fn add_node(&mut self, id: Id20, addr: SocketAddr) -> InsertResult { - let res = self.buckets.add_node(&self.id, id, addr); + pub fn add_node( + &mut self, + id: Id20, + addr: SocketAddr, + on_questionable_node: impl FnMut(SocketAddr) -> bool, + ) -> InsertResult { + let res = self + .buckets + .add_node(&self.id, id, addr, on_questionable_node); let replaced = match &res { InsertResult::WasExisting => false, InsertResult::ReplacedBad(..) => true, @@ -509,6 +557,15 @@ impl RoutingTable { r.mark_response(); true } + + pub fn mark_last_query(&mut self, id: &Id20) -> bool { + let r = match self.buckets.get_mut(id) { + Some(r) => r, + None => return false, + }; + r.mark_last_query(); + true + } } #[cfg(test)] @@ -603,7 +660,7 @@ mod tests { for _ in 0..length.unwrap_or(16536) { let other_id = random_id_20(); let addr = generate_socket_addr(); - rtable.add_node(other_id, addr); + rtable.add_node(other_id, addr, |_| false); } rtable } From 692fef13944f1bf76d6034f7d616c1385d03ae5c Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Mon, 27 Nov 2023 19:03:39 +0000 Subject: [PATCH 02/51] Poor mans algo to resend requests --- crates/dht/src/dht.rs | 55 +++++++++++++++++++++++++++++++++---------- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index 779c214..533c221 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -1,9 +1,9 @@ use std::{ - collections::{hash_map::Entry, HashMap, HashSet}, + collections::{hash_map::Entry, HashMap}, net::SocketAddr, sync::Arc, task::Poll, - time::Duration, + time::{Duration, Instant}, }; use crate::{ @@ -42,7 +42,16 @@ pub struct DhtStats { struct DhtState { id: Id20, next_transaction_id: u16, - outstanding_requests: HashMap<(u16, SocketAddr), Request>, + + // Created requests: (transaction_id, addr) => Requests. + // If we get a response, it gets removed from here. + // + // TODO: clean up old entries + outstanding_requests_by_transaction_id: HashMap<(u16, SocketAddr), Request>, + + // TODO: clean up old entries + made_requests_by_addr: HashMap<(Request, SocketAddr), Instant>, + routing_table: RoutingTable, listen_addr: SocketAddr, @@ -55,8 +64,6 @@ struct DhtState { seen_peers: HashMap>, get_peers_subscribers: HashMap>, - - made_requests: HashSet<(Request, SocketAddr)>, } impl DhtState { @@ -70,13 +77,13 @@ impl DhtState { Self { id, next_transaction_id: 0, - outstanding_requests: Default::default(), + outstanding_requests_by_transaction_id: Default::default(), routing_table, sender, listen_addr, seen_peers: Default::default(), get_peers_subscribers: Default::default(), - made_requests: Default::default(), + made_requests_by_addr: Default::default(), } } @@ -115,10 +122,11 @@ impl DhtState { kind: MessageKind::PingRequest(PingRequest { id: self.id }), }, }; - self.outstanding_requests + self.outstanding_requests_by_transaction_id .insert((transaction_id, addr), request); message } + fn on_incoming_from_remote( &mut self, msg: Message, @@ -153,7 +161,10 @@ impl DhtState { ) } let tid = ((msg.transaction_id[0] as u16) << 8) + (msg.transaction_id[1] as u16); - let request = match self.outstanding_requests.remove(&(tid, addr)) { + let request = match self + .outstanding_requests_by_transaction_id + .remove(&(tid, addr)) + { Some(req) => req, None => anyhow::bail!("outstanding request not found. Message: {:?}", msg), }; @@ -249,9 +260,9 @@ impl DhtState { pub fn get_stats(&self) -> DhtStats { DhtStats { id: self.id, - outstanding_requests: self.outstanding_requests.len(), + outstanding_requests: self.outstanding_requests_by_transaction_id.len(), seen_peers: self.seen_peers.values().map(|v| v.len()).sum(), - made_requests: self.made_requests.len(), + made_requests: self.made_requests_by_addr.len(), routing_table_size: self.routing_table.len(), } } @@ -299,6 +310,24 @@ impl DhtState { } } + fn should_request(&mut self, request: Request, addr: SocketAddr) -> bool { + const RE_REQUEST_TIME: Duration = Duration::from_secs(10 * 60); + match self.made_requests_by_addr.entry((request, addr)) { + Entry::Occupied(mut o) => { + if o.get().elapsed() > RE_REQUEST_TIME { + o.insert(Instant::now()); + true + } else { + false + } + } + Entry::Vacant(v) => { + v.insert(Instant::now()); + true + } + } + } + fn send_find_peers_if_not_yet( &mut self, info_hash: Id20, @@ -306,7 +335,7 @@ impl DhtState { addr: SocketAddr, ) -> anyhow::Result<()> { let request = Request::GetPeers(info_hash); - if self.made_requests.insert((request, addr)) { + if self.should_request(request, addr) { self.routing_table.mark_outgoing_request(&target_node); let msg = self.create_request(request, addr); self.sender.send((msg, addr))?; @@ -321,7 +350,7 @@ impl DhtState { addr: SocketAddr, ) -> anyhow::Result<()> { let request = Request::FindNode(search_id); - if self.made_requests.insert((request, addr)) { + if self.should_request(request, addr) { self.routing_table.mark_outgoing_request(&target_node); let msg = self.create_request(request, addr); self.sender.send((msg, addr))?; From 1a6eb05ca1ab0639dcf6e0de5b2627a6b667fb65 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Mon, 27 Nov 2023 23:19:24 +0000 Subject: [PATCH 03/51] Optional file logging --- TODO.md | 7 +++++-- crates/rqbit/src/main.rs | 40 ++++++++++++++++++++++++++++++++++------ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/TODO.md b/TODO.md index 339c3ac..7c24028 100644 --- a/TODO.md +++ b/TODO.md @@ -16,11 +16,14 @@ - [ ] DHT - [ ] for torrents with a few seeds might be cool to re-query DHT once in a while. - [x] it's sending many requests now way too fast, locks up Mac OS UI annoyingly + - [ ] After the search is exhausted, the client then inserts the peer contact information for itself onto the responding nodes with IDs closest to the infohash of the torrent. + - [ ] Bad actors: + - [ ] Ensure that if we query the "returned" nodes, they are even closer to our request than the responding node id was. someday: - [x] cancellation from the client-side for the lib (i.e. stop the torrent manager) -- [ ] favicons for Web UI +- [x] favicons for Web UI refactor: - [x] where are peers stored @@ -33,4 +36,4 @@ refactor: - [ ] if the torrent was completed, not need to re-check it - [x] checking is very slow on raspberry checked. nothing much can be done here. Even if raspberry's own libssl.so is used it's still super slow (sha1) -- [ ] .rqbit-session.json file has 0 bytes when disk full. I guess fs::rename does this when disk is full? at least on linux \ No newline at end of file +- [ ] .rqbit-session.json file has 0 bytes when disk full. I guess fs::rename does this when disk is full? at least on linux. Couldn't repro on MacOS \ No newline at end of file diff --git a/crates/rqbit/src/main.rs b/crates/rqbit/src/main.rs index bf96634..f9fa5c4 100644 --- a/crates/rqbit/src/main.rs +++ b/crates/rqbit/src/main.rs @@ -1,4 +1,4 @@ -use std::{net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; +use std::{io::BufWriter, net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; use anyhow::Context; use clap::{Parser, ValueEnum}; @@ -28,10 +28,18 @@ enum LogLevel { #[derive(Parser)] #[command(version, author, about)] struct Opts { - /// The loglevel + /// The console loglevel #[arg(value_enum, short = 'v')] log_level: Option, + /// The log filename to also write to in addition to the console. + #[arg(long = "log-file")] + log_file: Option, + + /// The value for RUST_LOG in the log file + #[arg(long = "log-file-rust-log", default_value = "librqbit=trace,info")] + log_file_rust_log: String, + /// The interval to poll trackers, e.g. 30s. /// Trackers send the refresh interval when we connect to them. Often this is /// pretty big, e.g. 30 minutes. This can force a certain value. @@ -193,10 +201,30 @@ fn init_logging(opts: &Opts) -> tokio::sync::mpsc::UnboundedSender { #[cfg(not(feature = "tokio-console"))] { - tracing_subscriber::registry() - .with(fmt::layer()) - .with(stderr_filter) - .init(); + let layered = tracing_subscriber::registry().with(fmt::layer().with_filter(stderr_filter)); + if let Some(log_file) = &opts.log_file { + let log_file = log_file.clone(); + let log_file = move || { + BufWriter::new( + std::fs::OpenOptions::new() + .create(true) + .append(true) + .write(true) + .open(&log_file) + .with_context(|| format!("error opening log file {:?}", log_file)) + .unwrap(), + ) + }; + layered + .with( + fmt::layer() + .with_writer(log_file) + .with_filter(EnvFilter::builder().parse(&opts.log_file_rust_log).unwrap()), + ) + .init(); + } else { + layered.init(); + } } let (reload_tx, mut reload_rx) = tokio::sync::mpsc::unbounded_channel::(); From eaf5021908a3c2c1b8c5d9df15cbf40c05d76d7f Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Tue, 28 Nov 2023 07:40:27 +0000 Subject: [PATCH 04/51] Next id -> AtomicU16 --- Cargo.lock | 1 + crates/dht/Cargo.toml | 1 + crates/dht/src/dht.rs | 50 ++++++++++++++++++++----------------------- 3 files changed, 25 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b7dec58..ee5227f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1087,6 +1087,7 @@ name = "librqbit-dht" version = "3.2.0" dependencies = [ "anyhow", + "dashmap", "directories", "futures", "hex 0.4.3", diff --git a/crates/dht/Cargo.toml b/crates/dht/Cargo.toml index ec5c9d5..e6647b5 100644 --- a/crates/dht/Cargo.toml +++ b/crates/dht/Cargo.toml @@ -31,6 +31,7 @@ futures = "0.3" rand = "0.8" indexmap = "2" directories = "5" +dashmap = "5.5.3" clone_to_owned = {path="../clone_to_owned", package="librqbit-clone-to-owned", version = "2.2.1"} librqbit-core = {path="../librqbit_core", version = "3.1.0"} diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index 533c221..ae040aa 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -1,7 +1,10 @@ use std::{ collections::{hash_map::Entry, HashMap}, net::SocketAddr, - sync::Arc, + sync::{ + atomic::{AtomicU16, Ordering}, + Arc, + }, task::Poll, time::{Duration, Instant}, }; @@ -41,7 +44,7 @@ pub struct DhtStats { struct DhtState { id: Id20, - next_transaction_id: u16, + next_transaction_id: AtomicU16, // Created requests: (transaction_id, addr) => Requests. // If we get a response, it gets removed from here. @@ -76,7 +79,7 @@ impl DhtState { let routing_table = routing_table.unwrap_or_else(|| RoutingTable::new(id)); Self { id, - next_transaction_id: 0, + next_transaction_id: AtomicU16::new(0), outstanding_requests_by_transaction_id: Default::default(), routing_table, sender, @@ -87,15 +90,17 @@ impl DhtState { } } - fn create_request(&mut self, request: Request, addr: SocketAddr) -> Message { - let transaction_id = self.next_transaction_id; + fn send_request(&mut self, request: Request, addr: SocketAddr) -> anyhow::Result<()> { + let (tid, msg) = self.create_request(request, addr); + self.outstanding_requests_by_transaction_id + .insert((tid, addr), request); + Ok(self.sender.send((msg, addr))?) + } + + fn create_request(&mut self, request: Request, addr: SocketAddr) -> (u16, Message) { + let transaction_id = self.next_transaction_id.fetch_add(1, Ordering::Relaxed); let transaction_id_buf = [(transaction_id >> 8) as u8, (transaction_id & 0xff) as u8]; - self.next_transaction_id = if transaction_id == u16::MAX { - 0 - } else { - transaction_id + 1 - }; let message = match request { Request::GetPeers(info_hash) => Message { transaction_id: ByteString::from(transaction_id_buf.as_ref()), @@ -122,9 +127,7 @@ impl DhtState { kind: MessageKind::PingRequest(PingRequest { id: self.id }), }, }; - self.outstanding_requests_by_transaction_id - .insert((transaction_id, addr), request); - message + (transaction_id, message) } fn on_incoming_from_remote( @@ -337,8 +340,7 @@ impl DhtState { let request = Request::GetPeers(info_hash); if self.should_request(request, addr) { self.routing_table.mark_outgoing_request(&target_node); - let msg = self.create_request(request, addr); - self.sender.send((msg, addr))?; + self.send_request(request, addr)?; } Ok(()) } @@ -352,8 +354,7 @@ impl DhtState { let request = Request::FindNode(search_id); if self.should_request(request, addr) { self.routing_table.mark_outgoing_request(&target_node); - let msg = self.create_request(request, addr); - self.sender.send((msg, addr))?; + self.send_request(request, addr)?; } Ok(()) } @@ -365,8 +366,7 @@ impl DhtState { true }); for addr in questionable_nodes { - let req = self.create_request(Request::Ping, addr); - let _ = self.sender.send((req, addr)); + let _ = self.send_request(Request::Ping, addr); } res } @@ -560,7 +560,6 @@ impl DhtWorker { async fn start( self, - in_tx: UnboundedSender<(Message, SocketAddr)>, in_rx: UnboundedReceiver<(Message, SocketAddr)>, bootstrap_addrs: &[String], ) -> anyhow::Result<()> { @@ -572,17 +571,14 @@ impl DhtWorker { // bootstrap for addr in bootstrap_addrs.iter() { let this = &self; - let in_tx = &in_tx; futs.push( async move { match tokio::net::lookup_host(addr).await { Ok(addrs) => { for addr in addrs { - let request = this - .state + this.state .write() - .create_request(Request::FindNode(this.peer_id), addr); - in_tx.send((request, addr))?; + .send_request(Request::FindNode(this.peer_id), addr)?; } } Err(e) => { @@ -730,7 +726,7 @@ impl Dht { let (in_tx, in_rx) = unbounded_channel(); let state = Arc::new(RwLock::new(DhtState::new( peer_id, - in_tx.clone(), + in_tx, config.routing_table, listen_addr, ))); @@ -743,7 +739,7 @@ impl Dht { peer_id, state, }; - worker.start(in_tx, in_rx, &bootstrap_addrs).await?; + worker.start(in_rx, &bootstrap_addrs).await?; Ok(()) } }); From c7cf5eedefed3de1a139274d5305b75d0ea9923a Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Tue, 28 Nov 2023 08:03:12 +0000 Subject: [PATCH 05/51] Remove the giant lock from dht --- crates/dht/examples/dht.rs | 4 +- crates/dht/src/dht.rs | 114 ++++++++++++++++--------------- crates/dht/src/lib.rs | 19 +++++- crates/dht/src/persistence.rs | 4 +- crates/librqbit/src/dht_utils.rs | 4 +- crates/librqbit/src/session.rs | 4 +- 6 files changed, 84 insertions(+), 65 deletions(-) diff --git a/crates/dht/examples/dht.rs b/crates/dht/examples/dht.rs index 8862cdc..38c5342 100644 --- a/crates/dht/examples/dht.rs +++ b/crates/dht/examples/dht.rs @@ -2,7 +2,7 @@ use std::time::Duration; use anyhow::Context; use librqbit_core::magnet::Magnet; -use librqbit_dht::Dht; +use librqbit_dht::{Dht, DhtBuilder}; use tokio_stream::StreamExt; use tracing::info; @@ -16,7 +16,7 @@ async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt::init(); - let dht = Dht::new().await.context("error initializing DHT")?; + let dht = DhtBuilder::new().await.context("error initializing DHT")?; let mut stream = dht.get_peers(info_hash)?; let stats_printer = async { diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index ae040aa..5769893 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -1,5 +1,4 @@ use std::{ - collections::{hash_map::Entry, HashMap}, net::SocketAddr, sync::{ atomic::{AtomicU16, Ordering}, @@ -18,6 +17,7 @@ use crate::{ }; use anyhow::Context; use bencode::ByteString; +use dashmap::DashMap; use futures::{stream::FuturesUnordered, Stream, StreamExt}; use indexmap::IndexSet; use leaky_bucket::RateLimiter; @@ -42,7 +42,7 @@ pub struct DhtStats { pub routing_table_size: usize, } -struct DhtState { +pub struct DhtState { id: Id20, next_transaction_id: AtomicU16, @@ -50,12 +50,12 @@ struct DhtState { // If we get a response, it gets removed from here. // // TODO: clean up old entries - outstanding_requests_by_transaction_id: HashMap<(u16, SocketAddr), Request>, + outstanding_requests_by_transaction_id: DashMap<(u16, SocketAddr), Request>, // TODO: clean up old entries - made_requests_by_addr: HashMap<(Request, SocketAddr), Instant>, + made_requests_by_addr: DashMap<(Request, SocketAddr), Instant>, - routing_table: RoutingTable, + routing_table: RwLock, listen_addr: SocketAddr, // This sender sends requests to the worker. @@ -65,12 +65,12 @@ struct DhtState { // Alternatively, we can lock only the parts that change, and use that internally inside DhtState... sender: UnboundedSender<(Message, SocketAddr)>, - seen_peers: HashMap>, - get_peers_subscribers: HashMap>, + seen_peers: DashMap>, + get_peers_subscribers: DashMap>, } impl DhtState { - fn new( + fn new_internal( id: Id20, sender: UnboundedSender<(Message, SocketAddr)>, routing_table: Option, @@ -81,7 +81,7 @@ impl DhtState { id, next_transaction_id: AtomicU16::new(0), outstanding_requests_by_transaction_id: Default::default(), - routing_table, + routing_table: RwLock::new(routing_table), sender, listen_addr, seen_peers: Default::default(), @@ -90,14 +90,14 @@ impl DhtState { } } - fn send_request(&mut self, request: Request, addr: SocketAddr) -> anyhow::Result<()> { - let (tid, msg) = self.create_request(request, addr); + fn send_request(self: &Arc, request: Request, addr: SocketAddr) -> anyhow::Result<()> { + let (tid, msg) = self.create_request(request); self.outstanding_requests_by_transaction_id .insert((tid, addr), request); Ok(self.sender.send((msg, addr))?) } - fn create_request(&mut self, request: Request, addr: SocketAddr) -> (u16, Message) { + fn create_request(&self, request: Request) -> (u16, Message) { let transaction_id = self.next_transaction_id.fetch_add(1, Ordering::Relaxed); let transaction_id_buf = [(transaction_id >> 8) as u8, (transaction_id & 0xff) as u8]; @@ -131,13 +131,14 @@ impl DhtState { } fn on_incoming_from_remote( - &mut self, + self: &Arc, msg: Message, addr: SocketAddr, ) -> anyhow::Result<()> { let generate_compact_nodes = |target| { let nodes = self .routing_table + .read() .sorted_by_distance_from(target) .into_iter() .filter_map(|r| { @@ -167,6 +168,7 @@ impl DhtState { let request = match self .outstanding_requests_by_transaction_id .remove(&(tid, addr)) + .map(|(_, v)| v) { Some(req) => req, None => anyhow::bail!("outstanding request not found. Message: {:?}", msg), @@ -178,7 +180,7 @@ impl DhtState { MessageKind::Response(r) => r, _ => unreachable!(), }; - self.routing_table.mark_response(&response.id); + self.routing_table.write().mark_response(&response.id); match request { Request::FindNode(id) => { let nodes = response.nodes.ok_or_else(|| { @@ -226,7 +228,7 @@ impl DhtState { None }; let compact_node_info = generate_compact_nodes(req.info_hash); - self.routing_table.mark_last_query(&req.id); + self.routing_table.write().mark_last_query(&req.id); let message = Message { transaction_id: msg.transaction_id, version: None, @@ -243,7 +245,7 @@ impl DhtState { } MessageKind::FindNodeRequest(req) => { let compact_node_info = generate_compact_nodes(req.target); - self.routing_table.mark_last_query(&req.id); + self.routing_table.write().mark_last_query(&req.id); let message = Message { transaction_id: msg.transaction_id, version: None, @@ -264,20 +266,21 @@ impl DhtState { DhtStats { id: self.id, outstanding_requests: self.outstanding_requests_by_transaction_id.len(), - seen_peers: self.seen_peers.values().map(|v| v.len()).sum(), + seen_peers: self.seen_peers.iter().map(|(e)| e.value().len()).sum(), made_requests: self.made_requests_by_addr.len(), - routing_table_size: self.routing_table.len(), + routing_table_size: self.routing_table.read().len(), } } #[allow(clippy::type_complexity)] - fn get_peers( - &mut self, + fn get_peers_internal( + self: &Arc, info_hash: Id20, ) -> anyhow::Result<( Option<(usize, usize)>, tokio::sync::broadcast::Receiver, )> { + use dashmap::mapref::entry::Entry; match self.get_peers_subscribers.entry(info_hash) { Entry::Occupied(o) => { let pos = self.seen_peers.get(&info_hash).and_then(|p| { @@ -299,6 +302,7 @@ impl DhtState { // We don't need to allocate/collect here, but the borrow checker is not happy otherwise. let nodes_to_query = self .routing_table + .read() .sorted_by_distance_from(info_hash) .iter() .map(|n| (n.id(), n.addr())) @@ -313,8 +317,9 @@ impl DhtState { } } - fn should_request(&mut self, request: Request, addr: SocketAddr) -> bool { + fn should_request(&self, request: Request, addr: SocketAddr) -> bool { const RE_REQUEST_TIME: Duration = Duration::from_secs(10 * 60); + use dashmap::mapref::entry::Entry; match self.made_requests_by_addr.entry((request, addr)) { Entry::Occupied(mut o) => { if o.get().elapsed() > RE_REQUEST_TIME { @@ -332,36 +337,40 @@ impl DhtState { } fn send_find_peers_if_not_yet( - &mut self, + self: &Arc, info_hash: Id20, target_node: Id20, addr: SocketAddr, ) -> anyhow::Result<()> { let request = Request::GetPeers(info_hash); if self.should_request(request, addr) { - self.routing_table.mark_outgoing_request(&target_node); + self.routing_table + .write() + .mark_outgoing_request(&target_node); self.send_request(request, addr)?; } Ok(()) } fn send_find_node_if_not_yet( - &mut self, + self: &Arc, search_id: Id20, target_node: Id20, addr: SocketAddr, ) -> anyhow::Result<()> { let request = Request::FindNode(search_id); if self.should_request(request, addr) { - self.routing_table.mark_outgoing_request(&target_node); + self.routing_table + .write() + .mark_outgoing_request(&target_node); self.send_request(request, addr)?; } Ok(()) } - fn routing_table_add_node(&mut self, id: Id20, addr: SocketAddr) -> InsertResult { + fn routing_table_add_node(self: &Arc, id: Id20, addr: SocketAddr) -> InsertResult { let mut questionable_nodes = Vec::new(); - let res = self.routing_table.add_node(id, addr, |addr| { + let res = self.routing_table.write().add_node(id, addr, |addr| { questionable_nodes.push(addr); true }); @@ -372,7 +381,7 @@ impl DhtState { } fn on_found_nodes( - &mut self, + self: &Arc, source: Id20, source_addr: SocketAddr, target: Id20, @@ -382,8 +391,8 @@ impl DhtState { // otherwise when we iterate self.searching_for_peers and mutating self in the loop. let searching_for_peers = self .get_peers_subscribers - .keys() - .copied() + .iter() + .map(|e| *e.key()) .collect::>(); // On newly discovered nodes, ask them for peers that we are interested in. @@ -411,14 +420,14 @@ impl DhtState { } fn on_found_peers_or_nodes( - &mut self, + self: &Arc, source: Id20, source_addr: SocketAddr, target: Id20, data: bprotocol::Response, ) -> anyhow::Result<()> { self.routing_table_add_node(source, source_addr); - self.routing_table.mark_response(&source); + self.routing_table.write().mark_response(&source); let bsender = match self.get_peers_subscribers.get(&target) { Some(s) => s, @@ -432,7 +441,7 @@ impl DhtState { }; if let Some(peers) = data.values { - let seen = self.seen_peers.entry(target).or_default(); + let mut seen = self.seen_peers.entry(target).or_default(); for peer in peers.iter() { if peer.addr.port() < 1024 { @@ -542,20 +551,15 @@ enum Request { Ping, } -#[derive(Clone)] -pub struct Dht { - state: Arc>, -} - struct DhtWorker { socket: UdpSocket, peer_id: Id20, - state: Arc>, + state: Arc, } impl DhtWorker { fn on_response(&self, msg: Message, addr: SocketAddr) -> anyhow::Result<()> { - self.state.write().on_incoming_from_remote(msg, addr) + self.state.on_incoming_from_remote(msg, addr) } async fn start( @@ -577,7 +581,6 @@ impl DhtWorker { Ok(addrs) => { for addr in addrs { this.state - .write() .send_request(Request::FindNode(this.peer_id), addr)?; } } @@ -641,7 +644,7 @@ impl DhtWorker { struct PeerStream { info_hash: Id20, - state: Arc>, + state: Arc, absolute_stream_pos: usize, initial_peers_pos: Option<(usize, usize)>, broadcast_rx: BroadcastStream, @@ -658,7 +661,6 @@ impl Stream for PeerStream { if let Some((pos, end)) = self.initial_peers_pos.take() { let addr = *self .state - .read() .seen_peers .get(&self.info_hash) .unwrap() @@ -698,11 +700,11 @@ pub struct DhtConfig { pub listen_addr: Option, } -impl Dht { - pub async fn new() -> anyhow::Result { +impl DhtState { + pub async fn new() -> anyhow::Result> { Self::with_config(DhtConfig::default()).await } - pub async fn with_config(config: DhtConfig) -> anyhow::Result { + pub async fn with_config(config: DhtConfig) -> anyhow::Result> { let socket = match config.listen_addr { Some(addr) => UdpSocket::bind(addr) .await @@ -724,12 +726,12 @@ impl Dht { .unwrap_or_else(|| crate::DHT_BOOTSTRAP.iter().map(|v| v.to_string()).collect()); let (in_tx, in_rx) = unbounded_channel(); - let state = Arc::new(RwLock::new(DhtState::new( + let state = Arc::new(Self::new_internal( peer_id, in_tx, config.routing_table, listen_addr, - ))); + )); spawn(error_span!("dht"), { let state = state.clone(); @@ -743,17 +745,17 @@ impl Dht { Ok(()) } }); - Ok(Dht { state }) + Ok(state) } pub fn get_peers( - &self, + self: &Arc, info_hash: Id20, ) -> anyhow::Result + Unpin> { - let (pos, rx) = self.state.write().get_peers(info_hash)?; + let (pos, rx) = self.get_peers_internal(info_hash)?; Ok(PeerStream { info_hash, - state: self.state.clone(), + state: self.clone(), absolute_stream_pos: 0, initial_peers_pos: pos, broadcast_rx: BroadcastStream::new(rx), @@ -761,18 +763,18 @@ impl Dht { } pub fn listen_addr(&self) -> SocketAddr { - self.state.read().listen_addr + self.listen_addr } pub fn stats(&self) -> DhtStats { - self.state.read().get_stats() + self.get_stats() } pub fn with_routing_table R>(&self, f: F) -> R { - f(&self.state.read().routing_table) + f(&self.routing_table.read()) } pub fn clone_routing_table(&self) -> RoutingTable { - self.state.read().routing_table.clone() + self.routing_table.read().clone() } } diff --git a/crates/dht/src/lib.rs b/crates/dht/src/lib.rs index 9000fcc..81713d3 100644 --- a/crates/dht/src/lib.rs +++ b/crates/dht/src/lib.rs @@ -4,9 +4,26 @@ mod persistence; mod routing_table; mod utils; +use std::sync::Arc; + pub use crate::dht::DhtStats; -pub use crate::dht::{Dht, DhtConfig}; +pub use crate::dht::{DhtConfig, DhtState}; pub use librqbit_core::id20::Id20; pub use persistence::{PersistentDht, PersistentDhtConfig}; +pub type Dht = Arc; + +pub struct DhtBuilder {} + +impl DhtBuilder { + #[allow(clippy::new_ret_no_self)] + pub async fn new() -> anyhow::Result { + DhtState::new().await + } + + pub async fn with_config(config: DhtConfig) -> anyhow::Result { + DhtState::with_config(config).await + } +} + pub static DHT_BOOTSTRAP: &[&str] = &["dht.transmissionbt.com:6881", "dht.libtorrent.org:25401"]; diff --git a/crates/dht/src/persistence.rs b/crates/dht/src/persistence.rs index a4f091e..bf91903 100644 --- a/crates/dht/src/persistence.rs +++ b/crates/dht/src/persistence.rs @@ -11,8 +11,8 @@ use std::time::Duration; use anyhow::Context; use tracing::{debug, error, error_span, info, trace, warn}; -use crate::dht::{Dht, DhtConfig}; use crate::routing_table::RoutingTable; +use crate::{Dht, DhtConfig, DhtState}; #[derive(Default, Clone)] pub struct PersistentDhtConfig { @@ -108,7 +108,7 @@ impl PersistentDht { listen_addr, ..Default::default() }; - let dht = Dht::with_config(dht_config).await?; + let dht = DhtState::with_config(dht_config).await?; spawn(error_span!("dht_persistence"), { let dht = dht.clone(); diff --git a/crates/librqbit/src/dht_utils.rs b/crates/librqbit/src/dht_utils.rs index 9e7d60f..fb5b339 100644 --- a/crates/librqbit/src/dht_utils.rs +++ b/crates/librqbit/src/dht_utils.rs @@ -86,7 +86,7 @@ pub async fn read_metainfo_from_peer_receiver + Unp #[cfg(test)] mod tests { - use dht::{Dht, Id20}; + use dht::{Dht, DhtBuilder, Id20}; use librqbit_core::peer_id::generate_peer_id; use super::*; @@ -106,7 +106,7 @@ mod tests { init_logging(); let info_hash = Id20::from_str("cf3ea75e2ebbd30e0da6e6e215e2226bf35f2e33").unwrap(); - let dht = Dht::new().await.unwrap(); + let dht = DhtBuilder::new().await.unwrap(); let peer_rx = dht.get_peers(info_hash).unwrap(); let peer_id = generate_peer_id(); match read_metainfo_from_peer_receiver(peer_id, info_hash, peer_rx, None).await { diff --git a/crates/librqbit/src/session.rs b/crates/librqbit/src/session.rs index d4a56bf..47bc9cc 100644 --- a/crates/librqbit/src/session.rs +++ b/crates/librqbit/src/session.rs @@ -11,7 +11,7 @@ use std::{ use anyhow::{bail, Context}; use buffers::ByteString; -use dht::{Dht, Id20, PersistentDht, PersistentDhtConfig}; +use dht::{Dht, DhtBuilder, Id20, PersistentDht, PersistentDhtConfig}; use librqbit_core::{ magnet::Magnet, peer_id::generate_peer_id, @@ -234,7 +234,7 @@ impl Session { None } else { let dht = if opts.disable_dht_persistence { - Dht::new().await + DhtBuilder::new().await } else { PersistentDht::create(opts.dht_config).await } From e012cd94a35d60da129120c687d1c30f2371fabe Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Tue, 28 Nov 2023 08:56:27 +0000 Subject: [PATCH 06/51] Remove timed out DHT requests --- crates/dht/src/dht.rs | 47 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index 5769893..52083b3 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -42,6 +42,11 @@ pub struct DhtStats { pub routing_table_size: usize, } +struct OutstandingRequest { + request: Request, + done: tokio::sync::oneshot::Sender<()>, +} + pub struct DhtState { id: Id20, next_transaction_id: AtomicU16, @@ -50,7 +55,7 @@ pub struct DhtState { // If we get a response, it gets removed from here. // // TODO: clean up old entries - outstanding_requests_by_transaction_id: DashMap<(u16, SocketAddr), Request>, + outstanding_requests_by_transaction_id: DashMap<(u16, SocketAddr), OutstandingRequest>, // TODO: clean up old entries made_requests_by_addr: DashMap<(Request, SocketAddr), Instant>, @@ -92,9 +97,39 @@ impl DhtState { fn send_request(self: &Arc, request: Request, addr: SocketAddr) -> anyhow::Result<()> { let (tid, msg) = self.create_request(request); + let (tx, rx) = tokio::sync::oneshot::channel(); self.outstanding_requests_by_transaction_id - .insert((tid, addr), request); - Ok(self.sender.send((msg, addr))?) + .insert((tid, addr), OutstandingRequest { request, done: tx }); + match self.sender.send((msg, addr)) { + Ok(_) => {} + Err(e) => { + self.outstanding_requests_by_transaction_id + .remove(&(tid, addr)); + return Err(e.into()); + } + }; + let this = self.clone(); + spawn( + debug_span!("dht_request", tid = tid, addr = addr.to_string()), + async move { + match tokio::time::timeout(Duration::from_secs(60), rx).await { + Ok(Ok(_)) => {} + Ok(Err(e)) => { + this.outstanding_requests_by_transaction_id + .remove(&(tid, addr)); + warn!("recv error, did not expect this: {:?}", e); + } + Err(e) => { + this.outstanding_requests_by_transaction_id + .remove(&(tid, addr)); + debug!("error: {:?}", e); + } + }; + + Ok(()) + }, + ); + Ok(()) } fn create_request(&self, request: Request) -> (u16, Message) { @@ -173,6 +208,10 @@ impl DhtState { Some(req) => req, None => anyhow::bail!("outstanding request not found. Message: {:?}", msg), }; + let request = { + let _ = request.done.send(()); + request.request + }; let response = match msg.kind { MessageKind::Error(e) => { anyhow::bail!("request {:?} received error response {:?}", request, e) @@ -266,7 +305,7 @@ impl DhtState { DhtStats { id: self.id, outstanding_requests: self.outstanding_requests_by_transaction_id.len(), - seen_peers: self.seen_peers.iter().map(|(e)| e.value().len()).sum(), + seen_peers: self.seen_peers.iter().map(|e| e.value().len()).sum(), made_requests: self.made_requests_by_addr.len(), routing_table_size: self.routing_table.read().len(), } From 0478577a728a1a7b44d48d4e7f8f632cb56f5cdd Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Tue, 28 Nov 2023 09:23:05 +0000 Subject: [PATCH 07/51] Nothing --- crates/dht/src/dht.rs | 40 +++++++++++++++++++-------------- crates/dht/src/lib.rs | 6 +++++ crates/dht/src/routing_table.rs | 5 ++--- 3 files changed, 31 insertions(+), 20 deletions(-) diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index 52083b3..393fc38 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -11,12 +11,13 @@ use std::{ use crate::{ bprotocol::{ self, CompactNodeInfo, CompactPeerInfo, FindNodeRequest, GetPeersRequest, Message, - MessageKind, Node, PingRequest, + MessageKind, Node, PingRequest, Response, }, routing_table::{InsertResult, RoutingTable}, + RESPONSE_TIMEOUT, }; use anyhow::Context; -use bencode::ByteString; +use bencode::{ByteBuf, ByteString}; use dashmap::DashMap; use futures::{stream::FuturesUnordered, Stream, StreamExt}; use indexmap::IndexSet; @@ -53,8 +54,6 @@ pub struct DhtState { // Created requests: (transaction_id, addr) => Requests. // If we get a response, it gets removed from here. - // - // TODO: clean up old entries outstanding_requests_by_transaction_id: DashMap<(u16, SocketAddr), OutstandingRequest>, // TODO: clean up old entries @@ -112,7 +111,7 @@ impl DhtState { spawn( debug_span!("dht_request", tid = tid, addr = addr.to_string()), async move { - match tokio::time::timeout(Duration::from_secs(60), rx).await { + match tokio::time::timeout(RESPONSE_TIMEOUT, rx).await { Ok(Ok(_)) => {} Ok(Err(e)) => { this.outstanding_requests_by_transaction_id @@ -165,6 +164,24 @@ impl DhtState { (transaction_id, message) } + fn on_response( + self: &Arc, + addr: SocketAddr, + request: Request, + response: Response, + ) -> anyhow::Result<()> { + match request { + Request::FindNode(id) => { + let nodes = response + .nodes + .ok_or_else(|| anyhow::anyhow!("expected nodes for find_node requests"))?; + self.on_found_nodes(response.id, addr, id, nodes) + } + Request::GetPeers(id) => self.on_found_peers_or_nodes(response.id, addr, id, response), + Request::Ping => Ok(()), + } + } + fn on_incoming_from_remote( self: &Arc, msg: Message, @@ -220,18 +237,7 @@ impl DhtState { _ => unreachable!(), }; self.routing_table.write().mark_response(&response.id); - match request { - Request::FindNode(id) => { - let nodes = response.nodes.ok_or_else(|| { - anyhow::anyhow!("expected nodes for find_node requests") - })?; - self.on_found_nodes(response.id, addr, id, nodes) - } - Request::GetPeers(id) => { - self.on_found_peers_or_nodes(response.id, addr, id, response) - } - Request::Ping => Ok(()), - } + self.on_response(addr, request, response) } MessageKind::PingRequest(_) => { let message = Message { diff --git a/crates/dht/src/lib.rs b/crates/dht/src/lib.rs index 81713d3..9e5bfe4 100644 --- a/crates/dht/src/lib.rs +++ b/crates/dht/src/lib.rs @@ -5,6 +5,7 @@ mod routing_table; mod utils; use std::sync::Arc; +use std::time::Duration; pub use crate::dht::DhtStats; pub use crate::dht::{DhtConfig, DhtState}; @@ -13,6 +14,11 @@ pub use persistence::{PersistentDht, PersistentDhtConfig}; pub type Dht = Arc; +// How long do we wait for a response from a DHT node. +pub(crate) const RESPONSE_TIMEOUT: Duration = Duration::from_secs(60); +// After how long should we ping the node again. +pub(crate) const INACTIVITY_TIMEOUT: Duration = Duration::from_secs(15 * 60); + pub struct DhtBuilder {} impl DhtBuilder { diff --git a/crates/dht/src/routing_table.rs b/crates/dht/src/routing_table.rs index 6e45ab9..fce5c14 100644 --- a/crates/dht/src/routing_table.rs +++ b/crates/dht/src/routing_table.rs @@ -7,6 +7,8 @@ use librqbit_core::id20::Id20; use serde::{ser::SerializeMap, Deserialize, Serialize}; use tracing::debug; +use crate::{INACTIVITY_TIMEOUT, RESPONSE_TIMEOUT}; + #[derive(Debug, Clone, Serialize, Deserialize)] enum BucketTreeNodeData { // TODO: maybe replace that with SmallVec<8>? @@ -438,9 +440,6 @@ impl RoutingTableNode { self.addr } pub fn status(&self) -> NodeStatus { - const RESPONSE_TIMEOUT: Duration = Duration::from_secs(10); - const INACTIVITY_TIMEOUT: Duration = Duration::from_secs(15 * 60); - match (self.last_request, self.last_response, self.last_query) { (None, _, _) => NodeStatus::Unknown, // Nodes become bad when they fail to respond to multiple queries in a row. From 336bf751e38d535e5b8adbf0163e46093987afbe Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Tue, 28 Nov 2023 10:53:22 +0000 Subject: [PATCH 08/51] DHT: better tracking requests/responses --- Cargo.lock | 1 + crates/dht/Cargo.toml | 1 + crates/dht/src/dht.rs | 273 +++++++++++++++++++++++++-------------- crates/rqbit/src/main.rs | 4 +- 4 files changed, 182 insertions(+), 97 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ee5227f..8e9c891 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1087,6 +1087,7 @@ name = "librqbit-dht" version = "3.2.0" dependencies = [ "anyhow", + "backoff", "dashmap", "directories", "futures", diff --git a/crates/dht/Cargo.toml b/crates/dht/Cargo.toml index e6647b5..81decc1 100644 --- a/crates/dht/Cargo.toml +++ b/crates/dht/Cargo.toml @@ -27,6 +27,7 @@ bencode = {path = "../bencode", default-features=false, package="librqbit-bencod anyhow = "1" parking_lot = "0.12" tracing = "0.1" +backoff = "0.4.0" futures = "0.3" rand = "0.8" indexmap = "2" diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index 393fc38..f15df9d 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -1,4 +1,5 @@ use std::{ + f32::consts::E, net::SocketAddr, sync::{ atomic::{AtomicU16, Ordering}, @@ -10,16 +11,17 @@ use std::{ use crate::{ bprotocol::{ - self, CompactNodeInfo, CompactPeerInfo, FindNodeRequest, GetPeersRequest, Message, - MessageKind, Node, PingRequest, Response, + self, CompactNodeInfo, CompactPeerInfo, ErrorDescription, FindNodeRequest, GetPeersRequest, + Message, MessageKind, Node, PingRequest, Response, }, routing_table::{InsertResult, RoutingTable}, RESPONSE_TIMEOUT, }; use anyhow::Context; +use backoff::{backoff::Backoff, ExponentialBackoffBuilder}; use bencode::{ByteBuf, ByteString}; use dashmap::DashMap; -use futures::{stream::FuturesUnordered, Stream, StreamExt}; +use futures::{future::join_all, stream::FuturesUnordered, Stream, StreamExt, TryFutureExt}; use indexmap::IndexSet; use leaky_bucket::RateLimiter; use librqbit_core::{id20::Id20, peer_id::generate_peer_id, spawn_utils::spawn}; @@ -44,8 +46,7 @@ pub struct DhtStats { } struct OutstandingRequest { - request: Request, - done: tokio::sync::oneshot::Sender<()>, + done: tokio::sync::oneshot::Sender, } pub struct DhtState { @@ -54,7 +55,7 @@ pub struct DhtState { // Created requests: (transaction_id, addr) => Requests. // If we get a response, it gets removed from here. - outstanding_requests_by_transaction_id: DashMap<(u16, SocketAddr), OutstandingRequest>, + inflight: DashMap<(u16, SocketAddr), OutstandingRequest>, // TODO: clean up old entries made_requests_by_addr: DashMap<(Request, SocketAddr), Instant>, @@ -62,11 +63,7 @@ pub struct DhtState { routing_table: RwLock, listen_addr: SocketAddr, - // This sender sends requests to the worker. - // It is unbounded so that the methods on Dht state don't need to be async. - // If the methods on Dht state were async, we would have a problem, as it's behind - // a lock. - // Alternatively, we can lock only the parts that change, and use that internally inside DhtState... + // Sending requests to the worker. sender: UnboundedSender<(Message, SocketAddr)>, seen_peers: DashMap>, @@ -84,7 +81,7 @@ impl DhtState { Self { id, next_transaction_id: AtomicU16::new(0), - outstanding_requests_by_transaction_id: Default::default(), + inflight: Default::default(), routing_table: RwLock::new(routing_table), sender, listen_addr, @@ -94,41 +91,53 @@ impl DhtState { } } - fn send_request(self: &Arc, request: Request, addr: SocketAddr) -> anyhow::Result<()> { + fn spawn_request(self: &Arc, request: Request, addr: SocketAddr) { + let this = self.clone(); + spawn( + error_span!(parent: None, "dht_request", addr=addr.to_string(), request=format!("{:?}", request)), + async move { this.send_request_and_handle_response(request, addr).await }, + ); + } + + async fn send_request_and_handle_response( + self: &Arc, + request: Request, + addr: SocketAddr, + ) -> anyhow::Result<()> { + let resp = self.request(request, addr).await?; + match resp { + ResponseOrError::Response(r) => self.on_response(addr, request, r), + ResponseOrError::Error(e) => { + anyhow::bail!("received error: {:?}", e); + } + } + } + + async fn request(&self, request: Request, addr: SocketAddr) -> anyhow::Result { let (tid, msg) = self.create_request(request); + let key = (tid, addr); let (tx, rx) = tokio::sync::oneshot::channel(); - self.outstanding_requests_by_transaction_id - .insert((tid, addr), OutstandingRequest { request, done: tx }); + self.inflight.insert(key, OutstandingRequest { done: tx }); match self.sender.send((msg, addr)) { Ok(_) => {} Err(e) => { - self.outstanding_requests_by_transaction_id - .remove(&(tid, addr)); + self.inflight.remove(&key); return Err(e.into()); } }; - let this = self.clone(); - spawn( - debug_span!("dht_request", tid = tid, addr = addr.to_string()), - async move { - match tokio::time::timeout(RESPONSE_TIMEOUT, rx).await { - Ok(Ok(_)) => {} - Ok(Err(e)) => { - this.outstanding_requests_by_transaction_id - .remove(&(tid, addr)); - warn!("recv error, did not expect this: {:?}", e); - } - Err(e) => { - this.outstanding_requests_by_transaction_id - .remove(&(tid, addr)); - debug!("error: {:?}", e); - } - }; - - Ok(()) - }, - ); - Ok(()) + match tokio::time::timeout(RESPONSE_TIMEOUT, rx).await { + Ok(Ok(r)) => Ok(r), + Ok(Err(e)) => { + self.inflight.remove(&key); + warn!("recv error, did not expect this: {:?}", e); + Err(e.into()) + } + Err(e) => { + self.inflight.remove(&key); + debug!("error: {:?}", e); + anyhow::bail!("timeout") + } + } } fn create_request(&self, request: Request) -> (u16, Message) { @@ -208,6 +217,8 @@ impl DhtState { }; match &msg.kind { + // If it's a response to a request we made, find the request task, notify it with the response, + // and let it handle it. MessageKind::Error(_) | MessageKind::Response(_) => { if msg.transaction_id.len() != 2 { anyhow::bail!( @@ -217,29 +228,32 @@ impl DhtState { ) } let tid = ((msg.transaction_id[0] as u16) << 8) + (msg.transaction_id[1] as u16); - let request = match self - .outstanding_requests_by_transaction_id - .remove(&(tid, addr)) - .map(|(_, v)| v) - { + let request = match self.inflight.remove(&(tid, addr)).map(|(_, v)| v) { Some(req) => req, None => anyhow::bail!("outstanding request not found. Message: {:?}", msg), }; - let request = { - let _ = request.done.send(()); - request.request - }; - let response = match msg.kind { - MessageKind::Error(e) => { - anyhow::bail!("request {:?} received error response {:?}", request, e) + + let response_or_error = match msg.kind { + MessageKind::Error(e) => ResponseOrError::Error(e), + MessageKind::Response(r) => { + self.routing_table.write().mark_response(&r.id); + ResponseOrError::Response(r) } - MessageKind::Response(r) => r, _ => unreachable!(), }; - self.routing_table.write().mark_response(&response.id); - self.on_response(addr, request, response) + match request.done.send(response_or_error) { + Ok(_) => {} + Err(e) => { + warn!( + "recieved response, but the receiver task is closed: {:?}", + e + ); + } + } + Ok(()) } - MessageKind::PingRequest(_) => { + // Otherwise, respond to a query. + MessageKind::PingRequest(req) => { let message = Message { transaction_id: msg.transaction_id, version: None, @@ -249,6 +263,7 @@ impl DhtState { ..Default::default() }), }; + self.routing_table.write().mark_last_query(&req.id); self.sender.send((message, addr))?; Ok(()) } @@ -310,7 +325,7 @@ impl DhtState { pub fn get_stats(&self) -> DhtStats { DhtStats { id: self.id, - outstanding_requests: self.outstanding_requests_by_transaction_id.len(), + outstanding_requests: self.inflight.len(), seen_peers: self.seen_peers.iter().map(|e| e.value().len()).sum(), made_requests: self.made_requests_by_addr.len(), routing_table_size: self.routing_table.read().len(), @@ -392,7 +407,7 @@ impl DhtState { self.routing_table .write() .mark_outgoing_request(&target_node); - self.send_request(request, addr)?; + self.spawn_request(request, addr); } Ok(()) } @@ -408,7 +423,7 @@ impl DhtState { self.routing_table .write() .mark_outgoing_request(&target_node); - self.send_request(request, addr)?; + self.spawn_request(request, addr); } Ok(()) } @@ -420,7 +435,7 @@ impl DhtState { true }); for addr in questionable_nodes { - let _ = self.send_request(Request::Ping, addr); + self.spawn_request(Request::Ping, addr); } res } @@ -596,6 +611,12 @@ enum Request { Ping, } +#[derive(Debug)] +enum ResponseOrError { + Response(Response), + Error(ErrorDescription), +} + struct DhtWorker { socket: UdpSocket, peer_id: Id20, @@ -607,6 +628,103 @@ impl DhtWorker { self.state.on_incoming_from_remote(msg, addr) } + async fn bootstrap_one_ip_with_backoff(&self, addr: SocketAddr) -> anyhow::Result<()> { + let mut backoff = ExponentialBackoffBuilder::new() + .with_initial_interval(Duration::from_secs(10)) + .with_multiplier(1.5) + .with_max_interval(Duration::from_secs(60)) + .with_max_elapsed_time(Some(Duration::from_secs(86400))) + .build(); + + loop { + let res = self + .state + .send_request_and_handle_response(Request::FindNode(self.peer_id), addr) + .await; + match res { + Ok(r) => return Ok(r), + Err(e) => { + debug!("error: {:?}", e); + if let Some(backoff) = backoff.next_backoff() { + tokio::time::sleep(backoff).await; + continue; + } + anyhow::bail!("given up bootstrapping, timed out") + } + } + } + } + + async fn bootstrap_hostname(&self, hostname: &str) -> anyhow::Result<()> { + let addrs = tokio::net::lookup_host(hostname) + .await + .with_context(|| format!("error looking up {}", hostname))?; + let mut futs = FuturesUnordered::new(); + for addr in addrs { + futs.push( + self.bootstrap_one_ip_with_backoff(addr) + .instrument(error_span!("addr", addr = addr.to_string())), + ); + } + let requests = futs.len(); + let mut successes = 0; + while let Some(resp) = futs.next().await { + if resp.is_ok() { + successes += 1 + }; + } + if successes == 0 { + anyhow::bail!("none of the {} bootstrap requests succeded", requests); + } + Ok(()) + } + + async fn bootstrap_hostname_with_backoff(&self, addr: &str) -> anyhow::Result<()> { + let mut backoff = ExponentialBackoffBuilder::new() + .with_initial_interval(Duration::from_secs(10)) + .with_multiplier(1.5) + .with_max_interval(Duration::from_secs(60)) + .with_max_elapsed_time(Some(Duration::from_secs(86400))) + .build(); + + loop { + let backoff = match self.bootstrap_hostname(addr).await { + Ok(_) => return Ok(()), + Err(e) => { + warn!("error: {}", e); + backoff.next_backoff() + } + }; + if let Some(backoff) = backoff { + tokio::time::sleep(backoff).await; + continue; + } + anyhow::bail!("bootstrap failed") + } + } + + async fn bootstrap(&self, bootstrap_addrs: &[String]) -> anyhow::Result<()> { + let mut futs = FuturesUnordered::new(); + + for addr in bootstrap_addrs.iter() { + let this = &self; + futs.push( + this.bootstrap_hostname_with_backoff(addr) + .instrument(error_span!("bootstrap", hostname = addr)), + ); + } + let mut successes = 0; + while let Some(resp) = futs.next().await { + if resp.is_ok() { + successes += 1 + } + } + if successes == 0 { + anyhow::bail!("bootstrapping failed") + } + Ok(()) + } + async fn start( self, in_rx: UnboundedReceiver<(Message, SocketAddr)>, @@ -615,42 +733,7 @@ impl DhtWorker { let (out_tx, mut out_rx) = channel(1); let framer = run_framer(&self.socket, in_rx, out_tx).instrument(debug_span!("dht_framer")); - let bootstrap = async { - let mut futs = FuturesUnordered::new(); - // bootstrap - for addr in bootstrap_addrs.iter() { - let this = &self; - futs.push( - async move { - match tokio::net::lookup_host(addr).await { - Ok(addrs) => { - for addr in addrs { - this.state - .send_request(Request::FindNode(this.peer_id), addr)?; - } - } - Err(e) => { - warn!("error looking up {}: {}", addr, e); - return Err(e.into()); - } - } - Ok::<_, anyhow::Error>(()) - } - .instrument(error_span!("dht_bootstrap", addr = addr)), - ); - } - let mut successes = 0; - while let Some(resp) = futs.next().await { - if resp.is_ok() { - successes += 1 - } - } - if successes == 0 { - anyhow::bail!("bootstrapping did not succeed") - } - Ok(()) - } - .instrument(debug_span!("dht_bootstrapper")); + let bootstrap = self.bootstrap(bootstrap_addrs); let mut bootstrap_done = false; let response_reader = { diff --git a/crates/rqbit/src/main.rs b/crates/rqbit/src/main.rs index f9fa5c4..861dd01 100644 --- a/crates/rqbit/src/main.rs +++ b/crates/rqbit/src/main.rs @@ -1,4 +1,4 @@ -use std::{io::BufWriter, net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; +use std::{io::LineWriter, net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; use anyhow::Context; use clap::{Parser, ValueEnum}; @@ -205,7 +205,7 @@ fn init_logging(opts: &Opts) -> tokio::sync::mpsc::UnboundedSender { if let Some(log_file) = &opts.log_file { let log_file = log_file.clone(); let log_file = move || { - BufWriter::new( + LineWriter::new( std::fs::OpenOptions::new() .create(true) .append(true) From e9b7103c261cfb1f409921dacfc436c5d30dbe49 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Tue, 28 Nov 2023 10:55:43 +0000 Subject: [PATCH 09/51] silence DHT timeout error --- crates/dht/src/dht.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index f15df9d..58c3e01 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -95,7 +95,15 @@ impl DhtState { let this = self.clone(); spawn( error_span!(parent: None, "dht_request", addr=addr.to_string(), request=format!("{:?}", request)), - async move { this.send_request_and_handle_response(request, addr).await }, + async move { + match this.send_request_and_handle_response(request, addr).await { + Ok(_) => {} + Err(e) => { + debug!("error: {:?}", e); + } + }; + Ok(()) + }, ); } @@ -134,7 +142,6 @@ impl DhtState { } Err(e) => { self.inflight.remove(&key); - debug!("error: {:?}", e); anyhow::bail!("timeout") } } From 7da46d0bbfc86decd871c6fe05cec22ab353c063 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Tue, 28 Nov 2023 11:31:34 +0000 Subject: [PATCH 10/51] UDP send errors now kill requests right away --- crates/dht/src/bprotocol.rs | 12 ++- crates/dht/src/dht.rs | 147 ++++++++++++++++++------------------ crates/rqbit/src/main.rs | 1 + 3 files changed, 85 insertions(+), 75 deletions(-) diff --git a/crates/dht/src/bprotocol.rs b/crates/dht/src/bprotocol.rs index d0f92a4..562a0ba 100644 --- a/crates/dht/src/bprotocol.rs +++ b/crates/dht/src/bprotocol.rs @@ -4,7 +4,7 @@ use std::{ net::{Ipv4Addr, SocketAddrV4}, }; -use bencode::ByteBuf; +use bencode::{ByteBuf, ByteString}; use clone_to_owned::CloneToOwned; use librqbit_core::id20::Id20; use serde::{ @@ -332,6 +332,16 @@ pub struct Message { pub kind: MessageKind, } +impl Message { + pub fn get_transaction_id(&self) -> Option { + if self.transaction_id.len() != 2 { + return None; + } + let tid = ((self.transaction_id[0] as u16) << 8) + (self.transaction_id[1] as u16); + Some(tid) + } +} + #[derive(Debug)] pub enum MessageKind { Error(ErrorDescription), diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index 58c3e01..7ffc9b8 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -46,7 +46,7 @@ pub struct DhtStats { } struct OutstandingRequest { - done: tokio::sync::oneshot::Sender, + done: tokio::sync::oneshot::Sender>, } pub struct DhtState { @@ -134,13 +134,13 @@ impl DhtState { } }; match tokio::time::timeout(RESPONSE_TIMEOUT, rx).await { - Ok(Ok(r)) => Ok(r), + Ok(Ok(r)) => r, Ok(Err(e)) => { self.inflight.remove(&key); warn!("recv error, did not expect this: {:?}", e); Err(e.into()) } - Err(e) => { + Err(_) => { self.inflight.remove(&key); anyhow::bail!("timeout") } @@ -227,14 +227,7 @@ impl DhtState { // If it's a response to a request we made, find the request task, notify it with the response, // and let it handle it. MessageKind::Error(_) | MessageKind::Response(_) => { - if msg.transaction_id.len() != 2 { - anyhow::bail!( - "{}: transaction id unrecognized, expected its length == 2. Message: {:?}", - addr, - msg - ) - } - let tid = ((msg.transaction_id[0] as u16) << 8) + (msg.transaction_id[1] as u16); + let tid = msg.get_transaction_id().context("bad transaction id")?; let request = match self.inflight.remove(&(tid, addr)).map(|(_, v)| v) { Some(req) => req, None => anyhow::bail!("outstanding request not found. Message: {:?}", msg), @@ -248,7 +241,7 @@ impl DhtState { } _ => unreachable!(), }; - match request.done.send(response_or_error) { + match request.done.send(Ok(response_or_error)) { Ok(_) => {} Err(e) => { warn!( @@ -550,67 +543,6 @@ fn make_rate_limiter() -> RateLimiter { .build() } -async fn run_framer( - socket: &UdpSocket, - mut input_rx: UnboundedReceiver<(Message, SocketAddr)>, - output_tx: Sender<(Message, SocketAddr)>, -) -> anyhow::Result<()> { - let writer = async { - let mut buf = Vec::new(); - let rate_limiter = make_rate_limiter(); - while let Some((msg, addr)) = input_rx.recv().await { - let addr = match addr { - SocketAddr::V4(v4) => v4, - SocketAddr::V6(_) => continue, - }; - rate_limiter.acquire_one().await; - trace!("{}: sending {:?}", addr, &msg); - buf.clear(); - bprotocol::serialize_message( - &mut buf, - msg.transaction_id, - msg.version, - msg.ip, - msg.kind, - ) - .unwrap(); - if let Err(e) = socket.send_to(&buf, addr).await { - warn!("could not send to {:?}: {}", addr, e) - } - } - Err::<(), _>(anyhow::anyhow!( - "DHT UDP socket writer over, nowhere to read messages from" - )) - }; - let reader = async { - let mut buf = vec![0u8; 16384]; - loop { - let (size, addr) = socket - .recv_from(&mut buf) - .await - .context("error reading from UDP socket")?; - match bprotocol::deserialize_message::(&buf[..size]) { - Ok(msg) => { - trace!("{}: received {:?}", addr, &msg); - match output_tx.send((msg, addr)).await { - Ok(_) => {} - Err(_) => break, - } - } - Err(e) => debug!("{}: error deserializing incoming message: {}", addr, e), - } - } - Err::<(), _>(anyhow::anyhow!( - "DHT UDP socket reader over, nowhere to send responses to" - )) - }; - let result = tokio::select! { - err = writer => err, - err = reader => err, - }; - result.context("DHT UDP framer closed") -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum Request { GetPeers(Id20), @@ -635,6 +567,12 @@ impl DhtWorker { self.state.on_incoming_from_remote(msg, addr) } + fn on_send_error(&self, tid: u16, addr: SocketAddr, err: anyhow::Error) { + if let Some((_, OutstandingRequest { done })) = self.state.inflight.remove(&(tid, addr)) { + let _ = done.send(Err(err)).is_err(); + }; + } + async fn bootstrap_one_ip_with_backoff(&self, addr: SocketAddr) -> anyhow::Result<()> { let mut backoff = ExponentialBackoffBuilder::new() .with_initial_interval(Duration::from_secs(10)) @@ -732,13 +670,74 @@ impl DhtWorker { Ok(()) } + async fn framer( + &self, + socket: &UdpSocket, + mut input_rx: UnboundedReceiver<(Message, SocketAddr)>, + output_tx: Sender<(Message, SocketAddr)>, + ) -> anyhow::Result<()> { + let writer = async { + let mut buf = Vec::new(); + let rate_limiter = make_rate_limiter(); + while let Some((msg, addr)) = input_rx.recv().await { + rate_limiter.acquire_one().await; + trace!("{}: sending {:?}", addr, &msg); + buf.clear(); + let tid = msg.get_transaction_id().unwrap(); + bprotocol::serialize_message( + &mut buf, + msg.transaction_id, + msg.version, + msg.ip, + msg.kind, + ) + .unwrap(); + if let Err(e) = socket.send_to(&buf, addr).await { + self.on_send_error(tid, addr, e.into()); + } + } + Err::<(), _>(anyhow::anyhow!( + "DHT UDP socket writer over, nowhere to read messages from" + )) + }; + let reader = async { + let mut buf = vec![0u8; 16384]; + loop { + let (size, addr) = socket + .recv_from(&mut buf) + .await + .context("error reading from UDP socket")?; + match bprotocol::deserialize_message::(&buf[..size]) { + Ok(msg) => { + trace!("{}: received {:?}", addr, &msg); + match output_tx.send((msg, addr)).await { + Ok(_) => {} + Err(_) => break, + } + } + Err(e) => debug!("{}: error deserializing incoming message: {}", addr, e), + } + } + Err::<(), _>(anyhow::anyhow!( + "DHT UDP socket reader over, nowhere to send responses to" + )) + }; + let result = tokio::select! { + err = writer => err, + err = reader => err, + }; + result.context("DHT UDP framer closed") + } + async fn start( self, in_rx: UnboundedReceiver<(Message, SocketAddr)>, bootstrap_addrs: &[String], ) -> anyhow::Result<()> { let (out_tx, mut out_rx) = channel(1); - let framer = run_framer(&self.socket, in_rx, out_tx).instrument(debug_span!("dht_framer")); + let framer = self + .framer(&self.socket, in_rx, out_tx) + .instrument(debug_span!("dht_framer")); let bootstrap = self.bootstrap(bootstrap_addrs); let mut bootstrap_done = false; diff --git a/crates/rqbit/src/main.rs b/crates/rqbit/src/main.rs index 861dd01..d7bc9df 100644 --- a/crates/rqbit/src/main.rs +++ b/crates/rqbit/src/main.rs @@ -218,6 +218,7 @@ fn init_logging(opts: &Opts) -> tokio::sync::mpsc::UnboundedSender { layered .with( fmt::layer() + .with_ansi(false) .with_writer(log_file) .with_filter(EnvFilter::builder().parse(&opts.log_file_rust_log).unwrap()), ) From 91c99a272f84c2ed9620d6964fbd510b1cc84b4d Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Tue, 28 Nov 2023 11:35:28 +0000 Subject: [PATCH 11/51] cargo fmt / clippy / fix --- crates/dht/examples/dht.rs | 2 +- crates/dht/src/dht.rs | 5 ++--- crates/dht/src/routing_table.rs | 5 +---- crates/librqbit/src/dht_utils.rs | 2 +- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/crates/dht/examples/dht.rs b/crates/dht/examples/dht.rs index 38c5342..dc0cc4f 100644 --- a/crates/dht/examples/dht.rs +++ b/crates/dht/examples/dht.rs @@ -2,7 +2,7 @@ use std::time::Duration; use anyhow::Context; use librqbit_core::magnet::Magnet; -use librqbit_dht::{Dht, DhtBuilder}; +use librqbit_dht::DhtBuilder; use tokio_stream::StreamExt; use tracing::info; diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index 7ffc9b8..3c42f1c 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -1,5 +1,4 @@ use std::{ - f32::consts::E, net::SocketAddr, sync::{ atomic::{AtomicU16, Ordering}, @@ -19,9 +18,9 @@ use crate::{ }; use anyhow::Context; use backoff::{backoff::Backoff, ExponentialBackoffBuilder}; -use bencode::{ByteBuf, ByteString}; +use bencode::ByteString; use dashmap::DashMap; -use futures::{future::join_all, stream::FuturesUnordered, Stream, StreamExt, TryFutureExt}; +use futures::{stream::FuturesUnordered, Stream, StreamExt}; use indexmap::IndexSet; use leaky_bucket::RateLimiter; use librqbit_core::{id20::Id20, peer_id::generate_peer_id, spawn_utils::spawn}; diff --git a/crates/dht/src/routing_table.rs b/crates/dht/src/routing_table.rs index fce5c14..965a414 100644 --- a/crates/dht/src/routing_table.rs +++ b/crates/dht/src/routing_table.rs @@ -1,7 +1,4 @@ -use std::{ - net::SocketAddr, - time::{Duration, Instant}, -}; +use std::{net::SocketAddr, time::Instant}; use librqbit_core::id20::Id20; use serde::{ser::SerializeMap, Deserialize, Serialize}; diff --git a/crates/librqbit/src/dht_utils.rs b/crates/librqbit/src/dht_utils.rs index fb5b339..69aacba 100644 --- a/crates/librqbit/src/dht_utils.rs +++ b/crates/librqbit/src/dht_utils.rs @@ -86,7 +86,7 @@ pub async fn read_metainfo_from_peer_receiver + Unp #[cfg(test)] mod tests { - use dht::{Dht, DhtBuilder, Id20}; + use dht::{DhtBuilder, Id20}; use librqbit_core::peer_id::generate_peer_id; use super::*; From 93740ec84ba915433ac3b4d6449e91a24aefc3ac Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Tue, 28 Nov 2023 15:35:27 +0000 Subject: [PATCH 12/51] Created more tasks but it impacts perf and memory badly --- crates/dht/src/bprotocol.rs | 3 +- crates/dht/src/dht.rs | 195 ++++++++++++++++++++++-------------- crates/dht/src/lib.rs | 2 + 3 files changed, 126 insertions(+), 74 deletions(-) diff --git a/crates/dht/src/bprotocol.rs b/crates/dht/src/bprotocol.rs index 562a0ba..3ab76ac 100644 --- a/crates/dht/src/bprotocol.rs +++ b/crates/dht/src/bprotocol.rs @@ -333,7 +333,8 @@ pub struct Message { } impl Message { - pub fn get_transaction_id(&self) -> Option { + // This implies that the transaction id was generated by us. + pub fn get_our_transaction_id(&self) -> Option { if self.transaction_id.len() != 2 { return None; } diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index 3c42f1c..a5d2ec8 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -5,7 +5,7 @@ use std::{ Arc, }, task::Poll, - time::{Duration, Instant}, + time::Duration, }; use crate::{ @@ -14,12 +14,12 @@ use crate::{ Message, MessageKind, Node, PingRequest, Response, }, routing_table::{InsertResult, RoutingTable}, - RESPONSE_TIMEOUT, + REQUERY_INTERVAL, RESPONSE_TIMEOUT, }; -use anyhow::Context; +use anyhow::{bail, Context}; use backoff::{backoff::Backoff, ExponentialBackoffBuilder}; use bencode::ByteString; -use dashmap::DashMap; +use dashmap::{DashMap, DashSet}; use futures::{stream::FuturesUnordered, Stream, StreamExt}; use indexmap::IndexSet; use leaky_bucket::RateLimiter; @@ -40,7 +40,7 @@ pub struct DhtStats { pub id: Id20, pub outstanding_requests: usize, pub seen_peers: usize, - pub made_requests: usize, + pub outstanding_backoff_tasks: usize, pub routing_table_size: usize, } @@ -54,10 +54,10 @@ pub struct DhtState { // Created requests: (transaction_id, addr) => Requests. // If we get a response, it gets removed from here. - inflight: DashMap<(u16, SocketAddr), OutstandingRequest>, + inflight_by_transaction_id: DashMap<(u16, SocketAddr), OutstandingRequest>, - // TODO: clean up old entries - made_requests_by_addr: DashMap<(Request, SocketAddr), Instant>, + // Current requests to addr being re-sent with backoff. + inflight_by_request: DashSet<(Request, SocketAddr)>, routing_table: RwLock, listen_addr: SocketAddr, @@ -80,13 +80,13 @@ impl DhtState { Self { id, next_transaction_id: AtomicU16::new(0), - inflight: Default::default(), + inflight_by_transaction_id: Default::default(), routing_table: RwLock::new(routing_table), sender, listen_addr, seen_peers: Default::default(), get_peers_subscribers: Default::default(), - made_requests_by_addr: Default::default(), + inflight_by_request: Default::default(), } } @@ -115,7 +115,7 @@ impl DhtState { match resp { ResponseOrError::Response(r) => self.on_response(addr, request, r), ResponseOrError::Error(e) => { - anyhow::bail!("received error: {:?}", e); + bail!("received error: {:?}", e); } } } @@ -124,24 +124,25 @@ impl DhtState { let (tid, msg) = self.create_request(request); let key = (tid, addr); let (tx, rx) = tokio::sync::oneshot::channel(); - self.inflight.insert(key, OutstandingRequest { done: tx }); + self.inflight_by_transaction_id + .insert(key, OutstandingRequest { done: tx }); match self.sender.send((msg, addr)) { Ok(_) => {} Err(e) => { - self.inflight.remove(&key); + self.inflight_by_transaction_id.remove(&key); return Err(e.into()); } }; match tokio::time::timeout(RESPONSE_TIMEOUT, rx).await { Ok(Ok(r)) => r, Ok(Err(e)) => { - self.inflight.remove(&key); + self.inflight_by_transaction_id.remove(&key); warn!("recv error, did not expect this: {:?}", e); Err(e.into()) } Err(_) => { - self.inflight.remove(&key); - anyhow::bail!("timeout") + self.inflight_by_transaction_id.remove(&key); + bail!("timeout") } } } @@ -192,12 +193,14 @@ impl DhtState { .ok_or_else(|| anyhow::anyhow!("expected nodes for find_node requests"))?; self.on_found_nodes(response.id, addr, id, nodes) } - Request::GetPeers(id) => self.on_found_peers_or_nodes(response.id, addr, id, response), Request::Ping => Ok(()), + Request::GetPeers(info_hash) => { + self.on_found_peers_or_nodes(response.id, addr, info_hash, response) + } } } - fn on_incoming_from_remote( + fn on_received_message( self: &Arc, msg: Message, addr: SocketAddr, @@ -226,10 +229,14 @@ impl DhtState { // If it's a response to a request we made, find the request task, notify it with the response, // and let it handle it. MessageKind::Error(_) | MessageKind::Response(_) => { - let tid = msg.get_transaction_id().context("bad transaction id")?; - let request = match self.inflight.remove(&(tid, addr)).map(|(_, v)| v) { + let tid = msg.get_our_transaction_id().context("bad transaction id")?; + let request = match self + .inflight_by_transaction_id + .remove(&(tid, addr)) + .map(|(_, v)| v) + { Some(req) => req, - None => anyhow::bail!("outstanding request not found. Message: {:?}", msg), + None => bail!("outstanding request not found. Message: {:?}", msg), }; let response_or_error = match msg.kind { @@ -324,9 +331,9 @@ impl DhtState { pub fn get_stats(&self) -> DhtStats { DhtStats { id: self.id, - outstanding_requests: self.inflight.len(), + outstanding_requests: self.inflight_by_transaction_id.len(), seen_peers: self.seen_peers.iter().map(|e| e.value().len()).sum(), - made_requests: self.made_requests_by_addr.len(), + outstanding_backoff_tasks: self.inflight_by_request.len(), routing_table_size: self.routing_table.read().len(), } } @@ -376,38 +383,86 @@ impl DhtState { } } - fn should_request(&self, request: Request, addr: SocketAddr) -> bool { - const RE_REQUEST_TIME: Duration = Duration::from_secs(10 * 60); - use dashmap::mapref::entry::Entry; - match self.made_requests_by_addr.entry((request, addr)) { - Entry::Occupied(mut o) => { - if o.get().elapsed() > RE_REQUEST_TIME { - o.insert(Instant::now()); - true - } else { - false - } - } - Entry::Vacant(v) => { - v.insert(Instant::now()); - true - } - } - } - fn send_find_peers_if_not_yet( self: &Arc, info_hash: Id20, target_node: Id20, addr: SocketAddr, ) -> anyhow::Result<()> { - let request = Request::GetPeers(info_hash); - if self.should_request(request, addr) { - self.routing_table - .write() - .mark_outgoing_request(&target_node); - self.spawn_request(request, addr); + self.send_request_if_not_yet(target_node, Request::GetPeers(info_hash), addr) + } + + fn send_request_if_not_yet( + self: &Arc, + target_node: Id20, + request: Request, + addr: SocketAddr, + ) -> anyhow::Result<()> { + let key = (request, addr); + if !self.inflight_by_request.insert(key) { + return Ok(()); } + + let this = self.clone(); + + let fut = async move { + let mut backoff = ExponentialBackoffBuilder::new() + .with_initial_interval(Duration::from_secs(60)) + .with_multiplier(1.5) + .with_max_interval(Duration::from_secs(10 * 60)) + .with_max_elapsed_time(Some(Duration::from_secs(15 * 60))) + .build(); + + loop { + this.routing_table + .write() + .mark_outgoing_request(&target_node); + + let resp = this.request(request, addr).await; + let sleep = match resp { + Ok(ResponseOrError::Response(response)) => { + match this.on_response(addr, request, response) { + Ok(()) => { + backoff.reset(); + Some(REQUERY_INTERVAL) + } + Err(e) => { + warn!("error in on_response: {:?}", e); + backoff.next_backoff() + } + } + } + Ok(ResponseOrError::Error(e)) => { + debug!("error response: {:?}", e); + backoff.next_backoff() + } + Err(e) => { + debug!("error: {:?}", e); + backoff.next_backoff() + } + }; + if let Some(sleep) = sleep { + tokio::time::sleep(sleep).await; + continue; + } + + tokio::task::spawn(async move { + this.inflight_by_request.remove(&key); + }); + + return Ok(()); + } + }; + + spawn( + error_span!( + parent: None, + "dht_request", + addr = addr.to_string(), + request = format!("{:?}", request), + ), + fut, + ); Ok(()) } @@ -417,14 +472,7 @@ impl DhtState { target_node: Id20, addr: SocketAddr, ) -> anyhow::Result<()> { - let request = Request::FindNode(search_id); - if self.should_request(request, addr) { - self.routing_table - .write() - .mark_outgoing_request(&target_node); - self.spawn_request(request, addr); - } - Ok(()) + self.send_request_if_not_yet(target_node, Request::FindNode(search_id), addr) } fn routing_table_add_node(self: &Arc, id: Id20, addr: SocketAddr) -> InsertResult { @@ -482,25 +530,25 @@ impl DhtState { self: &Arc, source: Id20, source_addr: SocketAddr, - target: Id20, + info_hash: Id20, data: bprotocol::Response, ) -> anyhow::Result<()> { self.routing_table_add_node(source, source_addr); self.routing_table.write().mark_response(&source); - let bsender = match self.get_peers_subscribers.get(&target) { + let bsender = match self.get_peers_subscribers.get(&info_hash) { Some(s) => s, None => { warn!( "ignoring get_peers response, no subscribers for {:?}", - target + info_hash ); return Ok(()); } }; if let Some(peers) = data.values { - let mut seen = self.seen_peers.entry(target).or_default(); + let mut seen = self.seen_peers.entry(info_hash).or_default(); for peer in peers.iter() { if peer.addr.port() < 1024 { @@ -518,7 +566,7 @@ impl DhtState { if let Some(nodes) = data.nodes { for node in nodes.nodes { self.routing_table_add_node(node.id, node.addr.into()); - self.send_find_peers_if_not_yet(target, node.id, node.addr.into())?; + self.send_find_peers_if_not_yet(info_hash, node.id, node.addr.into())?; } }; Ok(()) @@ -562,12 +610,10 @@ struct DhtWorker { } impl DhtWorker { - fn on_response(&self, msg: Message, addr: SocketAddr) -> anyhow::Result<()> { - self.state.on_incoming_from_remote(msg, addr) - } - fn on_send_error(&self, tid: u16, addr: SocketAddr, err: anyhow::Error) { - if let Some((_, OutstandingRequest { done })) = self.state.inflight.remove(&(tid, addr)) { + if let Some((_, OutstandingRequest { done })) = + self.state.inflight_by_transaction_id.remove(&(tid, addr)) + { let _ = done.send(Err(err)).is_err(); }; } @@ -593,7 +639,7 @@ impl DhtWorker { tokio::time::sleep(backoff).await; continue; } - anyhow::bail!("given up bootstrapping, timed out") + bail!("given up bootstrapping, timed out") } } } @@ -618,7 +664,7 @@ impl DhtWorker { }; } if successes == 0 { - anyhow::bail!("none of the {} bootstrap requests succeded", requests); + bail!("none of the {} bootstrap requests succeded", requests); } Ok(()) } @@ -643,7 +689,7 @@ impl DhtWorker { tokio::time::sleep(backoff).await; continue; } - anyhow::bail!("bootstrap failed") + bail!("bootstrap failed") } } @@ -664,7 +710,7 @@ impl DhtWorker { } } if successes == 0 { - anyhow::bail!("bootstrapping failed") + bail!("bootstrapping failed") } Ok(()) } @@ -682,7 +728,7 @@ impl DhtWorker { rate_limiter.acquire_one().await; trace!("{}: sending {:?}", addr, &msg); buf.clear(); - let tid = msg.get_transaction_id().unwrap(); + let tid = msg.get_our_transaction_id(); bprotocol::serialize_message( &mut buf, msg.transaction_id, @@ -692,7 +738,10 @@ impl DhtWorker { ) .unwrap(); if let Err(e) = socket.send_to(&buf, addr).await { - self.on_send_error(tid, addr, e.into()); + debug!("error sending to {addr}: {e:?}"); + if let Some(tid) = tid { + self.on_send_error(tid, addr, e.into()); + } } } Err::<(), _>(anyhow::anyhow!( @@ -745,7 +794,7 @@ impl DhtWorker { let this = &self; async move { while let Some((response, addr)) = out_rx.recv().await { - if let Err(e) = this.on_response(response, addr) { + if let Err(e) = this.state.on_received_message(response, addr) { debug!("error in on_response, addr={:?}: {}", addr, e) } } diff --git a/crates/dht/src/lib.rs b/crates/dht/src/lib.rs index 9e5bfe4..5a28d07 100644 --- a/crates/dht/src/lib.rs +++ b/crates/dht/src/lib.rs @@ -16,6 +16,8 @@ pub type Dht = Arc; // How long do we wait for a response from a DHT node. pub(crate) const RESPONSE_TIMEOUT: Duration = Duration::from_secs(60); +// TODO: Not sure if we should re-query tbh. +pub(crate) const REQUERY_INTERVAL: Duration = Duration::from_secs(60); // After how long should we ping the node again. pub(crate) const INACTIVITY_TIMEOUT: Duration = Duration::from_secs(15 * 60); From 81428e30a24cd2ad7934b68875ad9dcbce206ce1 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Tue, 28 Nov 2023 15:55:13 +0000 Subject: [PATCH 13/51] Nothing --- crates/dht/src/dht.rs | 60 +++++++++++++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index a5d2ec8..bfa5018 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -48,6 +48,12 @@ struct OutstandingRequest { done: tokio::sync::oneshot::Sender>, } +pub struct WorkerSendRequest { + our_tid: Option, + message: Message, + addr: SocketAddr, +} + pub struct DhtState { id: Id20, next_transaction_id: AtomicU16, @@ -63,7 +69,7 @@ pub struct DhtState { listen_addr: SocketAddr, // Sending requests to the worker. - sender: UnboundedSender<(Message, SocketAddr)>, + sender: UnboundedSender, seen_peers: DashMap>, get_peers_subscribers: DashMap>, @@ -72,7 +78,7 @@ pub struct DhtState { impl DhtState { fn new_internal( id: Id20, - sender: UnboundedSender<(Message, SocketAddr)>, + sender: UnboundedSender, routing_table: Option, listen_addr: SocketAddr, ) -> Self { @@ -121,12 +127,16 @@ impl DhtState { } async fn request(&self, request: Request, addr: SocketAddr) -> anyhow::Result { - let (tid, msg) = self.create_request(request); + let (tid, message) = self.create_request(request); let key = (tid, addr); let (tx, rx) = tokio::sync::oneshot::channel(); self.inflight_by_transaction_id .insert(key, OutstandingRequest { done: tx }); - match self.sender.send((msg, addr)) { + match self.sender.send(WorkerSendRequest { + our_tid: Some(tid), + message, + addr, + }) { Ok(_) => {} Err(e) => { self.inflight_by_transaction_id.remove(&key); @@ -270,7 +280,11 @@ impl DhtState { }), }; self.routing_table.write().mark_last_query(&req.id); - self.sender.send((message, addr))?; + self.sender.send(WorkerSendRequest { + our_tid: None, + message, + addr, + })?; Ok(()) } MessageKind::GetPeersRequest(req) => { @@ -306,7 +320,11 @@ impl DhtState { token, }), }; - self.sender.send((message, addr))?; + self.sender.send(WorkerSendRequest { + our_tid: None, + message, + addr, + })?; Ok(()) } MessageKind::FindNodeRequest(req) => { @@ -322,7 +340,11 @@ impl DhtState { ..Default::default() }), }; - self.sender.send((message, addr))?; + self.sender.send(WorkerSendRequest { + our_tid: None, + message, + addr, + })?; Ok(()) } } @@ -718,28 +740,32 @@ impl DhtWorker { async fn framer( &self, socket: &UdpSocket, - mut input_rx: UnboundedReceiver<(Message, SocketAddr)>, + mut input_rx: UnboundedReceiver, output_tx: Sender<(Message, SocketAddr)>, ) -> anyhow::Result<()> { let writer = async { let mut buf = Vec::new(); let rate_limiter = make_rate_limiter(); - while let Some((msg, addr)) = input_rx.recv().await { + while let Some(WorkerSendRequest { + our_tid, + message, + addr, + }) = input_rx.recv().await + { rate_limiter.acquire_one().await; - trace!("{}: sending {:?}", addr, &msg); + trace!("{}: sending {:?}", addr, &message); buf.clear(); - let tid = msg.get_our_transaction_id(); bprotocol::serialize_message( &mut buf, - msg.transaction_id, - msg.version, - msg.ip, - msg.kind, + message.transaction_id, + message.version, + message.ip, + message.kind, ) .unwrap(); if let Err(e) = socket.send_to(&buf, addr).await { debug!("error sending to {addr}: {e:?}"); - if let Some(tid) = tid { + if let Some(tid) = our_tid { self.on_send_error(tid, addr, e.into()); } } @@ -779,7 +805,7 @@ impl DhtWorker { async fn start( self, - in_rx: UnboundedReceiver<(Message, SocketAddr)>, + in_rx: UnboundedReceiver, bootstrap_addrs: &[String], ) -> anyhow::Result<()> { let (out_tx, mut out_rx) = channel(1); From 242f064328c42ce8455d8424dd27efdb94bf634b Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Tue, 28 Nov 2023 16:14:49 +0000 Subject: [PATCH 14/51] Get back old behavior --- TODO.md | 1 + crates/dht/src/dht.rs | 84 ++++++++++++++++++------------------------- 2 files changed, 36 insertions(+), 49 deletions(-) diff --git a/TODO.md b/TODO.md index 7c24028..ac09aa6 100644 --- a/TODO.md +++ b/TODO.md @@ -15,6 +15,7 @@ - [x] remove including from disk - [ ] DHT - [ ] for torrents with a few seeds might be cool to re-query DHT once in a while. + - [ ] Buckets that have not been changed in 15 minutes should be "refreshed." (per RFC) - [x] it's sending many requests now way too fast, locks up Mac OS UI annoyingly - [ ] After the search is exhausted, the client then inserts the peer contact information for itself onto the responding nodes with IDs closest to the infohash of the torrent. - [ ] Bad actors: diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index bfa5018..f6d8143 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -5,7 +5,7 @@ use std::{ Arc, }, task::Poll, - time::Duration, + time::{Duration, Instant}, }; use crate::{ @@ -40,7 +40,7 @@ pub struct DhtStats { pub id: Id20, pub outstanding_requests: usize, pub seen_peers: usize, - pub outstanding_backoff_tasks: usize, + pub recent_requests: usize, pub routing_table_size: usize, } @@ -63,7 +63,7 @@ pub struct DhtState { inflight_by_transaction_id: DashMap<(u16, SocketAddr), OutstandingRequest>, // Current requests to addr being re-sent with backoff. - inflight_by_request: DashSet<(Request, SocketAddr)>, + recent_requests: DashMap<(Request, SocketAddr), Instant>, routing_table: RwLock, listen_addr: SocketAddr, @@ -92,7 +92,7 @@ impl DhtState { listen_addr, seen_peers: Default::default(), get_peers_subscribers: Default::default(), - inflight_by_request: Default::default(), + recent_requests: Default::default(), } } @@ -355,7 +355,7 @@ impl DhtState { id: self.id, outstanding_requests: self.inflight_by_transaction_id.len(), seen_peers: self.seen_peers.iter().map(|e| e.value().len()).sum(), - outstanding_backoff_tasks: self.inflight_by_request.len(), + recent_requests: self.recent_requests.len(), routing_table_size: self.routing_table.read().len(), } } @@ -421,59 +421,45 @@ impl DhtState { addr: SocketAddr, ) -> anyhow::Result<()> { let key = (request, addr); - if !self.inflight_by_request.insert(key) { - return Ok(()); + + use dashmap::mapref::entry::Entry; + match self.recent_requests.entry(key) { + Entry::Occupied(mut o) => { + if o.get().elapsed() < REQUERY_INTERVAL { + return Ok(()); + } + o.insert(Instant::now()); + } + Entry::Vacant(v) => { + v.insert(Instant::now()); + } } let this = self.clone(); let fut = async move { - let mut backoff = ExponentialBackoffBuilder::new() - .with_initial_interval(Duration::from_secs(60)) - .with_multiplier(1.5) - .with_max_interval(Duration::from_secs(10 * 60)) - .with_max_elapsed_time(Some(Duration::from_secs(15 * 60))) - .build(); + this.routing_table + .write() + .mark_outgoing_request(&target_node); - loop { - this.routing_table - .write() - .mark_outgoing_request(&target_node); - - let resp = this.request(request, addr).await; - let sleep = match resp { - Ok(ResponseOrError::Response(response)) => { - match this.on_response(addr, request, response) { - Ok(()) => { - backoff.reset(); - Some(REQUERY_INTERVAL) - } - Err(e) => { - warn!("error in on_response: {:?}", e); - backoff.next_backoff() - } + let resp = this.request(request, addr).await; + match resp { + Ok(ResponseOrError::Response(response)) => { + match this.on_response(addr, request, response) { + Ok(()) => {} + Err(e) => { + warn!("error in on_response: {:?}", e); } } - Ok(ResponseOrError::Error(e)) => { - debug!("error response: {:?}", e); - backoff.next_backoff() - } - Err(e) => { - debug!("error: {:?}", e); - backoff.next_backoff() - } - }; - if let Some(sleep) = sleep { - tokio::time::sleep(sleep).await; - continue; } - - tokio::task::spawn(async move { - this.inflight_by_request.remove(&key); - }); - - return Ok(()); - } + Ok(ResponseOrError::Error(e)) => { + debug!("error response: {:?}", e); + } + Err(e) => { + debug!("error: {:?}", e); + } + }; + Ok(()) }; spawn( From 3b3af34152a50fec2526a5ff5030342cb9577158 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Tue, 28 Nov 2023 18:28:59 +0000 Subject: [PATCH 15/51] Wow, this isnt bad at all, now DHT makes much less queries but restarts --- crates/dht/src/bprotocol.rs | 6 +- crates/dht/src/dht.rs | 168 ++++++++++++++++++++++++++---------- 2 files changed, 125 insertions(+), 49 deletions(-) diff --git a/crates/dht/src/bprotocol.rs b/crates/dht/src/bprotocol.rs index 3ab76ac..5db82fe 100644 --- a/crates/dht/src/bprotocol.rs +++ b/crates/dht/src/bprotocol.rs @@ -292,12 +292,12 @@ pub struct FindNodeRequest { #[derive(Debug, Serialize, Deserialize, Default)] pub struct Response { + #[serde(skip_serializing_if = "Option::is_none")] + pub values: Option>, pub id: Id20, #[serde(skip_serializing_if = "Option::is_none")] pub nodes: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub values: Option>, - #[serde(skip_serializing_if = "Option::is_none")] pub token: Option, } @@ -326,10 +326,10 @@ pub struct GetPeersResponse { #[derive(Debug)] pub struct Message { + pub kind: MessageKind, pub transaction_id: BufT, pub version: Option, pub ip: Option, - pub kind: MessageKind, } impl Message { diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index f6d8143..365352c 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -1,4 +1,5 @@ use std::{ + cmp::Reverse, net::SocketAddr, sync::{ atomic::{AtomicU16, Ordering}, @@ -19,7 +20,7 @@ use crate::{ use anyhow::{bail, Context}; use backoff::{backoff::Backoff, ExponentialBackoffBuilder}; use bencode::ByteString; -use dashmap::{DashMap, DashSet}; +use dashmap::DashMap; use futures::{stream::FuturesUnordered, Stream, StreamExt}; use indexmap::IndexSet; use leaky_bucket::RateLimiter; @@ -54,6 +55,12 @@ pub struct WorkerSendRequest { addr: SocketAddr, } +struct MaybeUsefulNode { + id: Id20, + addr: SocketAddr, + last_response: Option, +} + pub struct DhtState { id: Id20, next_transaction_id: AtomicU16, @@ -72,6 +79,8 @@ pub struct DhtState { sender: UnboundedSender, seen_peers: DashMap>, + + closest_responding_nodes_for_info_hash: DashMap>, get_peers_subscribers: DashMap>, } @@ -92,6 +101,7 @@ impl DhtState { listen_addr, seen_peers: Default::default(), get_peers_subscribers: Default::default(), + closest_responding_nodes_for_info_hash: Default::default(), recent_requests: Default::default(), } } @@ -127,6 +137,7 @@ impl DhtState { } async fn request(&self, request: Request, addr: SocketAddr) -> anyhow::Result { + // self.rate_limiter.acquire_one().await; let (tid, message) = self.create_request(request); let key = (tid, addr); let (tx, rx) = tokio::sync::oneshot::channel(); @@ -387,18 +398,34 @@ impl DhtState { let (tx, rx) = tokio::sync::broadcast::channel(100); v.insert(tx); - // We don't need to allocate/collect here, but the borrow checker is not happy otherwise. - let nodes_to_query = self - .routing_table - .read() - .sorted_by_distance_from(info_hash) - .iter() - .map(|n| (n.id(), n.addr())) - .take(8) - .collect::>(); - for (id, addr) in nodes_to_query { - self.send_find_peers_if_not_yet(info_hash, id, addr)?; - } + let this = self.clone(); + spawn( + error_span!("peers_requester", info_hash = format!("{:?}", info_hash)), + async move { + loop { + // We don't need to allocate/collect here, but the borrow checker is not happy otherwise. + let nodes_to_query = this + .routing_table + .read() + .sorted_by_distance_from(info_hash) + .iter() + .map(|n| (n.id(), n.addr())) + .take(8) + .collect::>(); + for (id, addr) in nodes_to_query { + this.send_find_peers_if_not_yet(info_hash, id, addr)?; + } + if let Some(e) = + this.closest_responding_nodes_for_info_hash.get(&info_hash) + { + for MaybeUsefulNode { id, addr, .. } in e.value().iter() { + this.send_find_peers_if_not_yet(info_hash, *id, *addr)?; + } + } + tokio::time::sleep(REQUERY_INTERVAL).await; + } + }, + ); Ok((None, rx)) } @@ -422,18 +449,18 @@ impl DhtState { ) -> anyhow::Result<()> { let key = (request, addr); - use dashmap::mapref::entry::Entry; - match self.recent_requests.entry(key) { - Entry::Occupied(mut o) => { - if o.get().elapsed() < REQUERY_INTERVAL { - return Ok(()); - } - o.insert(Instant::now()); - } - Entry::Vacant(v) => { - v.insert(Instant::now()); - } - } + // use dashmap::mapref::entry::Entry; + // match self.recent_requests.entry(key) { + // Entry::Occupied(mut o) => { + // if o.get().elapsed() < REQUERY_INTERVAL { + // return Ok(()); + // } + // o.insert(Instant::now()); + // } + // Entry::Vacant(v) => { + // v.insert(Instant::now()); + // } + // } let this = self.clone(); @@ -534,6 +561,43 @@ impl DhtState { Ok(()) } + fn am_i_interested_in_node_for_this_info_hash( + &self, + info_hash: Id20, + node_id: Id20, + addr: SocketAddr, + ) -> bool { + use dashmap::mapref::entry::Entry; + let n = MaybeUsefulNode { + id: node_id, + addr, + last_response: None, + }; + match self.closest_responding_nodes_for_info_hash.entry(info_hash) { + Entry::Occupied(mut occ) => { + const LIMIT: usize = 128; + let v = occ.get_mut(); + v.push(n); + v.sort_by_key(|n| { + let responded = Reverse(n.last_response.is_some() as u8); + let distance = n.id.distance(&info_hash); + (responded, distance) + }); + while v.len() > LIMIT { + if v.pop().unwrap().id == node_id { + return false; + } + } + + true + } + Entry::Vacant(v) => { + v.insert(vec![n]); + true + } + } + } + fn on_found_peers_or_nodes( self: &Arc, source: Id20, @@ -555,6 +619,31 @@ impl DhtState { } }; + { + use dashmap::mapref::entry::Entry; + let n = MaybeUsefulNode { + id: source, + addr: source_addr, + last_response: Some(Instant::now()), + }; + match self.closest_responding_nodes_for_info_hash.entry(info_hash) { + Entry::Occupied(mut useful_nodes) => { + if let Some(useful_node) = useful_nodes + .get_mut() + .iter_mut() + .find(|n| n.id == source && n.addr == source_addr) + { + useful_node.last_response = Some(Instant::now()); + } else { + useful_nodes.get_mut().push(n); + } + } + Entry::Vacant(v) => { + v.insert(vec![n]); + } + }; + } + if let Some(peers) = data.values { let mut seen = self.seen_peers.entry(info_hash).or_default(); @@ -573,31 +662,20 @@ impl DhtState { }; if let Some(nodes) = data.nodes { for node in nodes.nodes { - self.routing_table_add_node(node.id, node.addr.into()); - self.send_find_peers_if_not_yet(info_hash, node.id, node.addr.into())?; + if self.am_i_interested_in_node_for_this_info_hash( + info_hash, + node.id, + node.addr.into(), + ) { + self.routing_table_add_node(node.id, node.addr.into()); + self.send_find_peers_if_not_yet(info_hash, node.id, node.addr.into())?; + } } }; Ok(()) } } -fn make_rate_limiter() -> RateLimiter { - // TODO: move to configuration, i'm lazy. - let dht_queries_per_second = std::env::var("DHT_QUERIES_PER_SECOND") - .map(|v| v.parse().expect("couldn't parse DHT_QUERIES_PER_SECOND")) - .unwrap_or(250usize); - - let per_100_ms = dht_queries_per_second / 10; - - RateLimiter::builder() - .initial(per_100_ms) - .max(dht_queries_per_second) - .interval(Duration::from_millis(100)) - .fair(false) - .refill(per_100_ms) - .build() -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum Request { GetPeers(Id20), @@ -731,14 +809,12 @@ impl DhtWorker { ) -> anyhow::Result<()> { let writer = async { let mut buf = Vec::new(); - let rate_limiter = make_rate_limiter(); while let Some(WorkerSendRequest { our_tid, message, addr, }) = input_rx.recv().await { - rate_limiter.acquire_one().await; trace!("{}: sending {:?}", addr, &message); buf.clear(); bprotocol::serialize_message( From 74c11415f18ee291157327bd4cbecacc68dc7405 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Tue, 28 Nov 2023 19:20:50 +0000 Subject: [PATCH 16/51] Return back rate limiting and not re-querying same nodes --- crates/dht/src/dht.rs | 46 +++++++++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index 365352c..5adadf5 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -61,6 +61,23 @@ struct MaybeUsefulNode { last_response: Option, } +fn make_rate_limiter() -> RateLimiter { + // TODO: move to configuration, i'm lazy. + let dht_queries_per_second = std::env::var("DHT_QUERIES_PER_SECOND") + .map(|v| v.parse().expect("couldn't parse DHT_QUERIES_PER_SECOND")) + .unwrap_or(250usize); + + let per_100_ms = dht_queries_per_second / 10; + + RateLimiter::builder() + .initial(per_100_ms) + .max(dht_queries_per_second) + .interval(Duration::from_millis(100)) + .fair(false) + .refill(per_100_ms) + .build() +} + pub struct DhtState { id: Id20, next_transaction_id: AtomicU16, @@ -76,6 +93,7 @@ pub struct DhtState { listen_addr: SocketAddr, // Sending requests to the worker. + rate_limiter: RateLimiter, sender: UnboundedSender, seen_peers: DashMap>, @@ -101,6 +119,7 @@ impl DhtState { listen_addr, seen_peers: Default::default(), get_peers_subscribers: Default::default(), + rate_limiter: make_rate_limiter(), closest_responding_nodes_for_info_hash: Default::default(), recent_requests: Default::default(), } @@ -137,7 +156,7 @@ impl DhtState { } async fn request(&self, request: Request, addr: SocketAddr) -> anyhow::Result { - // self.rate_limiter.acquire_one().await; + self.rate_limiter.acquire_one().await; let (tid, message) = self.create_request(request); let key = (tid, addr); let (tx, rx) = tokio::sync::oneshot::channel(); @@ -449,18 +468,19 @@ impl DhtState { ) -> anyhow::Result<()> { let key = (request, addr); - // use dashmap::mapref::entry::Entry; - // match self.recent_requests.entry(key) { - // Entry::Occupied(mut o) => { - // if o.get().elapsed() < REQUERY_INTERVAL { - // return Ok(()); - // } - // o.insert(Instant::now()); - // } - // Entry::Vacant(v) => { - // v.insert(Instant::now()); - // } - // } + use dashmap::mapref::entry::Entry; + match self.recent_requests.entry(key) { + Entry::Occupied(mut o) => { + // minus to account for randomness + if o.get().elapsed() < REQUERY_INTERVAL - Duration::from_secs(1) { + return Ok(()); + } + o.insert(Instant::now()); + } + Entry::Vacant(v) => { + v.insert(Instant::now()); + } + } let this = self.clone(); From dc3da89b59b4799fc0d61f6efee018944b9611ed Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Wed, 29 Nov 2023 10:40:29 +0000 Subject: [PATCH 17/51] DHT routing table tracking errors better --- TODO.md | 7 ++-- crates/dht/src/dht.rs | 28 ++++++++++------ crates/dht/src/routing_table.rs | 58 ++++++++++++++++++++++++++------- 3 files changed, 68 insertions(+), 25 deletions(-) diff --git a/TODO.md b/TODO.md index ac09aa6..017b3d6 100644 --- a/TODO.md +++ b/TODO.md @@ -14,12 +14,13 @@ - [x] pause/unpause - [x] remove including from disk - [ ] DHT - - [ ] for torrents with a few seeds might be cool to re-query DHT once in a while. + - [x] many nodes in "Unknown" status, do smth about it + - [x] for torrents with a few seeds might be cool to re-query DHT once in a while. + - [ ] don't leak memory when deleting torrents (i.e. remove torrent information (seen peers etc) once the torrent is deleted) - [ ] Buckets that have not been changed in 15 minutes should be "refreshed." (per RFC) - [x] it's sending many requests now way too fast, locks up Mac OS UI annoyingly - [ ] After the search is exhausted, the client then inserts the peer contact information for itself onto the responding nodes with IDs closest to the infohash of the torrent. - - [ ] Bad actors: - - [ ] Ensure that if we query the "returned" nodes, they are even closer to our request than the responding node id was. + - [x] Ensure that if we query the "returned" nodes, they are even closer to our request than the responding node id was. someday: - [x] cancellation from the client-side for the lib (i.e. stop the torrent manager) diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index 5adadf5..fea416a 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -55,10 +55,12 @@ pub struct WorkerSendRequest { addr: SocketAddr, } +#[derive(Debug)] struct MaybeUsefulNode { id: Id20, addr: SocketAddr, last_response: Option, + returned_peers: bool, } fn make_rate_limiter() -> RateLimiter { @@ -226,6 +228,7 @@ impl DhtState { request: Request, response: Response, ) -> anyhow::Result<()> { + self.routing_table.write().mark_response(&response.id); match request { Request::FindNode(id) => { let nodes = response @@ -281,10 +284,7 @@ impl DhtState { let response_or_error = match msg.kind { MessageKind::Error(e) => ResponseOrError::Error(e), - MessageKind::Response(r) => { - self.routing_table.write().mark_response(&r.id); - ResponseOrError::Response(r) - } + MessageKind::Response(r) => ResponseOrError::Response(r), _ => unreachable!(), }; match request.done.send(Ok(response_or_error)) { @@ -492,6 +492,7 @@ impl DhtState { let resp = this.request(request, addr).await; match resp { Ok(ResponseOrError::Response(response)) => { + this.routing_table.write().mark_response(&target_node); match this.on_response(addr, request, response) { Ok(()) => {} Err(e) => { @@ -500,9 +501,11 @@ impl DhtState { } } Ok(ResponseOrError::Error(e)) => { + this.routing_table.write().mark_response(&target_node); debug!("error response: {:?}", e); } Err(e) => { + this.routing_table.write().mark_error(&target_node); debug!("error: {:?}", e); } }; @@ -592,19 +595,24 @@ impl DhtState { id: node_id, addr, last_response: None, + + returned_peers: false, }; match self.closest_responding_nodes_for_info_hash.entry(info_hash) { Entry::Occupied(mut occ) => { - const LIMIT: usize = 128; + // How many nodes to query per torrent. + const LIMIT: usize = 256; let v = occ.get_mut(); v.push(n); v.sort_by_key(|n| { - let responded = Reverse(n.last_response.is_some() as u8); + let has_returned_peers_desc = Reverse(n.returned_peers); + let has_responded_desc = Reverse(n.last_response.is_some() as u8); let distance = n.id.distance(&info_hash); - (responded, distance) + (has_returned_peers_desc, has_responded_desc, distance) }); - while v.len() > LIMIT { - if v.pop().unwrap().id == node_id { + if v.len() > LIMIT { + let popped = v.pop().unwrap(); + if popped.id == node_id { return false; } } @@ -626,7 +634,6 @@ impl DhtState { data: bprotocol::Response, ) -> anyhow::Result<()> { self.routing_table_add_node(source, source_addr); - self.routing_table.write().mark_response(&source); let bsender = match self.get_peers_subscribers.get(&info_hash) { Some(s) => s, @@ -645,6 +652,7 @@ impl DhtState { id: source, addr: source_addr, last_response: Some(Instant::now()), + returned_peers: data.values.as_ref().map(|p| !p.is_empty()).unwrap_or(false), }; match self.closest_responding_nodes_for_info_hash.entry(info_hash) { Entry::Occupied(mut useful_nodes) => { diff --git a/crates/dht/src/routing_table.rs b/crates/dht/src/routing_table.rs index 965a414..6cfb6da 100644 --- a/crates/dht/src/routing_table.rs +++ b/crates/dht/src/routing_table.rs @@ -1,7 +1,10 @@ use std::{net::SocketAddr, time::Instant}; use librqbit_core::id20::Id20; -use serde::{ser::SerializeMap, Deserialize, Serialize}; +use serde::{ + ser::{SerializeMap, SerializeStruct}, + Deserialize, Serialize, +}; use tracing::debug; use crate::{INACTIVITY_TIMEOUT, RESPONSE_TIMEOUT}; @@ -320,7 +323,7 @@ impl BucketTree { last_request: None, last_response: None, last_query: None, - outstanding_queries_in_a_row: 0, + errors_in_a_row: 0, }; if nodes.len() < 8 { @@ -407,7 +410,7 @@ impl Default for BucketTree { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct RoutingTableNode { #[serde(serialize_with = "crate::utils::serialize_id20")] id: Id20, @@ -419,9 +422,33 @@ pub struct RoutingTableNode { #[serde(skip)] last_query: Option, #[serde(skip)] - outstanding_queries_in_a_row: usize, + errors_in_a_row: usize, } +impl Serialize for RoutingTableNode { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut s = serializer.serialize_struct("RoutingTableNode", 3)?; + s.serialize_field("id", &self.id.as_string())?; + s.serialize_field("addr", &self.addr)?; + s.serialize_field("status", &self.status())?; + if let Some(l) = self.last_request { + s.serialize_field("last_request_ago", &l.elapsed())?; + } + if let Some(l) = self.last_response { + s.serialize_field("last_response_ago", &l.elapsed())?; + } + if let Some(l) = self.last_query { + s.serialize_field("last_query_ago", &l.elapsed())?; + } + s.serialize_field("errors_in_a_row", &self.errors_in_a_row)?; + s.end() + } +} + +#[derive(Serialize, Debug)] pub enum NodeStatus { Good, Questionable, @@ -440,12 +467,7 @@ impl RoutingTableNode { match (self.last_request, self.last_response, self.last_query) { (None, _, _) => NodeStatus::Unknown, // Nodes become bad when they fail to respond to multiple queries in a row. - (Some(last_request), _, _) - if last_request.elapsed() > RESPONSE_TIMEOUT - && self.outstanding_queries_in_a_row >= 2 => - { - NodeStatus::Bad - } + (Some(_), _, _) if self.errors_in_a_row >= 2 => NodeStatus::Bad, // A good node is a node has responded to one of our queries within the last 15 minutes. // A node is also good if it has ever responded to one of our queries and has sent @@ -468,7 +490,6 @@ impl RoutingTableNode { pub fn mark_outgoing_request(&mut self) { self.last_request = Some(Instant::now()); - self.outstanding_queries_in_a_row += 1; } pub fn mark_last_query(&mut self) { @@ -481,7 +502,11 @@ impl RoutingTableNode { if self.last_request.is_none() { self.last_request = Some(now); } - self.outstanding_queries_in_a_row = 0; + self.errors_in_a_row = 0; + } + + pub fn mark_error(&mut self) { + self.errors_in_a_row += 1; } } @@ -554,6 +579,15 @@ impl RoutingTable { true } + pub fn mark_error(&mut self, id: &Id20) -> bool { + let r = match self.buckets.get_mut(id) { + Some(r) => r, + None => return false, + }; + r.mark_error(); + true + } + pub fn mark_last_query(&mut self, id: &Id20) -> bool { let r = match self.buckets.get_mut(id) { Some(r) => r, From 6518dc6effa65ee37ea8ec64906b661600f24f9b Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Wed, 29 Nov 2023 13:48:27 +0000 Subject: [PATCH 18/51] Saving before slight refactor --- TODO.md | 2 +- crates/dht/src/dht.rs | 43 +++++++++++++++++++++++++++++++++++-------- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/TODO.md b/TODO.md index 017b3d6..b21b187 100644 --- a/TODO.md +++ b/TODO.md @@ -28,6 +28,7 @@ someday: - [x] favicons for Web UI refactor: +- [ ] session persistence: should add torrents even if we haven't resolved it yet - [x] where are peers stored - [x] http api pause/unpause etc - [x] when a live torrent fails writing to disk, it should transition to error state @@ -35,7 +36,6 @@ refactor: - [x] silence this: WARN torrent{id=0}:external_peer_adder: librqbit::spawn_utils: finished with error: no longer live - [x] start from error state should be possible from UI -- [ ] if the torrent was completed, not need to re-check it - [x] checking is very slow on raspberry checked. nothing much can be done here. Even if raspberry's own libssl.so is used it's still super slow (sha1) - [ ] .rqbit-session.json file has 0 bytes when disk full. I guess fs::rename does this when disk is full? at least on linux. Couldn't repro on MacOS \ No newline at end of file diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index fea416a..0456810 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -130,7 +130,7 @@ impl DhtState { fn spawn_request(self: &Arc, request: Request, addr: SocketAddr) { let this = self.clone(); spawn( - error_span!(parent: None, "dht_request", addr=addr.to_string(), request=format!("{:?}", request)), + error_span!(parent: None, "dht_spawn_request", addr=addr.to_string(), request=format!("{:?}", request)), async move { match this.send_request_and_handle_response(request, addr).await { Ok(_) => {} @@ -421,7 +421,13 @@ impl DhtState { spawn( error_span!("peers_requester", info_hash = format!("{:?}", info_hash)), async move { + let mut iteration = 0usize; loop { + if !this.get_peers_subscribers.contains_key(&info_hash) { + debug!("no more subscribers, closing peers_requester"); + return Ok(()); + } + trace!("iteration {iteration}"); // We don't need to allocate/collect here, but the borrow checker is not happy otherwise. let nodes_to_query = this .routing_table @@ -442,6 +448,7 @@ impl DhtState { } } tokio::time::sleep(REQUERY_INTERVAL).await; + iteration += 1; } }, ); @@ -635,9 +642,10 @@ impl DhtState { ) -> anyhow::Result<()> { self.routing_table_add_node(source, source_addr); - let bsender = match self.get_peers_subscribers.get(&info_hash) { - Some(s) => s, - None => { + use dashmap::mapref::entry::Entry; + let bsender = match self.get_peers_subscribers.entry(info_hash) { + Entry::Occupied(o) => o, + Entry::Vacant(_) => { warn!( "ignoring get_peers response, no subscribers for {:?}", info_hash @@ -647,7 +655,6 @@ impl DhtState { }; { - use dashmap::mapref::entry::Entry; let n = MaybeUsefulNode { id: source, addr: source_addr, @@ -682,9 +689,29 @@ impl DhtState { } let addr = SocketAddr::V4(peer.addr); if seen.insert(addr) { - bsender - .send(addr) - .context("error sending peers to subscribers")?; + match bsender.get().send(addr) { + Ok(_) => {} + Err(_) => { + debug!("no more subscribers for {:?}, cleaning up", info_hash); + // bsender.remove(); + + // let this = self.clone(); + // spawn( + // error_span!("cleanup", info_hash = format!("{info_hash:?}")), + // async move { + // tokio::time::sleep(Duration::from_secs(10)).await; + // if !this.get_peers_subscribers.contains_key(&info_hash) { + // debug!("no more subscribers for {:?}, removed it from seen peers", info_hash); + // this.seen_peers.remove(&info_hash); + // this.closest_responding_nodes_for_info_hash + // .remove(&info_hash); + // } + // Ok(()) + // }, + // ); + return Ok(()); + } + } } } }; From 826d1b8f1dc572133352c8cca390f0861de1b9b2 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Wed, 29 Nov 2023 14:48:22 +0000 Subject: [PATCH 19/51] wtf... its getting worse. Lets see if we can simplify it a lot --- crates/dht/src/dht.rs | 206 +++++++++++++++++++----------------------- 1 file changed, 95 insertions(+), 111 deletions(-) diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index 0456810..d458650 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -80,6 +80,13 @@ fn make_rate_limiter() -> RateLimiter { .build() } +struct InfoHashMeta { + seen_peers: IndexSet, + subscriber: tokio::sync::broadcast::Sender, + closest_responding_nodes: Vec, + join_handle: tokio::task::JoinHandle<()>, +} + pub struct DhtState { id: Id20, next_transaction_id: AtomicU16, @@ -98,10 +105,8 @@ pub struct DhtState { rate_limiter: RateLimiter, sender: UnboundedSender, - seen_peers: DashMap>, - - closest_responding_nodes_for_info_hash: DashMap>, - get_peers_subscribers: DashMap>, + // Per-torrent stats. + info_hash_meta: DashMap, } impl DhtState { @@ -119,10 +124,8 @@ impl DhtState { routing_table: RwLock::new(routing_table), sender, listen_addr, - seen_peers: Default::default(), - get_peers_subscribers: Default::default(), rate_limiter: make_rate_limiter(), - closest_responding_nodes_for_info_hash: Default::default(), + info_hash_meta: Default::default(), recent_requests: Default::default(), } } @@ -318,8 +321,8 @@ impl DhtState { Ok(()) } MessageKind::GetPeersRequest(req) => { - let peers = self.seen_peers.get(&req.info_hash).map(|peers| { - peers + let peers = self.info_hash_meta.get(&req.info_hash).map(|meta| { + meta.seen_peers .iter() .copied() .filter_map(|a| match a { @@ -384,7 +387,11 @@ impl DhtState { DhtStats { id: self.id, outstanding_requests: self.inflight_by_transaction_id.len(), - seen_peers: self.seen_peers.iter().map(|e| e.value().len()).sum(), + seen_peers: self + .info_hash_meta + .iter() + .map(|e| e.value().seen_peers.len()) + .sum(), recent_requests: self.recent_requests.len(), routing_table_size: self.routing_table.read().len(), } @@ -399,36 +406,35 @@ impl DhtState { tokio::sync::broadcast::Receiver, )> { use dashmap::mapref::entry::Entry; - match self.get_peers_subscribers.entry(info_hash) { + match self.info_hash_meta.entry(info_hash) { Entry::Occupied(o) => { - let pos = self.seen_peers.get(&info_hash).and_then(|p| { - if p.is_empty() { - None - } else { - Some((0, p.len())) - } - }); - let rx = o.get().subscribe(); + let seen_peers = &o.get().seen_peers; + let pos = if seen_peers.is_empty() { + None + } else { + Some((0, seen_peers.len())) + }; + let rx = o.get().subscriber.subscribe(); Ok((pos, rx)) } Entry::Vacant(v) => { // DHT sends peers REALLY fast, so ideally the consumer of this broadcast should not lag behind. // In case it does though we have PeerStream to replay. - let (tx, rx) = tokio::sync::broadcast::channel(100); - v.insert(tx); let this = self.clone(); - spawn( + let join_handle = spawn( error_span!("peers_requester", info_hash = format!("{:?}", info_hash)), async move { let mut iteration = 0usize; loop { - if !this.get_peers_subscribers.contains_key(&info_hash) { - debug!("no more subscribers, closing peers_requester"); - return Ok(()); - } + let meta = match this.info_hash_meta.get(&info_hash) { + Some(meta) => meta, + None => { + debug!("no more subscribers, closing peers_requester"); + return Ok(()); + } + }; trace!("iteration {iteration}"); - // We don't need to allocate/collect here, but the borrow checker is not happy otherwise. let nodes_to_query = this .routing_table .read() @@ -440,19 +446,26 @@ impl DhtState { for (id, addr) in nodes_to_query { this.send_find_peers_if_not_yet(info_hash, id, addr)?; } - if let Some(e) = - this.closest_responding_nodes_for_info_hash.get(&info_hash) + for MaybeUsefulNode { id, addr, .. } in + meta.closest_responding_nodes.iter() { - for MaybeUsefulNode { id, addr, .. } in e.value().iter() { - this.send_find_peers_if_not_yet(info_hash, *id, *addr)?; - } + this.send_find_peers_if_not_yet(info_hash, *id, *addr)?; } + drop(meta); tokio::time::sleep(REQUERY_INTERVAL).await; iteration += 1; } }, ); + let (tx, rx) = tokio::sync::broadcast::channel(100); + v.insert(InfoHashMeta { + seen_peers: Default::default(), + subscriber: tx, + closest_responding_nodes: Default::default(), + join_handle, + }); + Ok((None, rx)) } } @@ -559,10 +572,8 @@ impl DhtState { target: Id20, nodes: CompactNodeInfo, ) -> anyhow::Result<()> { - // We don't need to allocate/collect here, but the borrow checker is not happy - // otherwise when we iterate self.searching_for_peers and mutating self in the loop. let searching_for_peers = self - .get_peers_subscribers + .info_hash_meta .iter() .map(|e| *e.key()) .collect::>(); @@ -596,41 +607,29 @@ impl DhtState { info_hash: Id20, node_id: Id20, addr: SocketAddr, + closest_nodes: &mut Vec, ) -> bool { - use dashmap::mapref::entry::Entry; - let n = MaybeUsefulNode { + closest_nodes.push(MaybeUsefulNode { id: node_id, addr, last_response: None, - returned_peers: false, - }; - match self.closest_responding_nodes_for_info_hash.entry(info_hash) { - Entry::Occupied(mut occ) => { - // How many nodes to query per torrent. - const LIMIT: usize = 256; - let v = occ.get_mut(); - v.push(n); - v.sort_by_key(|n| { - let has_returned_peers_desc = Reverse(n.returned_peers); - let has_responded_desc = Reverse(n.last_response.is_some() as u8); - let distance = n.id.distance(&info_hash); - (has_returned_peers_desc, has_responded_desc, distance) - }); - if v.len() > LIMIT { - let popped = v.pop().unwrap(); - if popped.id == node_id { - return false; - } - } + }); - true - } - Entry::Vacant(v) => { - v.insert(vec![n]); - true + const LIMIT: usize = 256; + closest_nodes.sort_by_key(|n| { + let has_returned_peers_desc = Reverse(n.returned_peers); + let has_responded_desc = Reverse(n.last_response.is_some() as u8); + let distance = n.id.distance(&info_hash); + (has_returned_peers_desc, has_responded_desc, distance) + }); + if closest_nodes.len() > LIMIT { + let popped = closest_nodes.pop().unwrap(); + if popped.id == node_id { + return false; } } + true } fn on_found_peers_or_nodes( @@ -643,72 +642,54 @@ impl DhtState { self.routing_table_add_node(source, source_addr); use dashmap::mapref::entry::Entry; - let bsender = match self.get_peers_subscribers.entry(info_hash) { + let mut meta = match self.info_hash_meta.entry(info_hash) { Entry::Occupied(o) => o, Entry::Vacant(_) => { warn!( - "ignoring get_peers response, no subscribers for {:?}", + "ignoring found_peers response, no subscribers for {:?}", info_hash ); return Ok(()); } }; + let meta_mut = meta.get_mut(); + { - let n = MaybeUsefulNode { - id: source, - addr: source_addr, - last_response: Some(Instant::now()), - returned_peers: data.values.as_ref().map(|p| !p.is_empty()).unwrap_or(false), - }; - match self.closest_responding_nodes_for_info_hash.entry(info_hash) { - Entry::Occupied(mut useful_nodes) => { - if let Some(useful_node) = useful_nodes - .get_mut() - .iter_mut() - .find(|n| n.id == source && n.addr == source_addr) - { - useful_node.last_response = Some(Instant::now()); - } else { - useful_nodes.get_mut().push(n); - } - } - Entry::Vacant(v) => { - v.insert(vec![n]); - } - }; + let now = Some(Instant::now()); + let returned_peers = data.values.as_ref().map(|p| !p.is_empty()).unwrap_or(false); + + if let Some(existing_useful_node) = meta_mut + .closest_responding_nodes + .iter_mut() + .find(|n| n.id == source && n.addr == source_addr) + { + existing_useful_node.last_response = now; + existing_useful_node.returned_peers |= returned_peers; + } else { + meta_mut.closest_responding_nodes.push(MaybeUsefulNode { + id: source, + addr: source_addr, + last_response: now, + returned_peers, + }); + } } if let Some(peers) = data.values { - let mut seen = self.seen_peers.entry(info_hash).or_default(); - for peer in peers.iter() { if peer.addr.port() < 1024 { debug!("bad peer port, ignoring: {}", peer.addr); continue; } let addr = SocketAddr::V4(peer.addr); - if seen.insert(addr) { - match bsender.get().send(addr) { + if meta_mut.seen_peers.insert(addr) { + match meta_mut.subscriber.send(addr) { Ok(_) => {} Err(_) => { debug!("no more subscribers for {:?}, cleaning up", info_hash); - // bsender.remove(); - - // let this = self.clone(); - // spawn( - // error_span!("cleanup", info_hash = format!("{info_hash:?}")), - // async move { - // tokio::time::sleep(Duration::from_secs(10)).await; - // if !this.get_peers_subscribers.contains_key(&info_hash) { - // debug!("no more subscribers for {:?}, removed it from seen peers", info_hash); - // this.seen_peers.remove(&info_hash); - // this.closest_responding_nodes_for_info_hash - // .remove(&info_hash); - // } - // Ok(()) - // }, - // ); + meta_mut.join_handle.abort(); + meta.remove(); return Ok(()); } } @@ -721,6 +702,7 @@ impl DhtState { info_hash, node.id, node.addr.into(), + &mut meta_mut.closest_responding_nodes, ) { self.routing_table_add_node(node.id, node.addr.into()); self.send_find_peers_if_not_yet(info_hash, node.id, node.addr.into())?; @@ -984,13 +966,15 @@ impl Stream for PeerStream { ) -> Poll> { loop { if let Some((pos, end)) = self.initial_peers_pos.take() { - let addr = *self + let addr = match self .state - .seen_peers + .info_hash_meta .get(&self.info_hash) - .unwrap() - .get_index(pos) - .unwrap(); + .and_then(|meta| meta.seen_peers.get_index(pos).copied()) + { + Some(addr) => addr, + None => return Poll::Ready(None), + }; if pos + 1 < end { self.initial_peers_pos = Some((pos + 1, end)); } From 672dcce484ec23a57253e4bdf641a89da2ac8f39 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Wed, 29 Nov 2023 16:39:09 +0000 Subject: [PATCH 20/51] rewrote it, still crappy but easier to understand --- crates/dht/src/dht.rs | 544 +++++++++++++++++------------------------- 1 file changed, 223 insertions(+), 321 deletions(-) diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index d458650..7bdfc30 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -21,7 +21,7 @@ use anyhow::{bail, Context}; use backoff::{backoff::Backoff, ExponentialBackoffBuilder}; use bencode::ByteString; use dashmap::DashMap; -use futures::{stream::FuturesUnordered, Stream, StreamExt}; +use futures::{future::BoxFuture, stream::FuturesUnordered, FutureExt, Stream, StreamExt}; use indexmap::IndexSet; use leaky_bucket::RateLimiter; use librqbit_core::{id20::Id20, peer_id::generate_peer_id, spawn_utils::spawn}; @@ -30,18 +30,19 @@ use rand::Rng; use serde::Serialize; use tokio::{ net::UdpSocket, - sync::mpsc::{channel, unbounded_channel, Sender, UnboundedReceiver, UnboundedSender}, + sync::{ + mpsc::{channel, unbounded_channel, Sender, UnboundedReceiver, UnboundedSender}, + Notify, + }, }; use tokio_stream::wrappers::{errors::BroadcastStreamRecvError, BroadcastStream}; -use tracing::{debug, debug_span, error_span, info, trace, warn, Instrument}; +use tracing::{debug, debug_span, error, error_span, info, trace, warn, Instrument}; #[derive(Debug, Serialize)] pub struct DhtStats { #[serde(serialize_with = "crate::utils::serialize_id20")] pub id: Id20, pub outstanding_requests: usize, - pub seen_peers: usize, - pub recent_requests: usize, pub routing_table_size: usize, } @@ -59,6 +60,7 @@ pub struct WorkerSendRequest { struct MaybeUsefulNode { id: Id20, addr: SocketAddr, + last_request: Instant, last_response: Option, returned_peers: bool, } @@ -80,33 +82,209 @@ fn make_rate_limiter() -> RateLimiter { .build() } -struct InfoHashMeta { - seen_peers: IndexSet, - subscriber: tokio::sync::broadcast::Sender, - closest_responding_nodes: Vec, - join_handle: tokio::task::JoinHandle<()>, +struct RequestPeers { + info_hash: Id20, + dht: Arc, + useful_nodes: RwLock>, + tx: tokio::sync::mpsc::UnboundedSender, +} + +struct RequestPeersStream { + rx: tokio::sync::mpsc::UnboundedReceiver, + cancel_join_handle: tokio::task::JoinHandle<()>, +} + +impl RequestPeersStream { + fn new(dht: Arc, info_hash: Id20) -> Self { + let (tx, rx) = unbounded_channel(); + let rp = Arc::new(RequestPeers { + info_hash, + dht, + useful_nodes: RwLock::new(Vec::new()), + tx, + }); + let join_handle = rp.request_peers_forever(); + Self { + rx, + cancel_join_handle: join_handle, + } + } +} + +impl Drop for RequestPeersStream { + fn drop(&mut self) { + self.cancel_join_handle.abort(); + } +} + +impl Stream for RequestPeersStream { + type Item = SocketAddr; + + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> Poll> { + self.rx.poll_recv(cx) + } +} + +impl RequestPeers { + fn request_peers_forever(self: Arc) -> tokio::task::JoinHandle<()> { + spawn( + error_span!("request_peers", info_hash = format!("{:?}", self.info_hash)), + async move { + let mut iteration = 0; + loop { + debug!("iteration {}", iteration); + let sleep_duration = match self.get_peers_root().await { + Ok(_) => Duration::from_secs(60), + Err(e) => { + debug!("error: {e:?}"); + Duration::from_secs(1) + } + }; + tokio::time::sleep(sleep_duration).await; + iteration += 1; + } + }, + ) + } + fn request_peers_one<'a>( + self: &'a Arc, + addr: SocketAddr, + ) -> BoxFuture<'a, anyhow::Result<()>> { + let fut = async move { + let response = self + .dht + .request(Request::GetPeers(self.info_hash), addr) + .await?; + let response = match response { + ResponseOrError::Response(r) => r, + ResponseOrError::Error(e) => { + bail!("error response: {:?}", e) + } + }; + + self.mark_node_responded(addr, &response); + + if let Some(peers) = response.values { + for peer in peers { + self.tx.send(SocketAddr::V4(peer.addr))?; + } + } + + let mut futs = FuturesUnordered::new(); + if let Some(nodes) = response.nodes { + for node in nodes.nodes { + let addr = SocketAddr::V4(node.addr); + if self.should_request_node(node.id, addr) { + futs.push(self.request_peers_one(addr)); + } + } + } + + while let Some(res) = futs.next().await { + if let Err(e) = res { + debug!("error: {e:?}") + } + } + + Ok(()) + }; + fut.boxed() + } + + async fn get_peers_root(self: &Arc) -> anyhow::Result<()> { + let mut futs = FuturesUnordered::new(); + for (_, addr) in self + .dht + .routing_table + .read() + .sorted_by_distance_from(self.info_hash) + .iter() + .map(|n| (n.id(), n.addr())) + .take(8) + { + futs.push(self.request_peers_one(addr)) + } + if futs.is_empty() { + bail!("no nodes in routing table") + } + while let Some(res) = futs.next().await { + if let Err(e) = res { + debug!("error: {e:?}") + } + } + Ok(()) + } + + fn mark_node_responded(&self, addr: SocketAddr, response: &Response) { + let mut closest_nodes = self.useful_nodes.write(); + for node in closest_nodes.iter_mut() { + if node.addr == addr { + node.last_response = Some(Instant::now()); + node.returned_peers = response + .values + .as_ref() + .map(|c| !c.is_empty()) + .unwrap_or(false); + break; + } + } + } + + fn should_request_node(&self, node_id: Id20, addr: SocketAddr) -> bool { + let mut closest_nodes = self.useful_nodes.write(); + + // If recently requested, ignore + if let Some(existing) = closest_nodes.iter_mut().find(|n| n.id == node_id) { + if existing.last_request.elapsed() > Duration::from_secs(60) { + existing.last_request = Instant::now(); + return true; + } + return false; + } + + closest_nodes.push(MaybeUsefulNode { + id: node_id, + addr, + last_request: Instant::now(), + last_response: None, + returned_peers: false, + }); + + const LIMIT: usize = 256; + closest_nodes.sort_by_key(|n| { + let has_returned_peers_desc = Reverse(n.returned_peers); + let has_responded_desc = Reverse(n.last_response.is_some() as u8); + let distance = n.id.distance(&self.info_hash); + (has_returned_peers_desc, has_responded_desc, distance) + }); + if closest_nodes.len() > LIMIT { + let popped = closest_nodes.pop().unwrap(); + if popped.id == node_id { + return false; + } + } + true + } } pub struct DhtState { id: Id20, next_transaction_id: AtomicU16, + bootstrapped: Notify, // Created requests: (transaction_id, addr) => Requests. // If we get a response, it gets removed from here. inflight_by_transaction_id: DashMap<(u16, SocketAddr), OutstandingRequest>, - // Current requests to addr being re-sent with backoff. - recent_requests: DashMap<(Request, SocketAddr), Instant>, - routing_table: RwLock, listen_addr: SocketAddr, // Sending requests to the worker. rate_limiter: RateLimiter, sender: UnboundedSender, - - // Per-torrent stats. - info_hash_meta: DashMap, } impl DhtState { @@ -119,14 +297,13 @@ impl DhtState { let routing_table = routing_table.unwrap_or_else(|| RoutingTable::new(id)); Self { id, + bootstrapped: Default::default(), next_transaction_id: AtomicU16::new(0), inflight_by_transaction_id: Default::default(), routing_table: RwLock::new(routing_table), sender, listen_addr, rate_limiter: make_rate_limiter(), - info_hash_meta: Default::default(), - recent_requests: Default::default(), } } @@ -241,7 +418,8 @@ impl DhtState { } Request::Ping => Ok(()), Request::GetPeers(info_hash) => { - self.on_found_peers_or_nodes(response.id, addr, info_hash, response) + todo!() + // self.on_found_peers_or_nodes(response.id, addr, info_hash, response) } } } @@ -321,26 +499,26 @@ impl DhtState { Ok(()) } MessageKind::GetPeersRequest(req) => { - let peers = self.info_hash_meta.get(&req.info_hash).map(|meta| { - meta.seen_peers - .iter() - .copied() - .filter_map(|a| match a { - SocketAddr::V4(v4) => Some(CompactPeerInfo { addr: v4 }), - // this should never happen in practice - SocketAddr::V6(_) => None, - }) - .take(50) - .collect::>() - }); - let token = if peers.is_some() { - let mut token = [0u8; 20]; - rand::thread_rng().fill(&mut token); - Some(ByteString::from(token.as_ref())) - } else { - None - }; - let compact_node_info = generate_compact_nodes(req.info_hash); + // let peers = self.info_hash_meta.get(&req.info_hash).map(|meta| { + // meta.seen_peers + // .iter() + // .copied() + // .filter_map(|a| match a { + // SocketAddr::V4(v4) => Some(CompactPeerInfo { addr: v4 }), + // // this should never happen in practice + // SocketAddr::V6(_) => None, + // }) + // .take(50) + // .collect::>() + // }); + // let token = if peers.is_some() { + // let mut token = [0u8; 20]; + // rand::thread_rng().fill(&mut token); + // Some(ByteString::from(token.as_ref())) + // } else { + // None + // }; + // let compact_node_info = generate_compact_nodes(req.info_hash); self.routing_table.write().mark_last_query(&req.id); let message = Message { transaction_id: msg.transaction_id, @@ -348,9 +526,9 @@ impl DhtState { ip: None, kind: MessageKind::Response(bprotocol::Response { id: self.id, - nodes: Some(compact_node_info), - values: peers, - token, + nodes: None, + values: None, + token: None, }), }; self.sender.send(WorkerSendRequest { @@ -387,123 +565,17 @@ impl DhtState { DhtStats { id: self.id, outstanding_requests: self.inflight_by_transaction_id.len(), - seen_peers: self - .info_hash_meta - .iter() - .map(|e| e.value().seen_peers.len()) - .sum(), - recent_requests: self.recent_requests.len(), routing_table_size: self.routing_table.read().len(), } } - #[allow(clippy::type_complexity)] - fn get_peers_internal( - self: &Arc, - info_hash: Id20, - ) -> anyhow::Result<( - Option<(usize, usize)>, - tokio::sync::broadcast::Receiver, - )> { - use dashmap::mapref::entry::Entry; - match self.info_hash_meta.entry(info_hash) { - Entry::Occupied(o) => { - let seen_peers = &o.get().seen_peers; - let pos = if seen_peers.is_empty() { - None - } else { - Some((0, seen_peers.len())) - }; - let rx = o.get().subscriber.subscribe(); - Ok((pos, rx)) - } - Entry::Vacant(v) => { - // DHT sends peers REALLY fast, so ideally the consumer of this broadcast should not lag behind. - // In case it does though we have PeerStream to replay. - - let this = self.clone(); - let join_handle = spawn( - error_span!("peers_requester", info_hash = format!("{:?}", info_hash)), - async move { - let mut iteration = 0usize; - loop { - let meta = match this.info_hash_meta.get(&info_hash) { - Some(meta) => meta, - None => { - debug!("no more subscribers, closing peers_requester"); - return Ok(()); - } - }; - trace!("iteration {iteration}"); - let nodes_to_query = this - .routing_table - .read() - .sorted_by_distance_from(info_hash) - .iter() - .map(|n| (n.id(), n.addr())) - .take(8) - .collect::>(); - for (id, addr) in nodes_to_query { - this.send_find_peers_if_not_yet(info_hash, id, addr)?; - } - for MaybeUsefulNode { id, addr, .. } in - meta.closest_responding_nodes.iter() - { - this.send_find_peers_if_not_yet(info_hash, *id, *addr)?; - } - drop(meta); - tokio::time::sleep(REQUERY_INTERVAL).await; - iteration += 1; - } - }, - ); - - let (tx, rx) = tokio::sync::broadcast::channel(100); - v.insert(InfoHashMeta { - seen_peers: Default::default(), - subscriber: tx, - closest_responding_nodes: Default::default(), - join_handle, - }); - - Ok((None, rx)) - } - } - } - - fn send_find_peers_if_not_yet( - self: &Arc, - info_hash: Id20, - target_node: Id20, - addr: SocketAddr, - ) -> anyhow::Result<()> { - self.send_request_if_not_yet(target_node, Request::GetPeers(info_hash), addr) - } - fn send_request_if_not_yet( self: &Arc, target_node: Id20, request: Request, addr: SocketAddr, ) -> anyhow::Result<()> { - let key = (request, addr); - - use dashmap::mapref::entry::Entry; - match self.recent_requests.entry(key) { - Entry::Occupied(mut o) => { - // minus to account for randomness - if o.get().elapsed() < REQUERY_INTERVAL - Duration::from_secs(1) { - return Ok(()); - } - o.insert(Instant::now()); - } - Entry::Vacant(v) => { - v.insert(Instant::now()); - } - } - let this = self.clone(); - let fut = async move { this.routing_table .write() @@ -572,27 +644,10 @@ impl DhtState { target: Id20, nodes: CompactNodeInfo, ) -> anyhow::Result<()> { - let searching_for_peers = self - .info_hash_meta - .iter() - .map(|e| *e.key()) - .collect::>(); - - // On newly discovered nodes, ask them for peers that we are interested in. - match self.routing_table_add_node(source, source_addr) { - InsertResult::ReplacedBad(_) | InsertResult::Added => { - for info_hash in &searching_for_peers { - self.send_find_peers_if_not_yet(*info_hash, source, source_addr)?; - } - } - _ => {} - }; + self.routing_table_add_node(source, source_addr); for node in nodes.nodes { match self.routing_table_add_node(node.id, node.addr.into()) { InsertResult::ReplacedBad(_) | InsertResult::Added => { - for info_hash in &searching_for_peers { - self.send_find_peers_if_not_yet(*info_hash, node.id, node.addr.into())?; - } // recursively find nodes closest to us until we can't find more. self.send_find_node_if_not_yet(target, source, source_addr)?; } @@ -601,116 +656,6 @@ impl DhtState { } Ok(()) } - - fn am_i_interested_in_node_for_this_info_hash( - &self, - info_hash: Id20, - node_id: Id20, - addr: SocketAddr, - closest_nodes: &mut Vec, - ) -> bool { - closest_nodes.push(MaybeUsefulNode { - id: node_id, - addr, - last_response: None, - returned_peers: false, - }); - - const LIMIT: usize = 256; - closest_nodes.sort_by_key(|n| { - let has_returned_peers_desc = Reverse(n.returned_peers); - let has_responded_desc = Reverse(n.last_response.is_some() as u8); - let distance = n.id.distance(&info_hash); - (has_returned_peers_desc, has_responded_desc, distance) - }); - if closest_nodes.len() > LIMIT { - let popped = closest_nodes.pop().unwrap(); - if popped.id == node_id { - return false; - } - } - true - } - - fn on_found_peers_or_nodes( - self: &Arc, - source: Id20, - source_addr: SocketAddr, - info_hash: Id20, - data: bprotocol::Response, - ) -> anyhow::Result<()> { - self.routing_table_add_node(source, source_addr); - - use dashmap::mapref::entry::Entry; - let mut meta = match self.info_hash_meta.entry(info_hash) { - Entry::Occupied(o) => o, - Entry::Vacant(_) => { - warn!( - "ignoring found_peers response, no subscribers for {:?}", - info_hash - ); - return Ok(()); - } - }; - - let meta_mut = meta.get_mut(); - - { - let now = Some(Instant::now()); - let returned_peers = data.values.as_ref().map(|p| !p.is_empty()).unwrap_or(false); - - if let Some(existing_useful_node) = meta_mut - .closest_responding_nodes - .iter_mut() - .find(|n| n.id == source && n.addr == source_addr) - { - existing_useful_node.last_response = now; - existing_useful_node.returned_peers |= returned_peers; - } else { - meta_mut.closest_responding_nodes.push(MaybeUsefulNode { - id: source, - addr: source_addr, - last_response: now, - returned_peers, - }); - } - } - - if let Some(peers) = data.values { - for peer in peers.iter() { - if peer.addr.port() < 1024 { - debug!("bad peer port, ignoring: {}", peer.addr); - continue; - } - let addr = SocketAddr::V4(peer.addr); - if meta_mut.seen_peers.insert(addr) { - match meta_mut.subscriber.send(addr) { - Ok(_) => {} - Err(_) => { - debug!("no more subscribers for {:?}, cleaning up", info_hash); - meta_mut.join_handle.abort(); - meta.remove(); - return Ok(()); - } - } - } - } - }; - if let Some(nodes) = data.nodes { - for node in nodes.nodes { - if self.am_i_interested_in_node_for_this_info_hash( - info_hash, - node.id, - node.addr.into(), - &mut meta_mut.closest_responding_nodes, - ) { - self.routing_table_add_node(node.id, node.addr.into()); - self.send_find_peers_if_not_yet(info_hash, node.id, node.addr.into())?; - } - } - }; - Ok(()) - } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -952,9 +897,6 @@ impl DhtWorker { struct PeerStream { info_hash: Id20, state: Arc, - absolute_stream_pos: usize, - initial_peers_pos: Option<(usize, usize)>, - broadcast_rx: BroadcastStream, } impl Stream for PeerStream { @@ -964,40 +906,7 @@ impl Stream for PeerStream { mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> Poll> { - loop { - if let Some((pos, end)) = self.initial_peers_pos.take() { - let addr = match self - .state - .info_hash_meta - .get(&self.info_hash) - .and_then(|meta| meta.seen_peers.get_index(pos).copied()) - { - Some(addr) => addr, - None => return Poll::Ready(None), - }; - if pos + 1 < end { - self.initial_peers_pos = Some((pos + 1, end)); - } - self.absolute_stream_pos += 1; - return Poll::Ready(Some(addr)); - } - - match self.broadcast_rx.poll_next_unpin(cx) { - Poll::Ready(Some(Ok(v))) => { - self.absolute_stream_pos += 1; - return Poll::Ready(Some(v)); - } - Poll::Ready(Some(Err(BroadcastStreamRecvError::Lagged(lagged_by)))) => { - debug!("peer stream is lagged by {}", lagged_by); - let s = self.absolute_stream_pos; - let e = s + lagged_by as usize; - self.initial_peers_pos = Some((s, e)); - continue; - } - Poll::Ready(None) => return Poll::Ready(None), - Poll::Pending => return Poll::Pending, - }; - } + todo!() } } @@ -1061,14 +970,7 @@ impl DhtState { self: &Arc, info_hash: Id20, ) -> anyhow::Result + Unpin> { - let (pos, rx) = self.get_peers_internal(info_hash)?; - Ok(PeerStream { - info_hash, - state: self.clone(), - absolute_stream_pos: 0, - initial_peers_pos: pos, - broadcast_rx: BroadcastStream::new(rx), - }) + Ok(RequestPeersStream::new(self.clone(), info_hash)) } pub fn listen_addr(&self) -> SocketAddr { From ea8cd02a7a603d26128554afabd26693467a9b2b Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Wed, 29 Nov 2023 18:22:00 +0000 Subject: [PATCH 21/51] peer handling now works well --- crates/dht/src/dht.rs | 214 +++++++++++++++++++++++++----------------- 1 file changed, 129 insertions(+), 85 deletions(-) diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index 7bdfc30..ae1fbbc 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -1,8 +1,9 @@ use std::{ + any, cmp::Reverse, net::SocketAddr, sync::{ - atomic::{AtomicU16, Ordering}, + atomic::{AtomicBool, AtomicU16, Ordering}, Arc, }, task::Poll, @@ -21,7 +22,9 @@ use anyhow::{bail, Context}; use backoff::{backoff::Backoff, ExponentialBackoffBuilder}; use bencode::ByteString; use dashmap::DashMap; -use futures::{future::BoxFuture, stream::FuturesUnordered, FutureExt, Stream, StreamExt}; +use futures::{ + future::BoxFuture, stream::FuturesUnordered, FutureExt, Stream, StreamExt, TryFutureExt, +}; use indexmap::IndexSet; use leaky_bucket::RateLimiter; use librqbit_core::{id20::Id20, peer_id::generate_peer_id, spawn_utils::spawn}; @@ -62,6 +65,7 @@ struct MaybeUsefulNode { addr: SocketAddr, last_request: Instant, last_response: Option, + errors_in_a_row: usize, returned_peers: bool, } @@ -86,26 +90,31 @@ struct RequestPeers { info_hash: Id20, dht: Arc, useful_nodes: RwLock>, - tx: tokio::sync::mpsc::UnboundedSender, + peer_tx: tokio::sync::mpsc::UnboundedSender, + node_tx: tokio::sync::mpsc::UnboundedSender, } struct RequestPeersStream { rx: tokio::sync::mpsc::UnboundedReceiver, cancel_join_handle: tokio::task::JoinHandle<()>, + request_peers: Arc, } impl RequestPeersStream { fn new(dht: Arc, info_hash: Id20) -> Self { - let (tx, rx) = unbounded_channel(); + let (peer_tx, peer_rx) = unbounded_channel(); + let (node_tx, node_rx) = unbounded_channel(); let rp = Arc::new(RequestPeers { info_hash, dht, useful_nodes: RwLock::new(Vec::new()), - tx, + peer_tx, + node_tx, }); - let join_handle = rp.request_peers_forever(); + let join_handle = rp.clone().request_peers_forever(node_rx); Self { - rx, + request_peers: rp, + rx: peer_rx, cancel_join_handle: join_handle, } } @@ -128,74 +137,100 @@ impl Stream for RequestPeersStream { } } +// So what do I want to do? +// Every 60 seconds, we add root nodes to the queue. +// We poll the following things: +// 1. The queue. If got item from there, insert into the futures unordered. +// 2. Futures unordered. +// If received, send to the resulting one. +struct Tmp {} + impl RequestPeers { - fn request_peers_forever(self: Arc) -> tokio::task::JoinHandle<()> { + fn request_peers_forever( + self: Arc, + mut node_rx: tokio::sync::mpsc::UnboundedReceiver, + ) -> tokio::task::JoinHandle<()> { spawn( error_span!("request_peers", info_hash = format!("{:?}", self.info_hash)), async move { - let mut iteration = 0; - loop { - debug!("iteration {}", iteration); - let sleep_duration = match self.get_peers_root().await { - Ok(_) => Duration::from_secs(60), - Err(e) => { - debug!("error: {e:?}"); - Duration::from_secs(1) + // Looper adds root nodes to the queue every 60 seconds. + let looper = { + let this = self.clone(); + async move { + let mut iteration = 0; + loop { + debug!("iteration {}", iteration); + let sleep = match this.get_peers_root() { + Ok(0) => Duration::from_secs(1), + Ok(n) if n < 8 => REQUERY_INTERVAL / 2, + Ok(_) => REQUERY_INTERVAL, + Err(e) => { + error!("error: {e:?}"); + return Err::<(), anyhow::Error>(e); + } + }; + tokio::time::sleep(sleep).await; + iteration += 1; } - }; - tokio::time::sleep(sleep_duration).await; - iteration += 1; + } + }; + tokio::pin!(looper); + + let mut futs = FuturesUnordered::new(); + loop { + tokio::select! { + addr = node_rx.recv() => { + let addr = addr.unwrap(); + futs.push( + self.get_peers_one(addr) + .map_err(|e| debug!("error: {e:?}")) + .instrument(error_span!("addr", addr=addr.to_string())) + ); + } + Some(_) = futs.next(), if !futs.is_empty() => {} + _ = &mut looper => {} + } } }, ) } - fn request_peers_one<'a>( - self: &'a Arc, - addr: SocketAddr, - ) -> BoxFuture<'a, anyhow::Result<()>> { - let fut = async move { - let response = self - .dht - .request(Request::GetPeers(self.info_hash), addr) - .await?; - let response = match response { - ResponseOrError::Response(r) => r, - ResponseOrError::Error(e) => { - bail!("error response: {:?}", e) - } - }; - self.mark_node_responded(addr, &response); - - if let Some(peers) = response.values { - for peer in peers { - self.tx.send(SocketAddr::V4(peer.addr))?; - } + async fn get_peers_one<'a>(self: &'a Arc, addr: SocketAddr) -> anyhow::Result<()> { + let response = self + .dht + .request(Request::GetPeers(self.info_hash), addr) + .await + .map_err(|e| { + self.mark_node_error(addr); + e + })?; + self.mark_node_responded(addr, &response); + let response = match response { + ResponseOrError::Response(r) => r, + ResponseOrError::Error(e) => { + bail!("error response: {:?}", e) } - - let mut futs = FuturesUnordered::new(); - if let Some(nodes) = response.nodes { - for node in nodes.nodes { - let addr = SocketAddr::V4(node.addr); - if self.should_request_node(node.id, addr) { - futs.push(self.request_peers_one(addr)); - } - } - } - - while let Some(res) = futs.next().await { - if let Err(e) = res { - debug!("error: {e:?}") - } - } - - Ok(()) }; - fut.boxed() + + if let Some(peers) = response.values { + for peer in peers { + self.peer_tx.send(SocketAddr::V4(peer.addr))?; + } + } + + if let Some(nodes) = response.nodes { + for node in nodes.nodes { + let addr = SocketAddr::V4(node.addr); + if self.should_request_node(node.id, addr) { + self.node_tx.send(addr)?; + } + } + } + Ok(()) } - async fn get_peers_root(self: &Arc) -> anyhow::Result<()> { - let mut futs = FuturesUnordered::new(); + fn get_peers_root(self: &Arc) -> anyhow::Result { + let mut count = 0; for (_, addr) in self .dht .routing_table @@ -205,32 +240,42 @@ impl RequestPeers { .map(|n| (n.id(), n.addr())) .take(8) { - futs.push(self.request_peers_one(addr)) + count += 1; + self.node_tx.send(addr)?; } - if futs.is_empty() { - bail!("no nodes in routing table") - } - while let Some(res) = futs.next().await { - if let Err(e) = res { - debug!("error: {e:?}") - } - } - Ok(()) + Ok(count) } - fn mark_node_responded(&self, addr: SocketAddr, response: &Response) { - let mut closest_nodes = self.useful_nodes.write(); - for node in closest_nodes.iter_mut() { - if node.addr == addr { + fn mark_node_error(&self, addr: SocketAddr) -> bool { + self.useful_nodes + .write() + .iter_mut() + .find(|n| n.addr == addr) + .map(|n| { + n.errors_in_a_row += 1; + }) + .is_some() + } + + fn mark_node_responded(&self, addr: SocketAddr, response: &ResponseOrError) -> bool { + self.useful_nodes + .write() + .iter_mut() + .find(|n| n.addr == addr) + .map(|node| { node.last_response = Some(Instant::now()); - node.returned_peers = response - .values - .as_ref() - .map(|c| !c.is_empty()) - .unwrap_or(false); - break; - } - } + node.errors_in_a_row = 0; + match response { + ResponseOrError::Response(r) => { + node.returned_peers = + r.values.as_ref().map(|c| !c.is_empty()).unwrap_or(false) + } + ResponseOrError::Error(_) => { + node.returned_peers = false; + } + } + }) + .is_some() } fn should_request_node(&self, node_id: Id20, addr: SocketAddr) -> bool { @@ -251,6 +296,7 @@ impl RequestPeers { last_request: Instant::now(), last_response: None, returned_peers: false, + errors_in_a_row: 0, }); const LIMIT: usize = 256; @@ -273,7 +319,6 @@ impl RequestPeers { pub struct DhtState { id: Id20, next_transaction_id: AtomicU16, - bootstrapped: Notify, // Created requests: (transaction_id, addr) => Requests. // If we get a response, it gets removed from here. @@ -297,7 +342,6 @@ impl DhtState { let routing_table = routing_table.unwrap_or_else(|| RoutingTable::new(id)); Self { id, - bootstrapped: Default::default(), next_transaction_id: AtomicU16::new(0), inflight_by_transaction_id: Default::default(), routing_table: RwLock::new(routing_table), From 69b9918e4fefce44d4b55a5b1badc5bcfac264fa Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Wed, 29 Nov 2023 19:34:29 +0000 Subject: [PATCH 22/51] Going so far again... --- TODO.md | 3 +- crates/dht/src/dht.rs | 389 +++++++++++++++++--------------- crates/dht/src/routing_table.rs | 2 +- 3 files changed, 204 insertions(+), 190 deletions(-) diff --git a/TODO.md b/TODO.md index b21b187..7214b9f 100644 --- a/TODO.md +++ b/TODO.md @@ -14,9 +14,10 @@ - [x] pause/unpause - [x] remove including from disk - [ ] DHT + - [ ] bootstrapping is lame - [x] many nodes in "Unknown" status, do smth about it - [x] for torrents with a few seeds might be cool to re-query DHT once in a while. - - [ ] don't leak memory when deleting torrents (i.e. remove torrent information (seen peers etc) once the torrent is deleted) + - [x] don't leak memory when deleting torrents (i.e. remove torrent information (seen peers etc) once the torrent is deleted) - [ ] Buckets that have not been changed in 15 minutes should be "refreshed." (per RFC) - [x] it's sending many requests now way too fast, locks up Mac OS UI annoyingly - [ ] After the search is exhausted, the client then inserts the peer contact information for itself onto the responding nodes with IDs closest to the infohash of the torrent. diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index ae1fbbc..f4b6695 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -1,9 +1,8 @@ use std::{ - any, cmp::Reverse, net::SocketAddr, sync::{ - atomic::{AtomicBool, AtomicU16, Ordering}, + atomic::{AtomicU16, Ordering}, Arc, }, task::Poll, @@ -12,8 +11,8 @@ use std::{ use crate::{ bprotocol::{ - self, CompactNodeInfo, CompactPeerInfo, ErrorDescription, FindNodeRequest, GetPeersRequest, - Message, MessageKind, Node, PingRequest, Response, + self, CompactNodeInfo, ErrorDescription, FindNodeRequest, GetPeersRequest, Message, + MessageKind, Node, PingRequest, Response, }, routing_table::{InsertResult, RoutingTable}, REQUERY_INTERVAL, RESPONSE_TIMEOUT, @@ -22,23 +21,18 @@ use anyhow::{bail, Context}; use backoff::{backoff::Backoff, ExponentialBackoffBuilder}; use bencode::ByteString; use dashmap::DashMap; -use futures::{ - future::BoxFuture, stream::FuturesUnordered, FutureExt, Stream, StreamExt, TryFutureExt, -}; -use indexmap::IndexSet; +use futures::{stream::FuturesUnordered, Stream, StreamExt, TryFutureExt}; + use leaky_bucket::RateLimiter; use librqbit_core::{id20::Id20, peer_id::generate_peer_id, spawn_utils::spawn}; use parking_lot::RwLock; -use rand::Rng; + use serde::Serialize; use tokio::{ net::UdpSocket, - sync::{ - mpsc::{channel, unbounded_channel, Sender, UnboundedReceiver, UnboundedSender}, - Notify, - }, + sync::mpsc::{channel, unbounded_channel, Sender, UnboundedReceiver, UnboundedSender}, }; -use tokio_stream::wrappers::{errors::BroadcastStreamRecvError, BroadcastStream}; + use tracing::{debug, debug_span, error, error_span, info, trace, warn, Instrument}; #[derive(Debug, Serialize)] @@ -86,34 +80,101 @@ fn make_rate_limiter() -> RateLimiter { .build() } -struct RequestPeers { +trait RecursiveRequestCallbacks: Sized + Send + Sync + 'static { + fn on_request_start( + &self, + req: &Arc>, + target_node: Id20, + addr: SocketAddr, + ); + fn on_request_end( + &self, + req: &Arc>, + target_node: Id20, + addr: SocketAddr, + resp: &anyhow::Result, + ); +} + +struct RecursiveRequestCallbacksGetPeers {} +impl RecursiveRequestCallbacks for RecursiveRequestCallbacksGetPeers { + fn on_request_start(&self, _: &Arc>, _: Id20, _: SocketAddr) {} + + fn on_request_end( + &self, + _: &Arc>, + _: Id20, + _: SocketAddr, + _: &anyhow::Result, + ) { + } +} + +struct RecursiveRequestCallbacksFindNodes {} +impl RecursiveRequestCallbacks for RecursiveRequestCallbacksFindNodes { + fn on_request_start( + &self, + req: &Arc>, + target_node: Id20, + addr: SocketAddr, + ) { + match req.dht.routing_table_add_node(target_node, addr) { + InsertResult::WasExisting | InsertResult::ReplacedBad(_) | InsertResult::Added => { + req.dht + .routing_table + .write() + .mark_outgoing_request(&target_node); + } + InsertResult::Ignored => {} + } + } + + fn on_request_end( + &self, + req: &Arc>, + target_node: Id20, + _addr: SocketAddr, + resp: &anyhow::Result, + ) { + let mut table = req.dht.routing_table.write(); + if resp.is_ok() { + table.mark_response(&target_node); + } else { + table.mark_error(&target_node); + } + } +} + +struct RecursiveRequest { info_hash: Id20, + request: Request, dht: Arc, useful_nodes: RwLock>, - peer_tx: tokio::sync::mpsc::UnboundedSender, - node_tx: tokio::sync::mpsc::UnboundedSender, + // peer_tx: tokio::sync::mpsc::UnboundedSender, + // node_tx: tokio::sync::mpsc::UnboundedSender<(Option, SocketAddr)>, + callbacks: C, } struct RequestPeersStream { rx: tokio::sync::mpsc::UnboundedReceiver, cancel_join_handle: tokio::task::JoinHandle<()>, - request_peers: Arc, } impl RequestPeersStream { fn new(dht: Arc, info_hash: Id20) -> Self { let (peer_tx, peer_rx) = unbounded_channel(); let (node_tx, node_rx) = unbounded_channel(); - let rp = Arc::new(RequestPeers { + let rp = Arc::new(RecursiveRequest { info_hash, + request: Request::GetPeers(info_hash), dht, useful_nodes: RwLock::new(Vec::new()), - peer_tx, - node_tx, + // peer_tx, + // node_tx, + callbacks: RecursiveRequestCallbacksGetPeers {}, }); - let join_handle = rp.clone().request_peers_forever(node_rx); + let join_handle = rp.clone().request_peers_forever(node_rx, node_tx, peer_tx); Self { - request_peers: rp, rx: peer_rx, cancel_join_handle: join_handle, } @@ -137,30 +198,73 @@ impl Stream for RequestPeersStream { } } -// So what do I want to do? -// Every 60 seconds, we add root nodes to the queue. -// We poll the following things: -// 1. The queue. If got item from there, insert into the futures unordered. -// 2. Futures unordered. -// If received, send to the resulting one. -struct Tmp {} +impl RecursiveRequest { + async fn find_node( + dht: Arc, + target: Id20, + root_addrs: impl Iterator, + ) -> anyhow::Result<()> { + let (peer_tx, peer_rx) = unbounded_channel(); + drop(peer_rx); -impl RequestPeers { + let (node_tx, mut node_rx) = unbounded_channel(); + let req = Arc::new(RecursiveRequest { + info_hash: target, + request: Request::FindNode(target), + dht, + useful_nodes: RwLock::new(Vec::new()), + // peer_tx: unbounded_channel().0, + // node_tx, + callbacks: RecursiveRequestCallbacksFindNodes {}, + }); + + let mut futs = FuturesUnordered::new(); + + for addr in root_addrs { + node_tx.send((None, addr)).unwrap(); + } + + loop { + tokio::select! { + r = node_rx.recv() => { + let (id, addr) = r.unwrap(); + futs.push( + req.request_one(id, addr, node_tx.clone(), peer_tx.clone()) + .instrument( + error_span!("find_node", target=format!("{target:?}"), addr=addr.to_string()) + ) + ) + }, + Some(f) = futs.next(), if !futs.is_empty() => { + if let Err(e) = f { + error!("error: {e:?}"); + } + } + } + } + Ok(()) + } +} + +impl RecursiveRequest { fn request_peers_forever( self: Arc, - mut node_rx: tokio::sync::mpsc::UnboundedReceiver, + mut node_rx: tokio::sync::mpsc::UnboundedReceiver<(Option, SocketAddr)>, + node_tx: tokio::sync::mpsc::UnboundedSender<(Option, SocketAddr)>, + peer_tx: tokio::sync::mpsc::UnboundedSender, ) -> tokio::task::JoinHandle<()> { spawn( - error_span!("request_peers", info_hash = format!("{:?}", self.info_hash)), + error_span!("get_peers", info_hash = format!("{:?}", self.info_hash)), async move { // Looper adds root nodes to the queue every 60 seconds. let looper = { let this = self.clone(); + let node_tx = node_tx.clone(); async move { let mut iteration = 0; loop { debug!("iteration {}", iteration); - let sleep = match this.get_peers_root() { + let sleep = match this.get_peers_root(&node_tx) { Ok(0) => Duration::from_secs(1), Ok(n) if n < 8 => REQUERY_INTERVAL / 2, Ok(_) => REQUERY_INTERVAL, @@ -180,9 +284,9 @@ impl RequestPeers { loop { tokio::select! { addr = node_rx.recv() => { - let addr = addr.unwrap(); + let (id, addr) = addr.unwrap(); futs.push( - self.get_peers_one(addr) + self.request_one(id, addr, node_tx.clone(), peer_tx.clone()) .map_err(|e| debug!("error: {e:?}")) .instrument(error_span!("addr", addr=addr.to_string())) ); @@ -195,43 +299,12 @@ impl RequestPeers { ) } - async fn get_peers_one<'a>(self: &'a Arc, addr: SocketAddr) -> anyhow::Result<()> { - let response = self - .dht - .request(Request::GetPeers(self.info_hash), addr) - .await - .map_err(|e| { - self.mark_node_error(addr); - e - })?; - self.mark_node_responded(addr, &response); - let response = match response { - ResponseOrError::Response(r) => r, - ResponseOrError::Error(e) => { - bail!("error response: {:?}", e) - } - }; - - if let Some(peers) = response.values { - for peer in peers { - self.peer_tx.send(SocketAddr::V4(peer.addr))?; - } - } - - if let Some(nodes) = response.nodes { - for node in nodes.nodes { - let addr = SocketAddr::V4(node.addr); - if self.should_request_node(node.id, addr) { - self.node_tx.send(addr)?; - } - } - } - Ok(()) - } - - fn get_peers_root(self: &Arc) -> anyhow::Result { + fn get_peers_root( + self: &Arc, + node_tx: &UnboundedSender<(Option, SocketAddr)>, + ) -> anyhow::Result { let mut count = 0; - for (_, addr) in self + for (id, addr) in self .dht .routing_table .read() @@ -241,10 +314,57 @@ impl RequestPeers { .take(8) { count += 1; - self.node_tx.send(addr)?; + node_tx.send((Some(id), addr))?; } Ok(count) } +} + +impl RecursiveRequest { + async fn request_one<'a>( + self: &'a Arc, + id: Option, + addr: SocketAddr, + node_tx: UnboundedSender<(Option, SocketAddr)>, + peer_tx: UnboundedSender, + ) -> anyhow::Result<()> { + if let Some(id) = id { + self.callbacks.on_request_start(self, id, addr); + } + + let response = self.dht.request(self.request, addr).await.map(|r| { + self.mark_node_responded(addr, &r); + r + }); + if let Some(id) = id { + self.callbacks.on_request_end(self, id, addr, &response); + } + + let response = match self.dht.request(self.request, addr).await { + Ok(ResponseOrError::Response(r)) => r, + Ok(ResponseOrError::Error(e)) => bail!("error response: {:?}", e), + Err(e) => { + self.mark_node_error(addr); + return Err(e); + } + }; + + if let Some(peers) = response.values { + for peer in peers { + peer_tx.send(SocketAddr::V4(peer.addr))?; + } + } + + if let Some(nodes) = response.nodes { + for node in nodes.nodes { + let addr = SocketAddr::V4(node.addr); + if self.should_request_node(node.id, addr) { + node_tx.send((Some(node.id), addr))?; + } + } + } + Ok(()) + } fn mark_node_error(&self, addr: SocketAddr) -> bool { self.useful_nodes @@ -351,22 +471,6 @@ impl DhtState { } } - fn spawn_request(self: &Arc, request: Request, addr: SocketAddr) { - let this = self.clone(); - spawn( - error_span!(parent: None, "dht_spawn_request", addr=addr.to_string(), request=format!("{:?}", request)), - async move { - match this.send_request_and_handle_response(request, addr).await { - Ok(_) => {} - Err(e) => { - debug!("error: {:?}", e); - } - }; - Ok(()) - }, - ); - } - async fn send_request_and_handle_response( self: &Arc, request: Request, @@ -455,15 +559,11 @@ impl DhtState { self.routing_table.write().mark_response(&response.id); match request { Request::FindNode(id) => { - let nodes = response - .nodes - .ok_or_else(|| anyhow::anyhow!("expected nodes for find_node requests"))?; - self.on_found_nodes(response.id, addr, id, nodes) + todo!() } Request::Ping => Ok(()), - Request::GetPeers(info_hash) => { + Request::GetPeers(_info_hash) => { todo!() - // self.on_found_peers_or_nodes(response.id, addr, info_hash, response) } } } @@ -613,62 +713,6 @@ impl DhtState { } } - fn send_request_if_not_yet( - self: &Arc, - target_node: Id20, - request: Request, - addr: SocketAddr, - ) -> anyhow::Result<()> { - let this = self.clone(); - let fut = async move { - this.routing_table - .write() - .mark_outgoing_request(&target_node); - - let resp = this.request(request, addr).await; - match resp { - Ok(ResponseOrError::Response(response)) => { - this.routing_table.write().mark_response(&target_node); - match this.on_response(addr, request, response) { - Ok(()) => {} - Err(e) => { - warn!("error in on_response: {:?}", e); - } - } - } - Ok(ResponseOrError::Error(e)) => { - this.routing_table.write().mark_response(&target_node); - debug!("error response: {:?}", e); - } - Err(e) => { - this.routing_table.write().mark_error(&target_node); - debug!("error: {:?}", e); - } - }; - Ok(()) - }; - - spawn( - error_span!( - parent: None, - "dht_request", - addr = addr.to_string(), - request = format!("{:?}", request), - ), - fut, - ); - Ok(()) - } - - fn send_find_node_if_not_yet( - self: &Arc, - search_id: Id20, - target_node: Id20, - addr: SocketAddr, - ) -> anyhow::Result<()> { - self.send_request_if_not_yet(target_node, Request::FindNode(search_id), addr) - } - fn routing_table_add_node(self: &Arc, id: Id20, addr: SocketAddr) -> InsertResult { let mut questionable_nodes = Vec::new(); let res = self.routing_table.write().add_node(id, addr, |addr| { @@ -676,30 +720,15 @@ impl DhtState { true }); for addr in questionable_nodes { - self.spawn_request(Request::Ping, addr); + let (_, req) = self.create_request(Request::Ping); + let _ = self.sender.send(WorkerSendRequest { + our_tid: None, + message: req, + addr, + }); } res } - - fn on_found_nodes( - self: &Arc, - source: Id20, - source_addr: SocketAddr, - target: Id20, - nodes: CompactNodeInfo, - ) -> anyhow::Result<()> { - self.routing_table_add_node(source, source_addr); - for node in nodes.nodes { - match self.routing_table_add_node(node.id, node.addr.into()) { - InsertResult::ReplacedBad(_) | InsertResult::Added => { - // recursively find nodes closest to us until we can't find more. - self.send_find_node_if_not_yet(target, source, source_addr)?; - } - _ => {} - }; - } - Ok(()) - } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -938,22 +967,6 @@ impl DhtWorker { } } -struct PeerStream { - info_hash: Id20, - state: Arc, -} - -impl Stream for PeerStream { - type Item = SocketAddr; - - fn poll_next( - mut self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> Poll> { - todo!() - } -} - #[derive(Default)] pub struct DhtConfig { pub peer_id: Option, diff --git a/crates/dht/src/routing_table.rs b/crates/dht/src/routing_table.rs index 6cfb6da..6da6ce7 100644 --- a/crates/dht/src/routing_table.rs +++ b/crates/dht/src/routing_table.rs @@ -7,7 +7,7 @@ use serde::{ }; use tracing::debug; -use crate::{INACTIVITY_TIMEOUT, RESPONSE_TIMEOUT}; +use crate::{INACTIVITY_TIMEOUT}; #[derive(Debug, Clone, Serialize, Deserialize)] enum BucketTreeNodeData { From aa2a41a53cbb9b39ca6b3e4b55fb346499bf7ae7 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Wed, 29 Nov 2023 23:12:20 +0000 Subject: [PATCH 23/51] Fixing up bugs, refactored DHT works alright now --- crates/dht/examples/dht.rs | 1 + crates/dht/src/dht.rs | 251 +++++++++++++------------------------ 2 files changed, 90 insertions(+), 162 deletions(-) diff --git a/crates/dht/examples/dht.rs b/crates/dht/examples/dht.rs index dc0cc4f..883ef79 100644 --- a/crates/dht/examples/dht.rs +++ b/crates/dht/examples/dht.rs @@ -36,6 +36,7 @@ async fn main() -> anyhow::Result<()> { let mut f = std::fs::OpenOptions::new() .create(true) .write(true) + .truncate(true) .open(filename) .unwrap(); serde_json::to_writer_pretty(&mut f, r).unwrap(); diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index f4b6695..cb7a756 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -81,15 +81,10 @@ fn make_rate_limiter() -> RateLimiter { } trait RecursiveRequestCallbacks: Sized + Send + Sync + 'static { - fn on_request_start( - &self, - req: &Arc>, - target_node: Id20, - addr: SocketAddr, - ); + fn on_request_start(&self, req: &RecursiveRequest, target_node: Id20, addr: SocketAddr); fn on_request_end( &self, - req: &Arc>, + req: &RecursiveRequest, target_node: Id20, addr: SocketAddr, resp: &anyhow::Result, @@ -98,11 +93,11 @@ trait RecursiveRequestCallbacks: Sized + Send + Sync + 'static { struct RecursiveRequestCallbacksGetPeers {} impl RecursiveRequestCallbacks for RecursiveRequestCallbacksGetPeers { - fn on_request_start(&self, _: &Arc>, _: Id20, _: SocketAddr) {} + fn on_request_start(&self, _: &RecursiveRequest, _: Id20, _: SocketAddr) {} fn on_request_end( &self, - _: &Arc>, + _: &RecursiveRequest, _: Id20, _: SocketAddr, _: &anyhow::Result, @@ -112,12 +107,7 @@ impl RecursiveRequestCallbacks for RecursiveRequestCallbacksGetPeers { struct RecursiveRequestCallbacksFindNodes {} impl RecursiveRequestCallbacks for RecursiveRequestCallbacksFindNodes { - fn on_request_start( - &self, - req: &Arc>, - target_node: Id20, - addr: SocketAddr, - ) { + fn on_request_start(&self, req: &RecursiveRequest, target_node: Id20, addr: SocketAddr) { match req.dht.routing_table_add_node(target_node, addr) { InsertResult::WasExisting | InsertResult::ReplacedBad(_) | InsertResult::Added => { req.dht @@ -131,7 +121,7 @@ impl RecursiveRequestCallbacks for RecursiveRequestCallbacksFindNodes { fn on_request_end( &self, - req: &Arc>, + req: &RecursiveRequest, target_node: Id20, _addr: SocketAddr, resp: &anyhow::Result, @@ -150,8 +140,8 @@ struct RecursiveRequest { request: Request, dht: Arc, useful_nodes: RwLock>, - // peer_tx: tokio::sync::mpsc::UnboundedSender, - // node_tx: tokio::sync::mpsc::UnboundedSender<(Option, SocketAddr)>, + peer_tx: tokio::sync::mpsc::UnboundedSender, + node_tx: tokio::sync::mpsc::UnboundedSender<(Option, SocketAddr)>, callbacks: C, } @@ -169,11 +159,11 @@ impl RequestPeersStream { request: Request::GetPeers(info_hash), dht, useful_nodes: RwLock::new(Vec::new()), - // peer_tx, - // node_tx, + peer_tx, + node_tx, callbacks: RecursiveRequestCallbacksGetPeers {}, }); - let join_handle = rp.clone().request_peers_forever(node_rx, node_tx, peer_tx); + let join_handle = rp.request_peers_forever(node_rx); Self { rx: peer_rx, cancel_join_handle: join_handle, @@ -199,77 +189,101 @@ impl Stream for RequestPeersStream { } impl RecursiveRequest { - async fn find_node( - dht: Arc, - target: Id20, - root_addrs: impl Iterator, - ) -> anyhow::Result<()> { - let (peer_tx, peer_rx) = unbounded_channel(); - drop(peer_rx); - + async fn bootstrap(dht: Arc, target: Id20, hostname: &str) -> anyhow::Result<()> { + let addrs = tokio::net::lookup_host(hostname) + .await + .with_context(|| format!("error looking up {}", hostname))?; let (node_tx, mut node_rx) = unbounded_channel(); - let req = Arc::new(RecursiveRequest { + let req = RecursiveRequest { info_hash: target, request: Request::FindNode(target), dht, useful_nodes: RwLock::new(Vec::new()), - // peer_tx: unbounded_channel().0, - // node_tx, + peer_tx: unbounded_channel().0, + node_tx, callbacks: RecursiveRequestCallbacksFindNodes {}, - }); + }; + + let request_one = |id, addr| { + req.request_one(id, addr) + .map_err(|e| { + debug!("error: {e:?}"); + e + }) + .instrument(error_span!( + "find_node", + target = format!("{target:?}"), + addr = addr.to_string() + )) + }; let mut futs = FuturesUnordered::new(); - for addr in root_addrs { - node_tx.send((None, addr)).unwrap(); + let mut initial_addrs = 0; + for addr in addrs { + futs.push(request_one(None, addr)); + initial_addrs += 1; } + let mut successes = 0; + let mut errors = 0; + loop { tokio::select! { + biased; + r = node_rx.recv() => { let (id, addr) = r.unwrap(); - futs.push( - req.request_one(id, addr, node_tx.clone(), peer_tx.clone()) - .instrument( - error_span!("find_node", target=format!("{target:?}"), addr=addr.to_string()) - ) - ) + futs.push(request_one(id, addr)) }, - Some(f) = futs.next(), if !futs.is_empty() => { - if let Err(e) = f { - error!("error: {e:?}"); + f = futs.next() => { + let f = match f { + Some(f) => f, + None => { + // find_node recursion finished. + break; + } + }; + if f.is_ok() { + successes += 1; + } else { + errors += 1; } } } } + if successes == 0 { + bail!("no successful lookups, errors = {errors}"); + } + debug!( + "finished, successes = {successes}, errors = {errors}, initial_addrs = {initial_addrs}" + ); Ok(()) } } impl RecursiveRequest { fn request_peers_forever( - self: Arc, + self: &Arc, mut node_rx: tokio::sync::mpsc::UnboundedReceiver<(Option, SocketAddr)>, - node_tx: tokio::sync::mpsc::UnboundedSender<(Option, SocketAddr)>, - peer_tx: tokio::sync::mpsc::UnboundedSender, ) -> tokio::task::JoinHandle<()> { + let this = self.clone(); spawn( - error_span!("get_peers", info_hash = format!("{:?}", self.info_hash)), + error_span!(parent: None, "get_peers", info_hash = format!("{:?}", self.info_hash)), async move { + let this = &this; // Looper adds root nodes to the queue every 60 seconds. let looper = { - let this = self.clone(); - let node_tx = node_tx.clone(); async move { let mut iteration = 0; loop { debug!("iteration {}", iteration); - let sleep = match this.get_peers_root(&node_tx) { + let sleep = match this.get_peers_root() { Ok(0) => Duration::from_secs(1), Ok(n) if n < 8 => REQUERY_INTERVAL / 2, Ok(_) => REQUERY_INTERVAL, Err(e) => { - error!("error: {e:?}"); + error!("error in get_peers_root(): {e:?}"); return Err::<(), anyhow::Error>(e); } }; @@ -286,7 +300,7 @@ impl RecursiveRequest { addr = node_rx.recv() => { let (id, addr) = addr.unwrap(); futs.push( - self.request_one(id, addr, node_tx.clone(), peer_tx.clone()) + this.request_one(id, addr) .map_err(|e| debug!("error: {e:?}")) .instrument(error_span!("addr", addr=addr.to_string())) ); @@ -299,10 +313,7 @@ impl RecursiveRequest { ) } - fn get_peers_root( - self: &Arc, - node_tx: &UnboundedSender<(Option, SocketAddr)>, - ) -> anyhow::Result { + fn get_peers_root(&self) -> anyhow::Result { let mut count = 0; for (id, addr) in self .dht @@ -314,20 +325,14 @@ impl RecursiveRequest { .take(8) { count += 1; - node_tx.send((Some(id), addr))?; + self.node_tx.send((Some(id), addr))?; } Ok(count) } } impl RecursiveRequest { - async fn request_one<'a>( - self: &'a Arc, - id: Option, - addr: SocketAddr, - node_tx: UnboundedSender<(Option, SocketAddr)>, - peer_tx: UnboundedSender, - ) -> anyhow::Result<()> { + async fn request_one(&self, id: Option, addr: SocketAddr) -> anyhow::Result<()> { if let Some(id) = id { self.callbacks.on_request_start(self, id, addr); } @@ -348,18 +353,26 @@ impl RecursiveRequest { return Err(e); } }; + trace!("received {response:?}"); if let Some(peers) = response.values { for peer in peers { - peer_tx.send(SocketAddr::V4(peer.addr))?; + self.peer_tx.send(SocketAddr::V4(peer.addr))?; } } if let Some(nodes) = response.nodes { for node in nodes.nodes { let addr = SocketAddr::V4(node.addr); - if self.should_request_node(node.id, addr) { - node_tx.send((Some(node.id), addr))?; + let should_request = self.should_request_node(node.id, addr); + trace!( + "should_request={}, id={:?}, addr={}", + should_request, + node.id, + addr + ); + if should_request { + self.node_tx.send((Some(node.id), addr))?; } } } @@ -471,20 +484,6 @@ impl DhtState { } } - async fn send_request_and_handle_response( - self: &Arc, - request: Request, - addr: SocketAddr, - ) -> anyhow::Result<()> { - let resp = self.request(request, addr).await?; - match resp { - ResponseOrError::Response(r) => self.on_response(addr, request, r), - ResponseOrError::Error(e) => { - bail!("received error: {:?}", e); - } - } - } - async fn request(&self, request: Request, addr: SocketAddr) -> anyhow::Result { self.rate_limiter.acquire_one().await; let (tid, message) = self.create_request(request); @@ -550,24 +549,6 @@ impl DhtState { (transaction_id, message) } - fn on_response( - self: &Arc, - addr: SocketAddr, - request: Request, - response: Response, - ) -> anyhow::Result<()> { - self.routing_table.write().mark_response(&response.id); - match request { - Request::FindNode(id) => { - todo!() - } - Request::Ping => Ok(()), - Request::GetPeers(_info_hash) => { - todo!() - } - } - } - fn on_received_message( self: &Arc, msg: Message, @@ -615,7 +596,7 @@ impl DhtState { match request.done.send(Ok(response_or_error)) { Ok(_) => {} Err(e) => { - warn!( + debug!( "recieved response, but the receiver task is closed: {:?}", e ); @@ -746,68 +727,22 @@ enum ResponseOrError { struct DhtWorker { socket: UdpSocket, - peer_id: Id20, - state: Arc, + dht: Arc, } impl DhtWorker { fn on_send_error(&self, tid: u16, addr: SocketAddr, err: anyhow::Error) { if let Some((_, OutstandingRequest { done })) = - self.state.inflight_by_transaction_id.remove(&(tid, addr)) + self.dht.inflight_by_transaction_id.remove(&(tid, addr)) { let _ = done.send(Err(err)).is_err(); }; } - async fn bootstrap_one_ip_with_backoff(&self, addr: SocketAddr) -> anyhow::Result<()> { - let mut backoff = ExponentialBackoffBuilder::new() - .with_initial_interval(Duration::from_secs(10)) - .with_multiplier(1.5) - .with_max_interval(Duration::from_secs(60)) - .with_max_elapsed_time(Some(Duration::from_secs(86400))) - .build(); - - loop { - let res = self - .state - .send_request_and_handle_response(Request::FindNode(self.peer_id), addr) - .await; - match res { - Ok(r) => return Ok(r), - Err(e) => { - debug!("error: {:?}", e); - if let Some(backoff) = backoff.next_backoff() { - tokio::time::sleep(backoff).await; - continue; - } - bail!("given up bootstrapping, timed out") - } - } - } - } - async fn bootstrap_hostname(&self, hostname: &str) -> anyhow::Result<()> { - let addrs = tokio::net::lookup_host(hostname) + RecursiveRequest::bootstrap(self.dht.clone(), self.dht.id, hostname) + .instrument(error_span!("bootstrap", hostname = hostname)) .await - .with_context(|| format!("error looking up {}", hostname))?; - let mut futs = FuturesUnordered::new(); - for addr in addrs { - futs.push( - self.bootstrap_one_ip_with_backoff(addr) - .instrument(error_span!("addr", addr = addr.to_string())), - ); - } - let requests = futs.len(); - let mut successes = 0; - while let Some(resp) = futs.next().await { - if resp.is_ok() { - successes += 1 - }; - } - if successes == 0 { - bail!("none of the {} bootstrap requests succeded", requests); - } - Ok(()) } async fn bootstrap_hostname_with_backoff(&self, addr: &str) -> anyhow::Result<()> { @@ -838,11 +773,7 @@ impl DhtWorker { let mut futs = FuturesUnordered::new(); for addr in bootstrap_addrs.iter() { - let this = &self; - futs.push( - this.bootstrap_hostname_with_backoff(addr) - .instrument(error_span!("bootstrap", hostname = addr)), - ); + futs.push(self.bootstrap_hostname_with_backoff(addr)); } let mut successes = 0; while let Some(resp) = futs.next().await { @@ -937,7 +868,7 @@ impl DhtWorker { let this = &self; async move { while let Some((response, addr)) = out_rx.recv().await { - if let Err(e) = this.state.on_received_message(response, addr) { + if let Err(e) = this.dht.on_received_message(response, addr) { debug!("error in on_response, addr={:?}: {}", addr, e) } } @@ -1011,11 +942,7 @@ impl DhtState { spawn(error_span!("dht"), { let state = state.clone(); async move { - let worker = DhtWorker { - socket, - peer_id, - state, - }; + let worker = DhtWorker { socket, dht: state }; worker.start(in_rx, &bootstrap_addrs).await?; Ok(()) } From a5ae2988b829a2baacafba8eba150572bd60fedb Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Wed, 29 Nov 2023 23:43:53 +0000 Subject: [PATCH 24/51] Downgraded a bunch of messages from debug to trace --- TODO.md | 8 ++++---- crates/dht/src/dht.rs | 2 +- crates/dht/src/persistence.rs | 2 +- crates/dht/src/routing_table.rs | 2 +- crates/librqbit/src/file_ops.rs | 20 +++++++++++++------ crates/librqbit/src/peer_connection.rs | 10 +++++----- crates/librqbit/src/peer_info_reader/mod.rs | 4 ++-- crates/librqbit/src/torrent_state/live/mod.rs | 18 ++++++++--------- crates/librqbit_core/src/spawn_utils.rs | 4 ++-- 9 files changed, 39 insertions(+), 31 deletions(-) diff --git a/TODO.md b/TODO.md index 7214b9f..fed1d9b 100644 --- a/TODO.md +++ b/TODO.md @@ -1,4 +1,4 @@ -- [ ] when we have the whole torrent, there's no point talking to peers that also have the whole torrent and keep reconnecting to them. +- [x] when we have the whole torrent, there's no point talking to peers that also have the whole torrent and keep reconnecting to them. - [ ] per-file stats - [x (partial)] per-peer stats - [x] use some concurrent hashmap e.g. flurry or dashmap @@ -8,13 +8,13 @@ - [x] initializing/checking - [x] blocks the whole process. Need to break it up. On slower devices (rpi) just hangs for a good while - [x] checking torrents should be visible right away -- [ ] server persistence - - [ ] it would be nice to restart the server and keep the state +- [x] server persistence + - [x] it would be nice to restart the server and keep the state - [x] torrent actions - [x] pause/unpause - [x] remove including from disk - [ ] DHT - - [ ] bootstrapping is lame + - [x] bootstrapping is lame - [x] many nodes in "Unknown" status, do smth about it - [x] for torrents with a few seeds might be cool to re-query DHT once in a while. - [x] don't leak memory when deleting torrents (i.e. remove torrent information (seen peers etc) once the torrent is deleted) diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index cb7a756..97a2101 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -277,7 +277,7 @@ impl RecursiveRequest { async move { let mut iteration = 0; loop { - debug!("iteration {}", iteration); + trace!("iteration {}", iteration); let sleep = match this.get_peers_root() { Ok(0) => Duration::from_secs(1), Ok(n) if n < 8 => REQUERY_INTERVAL / 2, diff --git a/crates/dht/src/persistence.rs b/crates/dht/src/persistence.rs index bf91903..f74b89f 100644 --- a/crates/dht/src/persistence.rs +++ b/crates/dht/src/persistence.rs @@ -44,7 +44,7 @@ fn dump_dht(dht: &Dht, filename: &Path, tempfile_name: &Path) -> anyhow::Result< .with_routing_table(|r| serde_json::to_writer(&mut file, &DhtSerialize { addr, table: r })) { Ok(_) => { - debug!("dumped DHT to {:?}", &tempfile_name); + trace!("dumped DHT to {:?}", &tempfile_name); } Err(e) => { return Err(e).with_context(|| { diff --git a/crates/dht/src/routing_table.rs b/crates/dht/src/routing_table.rs index 6da6ce7..1e21512 100644 --- a/crates/dht/src/routing_table.rs +++ b/crates/dht/src/routing_table.rs @@ -7,7 +7,7 @@ use serde::{ }; use tracing::debug; -use crate::{INACTIVITY_TIMEOUT}; +use crate::INACTIVITY_TIMEOUT; #[derive(Debug, Clone, Serialize, Deserialize)] enum BucketTreeNodeData { diff --git a/crates/librqbit/src/file_ops.rs b/crates/librqbit/src/file_ops.rs index aee6625..80cb07c 100644 --- a/crates/librqbit/src/file_ops.rs +++ b/crates/librqbit/src/file_ops.rs @@ -241,9 +241,13 @@ impl<'a, Sha1Impl: ISha1> FileOps<'a, Sha1Impl> { let to_read_in_file = std::cmp::min(file_remaining_len, piece_remaining_bytes as u64) as usize; let mut file_g = self.files[file_idx].lock(); - debug!( + trace!( "piece={}, handle={}, file_idx={}, seeking to {}. Last received chunk: {:?}", - piece_index, who_sent, file_idx, absolute_offset, &last_received_chunk + piece_index, + who_sent, + file_idx, + absolute_offset, + &last_received_chunk ); file_g .seek(SeekFrom::Start(absolute_offset)) @@ -269,7 +273,7 @@ impl<'a, Sha1Impl: ISha1> FileOps<'a, Sha1Impl> { match self.torrent.compare_hash(piece_index.get(), h.finish()) { Some(true) => { - debug!("piece={} hash matches", piece_index); + trace!("piece={} hash matches", piece_index); Ok(true) } Some(false) => { @@ -305,9 +309,13 @@ impl<'a, Sha1Impl: ISha1> FileOps<'a, Sha1Impl> { let to_read_in_file = std::cmp::min(file_remaining_len, buf.len() as u64) as usize; let mut file_g = self.files[file_idx].lock(); - debug!( + trace!( "piece={}, handle={}, file_idx={}, seeking to {}. To read chunk: {:?}", - chunk_info.piece_index, who_sent, file_idx, absolute_offset, &chunk_info + chunk_info.piece_index, + who_sent, + file_idx, + absolute_offset, + &chunk_info ); file_g .seek(SeekFrom::Start(absolute_offset)) @@ -354,7 +362,7 @@ impl<'a, Sha1Impl: ISha1> FileOps<'a, Sha1Impl> { let to_write = std::cmp::min(buf.len(), remaining_len as usize); let mut file_g = self.files[file_idx].lock(); - debug!( + trace!( "piece={}, chunk={:?}, handle={}, begin={}, file={}, writing {} bytes at {}", chunk_info.piece_index, chunk_info, diff --git a/crates/librqbit/src/peer_connection.rs b/crates/librqbit/src/peer_connection.rs index 289e061..84b3b89 100644 --- a/crates/librqbit/src/peer_connection.rs +++ b/crates/librqbit/src/peer_connection.rs @@ -13,7 +13,7 @@ use peer_binary_protocol::{ MessageOwned, PIECE_MESSAGE_DEFAULT_LEN, }; use tokio::time::timeout; -use tracing::{debug, trace}; +use tracing::trace; use crate::spawn_utils::BlockingSpawner; @@ -155,7 +155,7 @@ impl PeerConnection { let (h, size) = Handshake::deserialize(&read_buf[..read_so_far]) .map_err(|e| anyhow::anyhow!("error deserializing handshake: {:?}", e))?; - debug!("connected: id={:?}", try_decode_peer_id(Id20(h.peer_id))); + trace!("connected: id={:?}", try_decode_peer_id(Id20(h.peer_id))); if h.info_hash != self.info_hash.0 { anyhow::bail!("info hash does not match"); } @@ -210,7 +210,7 @@ impl PeerConnection { with_timeout(rwtimeout, write_half.write_all(&write_buf[..len])) .await .context("error writing bitfield to peer")?; - debug!("sent bitfield"); + trace!("sent bitfield"); } loop { @@ -249,7 +249,7 @@ impl PeerConnection { } }; - debug!("sending: {:?}, length={}", &req, len); + trace!("sending: {:?}, length={}", &req, len); with_timeout(rwtimeout, write_half.write_all(&write_buf[..len])) .await @@ -290,7 +290,7 @@ impl PeerConnection { r = reader => {r} r = writer => {r} }; - debug!("either reader or writer are done, exiting"); + trace!("either reader or writer are done, exiting"); r } } diff --git a/crates/librqbit/src/peer_info_reader/mod.rs b/crates/librqbit/src/peer_info_reader/mod.rs index a205a0d..6955aab 100644 --- a/crates/librqbit/src/peer_info_reader/mod.rs +++ b/crates/librqbit/src/peer_info_reader/mod.rs @@ -15,7 +15,7 @@ use peer_binary_protocol::{ }; use sha1w::{ISha1, Sha1}; use tokio::sync::mpsc::UnboundedSender; -use tracing::debug; +use tracing::trace; use crate::{ peer_connection::{ @@ -153,7 +153,7 @@ impl PeerConnectionHandler for Handler { } fn on_received_message(&self, msg: Message>) -> anyhow::Result<()> { - debug!("{}: received message: {:?}", self.addr, msg); + trace!("{}: received message: {:?}", self.addr, msg); if let Message::Extended(ExtendedMessage::UtMetadata(UtMetadata::Data { piece, diff --git a/crates/librqbit/src/torrent_state/live/mod.rs b/crates/librqbit/src/torrent_state/live/mod.rs index 5aba636..47eb680 100644 --- a/crates/librqbit/src/torrent_state/live/mod.rs +++ b/crates/librqbit/src/torrent_state/live/mod.rs @@ -510,7 +510,7 @@ impl TorrentStateLive { }); match result { Some(true) => { - debug!("set peer to live") + trace!("set peer to live") } Some(false) => debug!("can't set peer live, it was in wrong state"), None => debug!("can't set peer live, it disappeared"), @@ -750,7 +750,7 @@ impl<'a> PeerConnectionHandler for &'a PeerHandler { Message::Interested => self.on_peer_interested(), Message::Piece(piece) => self.on_received_piece(piece).context("on_received_piece")?, Message::KeepAlive => { - debug!("keepalive received"); + trace!("keepalive received"); } Message::Have(h) => self.on_have(h), Message::NotInterested => { @@ -767,7 +767,7 @@ impl<'a> PeerConnectionHandler for &'a PeerHandler { let g = self.state.lock_read("serialize_bitfield_message_to_buf"); let msg = Message::Bitfield(ByteBuf(g.get_chunks()?.get_have_pieces().as_raw_slice())); let len = msg.serialize(buf, None)?; - debug!("sending: {:?}, length={}", &msg, len); + trace!("sending: {:?}, length={}", &msg, len); Ok(len) } @@ -841,7 +841,7 @@ impl PeerHandler { let _error = match error { Some(e) => e, None => { - debug!("peer died without errors, not re-queueing"); + trace!("peer died without errors, not re-queueing"); pe.value_mut().state.set(PeerState::NotNeeded, pstats); return Ok(()); } @@ -850,7 +850,7 @@ impl PeerHandler { self.counters.errors.fetch_add(1, Ordering::Relaxed); if self.state.is_finished() { - debug!("torrent finished, not re-queueing"); + trace!("torrent finished, not re-queueing"); pe.value_mut().state.set(PeerState::NotNeeded, pstats); return Ok(()); } @@ -1014,7 +1014,7 @@ impl PeerHandler { // Theoretically, this could be done in the sending code, so that it reads straight into // the send buffer. let request = WriterRequest::ReadChunkRequest(chunk_info); - debug!("sending {:?}", &request); + trace!("sending {:?}", &request); Ok::<_, anyhow::Error>(self.tx.send(request)?) } @@ -1034,7 +1034,7 @@ impl PeerHandler { return; } }; - debug!("updated bitfield with have={}", have); + trace!("updated bitfield with have={}", have); }); } @@ -1168,7 +1168,7 @@ impl PeerHandler { } fn on_peer_interested(&self) { - debug!("peer is interested"); + trace!("peer is interested"); self.state.peers.mark_peer_interested(self.addr, true); } @@ -1266,7 +1266,7 @@ impl PeerHandler { match g.get_chunks_mut()?.mark_chunk_downloaded(&piece) { Some(ChunkMarkingResult::Completed) => { - debug!("piece={} done, will write and checksum", piece.index,); + trace!("piece={} done, will write and checksum", piece.index,); // This will prevent others from stealing it. { let piece = chunk_info.piece_index; diff --git a/crates/librqbit_core/src/spawn_utils.rs b/crates/librqbit_core/src/spawn_utils.rs index 81e9b00..6893e19 100644 --- a/crates/librqbit_core/src/spawn_utils.rs +++ b/crates/librqbit_core/src/spawn_utils.rs @@ -1,4 +1,4 @@ -use tracing::{debug, error, trace, Instrument}; +use tracing::{error, trace, Instrument}; pub fn spawn( span: tracing::Span, @@ -8,7 +8,7 @@ pub fn spawn( trace!("started"); match fut.await { Ok(_) => { - debug!("finished"); + trace!("finished"); } Err(e) => { error!("finished with error: {:#}", e) From 7d02d79ff50da5a8ff206807000ec085c2f29cbd Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Wed, 29 Nov 2023 23:57:11 +0000 Subject: [PATCH 25/51] Using response freshness in ordering --- crates/dht/src/dht.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index 97a2101..e6610aa 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -437,7 +437,16 @@ impl RecursiveRequest { let has_returned_peers_desc = Reverse(n.returned_peers); let has_responded_desc = Reverse(n.last_response.is_some() as u8); let distance = n.id.distance(&self.info_hash); - (has_returned_peers_desc, has_responded_desc, distance) + let freshest_response = n + .last_response + .map(|r| r.elapsed()) + .unwrap_or(Duration::MAX); + ( + has_returned_peers_desc, + has_responded_desc, + distance, + freshest_response, + ) }); if closest_nodes.len() > LIMIT { let popped = closest_nodes.pop().unwrap(); From 52883769e1047ee0b037ad4cba25679e57c84778 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Thu, 30 Nov 2023 00:48:57 +0000 Subject: [PATCH 26/51] Session persistence now saving full torrent contents --- Cargo.lock | 2 + crates/librqbit/Cargo.toml | 1 + crates/librqbit/src/session.rs | 69 +++++++++++++++++--- crates/librqbit_core/Cargo.toml | 5 +- crates/librqbit_core/src/torrent_metainfo.rs | 30 ++++++++- 5 files changed, 94 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8e9c891..3460a27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1005,6 +1005,7 @@ dependencies = [ "anyhow", "axum", "backoff", + "base64", "bincode", "bitvec", "byteorder", @@ -1076,6 +1077,7 @@ dependencies = [ "librqbit-clone-to-owned", "parking_lot", "serde", + "serde_json", "tokio", "tracing", "url", diff --git a/crates/librqbit/Cargo.toml b/crates/librqbit/Cargo.toml index 9e18974..4093071 100644 --- a/crates/librqbit/Cargo.toml +++ b/crates/librqbit/Cargo.toml @@ -61,6 +61,7 @@ url = "2" hex = "0.4" backoff = "0.4.0" dashmap = "5.5.3" +base64 = "0.21.5" [dev-dependencies] futures = {version = "0.3"} diff --git a/crates/librqbit/src/session.rs b/crates/librqbit/src/session.rs index 47bc9cc..55c67ee 100644 --- a/crates/librqbit/src/session.rs +++ b/crates/librqbit/src/session.rs @@ -10,6 +10,7 @@ use std::{ }; use anyhow::{bail, Context}; +use bencode::{bencode_serialize_to_writer, BencodeDeserializer}; use buffers::ByteString; use dht::{Dht, DhtBuilder, Id20, PersistentDht, PersistentDhtConfig}; use librqbit_core::{ @@ -19,7 +20,7 @@ use librqbit_core::{ }; use parking_lot::RwLock; use reqwest::Url; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use tokio_stream::StreamExt; use tracing::{debug, error, error_span, info, warn}; @@ -50,7 +51,7 @@ impl SessionDatabase { fn serialize(&self) -> SerializedSessionDatabase { SerializedSessionDatabase { - torrents: self + torrents_v2: self .torrents .values() .map(|torrent| SerializedTorrent { @@ -61,6 +62,7 @@ impl SessionDatabase { .map(|u| u.to_string()) .collect(), info_hash: torrent.info_hash().as_string(), + info: torrent.info().info.clone(), only_files: torrent.only_files.clone(), is_paused: torrent.with_state(|s| matches!(s, ManagedTorrentState::Paused(_))), output_folder: torrent.info().out_dir.clone(), @@ -73,15 +75,46 @@ impl SessionDatabase { #[derive(Serialize, Deserialize)] struct SerializedTorrent { info_hash: String, + #[serde( + serialize_with = "serialize_torrent", + deserialize_with = "deserialize_torrent" + )] + info: TorrentMetaV1Info, trackers: HashSet, output_folder: PathBuf, only_files: Option>, is_paused: bool, } +fn serialize_torrent(t: &TorrentMetaV1Info, serializer: S) -> Result +where + S: Serializer, +{ + use base64::{engine::general_purpose, Engine as _}; + use serde::ser::Error; + let mut writer = Vec::new(); + bencode_serialize_to_writer(t, &mut writer).map_err(S::Error::custom)?; + let s = general_purpose::STANDARD_NO_PAD.encode(&writer); + s.serialize(serializer) +} + +fn deserialize_torrent<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + use base64::{engine::general_purpose, Engine as _}; + use serde::de::Error; + let s = String::deserialize(deserializer)?; + let b = general_purpose::STANDARD_NO_PAD + .decode(s) + .map_err(D::Error::custom)?; + TorrentMetaV1Info::::deserialize(&mut BencodeDeserializer::new_from_buf(&b)) + .map_err(D::Error::custom) +} + #[derive(Serialize, Deserialize)] struct SerializedSessionDatabase { - torrents: Vec, + torrents_v2: Vec, } pub struct Session { @@ -171,6 +204,7 @@ pub fn read_local_file_including_stdin(filename: &str) -> anyhow::Result pub enum AddTorrent<'a> { Url(Cow<'a, str>), TorrentFileBytes(Cow<'a, [u8]>), + TorrentInfo(Box), } impl<'a> AddTorrent<'a> { @@ -201,6 +235,7 @@ impl<'a> AddTorrent<'a> { match self { Self::Url(s) => s.into_owned().into_bytes(), Self::TorrentFileBytes(b) => b.into_owned(), + Self::TorrentInfo(_) => unimplemented!(), } } } @@ -309,18 +344,33 @@ impl Session { let db: SerializedSessionDatabase = serde_json::from_reader(&mut rdr).context("error deserializing session database")?; let mut futures = Vec::new(); - for storrent in db.torrents.into_iter() { - let magnet = Magnet { - info_hash: Id20::from_str(&storrent.info_hash) - .context("error deserializing info_hash")?, - trackers: storrent.trackers.into_iter().collect(), + for storrent in db.torrents_v2.into_iter() { + let trackers: Vec = storrent + .trackers + .into_iter() + .map(|t| ByteString(t.into_bytes())) + .collect(); + let info = TorrentMetaV1Owned { + announce: trackers + .get(0) + .cloned() + .unwrap_or_else(|| ByteString(b"http://retracker.local/announce".into())), + announce_list: vec![trackers], + info: storrent.info, + comment: None, + created_by: None, + encoding: None, + publisher: None, + publisher_url: None, + creation_date: None, + info_hash: Id20::from_str(&storrent.info_hash)?, }; futures.push({ let session = self.clone(); async move { session .add_torrent( - AddTorrent::Url(Cow::Owned(magnet.to_string())), + AddTorrent::TorrentInfo(Box::new(info)), Some(AddTorrentOptions { paused: storrent.is_paused, output_folder: Some( @@ -442,6 +492,7 @@ impl Session { AddTorrent::TorrentFileBytes(bytes) => { torrent_from_bytes(&bytes).context("error decoding torrent")? } + AddTorrent::TorrentInfo(t) => *t, }; let dht_rx = match self.dht.as_ref() { diff --git a/crates/librqbit_core/Cargo.toml b/crates/librqbit_core/Cargo.toml index 4d1df54..0ffc7ef 100644 --- a/crates/librqbit_core/Cargo.toml +++ b/crates/librqbit_core/Cargo.toml @@ -18,7 +18,7 @@ sha1-rust = ["bencode/sha1-rust"] [dependencies] tracing = "0.1.40" -tokio = "1" +tokio = {version = "1", features = ["rt-multi-thread"]} hex = "0.4" anyhow = "1" url = "2" @@ -29,3 +29,6 @@ buffers = {path="../buffers", package="librqbit-buffers", version = "2.2.1"} bencode = {path = "../bencode", default-features=false, package="librqbit-bencode", version="2.2.1"} clone_to_owned = {path="../clone_to_owned", package="librqbit-clone-to-owned", version = "2.2.1"} itertools = "0.12" + +[dev-dependencies] +serde_json = "1" \ No newline at end of file diff --git a/crates/librqbit_core/src/torrent_metainfo.rs b/crates/librqbit_core/src/torrent_metainfo.rs index 6d8d765..c51ff06 100644 --- a/crates/librqbit_core/src/torrent_metainfo.rs +++ b/crates/librqbit_core/src/torrent_metainfo.rs @@ -5,7 +5,7 @@ use bencode::BencodeDeserializer; use buffers::{ByteBuf, ByteString}; use clone_to_owned::CloneToOwned; use itertools::Either; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use crate::id20::Id20; @@ -51,18 +51,23 @@ impl TorrentMetaV1 { } } -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct TorrentMetaV1Info { + #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, pub pieces: BufType, #[serde(rename = "piece length")] pub piece_length: u32, // Single-file mode + #[serde(skip_serializing_if = "Option::is_none")] pub length: Option, + + #[serde(skip_serializing_if = "Option::is_none")] pub md5sum: Option, // Multi-file mode + #[serde(skip_serializing_if = "Option::is_none")] pub files: Option>>, } @@ -180,7 +185,7 @@ impl> TorrentMetaV1Info { } } -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] pub struct TorrentMetaV1File { pub length: u64, pub path: Vec, @@ -299,4 +304,23 @@ mod tests { "64a980abe6e448226bb930ba061592e44c3781a1" ); } + + #[test] + fn test_serialize_then_deserialize_bencode() { + let mut buf = Vec::new(); + std::fs::File::open(TORRENT_FILENAME) + .unwrap() + .read_to_end(&mut buf) + .unwrap(); + + let torrent: TorrentMetaV1Info = torrent_from_bytes(&buf).unwrap().info; + let mut writer = Vec::new(); + bencode::bencode_serialize_to_writer(&torrent, &mut writer).unwrap(); + let deserialized = TorrentMetaV1Info::::deserialize( + &mut BencodeDeserializer::new_from_buf(&writer), + ) + .unwrap(); + + assert_eq!(torrent, deserialized); + } } From 6243c5f02f8ef9f1c12b8eb27b817d6693c85e7c Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Thu, 30 Nov 2023 07:48:10 +0000 Subject: [PATCH 27/51] Restoring sessions from DB preserving IDs --- crates/librqbit/src/session.rs | 75 ++++++++++++++++++++++++---------- 1 file changed, 53 insertions(+), 22 deletions(-) diff --git a/crates/librqbit/src/session.rs b/crates/librqbit/src/session.rs index 55c67ee..14cb4ed 100644 --- a/crates/librqbit/src/session.rs +++ b/crates/librqbit/src/session.rs @@ -37,12 +37,27 @@ pub type TorrentId = usize; #[derive(Default)] pub struct SessionDatabase { - next_id: usize, - torrents: HashMap, + next_id: TorrentId, + torrents: HashMap, } impl SessionDatabase { - fn add_torrent(&mut self, torrent: ManagedTorrentHandle) -> TorrentId { + fn add_torrent( + &mut self, + torrent: ManagedTorrentHandle, + preferred_id: Option, + ) -> TorrentId { + match preferred_id { + Some(id) if self.torrents.contains_key(&id) => { + warn!("id {id} already present in DB, ignoring \"preferred_id\" parameter"); + } + Some(id) => { + self.torrents.insert(id, torrent); + self.next_id = id.max(self.next_id).wrapping_add(1); + return id; + } + _ => {} + } let idx = self.next_id; self.torrents.insert(idx, torrent); self.next_id += 1; @@ -53,19 +68,25 @@ impl SessionDatabase { SerializedSessionDatabase { torrents_v2: self .torrents - .values() - .map(|torrent| SerializedTorrent { - trackers: torrent - .info() - .trackers - .iter() - .map(|u| u.to_string()) - .collect(), - info_hash: torrent.info_hash().as_string(), - info: torrent.info().info.clone(), - only_files: torrent.only_files.clone(), - is_paused: torrent.with_state(|s| matches!(s, ManagedTorrentState::Paused(_))), - output_folder: torrent.info().out_dir.clone(), + .iter() + .map(|(id, torrent)| { + ( + *id, + SerializedTorrent { + trackers: torrent + .info() + .trackers + .iter() + .map(|u| u.to_string()) + .collect(), + info_hash: torrent.info_hash().as_string(), + info: torrent.info().info.clone(), + only_files: torrent.only_files.clone(), + is_paused: torrent + .with_state(|s| matches!(s, ManagedTorrentState::Paused(_))), + output_folder: torrent.info().out_dir.clone(), + }, + ) }) .collect(), } @@ -114,7 +135,7 @@ where #[derive(Serialize, Deserialize)] struct SerializedSessionDatabase { - torrents_v2: Vec, + torrents_v2: HashMap, } pub struct Session { @@ -172,6 +193,9 @@ pub struct AddTorrentOptions { pub sub_folder: Option, pub peer_opts: Option, pub force_tracker_interval: Option, + + // This is used to restore the session. + pub preferred_id: Option, } pub struct ListOnlyResponse { @@ -344,7 +368,7 @@ impl Session { let db: SerializedSessionDatabase = serde_json::from_reader(&mut rdr).context("error deserializing session database")?; let mut futures = Vec::new(); - for storrent in db.torrents_v2.into_iter() { + for (id, storrent) in db.torrents_v2.into_iter() { let trackers: Vec = storrent .trackers .into_iter() @@ -382,6 +406,7 @@ impl Session { ), only_files: storrent.only_files, overwrite: true, + preferred_id: Some(id), ..Default::default() }), ) @@ -474,7 +499,13 @@ impl Session { } }; debug!("received result from DHT: {:?}", info); - (info_hash, info, Some(dht_rx), trackers, initial_peers) + ( + info_hash, + info, + if opts.paused { None } else { Some(dht_rx) }, + trackers, + initial_peers, + ) } other => { let torrent = match other { @@ -496,11 +527,11 @@ impl Session { }; let dht_rx = match self.dht.as_ref() { - Some(dht) => { + Some(dht) if !opts.paused => { debug!("reading peers for {:?} from DHT", torrent.info_hash); Some(dht.get_peers(torrent.info_hash)?) } - None => None, + _ => None, }; let trackers = torrent .iter_announce() @@ -633,7 +664,7 @@ impl Session { let next_id = g.torrents.len(); let managed_torrent = builder.build(error_span!(parent: None, "torrent", id = next_id))?; - let id = g.add_torrent(managed_torrent.clone()); + let id = g.add_torrent(managed_torrent.clone(), opts.preferred_id); (managed_torrent, id) }; From 210a3d5d3ed274d1fbdb17c9e87fa6614c9f3c27 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Thu, 30 Nov 2023 08:06:55 +0000 Subject: [PATCH 28/51] Use concrete type for DHT peers --- crates/dht/src/dht.rs | 7 ++----- crates/dht/src/lib.rs | 2 +- crates/librqbit/src/session.rs | 15 +++++++++------ crates/librqbit/src/torrent_state/mod.rs | 5 +++-- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index e6610aa..183b80a 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -145,7 +145,7 @@ struct RecursiveRequest { callbacks: C, } -struct RequestPeersStream { +pub struct RequestPeersStream { rx: tokio::sync::mpsc::UnboundedReceiver, cancel_join_handle: tokio::task::JoinHandle<()>, } @@ -959,10 +959,7 @@ impl DhtState { Ok(state) } - pub fn get_peers( - self: &Arc, - info_hash: Id20, - ) -> anyhow::Result + Unpin> { + pub fn get_peers(self: &Arc, info_hash: Id20) -> anyhow::Result { Ok(RequestPeersStream::new(self.clone(), info_hash)) } diff --git a/crates/dht/src/lib.rs b/crates/dht/src/lib.rs index 5a28d07..30bb171 100644 --- a/crates/dht/src/lib.rs +++ b/crates/dht/src/lib.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use std::time::Duration; pub use crate::dht::DhtStats; -pub use crate::dht::{DhtConfig, DhtState}; +pub use crate::dht::{DhtConfig, DhtState, RequestPeersStream}; pub use librqbit_core::id20::Id20; pub use persistence::{PersistentDht, PersistentDhtConfig}; diff --git a/crates/librqbit/src/session.rs b/crates/librqbit/src/session.rs index 14cb4ed..4304da2 100644 --- a/crates/librqbit/src/session.rs +++ b/crates/librqbit/src/session.rs @@ -12,7 +12,7 @@ use std::{ use anyhow::{bail, Context}; use bencode::{bencode_serialize_to_writer, BencodeDeserializer}; use buffers::ByteString; -use dht::{Dht, DhtBuilder, Id20, PersistentDht, PersistentDhtConfig}; +use dht::{Dht, DhtBuilder, Id20, PersistentDht, PersistentDhtConfig, RequestPeersStream}; use librqbit_core::{ magnet::Magnet, peer_id::generate_peer_id, @@ -21,7 +21,6 @@ use librqbit_core::{ use parking_lot::RwLock; use reqwest::Url; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use tokio_stream::StreamExt; use tracing::{debug, error, error_span, info, warn}; use crate::{ @@ -451,7 +450,7 @@ impl Session { pub async fn add_torrent( &self, - add: impl Into>, + add: AddTorrent<'_>, opts: Option, ) -> anyhow::Result { // Magnet links are different in that we first need to discover the metadata. @@ -502,7 +501,11 @@ impl Session { ( info_hash, info, - if opts.paused { None } else { Some(dht_rx) }, + if opts.paused || opts.list_only { + None + } else { + Some(dht_rx) + }, trackers, initial_peers, ) @@ -527,7 +530,7 @@ impl Session { }; let dht_rx = match self.dht.as_ref() { - Some(dht) if !opts.paused => { + Some(dht) if !opts.paused && !opts.list_only => { debug!("reading peers for {:?} from DHT", torrent.info_hash); Some(dht.get_peers(torrent.info_hash)?) } @@ -578,7 +581,7 @@ impl Session { &self, info_hash: Id20, info: TorrentMetaV1Info, - dht_peer_rx: Option + Unpin + Send + Sync + 'static>, + dht_peer_rx: Option, initial_peers: Vec, trackers: Vec, opts: AddTorrentOptions, diff --git a/crates/librqbit/src/torrent_state/mod.rs b/crates/librqbit/src/torrent_state/mod.rs index 1e9e72c..28035c4 100644 --- a/crates/librqbit/src/torrent_state/mod.rs +++ b/crates/librqbit/src/torrent_state/mod.rs @@ -15,6 +15,7 @@ use std::time::Duration; use anyhow::bail; use anyhow::Context; use buffers::ByteString; +use dht::RequestPeersStream; use librqbit_core::id20::Id20; use librqbit_core::lengths::Lengths; use librqbit_core::peer_id::generate_peer_id; @@ -165,7 +166,7 @@ impl ManagedTorrent { pub fn start( self: &Arc, initial_peers: Vec, - peer_rx: Option + Unpin + Send + Sync + 'static>, + peer_rx: Option, start_paused: bool, ) -> anyhow::Result<()> { let mut g = self.locked.write(); @@ -195,7 +196,7 @@ impl ManagedTorrent { fn spawn_peer_adder( live: &Arc, initial_peers: Vec, - peer_rx: Option + Unpin + Send + Sync + 'static>, + peer_rx: Option, ) { let span = live.meta().span.clone(); let live = Arc::downgrade(live); From f04277cc11cee756d9c7da185bb55df5f58bd9d3 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Thu, 30 Nov 2023 09:38:35 +0000 Subject: [PATCH 29/51] Make questionable node pings better --- crates/dht/src/dht.rs | 103 +++++++++++++++++++------------- crates/dht/src/routing_table.rs | 10 ++-- 2 files changed, 65 insertions(+), 48 deletions(-) diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index 183b80a..a0316c4 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -48,6 +48,7 @@ struct OutstandingRequest { } pub struct WorkerSendRequest { + // If this is set, we are tracking the response in inflight_by_transaction_id our_tid: Option, message: Message, addr: SocketAddr, @@ -471,13 +472,17 @@ pub struct DhtState { // Sending requests to the worker. rate_limiter: RateLimiter, - sender: UnboundedSender, + // This is to send raw messages + worker_sender: UnboundedSender, + // This is to send pings. + ping_sender: UnboundedSender<(Id20, SocketAddr)>, } impl DhtState { fn new_internal( id: Id20, sender: UnboundedSender, + ping_sender: UnboundedSender<(Id20, SocketAddr)>, routing_table: Option, listen_addr: SocketAddr, ) -> Self { @@ -487,8 +492,9 @@ impl DhtState { next_transaction_id: AtomicU16::new(0), inflight_by_transaction_id: Default::default(), routing_table: RwLock::new(routing_table), - sender, + worker_sender: sender, listen_addr, + ping_sender, rate_limiter: make_rate_limiter(), } } @@ -500,7 +506,8 @@ impl DhtState { let (tx, rx) = tokio::sync::oneshot::channel(); self.inflight_by_transaction_id .insert(key, OutstandingRequest { done: tx }); - match self.sender.send(WorkerSendRequest { + trace!("sending to {addr}, {message:?}"); + match self.worker_sender.send(WorkerSendRequest { our_tid: Some(tid), message, addr, @@ -594,7 +601,9 @@ impl DhtState { .map(|(_, v)| v) { Some(req) => req, - None => bail!("outstanding request not found. Message: {:?}", msg), + None => { + bail!("outstanding request not found. Message: {:?}", msg) + } }; let response_or_error = match msg.kind { @@ -625,7 +634,7 @@ impl DhtState { }), }; self.routing_table.write().mark_last_query(&req.id); - self.sender.send(WorkerSendRequest { + self.worker_sender.send(WorkerSendRequest { our_tid: None, message, addr, @@ -633,26 +642,7 @@ impl DhtState { Ok(()) } MessageKind::GetPeersRequest(req) => { - // let peers = self.info_hash_meta.get(&req.info_hash).map(|meta| { - // meta.seen_peers - // .iter() - // .copied() - // .filter_map(|a| match a { - // SocketAddr::V4(v4) => Some(CompactPeerInfo { addr: v4 }), - // // this should never happen in practice - // SocketAddr::V6(_) => None, - // }) - // .take(50) - // .collect::>() - // }); - // let token = if peers.is_some() { - // let mut token = [0u8; 20]; - // rand::thread_rng().fill(&mut token); - // Some(ByteString::from(token.as_ref())) - // } else { - // None - // }; - // let compact_node_info = generate_compact_nodes(req.info_hash); + // TODO: respond with peer info, for now sending an empty response. self.routing_table.write().mark_last_query(&req.id); let message = Message { transaction_id: msg.transaction_id, @@ -660,12 +650,10 @@ impl DhtState { ip: None, kind: MessageKind::Response(bprotocol::Response { id: self.id, - nodes: None, - values: None, - token: None, + ..Default::default() }), }; - self.sender.send(WorkerSendRequest { + self.worker_sender.send(WorkerSendRequest { our_tid: None, message, addr, @@ -685,7 +673,7 @@ impl DhtState { ..Default::default() }), }; - self.sender.send(WorkerSendRequest { + self.worker_sender.send(WorkerSendRequest { our_tid: None, message, addr, @@ -704,19 +692,10 @@ impl DhtState { } fn routing_table_add_node(self: &Arc, id: Id20, addr: SocketAddr) -> InsertResult { - let mut questionable_nodes = Vec::new(); - let res = self.routing_table.write().add_node(id, addr, |addr| { - questionable_nodes.push(addr); + let res = self.routing_table.write().add_node(id, addr, |id, addr| { + let _ = self.ping_sender.send((id, addr)); true }); - for addr in questionable_nodes { - let (_, req) = self.create_request(Request::Ping); - let _ = self.sender.send(WorkerSendRequest { - our_tid: None, - message: req, - addr, - }); - } res } } @@ -796,6 +775,33 @@ impl DhtWorker { Ok(()) } + async fn pinger(&self, mut rx: UnboundedReceiver<(Id20, SocketAddr)>) -> anyhow::Result<()> { + let mut futs = FuturesUnordered::new(); + loop { + tokio::select! { + r = rx.recv() => { + let (id, addr) = match r { + Some(r) => r, + None => return Ok(()), + }; + futs.push(async move { + self.dht.routing_table.write().mark_outgoing_request(&id); + match self.dht.request(Request::Ping, addr).await { + Ok(_) => { + self.dht.routing_table.write().mark_response(&id); + }, + Err(e) => { + self.dht.routing_table.write().mark_error(&id); + debug!("error: {e:?}"); + } + } + }.instrument(error_span!("ping", addr=addr.to_string()))) + }, + _ = futs.next() => {}, + } + } + } + async fn framer( &self, socket: &UdpSocket, @@ -810,7 +816,9 @@ impl DhtWorker { addr, }) = input_rx.recv().await { - trace!("{}: sending {:?}", addr, &message); + if our_tid.is_none() { + trace!("{}: sending {:?}", addr, &message); + } buf.clear(); bprotocol::serialize_message( &mut buf, @@ -863,6 +871,7 @@ impl DhtWorker { async fn start( self, in_rx: UnboundedReceiver, + ping_rx: UnboundedReceiver<(Id20, SocketAddr)>, bootstrap_addrs: &[String], ) -> anyhow::Result<()> { let (out_tx, mut out_rx) = channel(1); @@ -888,9 +897,12 @@ impl DhtWorker { } .instrument(debug_span!("dht_responese_reader")); + let pinger = self.pinger(ping_rx); + tokio::pin!(framer); tokio::pin!(bootstrap); tokio::pin!(response_reader); + tokio::pin!(pinger); loop { tokio::select! { @@ -901,6 +913,9 @@ impl DhtWorker { bootstrap_done = true; result?; }, + err = &mut pinger => { + anyhow::bail!("pinger quit: {:?}", err) + }, err = &mut response_reader => {anyhow::bail!("response reader quit: {:?}", err)} } } @@ -941,9 +956,11 @@ impl DhtState { .unwrap_or_else(|| crate::DHT_BOOTSTRAP.iter().map(|v| v.to_string()).collect()); let (in_tx, in_rx) = unbounded_channel(); + let (ping_tx, ping_rx) = unbounded_channel(); let state = Arc::new(Self::new_internal( peer_id, in_tx, + ping_tx, config.routing_table, listen_addr, )); @@ -952,7 +969,7 @@ impl DhtState { let state = state.clone(); async move { let worker = DhtWorker { socket, dht: state }; - worker.start(in_rx, &bootstrap_addrs).await?; + worker.start(in_rx, ping_rx, &bootstrap_addrs).await?; Ok(()) } }); diff --git a/crates/dht/src/routing_table.rs b/crates/dht/src/routing_table.rs index 1e21512..44f2fdd 100644 --- a/crates/dht/src/routing_table.rs +++ b/crates/dht/src/routing_table.rs @@ -287,7 +287,7 @@ impl BucketTree { self_id: &Id20, id: Id20, addr: SocketAddr, - on_questionable_node: impl FnMut(SocketAddr) -> bool, + on_questionable_node: impl FnMut(Id20, SocketAddr) -> bool, ) -> InsertResult { let idx = self.get_leaf(&id); self.insert_into_leaf(idx, self_id, id, addr, on_questionable_node) @@ -298,7 +298,7 @@ impl BucketTree { self_id: &Id20, id: Id20, addr: SocketAddr, - mut on_questionable_node: impl FnMut(SocketAddr) -> bool, + mut on_questionable_node: impl FnMut(Id20, SocketAddr) -> bool, ) -> InsertResult { // The loop here is for this case: // in case we split a node into two, and it degenerates into all the leaves @@ -337,7 +337,7 @@ impl BucketTree { .iter_mut() .find(|r| matches!(r.status(), NodeStatus::Questionable)) { - if on_questionable_node(questionable_node.addr) { + if on_questionable_node(questionable_node.id, questionable_node.addr) { questionable_node.mark_outgoing_request(); } } @@ -545,7 +545,7 @@ impl RoutingTable { &mut self, id: Id20, addr: SocketAddr, - on_questionable_node: impl FnMut(SocketAddr) -> bool, + on_questionable_node: impl FnMut(Id20, SocketAddr) -> bool, ) -> InsertResult { let res = self .buckets @@ -690,7 +690,7 @@ mod tests { for _ in 0..length.unwrap_or(16536) { let other_id = random_id_20(); let addr = generate_socket_addr(); - rtable.add_node(other_id, addr, |_| false); + rtable.add_node(other_id, addr, |_, _| false); } rtable } From 658bbdb652b35764bd0ba2706175652a540133b8 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Thu, 30 Nov 2023 09:59:13 +0000 Subject: [PATCH 30/51] Add "last_refreshed" property on buckets --- crates/dht/src/dht.rs | 4 ++ crates/dht/src/routing_table.rs | 103 +++++++++++++++++++++++++------- 2 files changed, 86 insertions(+), 21 deletions(-) diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index a0316c4..5cff57f 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -775,6 +775,10 @@ impl DhtWorker { Ok(()) } + async fn bucket_refresher(&self) -> anyhow::Result<()> { + todo!() + } + async fn pinger(&self, mut rx: UnboundedReceiver<(Id20, SocketAddr)>) -> anyhow::Result<()> { let mut futs = FuturesUnordered::new(); loop { diff --git a/crates/dht/src/routing_table.rs b/crates/dht/src/routing_table.rs index 44f2fdd..db45de5 100644 --- a/crates/dht/src/routing_table.rs +++ b/crates/dht/src/routing_table.rs @@ -3,16 +3,61 @@ use std::{net::SocketAddr, time::Instant}; use librqbit_core::id20::Id20; use serde::{ ser::{SerializeMap, SerializeStruct}, - Deserialize, Serialize, + Deserialize, Serialize, Serializer, }; use tracing::debug; use crate::INACTIVITY_TIMEOUT; +#[derive(Clone, Debug)] +struct LeafBucket { + nodes: Vec, + last_refreshed: Instant, +} + +impl Serialize for LeafBucket { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut s = serializer.serialize_struct("LeafBucket", 2)?; + s.serialize_field("nodes", &self.nodes)?; + s.serialize_field( + "last_refreshed", + &format!("{:?}", self.last_refreshed.elapsed()), + )?; + s.end() + } +} + +impl<'de> Deserialize<'de> for LeafBucket { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Tmp { + nodes: Vec, + } + Tmp::deserialize(deserializer).map(|t| Self { + nodes: t.nodes, + last_refreshed: Instant::now(), + }) + } +} + +impl Default for LeafBucket { + fn default() -> Self { + Self { + nodes: Default::default(), + last_refreshed: Instant::now(), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] enum BucketTreeNodeData { - // TODO: maybe replace that with SmallVec<8>? - Leaf(Vec), + Leaf(LeafBucket), LeftRight(usize, usize), } @@ -144,7 +189,7 @@ impl<'a> BucketTreeIterator<'a> { let mut current = 0; let current_slice = loop { match &tree.data[current].data { - BucketTreeNodeData::Leaf(nodes) => break nodes.iter(), + BucketTreeNodeData::Leaf(leaf) => break leaf.nodes.iter(), BucketTreeNodeData::LeftRight(left, right) => { queue.push(*right); current = *left; @@ -170,8 +215,8 @@ impl<'a> Iterator for BucketTreeIterator<'a> { loop { let idx = self.queue.pop()?; match &self.tree.data[idx].data { - BucketTreeNodeData::Leaf(nodes) => { - self.current = nodes.iter(); + BucketTreeNodeData::Leaf(leaf) => { + self.current = leaf.nodes.iter(); match self.current.next() { Some(v) => return Some(v), None => continue, @@ -248,7 +293,7 @@ impl BucketTree { bits: 160, start: Id20([0u8; 20]), end_inclusive: Id20([0xff; 20]), - data: BucketTreeNodeData::Leaf(Vec::new()), + data: BucketTreeNodeData::Leaf(Default::default()), }], } } @@ -274,10 +319,16 @@ impl BucketTree { } } - pub fn get_mut(&mut self, id: &Id20) -> Option<&mut RoutingTableNode> { + pub fn get_mut(&mut self, id: &Id20, refresh: bool) -> Option<&mut RoutingTableNode> { let idx = self.get_leaf(id); match &mut self.data[idx].data { - BucketTreeNodeData::Leaf(nodes) => nodes.iter_mut().find(|b| b.id == *id), + BucketTreeNodeData::Leaf(leaf) => { + let r = leaf.nodes.iter_mut().find(|b| b.id == *id); + if r.is_some() && refresh { + leaf.last_refreshed = Instant::now() + } + r + } BucketTreeNodeData::LeftRight(_, _) => unreachable!(), } } @@ -313,7 +364,7 @@ impl BucketTree { BucketTreeNodeData::LeftRight(_, _) => unreachable!(), }; // if already found, quit - if nodes.iter().any(|r| r.id == id) { + if nodes.nodes.iter().any(|r| r.id == id) { return InsertResult::WasExisting; } @@ -326,14 +377,16 @@ impl BucketTree { errors_in_a_row: 0, }; - if nodes.len() < 8 { - nodes.push(new_node); - nodes.sort_by_key(|n| n.id); + if nodes.nodes.len() < 8 { + nodes.nodes.push(new_node); + nodes.nodes.sort_by_key(|n| n.id); + nodes.last_refreshed = Instant::now(); return InsertResult::Added; } // Ping first questionable node if let Some(questionable_node) = nodes + .nodes .iter_mut() .find(|r| matches!(r.status(), NodeStatus::Questionable)) { @@ -344,12 +397,14 @@ impl BucketTree { // Try replace a bad node if let Some(bad_node) = nodes + .nodes .iter_mut() .find(|r| matches!(r.status(), NodeStatus::Bad)) { std::mem::swap(bad_node, &mut new_node); - nodes.sort_by_key(|n| n.id); + nodes.nodes.sort_by_key(|n| n.id); debug!("replaced bad node {:?}", new_node); + nodes.last_refreshed = Instant::now(); return InsertResult::ReplacedBad(new_node); } @@ -362,7 +417,7 @@ impl BucketTree { let ((ls, le), (rs, re)) = compute_split_start_end(leaf.start, leaf.end_inclusive, leaf.bits); let (mut ld, mut rd) = (Vec::new(), Vec::new()); - for node in nodes.drain(0..) { + for node in nodes.nodes.drain(0..) { if node.id < rs { ld.push(node); } else { @@ -374,13 +429,19 @@ impl BucketTree { bits: leaf.bits - 1, start: ls, end_inclusive: le, - data: BucketTreeNodeData::Leaf(ld), + data: BucketTreeNodeData::Leaf(LeafBucket { + nodes: ld, + ..Default::default() + }), }; let right = BucketTreeNode { bits: leaf.bits - 1, start: rs, end_inclusive: re, - data: BucketTreeNodeData::Leaf(rd), + data: BucketTreeNodeData::Leaf(LeafBucket { + nodes: rd, + ..Default::default() + }), }; let left_idx = { @@ -562,7 +623,7 @@ impl RoutingTable { res } pub fn mark_outgoing_request(&mut self, id: &Id20) -> bool { - let r = match self.buckets.get_mut(id) { + let r = match self.buckets.get_mut(id, false) { Some(r) => r, None => return false, }; @@ -571,7 +632,7 @@ impl RoutingTable { } pub fn mark_response(&mut self, id: &Id20) -> bool { - let r = match self.buckets.get_mut(id) { + let r = match self.buckets.get_mut(id, true) { Some(r) => r, None => return false, }; @@ -580,7 +641,7 @@ impl RoutingTable { } pub fn mark_error(&mut self, id: &Id20) -> bool { - let r = match self.buckets.get_mut(id) { + let r = match self.buckets.get_mut(id, false) { Some(r) => r, None => return false, }; @@ -589,7 +650,7 @@ impl RoutingTable { } pub fn mark_last_query(&mut self, id: &Id20) -> bool { - let r = match self.buckets.get_mut(id) { + let r = match self.buckets.get_mut(id, false) { Some(r) => r, None => return false, }; From c8967f2469919420172c1ecd144cb72eb2a1a759 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Thu, 30 Nov 2023 11:38:15 +0000 Subject: [PATCH 31/51] Bucket refresher. Broken --- crates/dht/src/dht.rs | 70 ++++++++++++++++++++--- crates/dht/src/routing_table.rs | 97 +++++++++++++++++++------------- crates/librqbit_core/src/id20.rs | 6 ++ 3 files changed, 125 insertions(+), 48 deletions(-) diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index 5cff57f..7cdd5a6 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -15,7 +15,7 @@ use crate::{ MessageKind, Node, PingRequest, Response, }, routing_table::{InsertResult, RoutingTable}, - REQUERY_INTERVAL, RESPONSE_TIMEOUT, + INACTIVITY_TIMEOUT, REQUERY_INTERVAL, RESPONSE_TIMEOUT, }; use anyhow::{bail, Context}; use backoff::{backoff::Backoff, ExponentialBackoffBuilder}; @@ -190,10 +190,11 @@ impl Stream for RequestPeersStream { } impl RecursiveRequest { - async fn bootstrap(dht: Arc, target: Id20, hostname: &str) -> anyhow::Result<()> { - let addrs = tokio::net::lookup_host(hostname) - .await - .with_context(|| format!("error looking up {}", hostname))?; + async fn find_node_for_routing_table( + dht: Arc, + target: Id20, + addrs: impl Iterator, + ) -> anyhow::Result<()> { let (node_tx, mut node_rx) = unbounded_channel(); let req = RecursiveRequest { info_hash: target, @@ -728,9 +729,10 @@ impl DhtWorker { } async fn bootstrap_hostname(&self, hostname: &str) -> anyhow::Result<()> { - RecursiveRequest::bootstrap(self.dht.clone(), self.dht.id, hostname) - .instrument(error_span!("bootstrap", hostname = hostname)) + let addrs = tokio::net::lookup_host(hostname) .await + .with_context(|| format!("error looking up {}", hostname))?; + RecursiveRequest::find_node_for_routing_table(self.dht.clone(), self.dht.id, addrs).await } async fn bootstrap_hostname_with_backoff(&self, addr: &str) -> anyhow::Result<()> { @@ -742,7 +744,11 @@ impl DhtWorker { .build(); loop { - let backoff = match self.bootstrap_hostname(addr).await { + let backoff = match self + .bootstrap_hostname(addr) + .instrument(error_span!("bootstrap", hostname = addr)) + .await + { Ok(_) => return Ok(()), Err(e) => { warn!("error: {}", e); @@ -776,7 +782,48 @@ impl DhtWorker { } async fn bucket_refresher(&self) -> anyhow::Result<()> { - todo!() + let (tx, mut rx) = unbounded_channel(); + + let mut futs = FuturesUnordered::new(); + let filler = async { + let mut interval = tokio::time::interval(INACTIVITY_TIMEOUT); + tokio::time::sleep(INACTIVITY_TIMEOUT).await; + loop { + interval.tick().await; + for bucket in self.dht.routing_table.read().iter_buckets() { + if bucket.leaf.last_refreshed.elapsed() < INACTIVITY_TIMEOUT { + continue; + } + let random_id = bucket.random_within(); + tx.send(random_id).unwrap(); + } + } + }; + + tokio::pin!(filler); + + loop { + tokio::select! { + _ = &mut filler => {}, + random_id = rx.recv() => { + let random_id = random_id.unwrap(); + let addrs = self + .dht + .routing_table + .read() + .sorted_by_distance_from(random_id) + .iter() + .map(|n| n.addr()) + .take(8).collect::>(); + futs.push( + RecursiveRequest::find_node_for_routing_table( + self.dht.clone(), random_id, addrs.into_iter() + ).instrument(error_span!("refresh_bucket", random_id=format!("{:?}", random_id))) + ); + }, + _ = futs.next(), if !futs.is_empty() => {}, + } + } } async fn pinger(&self, mut rx: UnboundedReceiver<(Id20, SocketAddr)>) -> anyhow::Result<()> { @@ -902,11 +949,13 @@ impl DhtWorker { .instrument(debug_span!("dht_responese_reader")); let pinger = self.pinger(ping_rx); + let bucket_refresher = self.bucket_refresher(); tokio::pin!(framer); tokio::pin!(bootstrap); tokio::pin!(response_reader); tokio::pin!(pinger); + tokio::pin!(bucket_refresher); loop { tokio::select! { @@ -920,6 +969,9 @@ impl DhtWorker { err = &mut pinger => { anyhow::bail!("pinger quit: {:?}", err) }, + err = &mut bucket_refresher => { + anyhow::bail!("bucket_refresher quit: {:?}", err) + }, err = &mut response_reader => {anyhow::bail!("response reader quit: {:?}", err)} } } diff --git a/crates/dht/src/routing_table.rs b/crates/dht/src/routing_table.rs index db45de5..4223757 100644 --- a/crates/dht/src/routing_table.rs +++ b/crates/dht/src/routing_table.rs @@ -1,6 +1,7 @@ use std::{net::SocketAddr, time::Instant}; use librqbit_core::id20::Id20; +use rand::RngCore; use serde::{ ser::{SerializeMap, SerializeStruct}, Deserialize, Serialize, Serializer, @@ -10,9 +11,9 @@ use tracing::debug; use crate::INACTIVITY_TIMEOUT; #[derive(Clone, Debug)] -struct LeafBucket { - nodes: Vec, - last_refreshed: Instant, +pub struct LeafBucket { + pub nodes: Vec, + pub last_refreshed: Instant, } impl Serialize for LeafBucket { @@ -177,61 +178,70 @@ impl Serialize for BucketTree { } } -pub struct BucketTreeIterator<'a> { +pub struct BucketTreeIteratorItem<'a> { + pub bits: u8, + pub start: &'a Id20, + pub end_inclusive: &'a Id20, + pub leaf: &'a LeafBucket, +} + +impl<'a> BucketTreeIteratorItem<'a> { + pub fn random_within(&self) -> Id20 { + generate_random_id(self.start, self.bits) + } +} + +struct BucketTreeIterator<'a> { tree: &'a BucketTree, - current: std::slice::Iter<'a, RoutingTableNode>, queue: Vec, } impl<'a> BucketTreeIterator<'a> { fn new(tree: &'a BucketTree) -> Self { - let mut queue = Vec::new(); - let mut current = 0; - let current_slice = loop { - match &tree.data[current].data { - BucketTreeNodeData::Leaf(leaf) => break leaf.nodes.iter(), - BucketTreeNodeData::LeftRight(left, right) => { - queue.push(*right); - current = *left; - } - } - }; - BucketTreeIterator { - tree, - current: current_slice, - queue, - } + let queue = vec![0]; + BucketTreeIterator { tree, queue } } } impl<'a> Iterator for BucketTreeIterator<'a> { - type Item = &'a RoutingTableNode; + type Item = BucketTreeIteratorItem<'a>; fn next(&mut self) -> Option { - if let Some(v) = self.current.next() { - return Some(v); - }; - loop { let idx = self.queue.pop()?; - match &self.tree.data[idx].data { - BucketTreeNodeData::Leaf(leaf) => { - self.current = leaf.nodes.iter(); - match self.current.next() { - Some(v) => return Some(v), - None => continue, + match self.tree.data.get(idx) { + Some(node) => match &node.data { + BucketTreeNodeData::Leaf(leaf) => { + return Some(BucketTreeIteratorItem { + bits: node.bits, + start: &node.start, + end_inclusive: &node.end_inclusive, + leaf, + }); } - } - BucketTreeNodeData::LeftRight(left, right) => { - self.queue.push(*right); - self.queue.push(*left); - continue; - } + BucketTreeNodeData::LeftRight(left, right) => { + self.queue.push(*right); + self.queue.push(*left); + continue; + } + }, + None => continue, } } } } +pub fn generate_random_id(start: &Id20, bits: u8) -> Id20 { + let mut data = [0u8; 20]; + rand::thread_rng().fill_bytes(&mut data); + let mut data = Id20(data); + let remaining_bits = 160 - bits; + for bit in 0..remaining_bits { + data.set_bit(bit, start.get_bit(bit)); + } + data +} + fn compute_split_start_end( start: Id20, end_inclusive: Id20, @@ -297,10 +307,15 @@ impl BucketTree { }], } } - pub fn iter(&self) -> BucketTreeIterator<'_> { + + fn iter_leaves(&self) -> BucketTreeIterator<'_> { BucketTreeIterator::new(self) } + fn iter(&self) -> impl Iterator + '_ { + self.iter_leaves().flat_map(|l| l.leaf.nodes.iter()) + } + fn get_leaf(&self, id: &Id20) -> usize { let mut idx = 0; loop { @@ -602,6 +617,10 @@ impl RoutingTable { result } + pub fn iter_buckets(&self) -> impl Iterator> + '_ { + self.buckets.iter_leaves() + } + pub fn add_node( &mut self, id: Id20, diff --git a/crates/librqbit_core/src/id20.rs b/crates/librqbit_core/src/id20.rs index eee5adc..8562a3d 100644 --- a/crates/librqbit_core/src/id20.rs +++ b/crates/librqbit_core/src/id20.rs @@ -102,6 +102,12 @@ impl Id20 { } Id20(xor) } + pub fn get_bit(&self, bit: u8) -> bool { + let n = self.0[(bit / 8) as usize]; + let mask = !(1 << (7 - bit % 8)); + n & mask > 0 + } + pub fn set_bit(&mut self, bit: u8, value: bool) { let n = &mut self.0[(bit / 8) as usize]; if value { From ebd0d818ebc6f147e869b39cb0ac9ddf18865091 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Thu, 30 Nov 2023 11:57:03 +0000 Subject: [PATCH 32/51] Fix a bug in pinger, attribute log messages better --- crates/dht/src/dht.rs | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index 7cdd5a6..51fa54c 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -355,7 +355,6 @@ impl RecursiveRequest { return Err(e); } }; - trace!("received {response:?}"); if let Some(peers) = response.values { for peer in peers { @@ -520,7 +519,10 @@ impl DhtState { } }; match tokio::time::timeout(RESPONSE_TIMEOUT, rx).await { - Ok(Ok(r)) => r, + Ok(Ok(r)) => r.map(|r| { + trace!("received {r:?}"); + r + }), Ok(Err(e)) => { self.inflight_by_transaction_id.remove(&key); warn!("recv error, did not expect this: {:?}", e); @@ -621,8 +623,14 @@ impl DhtState { ); } } - Ok(()) + return Ok(()); } + _ => {} + }; + + trace!("received query: {:?}", msg); + + match &msg.kind { // Otherwise, respond to a query. MessageKind::PingRequest(req) => { let message = Message { @@ -681,6 +689,7 @@ impl DhtState { })?; Ok(()) } + _ => unreachable!(), } } @@ -848,7 +857,7 @@ impl DhtWorker { } }.instrument(error_span!("ping", addr=addr.to_string()))) }, - _ = futs.next() => {}, + _ = futs.next(), if !futs.is_empty() => {}, } } } @@ -898,13 +907,10 @@ impl DhtWorker { .await .context("error reading from UDP socket")?; match bprotocol::deserialize_message::(&buf[..size]) { - Ok(msg) => { - trace!("{}: received {:?}", addr, &msg); - match output_tx.send((msg, addr)).await { - Ok(_) => {} - Err(_) => break, - } - } + Ok(msg) => match output_tx.send((msg, addr)).await { + Ok(_) => {} + Err(_) => break, + }, Err(e) => debug!("{}: error deserializing incoming message: {}", addr, e), } } From 8d58a9f419c90e78ed81cb00c5d1dfe718c9cc9d Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Thu, 30 Nov 2023 13:30:11 +0000 Subject: [PATCH 33/51] Doesnt refresh properly --- TODO.md | 6 +++- crates/dht/src/dht.rs | 56 +++++++++++++++++++++------------ crates/dht/src/lib.rs | 6 ++-- crates/dht/src/routing_table.rs | 28 ++++++++++++----- 4 files changed, 64 insertions(+), 32 deletions(-) diff --git a/TODO.md b/TODO.md index fed1d9b..0f929a8 100644 --- a/TODO.md +++ b/TODO.md @@ -18,7 +18,11 @@ - [x] many nodes in "Unknown" status, do smth about it - [x] for torrents with a few seeds might be cool to re-query DHT once in a while. - [x] don't leak memory when deleting torrents (i.e. remove torrent information (seen peers etc) once the torrent is deleted) - - [ ] Buckets that have not been changed in 15 minutes should be "refreshed." (per RFC) + - [ ] Routing table - is it balanced properly? + - [ ] Don't query Bad nodes + - [-] Buckets that have not been changed in 15 minutes should be "refreshed." (per RFC) + - [ ] Did it, but it's flawed: starts repeating the same queries again as neighboring refreshes + don't know about the other ones, and DHT returns the same nodes again and again. - [x] it's sending many requests now way too fast, locks up Mac OS UI annoyingly - [ ] After the search is exhausted, the client then inserts the peer contact information for itself onto the responding nodes with IDs closest to the infohash of the torrent. - [x] Ensure that if we query the "returned" nodes, they are even closer to our request than the responding node id was. diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index 51fa54c..77601d7 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -137,12 +137,14 @@ impl RecursiveRequestCallbacks for RecursiveRequestCallbacksFindNodes { } struct RecursiveRequest { + max_depth: usize, + useful_nodes_limit: usize, info_hash: Id20, request: Request, dht: Arc, useful_nodes: RwLock>, peer_tx: tokio::sync::mpsc::UnboundedSender, - node_tx: tokio::sync::mpsc::UnboundedSender<(Option, SocketAddr)>, + node_tx: tokio::sync::mpsc::UnboundedSender<(Option, SocketAddr, usize)>, callbacks: C, } @@ -156,7 +158,9 @@ impl RequestPeersStream { let (peer_tx, peer_rx) = unbounded_channel(); let (node_tx, node_rx) = unbounded_channel(); let rp = Arc::new(RecursiveRequest { + max_depth: 4, info_hash, + useful_nodes_limit: 256, request: Request::GetPeers(info_hash), dht, useful_nodes: RwLock::new(Vec::new()), @@ -197,17 +201,19 @@ impl RecursiveRequest { ) -> anyhow::Result<()> { let (node_tx, mut node_rx) = unbounded_channel(); let req = RecursiveRequest { + max_depth: 4, info_hash: target, request: Request::FindNode(target), dht, + useful_nodes_limit: 32, useful_nodes: RwLock::new(Vec::new()), peer_tx: unbounded_channel().0, node_tx, callbacks: RecursiveRequestCallbacksFindNodes {}, }; - let request_one = |id, addr| { - req.request_one(id, addr) + let request_one = |id, addr, depth| { + req.request_one(id, addr, depth) .map_err(|e| { debug!("error: {e:?}"); e @@ -223,7 +229,7 @@ impl RecursiveRequest { let mut initial_addrs = 0; for addr in addrs { - futs.push(request_one(None, addr)); + futs.push(request_one(None, addr, 0)); initial_addrs += 1; } @@ -235,8 +241,8 @@ impl RecursiveRequest { biased; r = node_rx.recv() => { - let (id, addr) = r.unwrap(); - futs.push(request_one(id, addr)) + let (id, addr, depth) = r.unwrap(); + futs.push(request_one(id, addr, depth)) }, f = futs.next() => { let f = match f { @@ -267,7 +273,7 @@ impl RecursiveRequest { impl RecursiveRequest { fn request_peers_forever( self: &Arc, - mut node_rx: tokio::sync::mpsc::UnboundedReceiver<(Option, SocketAddr)>, + mut node_rx: tokio::sync::mpsc::UnboundedReceiver<(Option, SocketAddr, usize)>, ) -> tokio::task::JoinHandle<()> { let this = self.clone(); spawn( @@ -300,9 +306,9 @@ impl RecursiveRequest { loop { tokio::select! { addr = node_rx.recv() => { - let (id, addr) = addr.unwrap(); + let (id, addr, depth) = addr.unwrap(); futs.push( - this.request_one(id, addr) + this.request_one(id, addr, depth) .map_err(|e| debug!("error: {e:?}")) .instrument(error_span!("addr", addr=addr.to_string())) ); @@ -327,14 +333,19 @@ impl RecursiveRequest { .take(8) { count += 1; - self.node_tx.send((Some(id), addr))?; + self.node_tx.send((Some(id), addr, 0))?; } Ok(count) } } impl RecursiveRequest { - async fn request_one(&self, id: Option, addr: SocketAddr) -> anyhow::Result<()> { + async fn request_one( + &self, + id: Option, + addr: SocketAddr, + depth: usize, + ) -> anyhow::Result<()> { if let Some(id) = id { self.callbacks.on_request_start(self, id, addr); } @@ -365,15 +376,17 @@ impl RecursiveRequest { if let Some(nodes) = response.nodes { for node in nodes.nodes { let addr = SocketAddr::V4(node.addr); - let should_request = self.should_request_node(node.id, addr); + let should_request = self.should_request_node(node.id, addr, depth); trace!( - "should_request={}, id={:?}, addr={}", + "should_request={}, id={:?}, addr={}, depth={}/{}", should_request, node.id, - addr + addr, + depth, + self.max_depth ); if should_request { - self.node_tx.send((Some(node.id), addr))?; + self.node_tx.send((Some(node.id), addr, depth + 1))?; } } } @@ -412,7 +425,11 @@ impl RecursiveRequest { .is_some() } - fn should_request_node(&self, node_id: Id20, addr: SocketAddr) -> bool { + fn should_request_node(&self, node_id: Id20, addr: SocketAddr, depth: usize) -> bool { + if depth >= self.max_depth { + return false; + } + let mut closest_nodes = self.useful_nodes.write(); // If recently requested, ignore @@ -433,7 +450,6 @@ impl RecursiveRequest { errors_in_a_row: 0, }); - const LIMIT: usize = 256; closest_nodes.sort_by_key(|n| { let has_returned_peers_desc = Reverse(n.returned_peers); let has_responded_desc = Reverse(n.last_response.is_some() as u8); @@ -449,7 +465,7 @@ impl RecursiveRequest { freshest_response, ) }); - if closest_nodes.len() > LIMIT { + if closest_nodes.len() > self.useful_nodes_limit { let popped = closest_nodes.pop().unwrap(); if popped.id == node_id { return false; @@ -506,7 +522,7 @@ impl DhtState { let (tx, rx) = tokio::sync::oneshot::channel(); self.inflight_by_transaction_id .insert(key, OutstandingRequest { done: tx }); - trace!("sending to {addr}, {message:?}"); + trace!("sending {message:?}"); match self.worker_sender.send(WorkerSendRequest { our_tid: Some(tid), message, @@ -827,7 +843,7 @@ impl DhtWorker { futs.push( RecursiveRequest::find_node_for_routing_table( self.dht.clone(), random_id, addrs.into_iter() - ).instrument(error_span!("refresh_bucket", random_id=format!("{:?}", random_id))) + ).instrument(error_span!("refresh_bucket")) ); }, _ = futs.next(), if !futs.is_empty() => {}, diff --git a/crates/dht/src/lib.rs b/crates/dht/src/lib.rs index 30bb171..62c161d 100644 --- a/crates/dht/src/lib.rs +++ b/crates/dht/src/lib.rs @@ -15,11 +15,11 @@ pub use persistence::{PersistentDht, PersistentDhtConfig}; pub type Dht = Arc; // How long do we wait for a response from a DHT node. -pub(crate) const RESPONSE_TIMEOUT: Duration = Duration::from_secs(60); +pub(crate) const RESPONSE_TIMEOUT: Duration = Duration::from_secs(10); // TODO: Not sure if we should re-query tbh. -pub(crate) const REQUERY_INTERVAL: Duration = Duration::from_secs(60); +pub(crate) const REQUERY_INTERVAL: Duration = Duration::from_secs(300); // After how long should we ping the node again. -pub(crate) const INACTIVITY_TIMEOUT: Duration = Duration::from_secs(15 * 60); +pub(crate) const INACTIVITY_TIMEOUT: Duration = Duration::from_secs(2 * 60); pub struct DhtBuilder {} diff --git a/crates/dht/src/routing_table.rs b/crates/dht/src/routing_table.rs index 4223757..1da4808 100644 --- a/crates/dht/src/routing_table.rs +++ b/crates/dht/src/routing_table.rs @@ -541,26 +541,29 @@ impl RoutingTableNode { } pub fn status(&self) -> NodeStatus { match (self.last_request, self.last_response, self.last_query) { - (None, _, _) => NodeStatus::Unknown, // Nodes become bad when they fail to respond to multiple queries in a row. (Some(_), _, _) if self.errors_in_a_row >= 2 => NodeStatus::Bad, // A good node is a node has responded to one of our queries within the last 15 minutes. // A node is also good if it has ever responded to one of our queries and has sent // us a query within the last 15 minutes. - (Some(_), Some(last_activity), _) | (Some(_), Some(_), Some(last_activity)) - if last_activity.elapsed() < INACTIVITY_TIMEOUT => + (Some(_), Some(last_incoming), _) | (Some(_), Some(_), Some(last_incoming)) + if last_incoming.elapsed() < INACTIVITY_TIMEOUT => { NodeStatus::Good } - // After 15 minutes of inactivity, a node becomes questionable - (_, _, Some(last_activity)) | (_, Some(last_activity), _) - if last_activity.elapsed() > INACTIVITY_TIMEOUT => + // After 15 minutes of inactivity, a node becomes questionable. + // The moment we send a request to it, it stops becoming questionable and becomes Unknown / Bad. + (last_outgoing, _, Some(last_incoming)) | (last_outgoing, Some(last_incoming), _) + if last_incoming.elapsed() > INACTIVITY_TIMEOUT + && last_outgoing + .map(|e| e.elapsed() > INACTIVITY_TIMEOUT) + .unwrap_or(true) => { NodeStatus::Questionable } - (Some(_), _, _) => NodeStatus::Unknown, + _ => NodeStatus::Unknown, } } @@ -613,7 +616,16 @@ impl RoutingTable { for node in self.buckets.iter() { result.push(node); } - result.sort_by_key(|n| id.distance(&n.id)); + result.sort_by_key(|n| { + // Query decent nodes first. + let status = match n.status() { + NodeStatus::Good => 0, + NodeStatus::Questionable => 0, + NodeStatus::Unknown => 2, + NodeStatus::Bad => 3, + }; + (status, id.distance(&n.id)) + }); result } From fee2690aae7bdf11b9f4e3e7a5d6ad249ee0d854 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Thu, 30 Nov 2023 13:58:33 +0000 Subject: [PATCH 34/51] With pinger its not entirely bad now, but still pretty horrible --- crates/dht/src/dht.rs | 69 +++++++++++++++++++-------------- crates/dht/src/routing_table.rs | 39 +++++-------------- 2 files changed, 49 insertions(+), 59 deletions(-) diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index 77601d7..ab0f6ce 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -14,7 +14,7 @@ use crate::{ self, CompactNodeInfo, ErrorDescription, FindNodeRequest, GetPeersRequest, Message, MessageKind, Node, PingRequest, Response, }, - routing_table::{InsertResult, RoutingTable}, + routing_table::{InsertResult, NodeStatus, RoutingTable}, INACTIVITY_TIMEOUT, REQUERY_INTERVAL, RESPONSE_TIMEOUT, }; use anyhow::{bail, Context}; @@ -109,12 +109,10 @@ impl RecursiveRequestCallbacks for RecursiveRequestCallbacksGetPeers { struct RecursiveRequestCallbacksFindNodes {} impl RecursiveRequestCallbacks for RecursiveRequestCallbacksFindNodes { fn on_request_start(&self, req: &RecursiveRequest, target_node: Id20, addr: SocketAddr) { - match req.dht.routing_table_add_node(target_node, addr) { + let mut rt = req.dht.routing_table.write(); + match rt.add_node(target_node, addr) { InsertResult::WasExisting | InsertResult::ReplacedBad(_) | InsertResult::Added => { - req.dht - .routing_table - .write() - .mark_outgoing_request(&target_node); + rt.mark_outgoing_request(&target_node); } InsertResult::Ignored => {} } @@ -490,15 +488,12 @@ pub struct DhtState { rate_limiter: RateLimiter, // This is to send raw messages worker_sender: UnboundedSender, - // This is to send pings. - ping_sender: UnboundedSender<(Id20, SocketAddr)>, } impl DhtState { fn new_internal( id: Id20, sender: UnboundedSender, - ping_sender: UnboundedSender<(Id20, SocketAddr)>, routing_table: Option, listen_addr: SocketAddr, ) -> Self { @@ -510,7 +505,6 @@ impl DhtState { routing_table: RwLock::new(routing_table), worker_sender: sender, listen_addr, - ping_sender, rate_limiter: make_rate_limiter(), } } @@ -716,14 +710,6 @@ impl DhtState { routing_table_size: self.routing_table.read().len(), } } - - fn routing_table_add_node(self: &Arc, id: Id20, addr: SocketAddr) -> InsertResult { - let res = self.routing_table.write().add_node(id, addr, |id, addr| { - let _ = self.ping_sender.send((id, addr)); - true - }); - res - } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -813,15 +799,20 @@ impl DhtWorker { let filler = async { let mut interval = tokio::time::interval(INACTIVITY_TIMEOUT); tokio::time::sleep(INACTIVITY_TIMEOUT).await; + let mut iteration = 0; loop { interval.tick().await; + let mut found = 0; for bucket in self.dht.routing_table.read().iter_buckets() { if bucket.leaf.last_refreshed.elapsed() < INACTIVITY_TIMEOUT { continue; } + found += 1; let random_id = bucket.random_within(); tx.send(random_id).unwrap(); } + trace!("iteration {}, refreshing {} buckets", iteration, found); + iteration += 1; } }; @@ -851,15 +842,36 @@ impl DhtWorker { } } - async fn pinger(&self, mut rx: UnboundedReceiver<(Id20, SocketAddr)>) -> anyhow::Result<()> { + async fn pinger(&self) -> anyhow::Result<()> { let mut futs = FuturesUnordered::new(); + let mut interval = tokio::time::interval(INACTIVITY_TIMEOUT / 4); + let (tx, mut rx) = unbounded_channel(); + let looper = async { + let mut iteration = 0; + loop { + interval.tick().await; + let mut found = 0; + for node in self.dht.routing_table.read().iter() { + if matches!( + node.status(), + NodeStatus::Questionable | NodeStatus::Unknown + ) { + found += 1; + tx.send((node.id(), node.addr())).unwrap(); + } + } + trace!("iteration {}, pinging {} nodes", iteration, found); + iteration += 1; + } + }; + + tokio::pin!(looper); + loop { tokio::select! { + _ = &mut looper => {}, r = rx.recv() => { - let (id, addr) = match r { - Some(r) => r, - None => return Ok(()), - }; + let (id, addr) = r.unwrap(); futs.push(async move { self.dht.routing_table.write().mark_outgoing_request(&id); match self.dht.request(Request::Ping, addr).await { @@ -944,7 +956,6 @@ impl DhtWorker { async fn start( self, in_rx: UnboundedReceiver, - ping_rx: UnboundedReceiver<(Id20, SocketAddr)>, bootstrap_addrs: &[String], ) -> anyhow::Result<()> { let (out_tx, mut out_rx) = channel(1); @@ -970,8 +981,10 @@ impl DhtWorker { } .instrument(debug_span!("dht_responese_reader")); - let pinger = self.pinger(ping_rx); - let bucket_refresher = self.bucket_refresher(); + let pinger = self.pinger().instrument(error_span!("pinger")); + let bucket_refresher = self + .bucket_refresher() + .instrument(error_span!("bucket_refresher")); tokio::pin!(framer); tokio::pin!(bootstrap); @@ -1034,11 +1047,9 @@ impl DhtState { .unwrap_or_else(|| crate::DHT_BOOTSTRAP.iter().map(|v| v.to_string()).collect()); let (in_tx, in_rx) = unbounded_channel(); - let (ping_tx, ping_rx) = unbounded_channel(); let state = Arc::new(Self::new_internal( peer_id, in_tx, - ping_tx, config.routing_table, listen_addr, )); @@ -1047,7 +1058,7 @@ impl DhtState { let state = state.clone(); async move { let worker = DhtWorker { socket, dht: state }; - worker.start(in_rx, ping_rx, &bootstrap_addrs).await?; + worker.start(in_rx, &bootstrap_addrs).await?; Ok(()) } }); diff --git a/crates/dht/src/routing_table.rs b/crates/dht/src/routing_table.rs index 1da4808..325d93c 100644 --- a/crates/dht/src/routing_table.rs +++ b/crates/dht/src/routing_table.rs @@ -348,15 +348,9 @@ impl BucketTree { } } - pub fn add_node( - &mut self, - self_id: &Id20, - id: Id20, - addr: SocketAddr, - on_questionable_node: impl FnMut(Id20, SocketAddr) -> bool, - ) -> InsertResult { + pub fn add_node(&mut self, self_id: &Id20, id: Id20, addr: SocketAddr) -> InsertResult { let idx = self.get_leaf(&id); - self.insert_into_leaf(idx, self_id, id, addr, on_questionable_node) + self.insert_into_leaf(idx, self_id, id, addr) } fn insert_into_leaf( &mut self, @@ -364,7 +358,6 @@ impl BucketTree { self_id: &Id20, id: Id20, addr: SocketAddr, - mut on_questionable_node: impl FnMut(Id20, SocketAddr) -> bool, ) -> InsertResult { // The loop here is for this case: // in case we split a node into two, and it degenerates into all the leaves @@ -399,17 +392,6 @@ impl BucketTree { return InsertResult::Added; } - // Ping first questionable node - if let Some(questionable_node) = nodes - .nodes - .iter_mut() - .find(|r| matches!(r.status(), NodeStatus::Questionable)) - { - if on_questionable_node(questionable_node.id, questionable_node.addr) { - questionable_node.mark_outgoing_request(); - } - } - // Try replace a bad node if let Some(bad_node) = nodes .nodes @@ -633,15 +615,12 @@ impl RoutingTable { self.buckets.iter_leaves() } - pub fn add_node( - &mut self, - id: Id20, - addr: SocketAddr, - on_questionable_node: impl FnMut(Id20, SocketAddr) -> bool, - ) -> InsertResult { - let res = self - .buckets - .add_node(&self.id, id, addr, on_questionable_node); + pub fn iter(&self) -> impl Iterator + '_ { + self.buckets.iter() + } + + pub fn add_node(&mut self, id: Id20, addr: SocketAddr) -> InsertResult { + let res = self.buckets.add_node(&self.id, id, addr); let replaced = match &res { InsertResult::WasExisting => false, InsertResult::ReplacedBad(..) => true, @@ -782,7 +761,7 @@ mod tests { for _ in 0..length.unwrap_or(16536) { let other_id = random_id_20(); let addr = generate_socket_addr(); - rtable.add_node(other_id, addr, |_, _| false); + rtable.add_node(other_id, addr); } rtable } From 16a4d22b6b99d720b743a9d1d296b728cddf786a Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Thu, 30 Nov 2023 14:49:39 +0000 Subject: [PATCH 35/51] Fix a bug in get_bit() --- crates/dht/src/routing_table.rs | 13 ++++++++++++- crates/librqbit_core/src/id20.rs | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/dht/src/routing_table.rs b/crates/dht/src/routing_table.rs index 325d93c..fe447af 100644 --- a/crates/dht/src/routing_table.rs +++ b/crates/dht/src/routing_table.rs @@ -682,7 +682,7 @@ mod tests { use crate::routing_table::compute_split_start_end; - use super::RoutingTable; + use super::{generate_random_id, RoutingTable}; #[test] fn compute_split_start_end_root() { @@ -790,4 +790,15 @@ mod tests { let v = serde_json::to_vec(&table).unwrap(); let _: RoutingTable = serde_json::from_reader(Cursor::new(v)).unwrap(); } + + #[test] + fn test_generate_random_id() { + let start = Id20::from_str("3000000000000000000000000000000000000000").unwrap(); + let end = Id20::from_str("3fffffffffffffffffffffffffffffffffffffff").unwrap(); + let bits = 156; + for _ in 0..100 { + let id = dbg!(generate_random_id(&start, bits)); + assert!(id >= start && id <= end, "{:?}", id); + } + } } diff --git a/crates/librqbit_core/src/id20.rs b/crates/librqbit_core/src/id20.rs index 8562a3d..2492f78 100644 --- a/crates/librqbit_core/src/id20.rs +++ b/crates/librqbit_core/src/id20.rs @@ -104,7 +104,7 @@ impl Id20 { } pub fn get_bit(&self, bit: u8) -> bool { let n = self.0[(bit / 8) as usize]; - let mask = !(1 << (7 - bit % 8)); + let mask = 1 << (7 - bit % 8); n & mask > 0 } From 4af26ae24654db71087803c45be89a67e1007eb8 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Thu, 30 Nov 2023 15:35:08 +0000 Subject: [PATCH 36/51] Add max size to routing table --- crates/dht/src/dht.rs | 4 +- crates/dht/src/routing_table.rs | 154 +++++++------------------------- 2 files changed, 32 insertions(+), 126 deletions(-) diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index ab0f6ce..465555f 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -497,7 +497,7 @@ impl DhtState { routing_table: Option, listen_addr: SocketAddr, ) -> Self { - let routing_table = routing_table.unwrap_or_else(|| RoutingTable::new(id)); + let routing_table = routing_table.unwrap_or_else(|| RoutingTable::new(id, None)); Self { id, next_transaction_id: AtomicU16::new(0), @@ -798,7 +798,7 @@ impl DhtWorker { let mut futs = FuturesUnordered::new(); let filler = async { let mut interval = tokio::time::interval(INACTIVITY_TIMEOUT); - tokio::time::sleep(INACTIVITY_TIMEOUT).await; + interval.tick().await; let mut iteration = 0; loop { interval.tick().await; diff --git a/crates/dht/src/routing_table.rs b/crates/dht/src/routing_table.rs index fe447af..1fe85bc 100644 --- a/crates/dht/src/routing_table.rs +++ b/crates/dht/src/routing_table.rs @@ -2,11 +2,8 @@ use std::{net::SocketAddr, time::Instant}; use librqbit_core::id20::Id20; use rand::RngCore; -use serde::{ - ser::{SerializeMap, SerializeStruct}, - Deserialize, Serialize, Serializer, -}; -use tracing::debug; +use serde::{ser::SerializeStruct, Deserialize, Serialize, Serializer}; +use tracing::{debug, trace}; use crate::INACTIVITY_TIMEOUT; @@ -72,110 +69,11 @@ struct BucketTreeNode { data: BucketTreeNodeData, } -#[derive(Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct BucketTree { data: Vec, -} - -impl<'de> Deserialize<'de> for BucketTree { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - struct Visitor; - impl<'de> serde::de::Visitor<'de> for Visitor { - type Value = BucketTree; - - fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "a map with key \"flat\"") - } - - fn visit_map(self, mut map: A) -> Result - where - A: serde::de::MapAccess<'de>, - { - let mut data: Option> = None; - loop { - match map.next_key::()?.as_deref() { - Some("flat") => { - let buckets = map.next_value::>()?; - data = Some(buckets) - } - Some(_) => { - map.next_value::()?; - } - None => { - use serde::de::Error; - match data.take() { - Some(data) => return Ok(BucketTree { data }), - None => return Err(A::Error::missing_field("flat")), - } - } - } - } - } - } - deserializer.deserialize_map(Visitor) - } -} - -impl Serialize for BucketTree { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - struct Node<'a> { - tree: &'a BucketTree, - idx: usize, - } - - impl<'a> Serialize for Node<'a> { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - let mut map = serializer.serialize_map(None)?; - let node = &self.tree.data[self.idx]; - map.serialize_entry("bits", &node.bits)?; - map.serialize_entry("start", &node.start.as_string())?; - map.serialize_entry("end", &node.end_inclusive.as_string())?; - match &node.data { - BucketTreeNodeData::Leaf(nodes) => { - map.serialize_entry("nodes", &nodes)?; - } - BucketTreeNodeData::LeftRight(l, r) => { - map.serialize_entry( - "left", - &(Node { - idx: *l, - tree: self.tree, - }), - )?; - map.serialize_entry( - "right", - &(Node { - idx: *r, - tree: self.tree, - }), - )?; - } - } - map.end() - } - } - - let mut map = serializer.serialize_map(None)?; - map.serialize_entry("nodes_len", &self.data.len())?; - map.serialize_entry("nodes_capacity", &self.data.capacity())?; - map.serialize_entry("node_memory_bytes", &std::mem::size_of::())?; - map.serialize_entry( - "nodes_memory_bytes", - &(std::mem::size_of::() * self.data.capacity()), - )?; - map.serialize_entry("tree", &Node { tree: self, idx: 0 })?; - map.serialize_entry("flat", &self.data)?; - map.end() - } + size: usize, + max_size: usize, } pub struct BucketTreeIteratorItem<'a> { @@ -297,7 +195,7 @@ pub enum InsertResult { } impl BucketTree { - pub fn new() -> Self { + pub fn new(max_size: usize) -> Self { BucketTree { data: vec![BucketTreeNode { bits: 160, @@ -305,6 +203,8 @@ impl BucketTree { end_inclusive: Id20([0xff; 20]), data: BucketTreeNodeData::Leaf(Default::default()), }], + size: 0, + max_size, } } @@ -385,13 +285,6 @@ impl BucketTree { errors_in_a_row: 0, }; - if nodes.nodes.len() < 8 { - nodes.nodes.push(new_node); - nodes.nodes.sort_by_key(|n| n.id); - nodes.last_refreshed = Instant::now(); - return InsertResult::Added; - } - // Try replace a bad node if let Some(bad_node) = nodes .nodes @@ -405,6 +298,23 @@ impl BucketTree { return InsertResult::ReplacedBad(new_node); } + // if max size reached, don't bother + if self.size == self.max_size { + trace!( + "can't add node to routing table, max size of {} reached", + self.max_size + ); + return InsertResult::Ignored; + } + + if nodes.nodes.len() < 8 { + nodes.nodes.push(new_node); + nodes.nodes.sort_by_key(|n| n.id); + nodes.last_refreshed = Instant::now(); + self.size += 1; + return InsertResult::Added; + } + // if our id is not inside, don't bother. if *self_id < leaf.start || *self_id > leaf.end_inclusive { return InsertResult::Ignored; @@ -462,12 +372,6 @@ impl BucketTree { } } -impl Default for BucketTree { - fn default() -> Self { - Self::new() - } -} - #[derive(Debug, Clone, Deserialize)] pub struct RoutingTableNode { #[serde(serialize_with = "crate::utils::serialize_id20")] @@ -580,10 +484,12 @@ pub struct RoutingTable { } impl RoutingTable { - pub fn new(id: Id20) -> Self { + const DEFAULT_MAX_SIZE: usize = 512; + + pub fn new(id: Id20, max_size: Option) -> Self { Self { id, - buckets: BucketTree::new(), + buckets: BucketTree::new(max_size.unwrap_or(Self::DEFAULT_MAX_SIZE)), size: 0, } } @@ -757,7 +663,7 @@ mod tests { fn generate_table(length: Option) -> RoutingTable { let my_id = random_id_20(); - let mut rtable = RoutingTable::new(my_id); + let mut rtable = RoutingTable::new(my_id, None); for _ in 0..length.unwrap_or(16536) { let other_id = random_id_20(); let addr = generate_socket_addr(); From dc50de31dc6f64a3dd15d4251d2e53715f1d6e19 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Thu, 30 Nov 2023 15:38:22 +0000 Subject: [PATCH 37/51] Restore DHT lib timeouts --- crates/dht/src/lib.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/dht/src/lib.rs b/crates/dht/src/lib.rs index 62c161d..a7c50cc 100644 --- a/crates/dht/src/lib.rs +++ b/crates/dht/src/lib.rs @@ -15,11 +15,11 @@ pub use persistence::{PersistentDht, PersistentDhtConfig}; pub type Dht = Arc; // How long do we wait for a response from a DHT node. -pub(crate) const RESPONSE_TIMEOUT: Duration = Duration::from_secs(10); +pub(crate) const RESPONSE_TIMEOUT: Duration = Duration::from_secs(60); // TODO: Not sure if we should re-query tbh. -pub(crate) const REQUERY_INTERVAL: Duration = Duration::from_secs(300); -// After how long should we ping the node again. -pub(crate) const INACTIVITY_TIMEOUT: Duration = Duration::from_secs(2 * 60); +pub(crate) const REQUERY_INTERVAL: Duration = Duration::from_secs(60); +// After how long we consider a routing table node questionable. +pub(crate) const INACTIVITY_TIMEOUT: Duration = Duration::from_secs(15 * 60); pub struct DhtBuilder {} From a0feee27a68d0c21c5c14290edb4fab993940d00 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Thu, 30 Nov 2023 16:05:48 +0000 Subject: [PATCH 38/51] Add a button to increase peer timeouts --- crates/librqbit/src/http_api.rs | 11 ++++- crates/librqbit/src/http_api_client.rs | 1 + crates/librqbit/webui/dist/assets/index.js | 14 +++--- crates/librqbit/webui/dist/manifest.json | 2 +- crates/librqbit/webui/src/api.ts | 5 +- crates/librqbit/webui/src/index.tsx | 56 +++++++++++++++------- 6 files changed, 61 insertions(+), 28 deletions(-) diff --git a/crates/librqbit/src/http_api.rs b/crates/librqbit/src/http_api.rs index 440164d..be3bee4 100644 --- a/crates/librqbit/src/http_api.rs +++ b/crates/librqbit/src/http_api.rs @@ -12,12 +12,14 @@ use librqbit_core::torrent_metainfo::TorrentMetaV1Info; use serde::{Deserialize, Serialize}; use std::net::SocketAddr; use std::sync::Arc; +use std::time::Duration; use tokio::sync::mpsc::UnboundedSender; use tracing::{info, warn}; use axum::Router; use crate::http_api_error::{ApiError, ApiErrorExt}; +use crate::peer_connection::PeerConnectionOptions; use crate::session::{ AddTorrent, AddTorrentOptions, AddTorrentResponse, ListOnlyResponse, Session, TorrentId, }; @@ -322,13 +324,15 @@ impl<'de> Deserialize<'de> for OnlyFiles { } } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Default)] pub struct TorrentAddQueryParams { pub overwrite: Option, pub output_folder: Option, pub sub_folder: Option, pub only_files_regex: Option, pub only_files: Option, + pub peer_connect_timeout: Option, + pub peer_read_write_timeout: Option, pub list_only: Option, } @@ -341,6 +345,11 @@ impl TorrentAddQueryParams { output_folder: self.output_folder, sub_folder: self.sub_folder, list_only: self.list_only.unwrap_or(false), + peer_opts: Some(PeerConnectionOptions { + connect_timeout: self.peer_connect_timeout.map(Duration::from_secs), + read_write_timeout: self.peer_read_write_timeout.map(Duration::from_secs), + ..Default::default() + }), ..Default::default() } } diff --git a/crates/librqbit/src/http_api_client.rs b/crates/librqbit/src/http_api_client.rs index f1cd8dd..f763af9 100644 --- a/crates/librqbit/src/http_api_client.rs +++ b/crates/librqbit/src/http_api_client.rs @@ -91,6 +91,7 @@ impl HttpApiClient { output_folder: opts.output_folder, sub_folder: opts.sub_folder, list_only: Some(opts.list_only), + ..Default::default() }; let qs = serde_urlencoded::to_string(¶ms).unwrap(); let url = format!("{}torrents?{}", &self.base_url, qs); diff --git a/crates/librqbit/webui/dist/assets/index.js b/crates/librqbit/webui/dist/assets/index.js index f2f21b2..ec39cab 100644 --- a/crates/librqbit/webui/dist/assets/index.js +++ b/crates/librqbit/webui/dist/assets/index.js @@ -6,7 +6,7 @@ * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. - */var $r=Symbol.for("react.element"),Wd=Symbol.for("react.portal"),Vd=Symbol.for("react.fragment"),Qd=Symbol.for("react.strict_mode"),Kd=Symbol.for("react.profiler"),Gd=Symbol.for("react.provider"),Yd=Symbol.for("react.context"),Xd=Symbol.for("react.forward_ref"),Zd=Symbol.for("react.suspense"),Jd=Symbol.for("react.memo"),qd=Symbol.for("react.lazy"),Ju=Symbol.iterator;function bd(e){return e===null||typeof e!="object"?null:(e=Ju&&e[Ju]||e["@@iterator"],typeof e=="function"?e:null)}var Ea={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},xa=Object.assign,Ca={};function Bn(e,t,n){this.props=e,this.context=t,this.refs=Ca,this.updater=n||Ea}Bn.prototype.isReactComponent={};Bn.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};Bn.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function Na(){}Na.prototype=Bn.prototype;function Yi(e,t,n){this.props=e,this.context=t,this.refs=Ca,this.updater=n||Ea}var Xi=Yi.prototype=new Na;Xi.constructor=Yi;xa(Xi,Bn.prototype);Xi.isPureReactComponent=!0;var qu=Array.isArray,Ta=Object.prototype.hasOwnProperty,Zi={current:null},_a={key:!0,ref:!0,__self:!0,__source:!0};function ja(e,t,n){var r,l={},o=null,i=null;if(t!=null)for(r in t.ref!==void 0&&(i=t.ref),t.key!==void 0&&(o=""+t.key),t)Ta.call(t,r)&&!_a.hasOwnProperty(r)&&(l[r]=t[r]);var u=arguments.length-2;if(u===1)l.children=n;else if(1>>1,A=x[D];if(0>>1;Dl(qe,O))Rel(mt,qe)?(x[D]=mt,x[Re]=O,D=Re):(x[D]=qe,x[je]=O,D=je);else if(Rel(mt,O))x[D]=mt,x[Re]=O,D=Re;else break e}}return L}function l(x,L){var O=x.sortIndex-L.sortIndex;return O!==0?O:x.id-L.id}if(typeof performance=="object"&&typeof performance.now=="function"){var o=performance;e.unstable_now=function(){return o.now()}}else{var i=Date,u=i.now();e.unstable_now=function(){return i.now()-u}}var s=[],a=[],d=1,m=null,p=3,g=!1,w=!1,k=!1,R=typeof setTimeout=="function"?setTimeout:null,f=typeof clearTimeout=="function"?clearTimeout:null,c=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function h(x){for(var L=n(a);L!==null;){if(L.callback===null)r(a);else if(L.startTime<=x)r(a),L.sortIndex=L.expirationTime,t(s,L);else break;L=n(a)}}function S(x){if(k=!1,h(x),!w)if(n(s)!==null)w=!0,_e(C);else{var L=n(a);L!==null&&Ke(S,L.startTime-x)}}function C(x,L){w=!1,k&&(k=!1,f(j),j=-1),g=!0;var O=p;try{for(h(L),m=n(s);m!==null&&(!(m.expirationTime>L)||x&&!ie());){var D=m.callback;if(typeof D=="function"){m.callback=null,p=m.priorityLevel;var A=D(m.expirationTime<=L);L=e.unstable_now(),typeof A=="function"?m.callback=A:m===n(s)&&r(s),h(L)}else r(s);m=n(s)}if(m!==null)var fe=!0;else{var je=n(a);je!==null&&Ke(S,je.startTime-L),fe=!1}return fe}finally{m=null,p=O,g=!1}}var N=!1,T=null,j=-1,B=5,P=-1;function ie(){return!(e.unstable_now()-Px||125D?(x.sortIndex=O,t(a,x),n(s)===null&&x===n(a)&&(k?(f(j),j=-1):k=!0,Ke(S,O-D))):(x.sortIndex=A,t(s,x),w||g||(w=!0,_e(C))),x},e.unstable_shouldYield=ie,e.unstable_wrapCallback=function(x){var L=p;return function(){var O=p;p=L;try{return x.apply(this,arguments)}finally{p=O}}}})(Pa);Oa.exports=Pa;var cp=Oa.exports;/** + */(function(e){function t(E,L){var O=E.length;E.push(L);e:for(;0>>1,A=E[D];if(0>>1;Dl(qe,O))Rel(mt,qe)?(E[D]=mt,E[Re]=O,D=Re):(E[D]=qe,E[je]=O,D=je);else if(Rel(mt,O))E[D]=mt,E[Re]=O,D=Re;else break e}}return L}function l(E,L){var O=E.sortIndex-L.sortIndex;return O!==0?O:E.id-L.id}if(typeof performance=="object"&&typeof performance.now=="function"){var o=performance;e.unstable_now=function(){return o.now()}}else{var i=Date,u=i.now();e.unstable_now=function(){return i.now()-u}}var s=[],a=[],f=1,h=null,d=3,g=!1,S=!1,k=!1,R=typeof setTimeout=="function"?setTimeout:null,p=typeof clearTimeout=="function"?clearTimeout:null,c=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function m(E){for(var L=n(a);L!==null;){if(L.callback===null)r(a);else if(L.startTime<=E)r(a),L.sortIndex=L.expirationTime,t(s,L);else break;L=n(a)}}function w(E){if(k=!1,m(E),!S)if(n(s)!==null)S=!0,_e(C);else{var L=n(a);L!==null&&Ke(w,L.startTime-E)}}function C(E,L){S=!1,k&&(k=!1,p(j),j=-1),g=!0;var O=d;try{for(m(L),h=n(s);h!==null&&(!(h.expirationTime>L)||E&&!ie());){var D=h.callback;if(typeof D=="function"){h.callback=null,d=h.priorityLevel;var A=D(h.expirationTime<=L);L=e.unstable_now(),typeof A=="function"?h.callback=A:h===n(s)&&r(s),m(L)}else r(s);h=n(s)}if(h!==null)var fe=!0;else{var je=n(a);je!==null&&Ke(w,je.startTime-L),fe=!1}return fe}finally{h=null,d=O,g=!1}}var N=!1,T=null,j=-1,U=5,P=-1;function ie(){return!(e.unstable_now()-PE||125D?(E.sortIndex=O,t(a,E),n(s)===null&&E===n(a)&&(k?(p(j),j=-1):k=!0,Ke(w,O-D))):(E.sortIndex=A,t(s,E),S||g||(S=!0,_e(C))),E},e.unstable_shouldYield=ie,e.unstable_wrapCallback=function(E){var L=d;return function(){var O=d;d=L;try{return E.apply(this,arguments)}finally{d=O}}}})(Pa);Oa.exports=Pa;var cp=Oa.exports;/** * @license React * react-dom.production.min.js * @@ -30,15 +30,15 @@ * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. - */var Fa=y,Ce=cp;function E(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Xo=Object.prototype.hasOwnProperty,fp=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,es={},ts={};function dp(e){return Xo.call(ts,e)?!0:Xo.call(es,e)?!1:fp.test(e)?ts[e]=!0:(es[e]=!0,!1)}function pp(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function mp(e,t,n,r){if(t===null||typeof t>"u"||pp(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function he(e,t,n,r,l,o,i){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=o,this.removeEmptyString=i}var oe={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){oe[e]=new he(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];oe[t]=new he(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){oe[e]=new he(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){oe[e]=new he(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){oe[e]=new he(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){oe[e]=new he(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){oe[e]=new he(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){oe[e]=new he(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){oe[e]=new he(e,5,!1,e.toLowerCase(),null,!1,!1)});var qi=/[\-:]([a-z])/g;function bi(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(qi,bi);oe[t]=new he(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(qi,bi);oe[t]=new he(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(qi,bi);oe[t]=new he(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){oe[e]=new he(e,1,!1,e.toLowerCase(),null,!1,!1)});oe.xlinkHref=new he("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){oe[e]=new he(e,1,!1,e.toLowerCase(),null,!0,!0)});function eu(e,t,n,r){var l=oe.hasOwnProperty(t)?oe[t]:null;(l!==null?l.type!==0:r||!(2"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Xo=Object.prototype.hasOwnProperty,fp=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,es={},ts={};function dp(e){return Xo.call(ts,e)?!0:Xo.call(es,e)?!1:fp.test(e)?ts[e]=!0:(es[e]=!0,!1)}function pp(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function mp(e,t,n,r){if(t===null||typeof t>"u"||pp(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function he(e,t,n,r,l,o,i){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=o,this.removeEmptyString=i}var oe={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){oe[e]=new he(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];oe[t]=new he(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){oe[e]=new he(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){oe[e]=new he(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){oe[e]=new he(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){oe[e]=new he(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){oe[e]=new he(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){oe[e]=new he(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){oe[e]=new he(e,5,!1,e.toLowerCase(),null,!1,!1)});var qi=/[\-:]([a-z])/g;function bi(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(qi,bi);oe[t]=new he(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(qi,bi);oe[t]=new he(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(qi,bi);oe[t]=new he(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){oe[e]=new he(e,1,!1,e.toLowerCase(),null,!1,!1)});oe.xlinkHref=new he("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){oe[e]=new he(e,1,!1,e.toLowerCase(),null,!0,!0)});function eu(e,t,n,r){var l=oe.hasOwnProperty(t)?oe[t]:null;(l!==null?l.type!==0:r||!(2u||l[i]!==o[u]){var s=` -`+l[i].replace(" at new "," at ");return e.displayName&&s.includes("")&&(s=s.replace("",e.displayName)),s}while(1<=i&&0<=u);break}}}finally{go=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?rr(e):""}function hp(e){switch(e.tag){case 5:return rr(e.type);case 16:return rr("Lazy");case 13:return rr("Suspense");case 19:return rr("SuspenseList");case 0:case 2:case 15:return e=wo(e.type,!1),e;case 11:return e=wo(e.type.render,!1),e;case 1:return e=wo(e.type,!0),e;default:return""}}function bo(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case pn:return"Fragment";case dn:return"Portal";case Zo:return"Profiler";case tu:return"StrictMode";case Jo:return"Suspense";case qo:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case $a:return(e.displayName||"Context")+".Consumer";case za:return(e._context.displayName||"Context")+".Provider";case nu:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case ru:return t=e.displayName||null,t!==null?t:bo(e.type)||"Memo";case gt:t=e._payload,e=e._init;try{return bo(e(t))}catch{}}return null}function vp(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return bo(t);case 8:return t===tu?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function Ft(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function Ia(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function yp(e){var t=Ia(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,o=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(i){r=""+i,o.call(this,i)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(i){r=""+i},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function Qr(e){e._valueTracker||(e._valueTracker=yp(e))}function Aa(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=Ia(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function kl(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function ei(e,t){var n=t.checked;return X({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function rs(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=Ft(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function Ba(e,t){t=t.checked,t!=null&&eu(e,"checked",t,!1)}function ti(e,t){Ba(e,t);var n=Ft(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?ni(e,t.type,n):t.hasOwnProperty("defaultValue")&&ni(e,t.type,Ft(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function ls(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function ni(e,t,n){(t!=="number"||kl(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var lr=Array.isArray;function Nn(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=Kr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function gr(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var sr={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},gp=["Webkit","ms","Moz","O"];Object.keys(sr).forEach(function(e){gp.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),sr[t]=sr[e]})});function Va(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||sr.hasOwnProperty(e)&&sr[e]?(""+t).trim():t+"px"}function Qa(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=Va(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var wp=X({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function oi(e,t){if(t){if(wp[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(E(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(E(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(E(61))}if(t.style!=null&&typeof t.style!="object")throw Error(E(62))}}function ii(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var ui=null;function lu(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var si=null,Tn=null,_n=null;function us(e){if(e=Ar(e)){if(typeof si!="function")throw Error(E(280));var t=e.stateNode;t&&(t=eo(t),si(e.stateNode,e.type,t))}}function Ka(e){Tn?_n?_n.push(e):_n=[e]:Tn=e}function Ga(){if(Tn){var e=Tn,t=_n;if(_n=Tn=null,us(e),t)for(e=0;e>>=0,e===0?32:31-(Lp(e)/Op|0)|0}var Gr=64,Yr=4194304;function or(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Nl(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,o=e.pingedLanes,i=n&268435455;if(i!==0){var u=i&~l;u!==0?r=or(u):(o&=i,o!==0&&(r=or(o)))}else i=n&~l,i!==0?r=or(i):o!==0&&(r=or(o));if(r===0)return 0;if(t!==0&&t!==r&&!(t&l)&&(l=r&-r,o=t&-t,l>=o||l===16&&(o&4194240)!==0))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function Dr(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Ue(t),e[t]=n}function zp(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=cr),vs=String.fromCharCode(32),ys=!1;function pc(e,t){switch(e){case"keyup":return am.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function mc(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var mn=!1;function fm(e,t){switch(e){case"compositionend":return mc(t);case"keypress":return t.which!==32?null:(ys=!0,vs);case"textInput":return e=t.data,e===vs&&ys?null:e;default:return null}}function dm(e,t){if(mn)return e==="compositionend"||!du&&pc(e,t)?(e=fc(),fl=au=xt=null,mn=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=ks(n)}}function gc(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?gc(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function wc(){for(var e=window,t=kl();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=kl(e.document)}return t}function pu(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function km(e){var t=wc(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&gc(n.ownerDocument.documentElement,n)){if(r!==null&&pu(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,o=Math.min(r.start,l);r=r.end===void 0?o:Math.min(r.end,l),!e.extend&&o>r&&(l=r,r=o,o=l),l=Es(n,o);var i=Es(n,r);l&&i&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==i.node||e.focusOffset!==i.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),o>r?(e.addRange(t),e.extend(i.node,i.offset)):(t.setEnd(i.node,i.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,hn=null,mi=null,dr=null,hi=!1;function xs(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;hi||hn==null||hn!==kl(r)||(r=hn,"selectionStart"in r&&pu(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),dr&&Cr(dr,r)||(dr=r,r=jl(mi,"onSelect"),0gn||(e.current=ki[gn],ki[gn]=null,gn--)}function U(e,t){gn++,ki[gn]=e.current,e.current=t}var Mt={},ce=$t(Mt),ge=$t(!1),Zt=Mt;function Fn(e,t){var n=e.type.contextTypes;if(!n)return Mt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},o;for(o in n)l[o]=t[o];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function we(e){return e=e.childContextTypes,e!=null}function Ll(){V(ge),V(ce)}function Ls(e,t,n){if(ce.current!==Mt)throw Error(E(168));U(ce,t),U(ge,n)}function jc(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(E(108,vp(e)||"Unknown",l));return X({},n,r)}function Ol(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Mt,Zt=ce.current,U(ce,e),U(ge,ge.current),!0}function Os(e,t,n){var r=e.stateNode;if(!r)throw Error(E(169));n?(e=jc(e,t,Zt),r.__reactInternalMemoizedMergedChildContext=e,V(ge),V(ce),U(ce,e)):V(ge),U(ge,n)}var et=null,to=!1,Fo=!1;function Rc(e){et===null?et=[e]:et.push(e)}function Fm(e){to=!0,Rc(e)}function Dt(){if(!Fo&&et!==null){Fo=!0;var e=0,t=I;try{var n=et;for(I=1;e>=i,l-=i,nt=1<<32-Ue(t)+l|n<j?(B=T,T=null):B=T.sibling;var P=p(f,T,h[j],S);if(P===null){T===null&&(T=B);break}e&&T&&P.alternate===null&&t(f,T),c=o(P,c,j),N===null?C=P:N.sibling=P,N=P,T=B}if(j===h.length)return n(f,T),Q&&At(f,j),C;if(T===null){for(;jj?(B=T,T=null):B=T.sibling;var ie=p(f,T,P.value,S);if(ie===null){T===null&&(T=B);break}e&&T&&ie.alternate===null&&t(f,T),c=o(ie,c,j),N===null?C=ie:N.sibling=ie,N=ie,T=B}if(P.done)return n(f,T),Q&&At(f,j),C;if(T===null){for(;!P.done;j++,P=h.next())P=m(f,P.value,S),P!==null&&(c=o(P,c,j),N===null?C=P:N.sibling=P,N=P);return Q&&At(f,j),C}for(T=r(f,T);!P.done;j++,P=h.next())P=g(T,f,j,P.value,S),P!==null&&(e&&P.alternate!==null&&T.delete(P.key===null?j:P.key),c=o(P,c,j),N===null?C=P:N.sibling=P,N=P);return e&&T.forEach(function(Ve){return t(f,Ve)}),Q&&At(f,j),C}function R(f,c,h,S){if(typeof h=="object"&&h!==null&&h.type===pn&&h.key===null&&(h=h.props.children),typeof h=="object"&&h!==null){switch(h.$$typeof){case Vr:e:{for(var C=h.key,N=c;N!==null;){if(N.key===C){if(C=h.type,C===pn){if(N.tag===7){n(f,N.sibling),c=l(N,h.props.children),c.return=f,f=c;break e}}else if(N.elementType===C||typeof C=="object"&&C!==null&&C.$$typeof===gt&&Is(C)===N.type){n(f,N.sibling),c=l(N,h.props),c.ref=er(f,N,h),c.return=f,f=c;break e}n(f,N);break}else t(f,N);N=N.sibling}h.type===pn?(c=Yt(h.props.children,f.mode,S,h.key),c.return=f,f=c):(S=wl(h.type,h.key,h.props,null,f.mode,S),S.ref=er(f,c,h),S.return=f,f=S)}return i(f);case dn:e:{for(N=h.key;c!==null;){if(c.key===N)if(c.tag===4&&c.stateNode.containerInfo===h.containerInfo&&c.stateNode.implementation===h.implementation){n(f,c.sibling),c=l(c,h.children||[]),c.return=f,f=c;break e}else{n(f,c);break}else t(f,c);c=c.sibling}c=Uo(h,f.mode,S),c.return=f,f=c}return i(f);case gt:return N=h._init,R(f,c,N(h._payload),S)}if(lr(h))return w(f,c,h,S);if(Xn(h))return k(f,c,h,S);tl(f,h)}return typeof h=="string"&&h!==""||typeof h=="number"?(h=""+h,c!==null&&c.tag===6?(n(f,c.sibling),c=l(c,h),c.return=f,f=c):(n(f,c),c=Bo(h,f.mode,S),c.return=f,f=c),i(f)):n(f,c)}return R}var zn=Dc(!0),Ic=Dc(!1),Br={},Je=$t(Br),jr=$t(Br),Rr=$t(Br);function Kt(e){if(e===Br)throw Error(E(174));return e}function Eu(e,t){switch(U(Rr,t),U(jr,e),U(Je,Br),e=t.nodeType,e){case 9:case 11:t=(t=t.documentElement)?t.namespaceURI:li(null,"");break;default:e=e===8?t.parentNode:t,t=e.namespaceURI||null,e=e.tagName,t=li(t,e)}V(Je),U(Je,t)}function $n(){V(Je),V(jr),V(Rr)}function Ac(e){Kt(Rr.current);var t=Kt(Je.current),n=li(t,e.type);t!==n&&(U(jr,e),U(Je,n))}function xu(e){jr.current===e&&(V(Je),V(jr))}var G=$t(0);function Dl(e){for(var t=e;t!==null;){if(t.tag===13){var n=t.memoizedState;if(n!==null&&(n=n.dehydrated,n===null||n.data==="$?"||n.data==="$!"))return t}else if(t.tag===19&&t.memoizedProps.revealOrder!==void 0){if(t.flags&128)return t}else if(t.child!==null){t.child.return=t,t=t.child;continue}if(t===e)break;for(;t.sibling===null;){if(t.return===null||t.return===e)return null;t=t.return}t.sibling.return=t.return,t=t.sibling}return null}var Mo=[];function Cu(){for(var e=0;en?n:4,e(!0);var r=zo.transition;zo.transition={};try{e(!1),t()}finally{I=n,zo.transition=r}}function tf(){return $e().memoizedState}function Dm(e,t,n){var r=Ot(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},nf(e))rf(t,n);else if(n=Fc(e,t,n,r),n!==null){var l=pe();He(n,e,r,l),lf(n,t,r)}}function Im(e,t,n){var r=Ot(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(nf(e))rf(t,l);else{var o=e.alternate;if(e.lanes===0&&(o===null||o.lanes===0)&&(o=t.lastRenderedReducer,o!==null))try{var i=t.lastRenderedState,u=o(i,n);if(l.hasEagerState=!0,l.eagerState=u,We(u,i)){var s=t.interleaved;s===null?(l.next=l,Su(t)):(l.next=s.next,s.next=l),t.interleaved=l;return}}catch{}finally{}n=Fc(e,t,l,r),n!==null&&(l=pe(),He(n,e,r,l),lf(n,t,r))}}function nf(e){var t=e.alternate;return e===Y||t!==null&&t===Y}function rf(e,t){pr=Il=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function lf(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,iu(e,n)}}var Al={readContext:ze,useCallback:ue,useContext:ue,useEffect:ue,useImperativeHandle:ue,useInsertionEffect:ue,useLayoutEffect:ue,useMemo:ue,useReducer:ue,useRef:ue,useState:ue,useDebugValue:ue,useDeferredValue:ue,useTransition:ue,useMutableSource:ue,useSyncExternalStore:ue,useId:ue,unstable_isNewReconciler:!1},Am={readContext:ze,useCallback:function(e,t){return Ye().memoizedState=[e,t===void 0?null:t],e},useContext:ze,useEffect:Bs,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,hl(4194308,4,Zc.bind(null,t,e),n)},useLayoutEffect:function(e,t){return hl(4194308,4,e,t)},useInsertionEffect:function(e,t){return hl(4,2,e,t)},useMemo:function(e,t){var n=Ye();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=Ye();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=Dm.bind(null,Y,e),[r.memoizedState,e]},useRef:function(e){var t=Ye();return e={current:e},t.memoizedState=e},useState:As,useDebugValue:Ru,useDeferredValue:function(e){return Ye().memoizedState=e},useTransition:function(){var e=As(!1),t=e[0];return e=$m.bind(null,e[1]),Ye().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=Y,l=Ye();if(Q){if(n===void 0)throw Error(E(407));n=n()}else{if(n=t(),ne===null)throw Error(E(349));qt&30||Hc(r,t,n)}l.memoizedState=n;var o={value:n,getSnapshot:t};return l.queue=o,Bs(Vc.bind(null,r,o,e),[e]),r.flags|=2048,Pr(9,Wc.bind(null,r,o,n,t),void 0,null),n},useId:function(){var e=Ye(),t=ne.identifierPrefix;if(Q){var n=rt,r=nt;n=(r&~(1<<32-Ue(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=Lr++,0")&&(s=s.replace("",e.displayName)),s}while(1<=i&&0<=u);break}}}finally{go=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?rr(e):""}function hp(e){switch(e.tag){case 5:return rr(e.type);case 16:return rr("Lazy");case 13:return rr("Suspense");case 19:return rr("SuspenseList");case 0:case 2:case 15:return e=wo(e.type,!1),e;case 11:return e=wo(e.type.render,!1),e;case 1:return e=wo(e.type,!0),e;default:return""}}function bo(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case mn:return"Fragment";case pn:return"Portal";case Zo:return"Profiler";case tu:return"StrictMode";case Jo:return"Suspense";case qo:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case $a:return(e.displayName||"Context")+".Consumer";case za:return(e._context.displayName||"Context")+".Provider";case nu:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case ru:return t=e.displayName||null,t!==null?t:bo(e.type)||"Memo";case gt:t=e._payload,e=e._init;try{return bo(e(t))}catch{}}return null}function vp(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return bo(t);case 8:return t===tu?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function Mt(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function Ia(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function yp(e){var t=Ia(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,o=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(i){r=""+i,o.call(this,i)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(i){r=""+i},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function Qr(e){e._valueTracker||(e._valueTracker=yp(e))}function Aa(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=Ia(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function kl(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function ei(e,t){var n=t.checked;return X({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function rs(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=Mt(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function Ua(e,t){t=t.checked,t!=null&&eu(e,"checked",t,!1)}function ti(e,t){Ua(e,t);var n=Mt(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?ni(e,t.type,n):t.hasOwnProperty("defaultValue")&&ni(e,t.type,Mt(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function ls(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function ni(e,t,n){(t!=="number"||kl(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var lr=Array.isArray;function Tn(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=Kr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function gr(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var sr={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},gp=["Webkit","ms","Moz","O"];Object.keys(sr).forEach(function(e){gp.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),sr[t]=sr[e]})});function Va(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||sr.hasOwnProperty(e)&&sr[e]?(""+t).trim():t+"px"}function Qa(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=Va(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var wp=X({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function oi(e,t){if(t){if(wp[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(x(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(x(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(x(61))}if(t.style!=null&&typeof t.style!="object")throw Error(x(62))}}function ii(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var ui=null;function lu(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var si=null,_n=null,jn=null;function us(e){if(e=Ar(e)){if(typeof si!="function")throw Error(x(280));var t=e.stateNode;t&&(t=eo(t),si(e.stateNode,e.type,t))}}function Ka(e){_n?jn?jn.push(e):jn=[e]:_n=e}function Ga(){if(_n){var e=_n,t=jn;if(jn=_n=null,us(e),t)for(e=0;e>>=0,e===0?32:31-(Lp(e)/Op|0)|0}var Gr=64,Yr=4194304;function or(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Nl(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,o=e.pingedLanes,i=n&268435455;if(i!==0){var u=i&~l;u!==0?r=or(u):(o&=i,o!==0&&(r=or(o)))}else i=n&~l,i!==0?r=or(i):o!==0&&(r=or(o));if(r===0)return 0;if(t!==0&&t!==r&&!(t&l)&&(l=r&-r,o=t&-t,l>=o||l===16&&(o&4194240)!==0))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function Dr(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Be(t),e[t]=n}function zp(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=cr),vs=String.fromCharCode(32),ys=!1;function pc(e,t){switch(e){case"keyup":return am.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function mc(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var hn=!1;function fm(e,t){switch(e){case"compositionend":return mc(t);case"keypress":return t.which!==32?null:(ys=!0,vs);case"textInput":return e=t.data,e===vs&&ys?null:e;default:return null}}function dm(e,t){if(hn)return e==="compositionend"||!du&&pc(e,t)?(e=fc(),fl=au=Ct=null,hn=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=ks(n)}}function gc(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?gc(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function wc(){for(var e=window,t=kl();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=kl(e.document)}return t}function pu(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function km(e){var t=wc(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&gc(n.ownerDocument.documentElement,n)){if(r!==null&&pu(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,o=Math.min(r.start,l);r=r.end===void 0?o:Math.min(r.end,l),!e.extend&&o>r&&(l=r,r=o,o=l),l=xs(n,o);var i=xs(n,r);l&&i&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==i.node||e.focusOffset!==i.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),o>r?(e.addRange(t),e.extend(i.node,i.offset)):(t.setEnd(i.node,i.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,vn=null,mi=null,dr=null,hi=!1;function Es(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;hi||vn==null||vn!==kl(r)||(r=vn,"selectionStart"in r&&pu(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),dr&&Cr(dr,r)||(dr=r,r=jl(mi,"onSelect"),0wn||(e.current=ki[wn],ki[wn]=null,wn--)}function B(e,t){wn++,ki[wn]=e.current,e.current=t}var zt={},ce=Dt(zt),ge=Dt(!1),Jt=zt;function Fn(e,t){var n=e.type.contextTypes;if(!n)return zt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},o;for(o in n)l[o]=t[o];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function we(e){return e=e.childContextTypes,e!=null}function Ll(){V(ge),V(ce)}function Ls(e,t,n){if(ce.current!==zt)throw Error(x(168));B(ce,t),B(ge,n)}function jc(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(x(108,vp(e)||"Unknown",l));return X({},n,r)}function Ol(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||zt,Jt=ce.current,B(ce,e),B(ge,ge.current),!0}function Os(e,t,n){var r=e.stateNode;if(!r)throw Error(x(169));n?(e=jc(e,t,Jt),r.__reactInternalMemoizedMergedChildContext=e,V(ge),V(ce),B(ce,e)):V(ge),B(ge,n)}var et=null,to=!1,Fo=!1;function Rc(e){et===null?et=[e]:et.push(e)}function Fm(e){to=!0,Rc(e)}function It(){if(!Fo&&et!==null){Fo=!0;var e=0,t=I;try{var n=et;for(I=1;e>=i,l-=i,nt=1<<32-Be(t)+l|n<j?(U=T,T=null):U=T.sibling;var P=d(p,T,m[j],w);if(P===null){T===null&&(T=U);break}e&&T&&P.alternate===null&&t(p,T),c=o(P,c,j),N===null?C=P:N.sibling=P,N=P,T=U}if(j===m.length)return n(p,T),Q&&Ut(p,j),C;if(T===null){for(;jj?(U=T,T=null):U=T.sibling;var ie=d(p,T,P.value,w);if(ie===null){T===null&&(T=U);break}e&&T&&ie.alternate===null&&t(p,T),c=o(ie,c,j),N===null?C=ie:N.sibling=ie,N=ie,T=U}if(P.done)return n(p,T),Q&&Ut(p,j),C;if(T===null){for(;!P.done;j++,P=m.next())P=h(p,P.value,w),P!==null&&(c=o(P,c,j),N===null?C=P:N.sibling=P,N=P);return Q&&Ut(p,j),C}for(T=r(p,T);!P.done;j++,P=m.next())P=g(T,p,j,P.value,w),P!==null&&(e&&P.alternate!==null&&T.delete(P.key===null?j:P.key),c=o(P,c,j),N===null?C=P:N.sibling=P,N=P);return e&&T.forEach(function(Ve){return t(p,Ve)}),Q&&Ut(p,j),C}function R(p,c,m,w){if(typeof m=="object"&&m!==null&&m.type===mn&&m.key===null&&(m=m.props.children),typeof m=="object"&&m!==null){switch(m.$$typeof){case Vr:e:{for(var C=m.key,N=c;N!==null;){if(N.key===C){if(C=m.type,C===mn){if(N.tag===7){n(p,N.sibling),c=l(N,m.props.children),c.return=p,p=c;break e}}else if(N.elementType===C||typeof C=="object"&&C!==null&&C.$$typeof===gt&&Is(C)===N.type){n(p,N.sibling),c=l(N,m.props),c.ref=er(p,N,m),c.return=p,p=c;break e}n(p,N);break}else t(p,N);N=N.sibling}m.type===mn?(c=Xt(m.props.children,p.mode,w,m.key),c.return=p,p=c):(w=wl(m.type,m.key,m.props,null,p.mode,w),w.ref=er(p,c,m),w.return=p,p=w)}return i(p);case pn:e:{for(N=m.key;c!==null;){if(c.key===N)if(c.tag===4&&c.stateNode.containerInfo===m.containerInfo&&c.stateNode.implementation===m.implementation){n(p,c.sibling),c=l(c,m.children||[]),c.return=p,p=c;break e}else{n(p,c);break}else t(p,c);c=c.sibling}c=Bo(m,p.mode,w),c.return=p,p=c}return i(p);case gt:return N=m._init,R(p,c,N(m._payload),w)}if(lr(m))return S(p,c,m,w);if(Xn(m))return k(p,c,m,w);tl(p,m)}return typeof m=="string"&&m!==""||typeof m=="number"?(m=""+m,c!==null&&c.tag===6?(n(p,c.sibling),c=l(c,m),c.return=p,p=c):(n(p,c),c=Uo(m,p.mode,w),c.return=p,p=c),i(p)):n(p,c)}return R}var zn=Dc(!0),Ic=Dc(!1),Ur={},Je=Dt(Ur),jr=Dt(Ur),Rr=Dt(Ur);function Gt(e){if(e===Ur)throw Error(x(174));return e}function xu(e,t){switch(B(Rr,t),B(jr,e),B(Je,Ur),e=t.nodeType,e){case 9:case 11:t=(t=t.documentElement)?t.namespaceURI:li(null,"");break;default:e=e===8?t.parentNode:t,t=e.namespaceURI||null,e=e.tagName,t=li(t,e)}V(Je),B(Je,t)}function $n(){V(Je),V(jr),V(Rr)}function Ac(e){Gt(Rr.current);var t=Gt(Je.current),n=li(t,e.type);t!==n&&(B(jr,e),B(Je,n))}function Eu(e){jr.current===e&&(V(Je),V(jr))}var G=Dt(0);function Dl(e){for(var t=e;t!==null;){if(t.tag===13){var n=t.memoizedState;if(n!==null&&(n=n.dehydrated,n===null||n.data==="$?"||n.data==="$!"))return t}else if(t.tag===19&&t.memoizedProps.revealOrder!==void 0){if(t.flags&128)return t}else if(t.child!==null){t.child.return=t,t=t.child;continue}if(t===e)break;for(;t.sibling===null;){if(t.return===null||t.return===e)return null;t=t.return}t.sibling.return=t.return,t=t.sibling}return null}var Mo=[];function Cu(){for(var e=0;en?n:4,e(!0);var r=zo.transition;zo.transition={};try{e(!1),t()}finally{I=n,zo.transition=r}}function tf(){return $e().memoizedState}function Dm(e,t,n){var r=Pt(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},nf(e))rf(t,n);else if(n=Fc(e,t,n,r),n!==null){var l=pe();He(n,e,r,l),lf(n,t,r)}}function Im(e,t,n){var r=Pt(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(nf(e))rf(t,l);else{var o=e.alternate;if(e.lanes===0&&(o===null||o.lanes===0)&&(o=t.lastRenderedReducer,o!==null))try{var i=t.lastRenderedState,u=o(i,n);if(l.hasEagerState=!0,l.eagerState=u,We(u,i)){var s=t.interleaved;s===null?(l.next=l,Su(t)):(l.next=s.next,s.next=l),t.interleaved=l;return}}catch{}finally{}n=Fc(e,t,l,r),n!==null&&(l=pe(),He(n,e,r,l),lf(n,t,r))}}function nf(e){var t=e.alternate;return e===Y||t!==null&&t===Y}function rf(e,t){pr=Il=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function lf(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,iu(e,n)}}var Al={readContext:ze,useCallback:ue,useContext:ue,useEffect:ue,useImperativeHandle:ue,useInsertionEffect:ue,useLayoutEffect:ue,useMemo:ue,useReducer:ue,useRef:ue,useState:ue,useDebugValue:ue,useDeferredValue:ue,useTransition:ue,useMutableSource:ue,useSyncExternalStore:ue,useId:ue,unstable_isNewReconciler:!1},Am={readContext:ze,useCallback:function(e,t){return Ye().memoizedState=[e,t===void 0?null:t],e},useContext:ze,useEffect:Us,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,hl(4194308,4,Zc.bind(null,t,e),n)},useLayoutEffect:function(e,t){return hl(4194308,4,e,t)},useInsertionEffect:function(e,t){return hl(4,2,e,t)},useMemo:function(e,t){var n=Ye();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=Ye();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=Dm.bind(null,Y,e),[r.memoizedState,e]},useRef:function(e){var t=Ye();return e={current:e},t.memoizedState=e},useState:As,useDebugValue:Ru,useDeferredValue:function(e){return Ye().memoizedState=e},useTransition:function(){var e=As(!1),t=e[0];return e=$m.bind(null,e[1]),Ye().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=Y,l=Ye();if(Q){if(n===void 0)throw Error(x(407));n=n()}else{if(n=t(),ne===null)throw Error(x(349));bt&30||Hc(r,t,n)}l.memoizedState=n;var o={value:n,getSnapshot:t};return l.queue=o,Us(Vc.bind(null,r,o,e),[e]),r.flags|=2048,Pr(9,Wc.bind(null,r,o,n,t),void 0,null),n},useId:function(){var e=Ye(),t=ne.identifierPrefix;if(Q){var n=rt,r=nt;n=(r&~(1<<32-Be(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=Lr++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=i.createElement(n,{is:r.is}):(e=i.createElement(n),n==="select"&&(i=e,r.multiple?i.multiple=!0:r.size&&(i.size=r.size))):e=i.createElementNS(e,n),e[Xe]=t,e[_r]=r,mf(e,t,!1,!1),t.stateNode=e;e:{switch(i=ii(n,r),n){case"dialog":W("cancel",e),W("close",e),l=r;break;case"iframe":case"object":case"embed":W("load",e),l=r;break;case"video":case"audio":for(l=0;lIn&&(t.flags|=128,r=!0,tr(o,!1),t.lanes=4194304)}else{if(!r)if(e=Dl(i),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),tr(o,!0),o.tail===null&&o.tailMode==="hidden"&&!i.alternate&&!Q)return se(t),null}else 2*J()-o.renderingStartTime>In&&n!==1073741824&&(t.flags|=128,r=!0,tr(o,!1),t.lanes=4194304);o.isBackwards?(i.sibling=t.child,t.child=i):(n=o.last,n!==null?n.sibling=i:t.child=i,o.last=i)}return o.tail!==null?(t=o.tail,o.rendering=t,o.tail=t.sibling,o.renderingStartTime=J(),t.sibling=null,n=G.current,U(G,r?n&1|2:n&1),t):(se(t),null);case 22:case 23:return zu(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?ke&1073741824&&(se(t),t.subtreeFlags&6&&(t.flags|=8192)):se(t),null;case 24:return null;case 25:return null}throw Error(E(156,t.tag))}function Gm(e,t){switch(hu(t),t.tag){case 1:return we(t.type)&&Ll(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return $n(),V(ge),V(ce),Cu(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return xu(t),null;case 13:if(V(G),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(E(340));Mn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return V(G),null;case 4:return $n(),null;case 10:return wu(t.type._context),null;case 22:case 23:return zu(),null;case 24:return null;default:return null}}var rl=!1,ae=!1,Ym=typeof WeakSet=="function"?WeakSet:Set,_=null;function En(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){Z(e,t,r)}else n.current=null}function Fi(e,t,n){try{n()}catch(r){Z(e,t,r)}}var Xs=!1;function Xm(e,t){if(vi=Tl,e=wc(),pu(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break e}var i=0,u=-1,s=-1,a=0,d=0,m=e,p=null;t:for(;;){for(var g;m!==n||l!==0&&m.nodeType!==3||(u=i+l),m!==o||r!==0&&m.nodeType!==3||(s=i+r),m.nodeType===3&&(i+=m.nodeValue.length),(g=m.firstChild)!==null;)p=m,m=g;for(;;){if(m===e)break t;if(p===n&&++a===l&&(u=i),p===o&&++d===r&&(s=i),(g=m.nextSibling)!==null)break;m=p,p=m.parentNode}m=g}n=u===-1||s===-1?null:{start:u,end:s}}else n=null}n=n||{start:0,end:0}}else n=null;for(yi={focusedElem:e,selectionRange:n},Tl=!1,_=t;_!==null;)if(t=_,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,_=e;else for(;_!==null;){t=_;try{var w=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(w!==null){var k=w.memoizedProps,R=w.memoizedState,f=t.stateNode,c=f.getSnapshotBeforeUpdate(t.elementType===t.type?k:Ie(t.type,k),R);f.__reactInternalSnapshotBeforeUpdate=c}break;case 3:var h=t.stateNode.containerInfo;h.nodeType===1?h.textContent="":h.nodeType===9&&h.documentElement&&h.removeChild(h.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(E(163))}}catch(S){Z(t,t.return,S)}if(e=t.sibling,e!==null){e.return=t.return,_=e;break}_=t.return}return w=Xs,Xs=!1,w}function mr(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var o=l.destroy;l.destroy=void 0,o!==void 0&&Fi(t,n,o)}l=l.next}while(l!==r)}}function lo(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function Mi(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function yf(e){var t=e.alternate;t!==null&&(e.alternate=null,yf(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Xe],delete t[_r],delete t[Si],delete t[Om],delete t[Pm])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function gf(e){return e.tag===5||e.tag===3||e.tag===4}function Zs(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||gf(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function zi(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Rl));else if(r!==4&&(e=e.child,e!==null))for(zi(e,t,n),e=e.sibling;e!==null;)zi(e,t,n),e=e.sibling}function $i(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for($i(e,t,n),e=e.sibling;e!==null;)$i(e,t,n),e=e.sibling}var re=null,Ae=!1;function ht(e,t,n){for(n=n.child;n!==null;)wf(e,t,n),n=n.sibling}function wf(e,t,n){if(Ze&&typeof Ze.onCommitFiberUnmount=="function")try{Ze.onCommitFiberUnmount(Zl,n)}catch{}switch(n.tag){case 5:ae||En(n,t);case 6:var r=re,l=Ae;re=null,ht(e,t,n),re=r,Ae=l,re!==null&&(Ae?(e=re,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):re.removeChild(n.stateNode));break;case 18:re!==null&&(Ae?(e=re,n=n.stateNode,e.nodeType===8?Po(e.parentNode,n):e.nodeType===1&&Po(e,n),Er(e)):Po(re,n.stateNode));break;case 4:r=re,l=Ae,re=n.stateNode.containerInfo,Ae=!0,ht(e,t,n),re=r,Ae=l;break;case 0:case 11:case 14:case 15:if(!ae&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var o=l,i=o.destroy;o=o.tag,i!==void 0&&(o&2||o&4)&&Fi(n,t,i),l=l.next}while(l!==r)}ht(e,t,n);break;case 1:if(!ae&&(En(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(u){Z(n,t,u)}ht(e,t,n);break;case 21:ht(e,t,n);break;case 22:n.mode&1?(ae=(r=ae)||n.memoizedState!==null,ht(e,t,n),ae=r):ht(e,t,n);break;default:ht(e,t,n)}}function Js(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new Ym),t.forEach(function(r){var l=lh.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function De(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=i),r&=~o}if(r=l,r=J()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*Jm(r/1960))-r,10e?16:e,Ct===null)var r=!1;else{if(e=Ct,Ct=null,Hl=0,z&6)throw Error(E(331));var l=z;for(z|=4,_=e.current;_!==null;){var o=_,i=o.child;if(_.flags&16){var u=o.deletions;if(u!==null){for(var s=0;sJ()-Fu?Gt(e,0):Pu|=n),Se(e,t)}function _f(e,t){t===0&&(e.mode&1?(t=Yr,Yr<<=1,!(Yr&130023424)&&(Yr=4194304)):t=1);var n=pe();e=st(e,t),e!==null&&(Dr(e,t,n),Se(e,n))}function rh(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),_f(e,n)}function lh(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(E(314))}r!==null&&r.delete(t),_f(e,n)}var jf;jf=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||ge.current)ye=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return ye=!1,Qm(e,t,n);ye=!!(e.flags&131072)}else ye=!1,Q&&t.flags&1048576&&Lc(t,Fl,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;vl(e,t),e=t.pendingProps;var l=Fn(t,ce.current);Rn(t,n),l=Tu(null,t,r,e,l,n);var o=_u();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,we(r)?(o=!0,Ol(t)):o=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,ku(t),l.updater=no,t.stateNode=l,l._reactInternals=t,Ti(t,r,e,n),t=Ri(null,t,r,!0,o,n)):(t.tag=0,Q&&o&&mu(t),de(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(vl(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=ih(r),e=Ie(r,e),l){case 0:t=ji(null,t,r,e,n);break e;case 1:t=Ks(null,t,r,e,n);break e;case 11:t=Vs(null,t,r,e,n);break e;case 14:t=Qs(null,t,r,Ie(r.type,e),n);break e}throw Error(E(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Ie(r,l),ji(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Ie(r,l),Ks(e,t,r,l,n);case 3:e:{if(ff(t),e===null)throw Error(E(387));r=t.pendingProps,o=t.memoizedState,l=o.element,Mc(e,t),$l(t,r,null,n);var i=t.memoizedState;if(r=i.element,o.isDehydrated)if(o={element:r,isDehydrated:!1,cache:i.cache,pendingSuspenseBoundaries:i.pendingSuspenseBoundaries,transitions:i.transitions},t.updateQueue.baseState=o,t.memoizedState=o,t.flags&256){l=Dn(Error(E(423)),t),t=Gs(e,t,r,n,l);break e}else if(r!==l){l=Dn(Error(E(424)),t),t=Gs(e,t,r,n,l);break e}else for(Ee=jt(t.stateNode.containerInfo.firstChild),xe=t,Q=!0,Be=null,n=Ic(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(Mn(),r===l){t=at(e,t,n);break e}de(e,t,r,n)}t=t.child}return t;case 5:return Ac(t),e===null&&xi(t),r=t.type,l=t.pendingProps,o=e!==null?e.memoizedProps:null,i=l.children,gi(r,l)?i=null:o!==null&&gi(r,o)&&(t.flags|=32),cf(e,t),de(e,t,i,n),t.child;case 6:return e===null&&xi(t),null;case 13:return df(e,t,n);case 4:return Eu(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=zn(t,null,r,n):de(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Ie(r,l),Vs(e,t,r,l,n);case 7:return de(e,t,t.pendingProps,n),t.child;case 8:return de(e,t,t.pendingProps.children,n),t.child;case 12:return de(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,o=t.memoizedProps,i=l.value,U(Ml,r._currentValue),r._currentValue=i,o!==null)if(We(o.value,i)){if(o.children===l.children&&!ge.current){t=at(e,t,n);break e}}else for(o=t.child,o!==null&&(o.return=t);o!==null;){var u=o.dependencies;if(u!==null){i=o.child;for(var s=u.firstContext;s!==null;){if(s.context===r){if(o.tag===1){s=lt(-1,n&-n),s.tag=2;var a=o.updateQueue;if(a!==null){a=a.shared;var d=a.pending;d===null?s.next=s:(s.next=d.next,d.next=s),a.pending=s}}o.lanes|=n,s=o.alternate,s!==null&&(s.lanes|=n),Ci(o.return,n,t),u.lanes|=n;break}s=s.next}}else if(o.tag===10)i=o.type===t.type?null:o.child;else if(o.tag===18){if(i=o.return,i===null)throw Error(E(341));i.lanes|=n,u=i.alternate,u!==null&&(u.lanes|=n),Ci(i,n,t),i=o.sibling}else i=o.child;if(i!==null)i.return=o;else for(i=o;i!==null;){if(i===t){i=null;break}if(o=i.sibling,o!==null){o.return=i.return,i=o;break}i=i.return}o=i}de(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,Rn(t,n),l=ze(l),r=r(l),t.flags|=1,de(e,t,r,n),t.child;case 14:return r=t.type,l=Ie(r,t.pendingProps),l=Ie(r.type,l),Qs(e,t,r,l,n);case 15:return sf(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Ie(r,l),vl(e,t),t.tag=1,we(r)?(e=!0,Ol(t)):e=!1,Rn(t,n),$c(t,r,l),Ti(t,r,l,n),Ri(null,t,r,!0,e,n);case 19:return pf(e,t,n);case 22:return af(e,t,n)}throw Error(E(156,t.tag))};function Rf(e,t){return ec(e,t)}function oh(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Fe(e,t,n,r){return new oh(e,t,n,r)}function Du(e){return e=e.prototype,!(!e||!e.isReactComponent)}function ih(e){if(typeof e=="function")return Du(e)?1:0;if(e!=null){if(e=e.$$typeof,e===nu)return 11;if(e===ru)return 14}return 2}function Pt(e,t){var n=e.alternate;return n===null?(n=Fe(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function wl(e,t,n,r,l,o){var i=2;if(r=e,typeof e=="function")Du(e)&&(i=1);else if(typeof e=="string")i=5;else e:switch(e){case pn:return Yt(n.children,l,o,t);case tu:i=8,l|=8;break;case Zo:return e=Fe(12,n,t,l|2),e.elementType=Zo,e.lanes=o,e;case Jo:return e=Fe(13,n,t,l),e.elementType=Jo,e.lanes=o,e;case qo:return e=Fe(19,n,t,l),e.elementType=qo,e.lanes=o,e;case Da:return io(n,l,o,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case za:i=10;break e;case $a:i=9;break e;case nu:i=11;break e;case ru:i=14;break e;case gt:i=16,r=null;break e}throw Error(E(130,e==null?e:typeof e,""))}return t=Fe(i,n,t,l),t.elementType=e,t.type=r,t.lanes=o,t}function Yt(e,t,n,r){return e=Fe(7,e,r,t),e.lanes=n,e}function io(e,t,n,r){return e=Fe(22,e,r,t),e.elementType=Da,e.lanes=n,e.stateNode={isHidden:!1},e}function Bo(e,t,n){return e=Fe(6,e,null,t),e.lanes=n,e}function Uo(e,t,n){return t=Fe(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function uh(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=ko(0),this.expirationTimes=ko(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=ko(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function Iu(e,t,n,r,l,o,i,u,s){return e=new uh(e,t,n,u,s),t===1?(t=1,o===!0&&(t|=8)):t=0,o=Fe(3,null,null,t),e.current=o,o.stateNode=e,o.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},ku(o),e}function sh(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(Ff)}catch(e){console.error(e)}}Ff(),La.exports=Ne;var Mf=La.exports;const Cn=Yl(Mf);var oa=Mf;Yo.createRoot=oa.createRoot,Yo.hydrateRoot=oa.hydrateRoot;var zf={exports:{}};/*! +`+o.stack}return{value:e,source:t,stack:l,digest:null}}function Io(e,t,n){return{value:e,source:null,stack:n??null,digest:t??null}}function _i(e,t){try{console.error(t.value)}catch(n){setTimeout(function(){throw n})}}var Hm=typeof WeakMap=="function"?WeakMap:Map;function of(e,t,n){n=lt(-1,n),n.tag=3,n.payload={element:null};var r=t.value;return n.callback=function(){Bl||(Bl=!0,Di=r),_i(e,t)},n}function uf(e,t,n){n=lt(-1,n),n.tag=3;var r=e.type.getDerivedStateFromError;if(typeof r=="function"){var l=t.value;n.payload=function(){return r(l)},n.callback=function(){_i(e,t)}}var o=e.stateNode;return o!==null&&typeof o.componentDidCatch=="function"&&(n.callback=function(){_i(e,t),typeof r!="function"&&(Ot===null?Ot=new Set([this]):Ot.add(this));var i=t.stack;this.componentDidCatch(t.value,{componentStack:i!==null?i:""})}),n}function Bs(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new Hm;var l=new Set;r.set(t,l)}else l=r.get(t),l===void 0&&(l=new Set,r.set(t,l));l.has(n)||(l.add(n),e=nh.bind(null,e,t,n),t.then(e,e))}function Hs(e){do{var t;if((t=e.tag===13)&&(t=e.memoizedState,t=t!==null?t.dehydrated!==null:!0),t)return e;e=e.return}while(e!==null);return null}function Ws(e,t,n,r,l){return e.mode&1?(e.flags|=65536,e.lanes=l,e):(e===t?e.flags|=65536:(e.flags|=128,n.flags|=131072,n.flags&=-52805,n.tag===1&&(n.alternate===null?n.tag=17:(t=lt(-1,1),t.tag=2,Lt(n,t,1))),n.lanes|=1),e)}var Wm=dt.ReactCurrentOwner,ye=!1;function de(e,t,n,r){t.child=e===null?Ic(t,null,n,r):zn(t,e.child,n,r)}function Vs(e,t,n,r,l){n=n.render;var o=t.ref;return Ln(t,l),r=Tu(e,t,n,r,o,l),n=_u(),e!==null&&!ye?(t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~l,at(e,t,l)):(Q&&n&&mu(t),t.flags|=1,de(e,t,r,l),t.child)}function Qs(e,t,n,r,l){if(e===null){var o=n.type;return typeof o=="function"&&!Du(o)&&o.defaultProps===void 0&&n.compare===null&&n.defaultProps===void 0?(t.tag=15,t.type=o,sf(e,t,o,r,l)):(e=wl(n.type,null,r,t,t.mode,l),e.ref=t.ref,e.return=t,t.child=e)}if(o=e.child,!(e.lanes&l)){var i=o.memoizedProps;if(n=n.compare,n=n!==null?n:Cr,n(i,r)&&e.ref===t.ref)return at(e,t,l)}return t.flags|=1,e=Ft(o,r),e.ref=t.ref,e.return=t,t.child=e}function sf(e,t,n,r,l){if(e!==null){var o=e.memoizedProps;if(Cr(o,r)&&e.ref===t.ref)if(ye=!1,t.pendingProps=r=o,(e.lanes&l)!==0)e.flags&131072&&(ye=!0);else return t.lanes=e.lanes,at(e,t,l)}return ji(e,t,n,r,l)}function af(e,t,n){var r=t.pendingProps,l=r.children,o=e!==null?e.memoizedState:null;if(r.mode==="hidden")if(!(t.mode&1))t.memoizedState={baseLanes:0,cachePool:null,transitions:null},B(Cn,ke),ke|=n;else{if(!(n&1073741824))return e=o!==null?o.baseLanes|n:n,t.lanes=t.childLanes=1073741824,t.memoizedState={baseLanes:e,cachePool:null,transitions:null},t.updateQueue=null,B(Cn,ke),ke|=e,null;t.memoizedState={baseLanes:0,cachePool:null,transitions:null},r=o!==null?o.baseLanes:n,B(Cn,ke),ke|=r}else o!==null?(r=o.baseLanes|n,t.memoizedState=null):r=n,B(Cn,ke),ke|=r;return de(e,t,l,n),t.child}function cf(e,t){var n=t.ref;(e===null&&n!==null||e!==null&&e.ref!==n)&&(t.flags|=512,t.flags|=2097152)}function ji(e,t,n,r,l){var o=we(n)?Jt:ce.current;return o=Fn(t,o),Ln(t,l),n=Tu(e,t,n,r,o,l),r=_u(),e!==null&&!ye?(t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~l,at(e,t,l)):(Q&&r&&mu(t),t.flags|=1,de(e,t,n,l),t.child)}function Ks(e,t,n,r,l){if(we(n)){var o=!0;Ol(t)}else o=!1;if(Ln(t,l),t.stateNode===null)vl(e,t),$c(t,n,r),Ti(t,n,r,l),r=!0;else if(e===null){var i=t.stateNode,u=t.memoizedProps;i.props=u;var s=i.context,a=n.contextType;typeof a=="object"&&a!==null?a=ze(a):(a=we(n)?Jt:ce.current,a=Fn(t,a));var f=n.getDerivedStateFromProps,h=typeof f=="function"||typeof i.getSnapshotBeforeUpdate=="function";h||typeof i.UNSAFE_componentWillReceiveProps!="function"&&typeof i.componentWillReceiveProps!="function"||(u!==r||s!==a)&&Ds(t,i,r,a),wt=!1;var d=t.memoizedState;i.state=d,$l(t,r,i,l),s=t.memoizedState,u!==r||d!==s||ge.current||wt?(typeof f=="function"&&(Ni(t,n,f,r),s=t.memoizedState),(u=wt||$s(t,n,u,r,d,s,a))?(h||typeof i.UNSAFE_componentWillMount!="function"&&typeof i.componentWillMount!="function"||(typeof i.componentWillMount=="function"&&i.componentWillMount(),typeof i.UNSAFE_componentWillMount=="function"&&i.UNSAFE_componentWillMount()),typeof i.componentDidMount=="function"&&(t.flags|=4194308)):(typeof i.componentDidMount=="function"&&(t.flags|=4194308),t.memoizedProps=r,t.memoizedState=s),i.props=r,i.state=s,i.context=a,r=u):(typeof i.componentDidMount=="function"&&(t.flags|=4194308),r=!1)}else{i=t.stateNode,Mc(e,t),u=t.memoizedProps,a=t.type===t.elementType?u:Ie(t.type,u),i.props=a,h=t.pendingProps,d=i.context,s=n.contextType,typeof s=="object"&&s!==null?s=ze(s):(s=we(n)?Jt:ce.current,s=Fn(t,s));var g=n.getDerivedStateFromProps;(f=typeof g=="function"||typeof i.getSnapshotBeforeUpdate=="function")||typeof i.UNSAFE_componentWillReceiveProps!="function"&&typeof i.componentWillReceiveProps!="function"||(u!==h||d!==s)&&Ds(t,i,r,s),wt=!1,d=t.memoizedState,i.state=d,$l(t,r,i,l);var S=t.memoizedState;u!==h||d!==S||ge.current||wt?(typeof g=="function"&&(Ni(t,n,g,r),S=t.memoizedState),(a=wt||$s(t,n,a,r,d,S,s)||!1)?(f||typeof i.UNSAFE_componentWillUpdate!="function"&&typeof i.componentWillUpdate!="function"||(typeof i.componentWillUpdate=="function"&&i.componentWillUpdate(r,S,s),typeof i.UNSAFE_componentWillUpdate=="function"&&i.UNSAFE_componentWillUpdate(r,S,s)),typeof i.componentDidUpdate=="function"&&(t.flags|=4),typeof i.getSnapshotBeforeUpdate=="function"&&(t.flags|=1024)):(typeof i.componentDidUpdate!="function"||u===e.memoizedProps&&d===e.memoizedState||(t.flags|=4),typeof i.getSnapshotBeforeUpdate!="function"||u===e.memoizedProps&&d===e.memoizedState||(t.flags|=1024),t.memoizedProps=r,t.memoizedState=S),i.props=r,i.state=S,i.context=s,r=a):(typeof i.componentDidUpdate!="function"||u===e.memoizedProps&&d===e.memoizedState||(t.flags|=4),typeof i.getSnapshotBeforeUpdate!="function"||u===e.memoizedProps&&d===e.memoizedState||(t.flags|=1024),r=!1)}return Ri(e,t,n,r,o,l)}function Ri(e,t,n,r,l,o){cf(e,t);var i=(t.flags&128)!==0;if(!r&&!i)return l&&Os(t,n,!1),at(e,t,o);r=t.stateNode,Wm.current=t;var u=i&&typeof n.getDerivedStateFromError!="function"?null:r.render();return t.flags|=1,e!==null&&i?(t.child=zn(t,e.child,null,o),t.child=zn(t,null,u,o)):de(e,t,u,o),t.memoizedState=r.state,l&&Os(t,n,!0),t.child}function ff(e){var t=e.stateNode;t.pendingContext?Ls(e,t.pendingContext,t.pendingContext!==t.context):t.context&&Ls(e,t.context,!1),xu(e,t.containerInfo)}function Gs(e,t,n,r,l){return Mn(),vu(l),t.flags|=256,de(e,t,n,r),t.child}var Li={dehydrated:null,treeContext:null,retryLane:0};function Oi(e){return{baseLanes:e,cachePool:null,transitions:null}}function df(e,t,n){var r=t.pendingProps,l=G.current,o=!1,i=(t.flags&128)!==0,u;if((u=i)||(u=e!==null&&e.memoizedState===null?!1:(l&2)!==0),u?(o=!0,t.flags&=-129):(e===null||e.memoizedState!==null)&&(l|=1),B(G,l&1),e===null)return Ei(t),e=t.memoizedState,e!==null&&(e=e.dehydrated,e!==null)?(t.mode&1?e.data==="$!"?t.lanes=8:t.lanes=1073741824:t.lanes=1,null):(i=r.children,e=r.fallback,o?(r=t.mode,o=t.child,i={mode:"hidden",children:i},!(r&1)&&o!==null?(o.childLanes=0,o.pendingProps=i):o=io(i,r,0,null),e=Xt(e,r,n,null),o.return=t,e.return=t,o.sibling=e,t.child=o,t.child.memoizedState=Oi(n),t.memoizedState=Li,e):Lu(t,i));if(l=e.memoizedState,l!==null&&(u=l.dehydrated,u!==null))return Vm(e,t,i,r,u,l,n);if(o){o=r.fallback,i=t.mode,l=e.child,u=l.sibling;var s={mode:"hidden",children:r.children};return!(i&1)&&t.child!==l?(r=t.child,r.childLanes=0,r.pendingProps=s,t.deletions=null):(r=Ft(l,s),r.subtreeFlags=l.subtreeFlags&14680064),u!==null?o=Ft(u,o):(o=Xt(o,i,n,null),o.flags|=2),o.return=t,r.return=t,r.sibling=o,t.child=r,r=o,o=t.child,i=e.child.memoizedState,i=i===null?Oi(n):{baseLanes:i.baseLanes|n,cachePool:null,transitions:i.transitions},o.memoizedState=i,o.childLanes=e.childLanes&~n,t.memoizedState=Li,r}return o=e.child,e=o.sibling,r=Ft(o,{mode:"visible",children:r.children}),!(t.mode&1)&&(r.lanes=n),r.return=t,r.sibling=null,e!==null&&(n=t.deletions,n===null?(t.deletions=[e],t.flags|=16):n.push(e)),t.child=r,t.memoizedState=null,r}function Lu(e,t){return t=io({mode:"visible",children:t},e.mode,0,null),t.return=e,e.child=t}function nl(e,t,n,r){return r!==null&&vu(r),zn(t,e.child,null,n),e=Lu(t,t.pendingProps.children),e.flags|=2,t.memoizedState=null,e}function Vm(e,t,n,r,l,o,i){if(n)return t.flags&256?(t.flags&=-257,r=Io(Error(x(422))),nl(e,t,i,r)):t.memoizedState!==null?(t.child=e.child,t.flags|=128,null):(o=r.fallback,l=t.mode,r=io({mode:"visible",children:r.children},l,0,null),o=Xt(o,l,i,null),o.flags|=2,r.return=t,o.return=t,r.sibling=o,t.child=r,t.mode&1&&zn(t,e.child,null,i),t.child.memoizedState=Oi(i),t.memoizedState=Li,o);if(!(t.mode&1))return nl(e,t,i,null);if(l.data==="$!"){if(r=l.nextSibling&&l.nextSibling.dataset,r)var u=r.dgst;return r=u,o=Error(x(419)),r=Io(o,r,void 0),nl(e,t,i,r)}if(u=(i&e.childLanes)!==0,ye||u){if(r=ne,r!==null){switch(i&-i){case 4:l=2;break;case 16:l=8;break;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:l=32;break;case 536870912:l=268435456;break;default:l=0}l=l&(r.suspendedLanes|i)?0:l,l!==0&&l!==o.retryLane&&(o.retryLane=l,st(e,l),He(r,e,l,-1))}return $u(),r=Io(Error(x(421))),nl(e,t,i,r)}return l.data==="$?"?(t.flags|=128,t.child=e.child,t=rh.bind(null,e),l._reactRetry=t,null):(e=o.treeContext,xe=Rt(l.nextSibling),Ee=t,Q=!0,Ue=null,e!==null&&(Le[Oe++]=nt,Le[Oe++]=rt,Le[Oe++]=qt,nt=e.id,rt=e.overflow,qt=t),t=Lu(t,r.children),t.flags|=4096,t)}function Ys(e,t,n){e.lanes|=t;var r=e.alternate;r!==null&&(r.lanes|=t),Ci(e.return,t,n)}function Ao(e,t,n,r,l){var o=e.memoizedState;o===null?e.memoizedState={isBackwards:t,rendering:null,renderingStartTime:0,last:r,tail:n,tailMode:l}:(o.isBackwards=t,o.rendering=null,o.renderingStartTime=0,o.last=r,o.tail=n,o.tailMode=l)}function pf(e,t,n){var r=t.pendingProps,l=r.revealOrder,o=r.tail;if(de(e,t,r.children,n),r=G.current,r&2)r=r&1|2,t.flags|=128;else{if(e!==null&&e.flags&128)e:for(e=t.child;e!==null;){if(e.tag===13)e.memoizedState!==null&&Ys(e,n,t);else if(e.tag===19)Ys(e,n,t);else if(e.child!==null){e.child.return=e,e=e.child;continue}if(e===t)break e;for(;e.sibling===null;){if(e.return===null||e.return===t)break e;e=e.return}e.sibling.return=e.return,e=e.sibling}r&=1}if(B(G,r),!(t.mode&1))t.memoizedState=null;else switch(l){case"forwards":for(n=t.child,l=null;n!==null;)e=n.alternate,e!==null&&Dl(e)===null&&(l=n),n=n.sibling;n=l,n===null?(l=t.child,t.child=null):(l=n.sibling,n.sibling=null),Ao(t,!1,l,n,o);break;case"backwards":for(n=null,l=t.child,t.child=null;l!==null;){if(e=l.alternate,e!==null&&Dl(e)===null){t.child=l;break}e=l.sibling,l.sibling=n,n=l,l=e}Ao(t,!0,n,null,o);break;case"together":Ao(t,!1,null,null,void 0);break;default:t.memoizedState=null}return t.child}function vl(e,t){!(t.mode&1)&&e!==null&&(e.alternate=null,t.alternate=null,t.flags|=2)}function at(e,t,n){if(e!==null&&(t.dependencies=e.dependencies),en|=t.lanes,!(n&t.childLanes))return null;if(e!==null&&t.child!==e.child)throw Error(x(153));if(t.child!==null){for(e=t.child,n=Ft(e,e.pendingProps),t.child=n,n.return=t;e.sibling!==null;)e=e.sibling,n=n.sibling=Ft(e,e.pendingProps),n.return=t;n.sibling=null}return t.child}function Qm(e,t,n){switch(t.tag){case 3:ff(t),Mn();break;case 5:Ac(t);break;case 1:we(t.type)&&Ol(t);break;case 4:xu(t,t.stateNode.containerInfo);break;case 10:var r=t.type._context,l=t.memoizedProps.value;B(Ml,r._currentValue),r._currentValue=l;break;case 13:if(r=t.memoizedState,r!==null)return r.dehydrated!==null?(B(G,G.current&1),t.flags|=128,null):n&t.child.childLanes?df(e,t,n):(B(G,G.current&1),e=at(e,t,n),e!==null?e.sibling:null);B(G,G.current&1);break;case 19:if(r=(n&t.childLanes)!==0,e.flags&128){if(r)return pf(e,t,n);t.flags|=128}if(l=t.memoizedState,l!==null&&(l.rendering=null,l.tail=null,l.lastEffect=null),B(G,G.current),r)break;return null;case 22:case 23:return t.lanes=0,af(e,t,n)}return at(e,t,n)}var mf,Pi,hf,vf;mf=function(e,t){for(var n=t.child;n!==null;){if(n.tag===5||n.tag===6)e.appendChild(n.stateNode);else if(n.tag!==4&&n.child!==null){n.child.return=n,n=n.child;continue}if(n===t)break;for(;n.sibling===null;){if(n.return===null||n.return===t)return;n=n.return}n.sibling.return=n.return,n=n.sibling}};Pi=function(){};hf=function(e,t,n,r){var l=e.memoizedProps;if(l!==r){e=t.stateNode,Gt(Je.current);var o=null;switch(n){case"input":l=ei(e,l),r=ei(e,r),o=[];break;case"select":l=X({},l,{value:void 0}),r=X({},r,{value:void 0}),o=[];break;case"textarea":l=ri(e,l),r=ri(e,r),o=[];break;default:typeof l.onClick!="function"&&typeof r.onClick=="function"&&(e.onclick=Rl)}oi(n,r);var i;n=null;for(a in l)if(!r.hasOwnProperty(a)&&l.hasOwnProperty(a)&&l[a]!=null)if(a==="style"){var u=l[a];for(i in u)u.hasOwnProperty(i)&&(n||(n={}),n[i]="")}else a!=="dangerouslySetInnerHTML"&&a!=="children"&&a!=="suppressContentEditableWarning"&&a!=="suppressHydrationWarning"&&a!=="autoFocus"&&(yr.hasOwnProperty(a)?o||(o=[]):(o=o||[]).push(a,null));for(a in r){var s=r[a];if(u=l!=null?l[a]:void 0,r.hasOwnProperty(a)&&s!==u&&(s!=null||u!=null))if(a==="style")if(u){for(i in u)!u.hasOwnProperty(i)||s&&s.hasOwnProperty(i)||(n||(n={}),n[i]="");for(i in s)s.hasOwnProperty(i)&&u[i]!==s[i]&&(n||(n={}),n[i]=s[i])}else n||(o||(o=[]),o.push(a,n)),n=s;else a==="dangerouslySetInnerHTML"?(s=s?s.__html:void 0,u=u?u.__html:void 0,s!=null&&u!==s&&(o=o||[]).push(a,s)):a==="children"?typeof s!="string"&&typeof s!="number"||(o=o||[]).push(a,""+s):a!=="suppressContentEditableWarning"&&a!=="suppressHydrationWarning"&&(yr.hasOwnProperty(a)?(s!=null&&a==="onScroll"&&W("scroll",e),o||u===s||(o=[])):(o=o||[]).push(a,s))}n&&(o=o||[]).push("style",n);var a=o;(t.updateQueue=a)&&(t.flags|=4)}};vf=function(e,t,n,r){n!==r&&(t.flags|=4)};function tr(e,t){if(!Q)switch(e.tailMode){case"hidden":t=e.tail;for(var n=null;t!==null;)t.alternate!==null&&(n=t),t=t.sibling;n===null?e.tail=null:n.sibling=null;break;case"collapsed":n=e.tail;for(var r=null;n!==null;)n.alternate!==null&&(r=n),n=n.sibling;r===null?t||e.tail===null?e.tail=null:e.tail.sibling=null:r.sibling=null}}function se(e){var t=e.alternate!==null&&e.alternate.child===e.child,n=0,r=0;if(t)for(var l=e.child;l!==null;)n|=l.lanes|l.childLanes,r|=l.subtreeFlags&14680064,r|=l.flags&14680064,l.return=e,l=l.sibling;else for(l=e.child;l!==null;)n|=l.lanes|l.childLanes,r|=l.subtreeFlags,r|=l.flags,l.return=e,l=l.sibling;return e.subtreeFlags|=r,e.childLanes=n,t}function Km(e,t,n){var r=t.pendingProps;switch(hu(t),t.tag){case 2:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return se(t),null;case 1:return we(t.type)&&Ll(),se(t),null;case 3:return r=t.stateNode,$n(),V(ge),V(ce),Cu(),r.pendingContext&&(r.context=r.pendingContext,r.pendingContext=null),(e===null||e.child===null)&&(el(t)?t.flags|=4:e===null||e.memoizedState.isDehydrated&&!(t.flags&256)||(t.flags|=1024,Ue!==null&&(Ui(Ue),Ue=null))),Pi(e,t),se(t),null;case 5:Eu(t);var l=Gt(Rr.current);if(n=t.type,e!==null&&t.stateNode!=null)hf(e,t,n,r,l),e.ref!==t.ref&&(t.flags|=512,t.flags|=2097152);else{if(!r){if(t.stateNode===null)throw Error(x(166));return se(t),null}if(e=Gt(Je.current),el(t)){r=t.stateNode,n=t.type;var o=t.memoizedProps;switch(r[Xe]=t,r[_r]=o,e=(t.mode&1)!==0,n){case"dialog":W("cancel",r),W("close",r);break;case"iframe":case"object":case"embed":W("load",r);break;case"video":case"audio":for(l=0;l<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=i.createElement(n,{is:r.is}):(e=i.createElement(n),n==="select"&&(i=e,r.multiple?i.multiple=!0:r.size&&(i.size=r.size))):e=i.createElementNS(e,n),e[Xe]=t,e[_r]=r,mf(e,t,!1,!1),t.stateNode=e;e:{switch(i=ii(n,r),n){case"dialog":W("cancel",e),W("close",e),l=r;break;case"iframe":case"object":case"embed":W("load",e),l=r;break;case"video":case"audio":for(l=0;lIn&&(t.flags|=128,r=!0,tr(o,!1),t.lanes=4194304)}else{if(!r)if(e=Dl(i),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),tr(o,!0),o.tail===null&&o.tailMode==="hidden"&&!i.alternate&&!Q)return se(t),null}else 2*J()-o.renderingStartTime>In&&n!==1073741824&&(t.flags|=128,r=!0,tr(o,!1),t.lanes=4194304);o.isBackwards?(i.sibling=t.child,t.child=i):(n=o.last,n!==null?n.sibling=i:t.child=i,o.last=i)}return o.tail!==null?(t=o.tail,o.rendering=t,o.tail=t.sibling,o.renderingStartTime=J(),t.sibling=null,n=G.current,B(G,r?n&1|2:n&1),t):(se(t),null);case 22:case 23:return zu(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?ke&1073741824&&(se(t),t.subtreeFlags&6&&(t.flags|=8192)):se(t),null;case 24:return null;case 25:return null}throw Error(x(156,t.tag))}function Gm(e,t){switch(hu(t),t.tag){case 1:return we(t.type)&&Ll(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return $n(),V(ge),V(ce),Cu(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return Eu(t),null;case 13:if(V(G),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(x(340));Mn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return V(G),null;case 4:return $n(),null;case 10:return wu(t.type._context),null;case 22:case 23:return zu(),null;case 24:return null;default:return null}}var rl=!1,ae=!1,Ym=typeof WeakSet=="function"?WeakSet:Set,_=null;function En(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){Z(e,t,r)}else n.current=null}function Fi(e,t,n){try{n()}catch(r){Z(e,t,r)}}var Xs=!1;function Xm(e,t){if(vi=Tl,e=wc(),pu(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break e}var i=0,u=-1,s=-1,a=0,f=0,h=e,d=null;t:for(;;){for(var g;h!==n||l!==0&&h.nodeType!==3||(u=i+l),h!==o||r!==0&&h.nodeType!==3||(s=i+r),h.nodeType===3&&(i+=h.nodeValue.length),(g=h.firstChild)!==null;)d=h,h=g;for(;;){if(h===e)break t;if(d===n&&++a===l&&(u=i),d===o&&++f===r&&(s=i),(g=h.nextSibling)!==null)break;h=d,d=h.parentNode}h=g}n=u===-1||s===-1?null:{start:u,end:s}}else n=null}n=n||{start:0,end:0}}else n=null;for(yi={focusedElem:e,selectionRange:n},Tl=!1,_=t;_!==null;)if(t=_,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,_=e;else for(;_!==null;){t=_;try{var S=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(S!==null){var k=S.memoizedProps,R=S.memoizedState,p=t.stateNode,c=p.getSnapshotBeforeUpdate(t.elementType===t.type?k:Ie(t.type,k),R);p.__reactInternalSnapshotBeforeUpdate=c}break;case 3:var m=t.stateNode.containerInfo;m.nodeType===1?m.textContent="":m.nodeType===9&&m.documentElement&&m.removeChild(m.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(x(163))}}catch(w){Z(t,t.return,w)}if(e=t.sibling,e!==null){e.return=t.return,_=e;break}_=t.return}return S=Xs,Xs=!1,S}function mr(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var o=l.destroy;l.destroy=void 0,o!==void 0&&Fi(t,n,o)}l=l.next}while(l!==r)}}function lo(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function Mi(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function yf(e){var t=e.alternate;t!==null&&(e.alternate=null,yf(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Xe],delete t[_r],delete t[Si],delete t[Om],delete t[Pm])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function gf(e){return e.tag===5||e.tag===3||e.tag===4}function Zs(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||gf(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function zi(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Rl));else if(r!==4&&(e=e.child,e!==null))for(zi(e,t,n),e=e.sibling;e!==null;)zi(e,t,n),e=e.sibling}function $i(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for($i(e,t,n),e=e.sibling;e!==null;)$i(e,t,n),e=e.sibling}var re=null,Ae=!1;function ht(e,t,n){for(n=n.child;n!==null;)wf(e,t,n),n=n.sibling}function wf(e,t,n){if(Ze&&typeof Ze.onCommitFiberUnmount=="function")try{Ze.onCommitFiberUnmount(Zl,n)}catch{}switch(n.tag){case 5:ae||En(n,t);case 6:var r=re,l=Ae;re=null,ht(e,t,n),re=r,Ae=l,re!==null&&(Ae?(e=re,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):re.removeChild(n.stateNode));break;case 18:re!==null&&(Ae?(e=re,n=n.stateNode,e.nodeType===8?Po(e.parentNode,n):e.nodeType===1&&Po(e,n),xr(e)):Po(re,n.stateNode));break;case 4:r=re,l=Ae,re=n.stateNode.containerInfo,Ae=!0,ht(e,t,n),re=r,Ae=l;break;case 0:case 11:case 14:case 15:if(!ae&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var o=l,i=o.destroy;o=o.tag,i!==void 0&&(o&2||o&4)&&Fi(n,t,i),l=l.next}while(l!==r)}ht(e,t,n);break;case 1:if(!ae&&(En(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(u){Z(n,t,u)}ht(e,t,n);break;case 21:ht(e,t,n);break;case 22:n.mode&1?(ae=(r=ae)||n.memoizedState!==null,ht(e,t,n),ae=r):ht(e,t,n);break;default:ht(e,t,n)}}function Js(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new Ym),t.forEach(function(r){var l=lh.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function De(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=i),r&=~o}if(r=l,r=J()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*Jm(r/1960))-r,10e?16:e,Nt===null)var r=!1;else{if(e=Nt,Nt=null,Hl=0,z&6)throw Error(x(331));var l=z;for(z|=4,_=e.current;_!==null;){var o=_,i=o.child;if(_.flags&16){var u=o.deletions;if(u!==null){for(var s=0;sJ()-Fu?Yt(e,0):Pu|=n),Se(e,t)}function _f(e,t){t===0&&(e.mode&1?(t=Yr,Yr<<=1,!(Yr&130023424)&&(Yr=4194304)):t=1);var n=pe();e=st(e,t),e!==null&&(Dr(e,t,n),Se(e,n))}function rh(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),_f(e,n)}function lh(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(x(314))}r!==null&&r.delete(t),_f(e,n)}var jf;jf=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||ge.current)ye=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return ye=!1,Qm(e,t,n);ye=!!(e.flags&131072)}else ye=!1,Q&&t.flags&1048576&&Lc(t,Fl,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;vl(e,t),e=t.pendingProps;var l=Fn(t,ce.current);Ln(t,n),l=Tu(null,t,r,e,l,n);var o=_u();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,we(r)?(o=!0,Ol(t)):o=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,ku(t),l.updater=no,t.stateNode=l,l._reactInternals=t,Ti(t,r,e,n),t=Ri(null,t,r,!0,o,n)):(t.tag=0,Q&&o&&mu(t),de(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(vl(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=ih(r),e=Ie(r,e),l){case 0:t=ji(null,t,r,e,n);break e;case 1:t=Ks(null,t,r,e,n);break e;case 11:t=Vs(null,t,r,e,n);break e;case 14:t=Qs(null,t,r,Ie(r.type,e),n);break e}throw Error(x(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Ie(r,l),ji(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Ie(r,l),Ks(e,t,r,l,n);case 3:e:{if(ff(t),e===null)throw Error(x(387));r=t.pendingProps,o=t.memoizedState,l=o.element,Mc(e,t),$l(t,r,null,n);var i=t.memoizedState;if(r=i.element,o.isDehydrated)if(o={element:r,isDehydrated:!1,cache:i.cache,pendingSuspenseBoundaries:i.pendingSuspenseBoundaries,transitions:i.transitions},t.updateQueue.baseState=o,t.memoizedState=o,t.flags&256){l=Dn(Error(x(423)),t),t=Gs(e,t,r,n,l);break e}else if(r!==l){l=Dn(Error(x(424)),t),t=Gs(e,t,r,n,l);break e}else for(xe=Rt(t.stateNode.containerInfo.firstChild),Ee=t,Q=!0,Ue=null,n=Ic(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(Mn(),r===l){t=at(e,t,n);break e}de(e,t,r,n)}t=t.child}return t;case 5:return Ac(t),e===null&&Ei(t),r=t.type,l=t.pendingProps,o=e!==null?e.memoizedProps:null,i=l.children,gi(r,l)?i=null:o!==null&&gi(r,o)&&(t.flags|=32),cf(e,t),de(e,t,i,n),t.child;case 6:return e===null&&Ei(t),null;case 13:return df(e,t,n);case 4:return xu(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=zn(t,null,r,n):de(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Ie(r,l),Vs(e,t,r,l,n);case 7:return de(e,t,t.pendingProps,n),t.child;case 8:return de(e,t,t.pendingProps.children,n),t.child;case 12:return de(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,o=t.memoizedProps,i=l.value,B(Ml,r._currentValue),r._currentValue=i,o!==null)if(We(o.value,i)){if(o.children===l.children&&!ge.current){t=at(e,t,n);break e}}else for(o=t.child,o!==null&&(o.return=t);o!==null;){var u=o.dependencies;if(u!==null){i=o.child;for(var s=u.firstContext;s!==null;){if(s.context===r){if(o.tag===1){s=lt(-1,n&-n),s.tag=2;var a=o.updateQueue;if(a!==null){a=a.shared;var f=a.pending;f===null?s.next=s:(s.next=f.next,f.next=s),a.pending=s}}o.lanes|=n,s=o.alternate,s!==null&&(s.lanes|=n),Ci(o.return,n,t),u.lanes|=n;break}s=s.next}}else if(o.tag===10)i=o.type===t.type?null:o.child;else if(o.tag===18){if(i=o.return,i===null)throw Error(x(341));i.lanes|=n,u=i.alternate,u!==null&&(u.lanes|=n),Ci(i,n,t),i=o.sibling}else i=o.child;if(i!==null)i.return=o;else for(i=o;i!==null;){if(i===t){i=null;break}if(o=i.sibling,o!==null){o.return=i.return,i=o;break}i=i.return}o=i}de(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,Ln(t,n),l=ze(l),r=r(l),t.flags|=1,de(e,t,r,n),t.child;case 14:return r=t.type,l=Ie(r,t.pendingProps),l=Ie(r.type,l),Qs(e,t,r,l,n);case 15:return sf(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Ie(r,l),vl(e,t),t.tag=1,we(r)?(e=!0,Ol(t)):e=!1,Ln(t,n),$c(t,r,l),Ti(t,r,l,n),Ri(null,t,r,!0,e,n);case 19:return pf(e,t,n);case 22:return af(e,t,n)}throw Error(x(156,t.tag))};function Rf(e,t){return ec(e,t)}function oh(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Fe(e,t,n,r){return new oh(e,t,n,r)}function Du(e){return e=e.prototype,!(!e||!e.isReactComponent)}function ih(e){if(typeof e=="function")return Du(e)?1:0;if(e!=null){if(e=e.$$typeof,e===nu)return 11;if(e===ru)return 14}return 2}function Ft(e,t){var n=e.alternate;return n===null?(n=Fe(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function wl(e,t,n,r,l,o){var i=2;if(r=e,typeof e=="function")Du(e)&&(i=1);else if(typeof e=="string")i=5;else e:switch(e){case mn:return Xt(n.children,l,o,t);case tu:i=8,l|=8;break;case Zo:return e=Fe(12,n,t,l|2),e.elementType=Zo,e.lanes=o,e;case Jo:return e=Fe(13,n,t,l),e.elementType=Jo,e.lanes=o,e;case qo:return e=Fe(19,n,t,l),e.elementType=qo,e.lanes=o,e;case Da:return io(n,l,o,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case za:i=10;break e;case $a:i=9;break e;case nu:i=11;break e;case ru:i=14;break e;case gt:i=16,r=null;break e}throw Error(x(130,e==null?e:typeof e,""))}return t=Fe(i,n,t,l),t.elementType=e,t.type=r,t.lanes=o,t}function Xt(e,t,n,r){return e=Fe(7,e,r,t),e.lanes=n,e}function io(e,t,n,r){return e=Fe(22,e,r,t),e.elementType=Da,e.lanes=n,e.stateNode={isHidden:!1},e}function Uo(e,t,n){return e=Fe(6,e,null,t),e.lanes=n,e}function Bo(e,t,n){return t=Fe(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function uh(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=ko(0),this.expirationTimes=ko(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=ko(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function Iu(e,t,n,r,l,o,i,u,s){return e=new uh(e,t,n,u,s),t===1?(t=1,o===!0&&(t|=8)):t=0,o=Fe(3,null,null,t),e.current=o,o.stateNode=e,o.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},ku(o),e}function sh(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(Ff)}catch(e){console.error(e)}}Ff(),La.exports=Ne;var Mf=La.exports;const Nn=Yl(Mf);var oa=Mf;Yo.createRoot=oa.createRoot,Yo.hydrateRoot=oa.hydrateRoot;var zf={exports:{}};/*! Copyright (c) 2018 Jed Watson. Licensed under the MIT License (MIT), see http://jedwatson.github.io/classnames -*/(function(e){(function(){var t={}.hasOwnProperty;function n(){for(var r=[],l=0;l=0)&&(n[l]=e[l]);return n}function ia(e){return"default"+e.charAt(0).toUpperCase()+e.substr(1)}function mh(e){var t=hh(e,"string");return typeof t=="symbol"?t:String(t)}function hh(e,t){if(typeof e!="object"||e===null)return e;var n=e[Symbol.toPrimitive];if(n!==void 0){var r=n.call(e,t||"default");if(typeof r!="object")return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return(t==="string"?String:Number)(e)}function vh(e,t,n){var r=y.useRef(e!==void 0),l=y.useState(t),o=l[0],i=l[1],u=e!==void 0,s=r.current;return r.current=u,!u&&s&&o!==t&&i(t),[u?e:o,y.useCallback(function(a){for(var d=arguments.length,m=new Array(d>1?d-1:0),p=1;p{o.target===e&&(l(),t(o))},n+r)}function Bh(e){e.offsetHeight}const aa=e=>!e||typeof e=="function"?e:t=>{e.current=t};function Uh(e,t){const n=aa(e),r=aa(t);return l=>{n&&n(l),r&&r(l)}}function mo(e,t){return y.useMemo(()=>Uh(e,t),[e,t])}function Hh(e){return e&&"setState"in e?Cn.findDOMNode(e):e??null}const Wh=Wt.forwardRef(({onEnter:e,onEntering:t,onEntered:n,onExit:r,onExiting:l,onExited:o,addEndListener:i,children:u,childRef:s,...a},d)=>{const m=y.useRef(null),p=mo(m,s),g=N=>{p(Hh(N))},w=N=>T=>{N&&m.current&&N(m.current,T)},k=y.useCallback(w(e),[e]),R=y.useCallback(w(t),[t]),f=y.useCallback(w(n),[n]),c=y.useCallback(w(r),[r]),h=y.useCallback(w(l),[l]),S=y.useCallback(w(o),[o]),C=y.useCallback(w(i),[i]);return v.jsx(zh,{ref:d,...a,onEnter:k,onEntered:f,onEntering:R,onExit:c,onExited:S,onExiting:h,addEndListener:C,nodeRef:m,children:typeof u=="function"?(N,T)=>u(N,{...T,ref:g}):Wt.cloneElement(u,{ref:g})})}),Vh=Wh;function Qh(e){const t=y.useRef(e);return y.useEffect(()=>{t.current=e},[e]),t}function Pe(e){const t=Qh(e);return y.useCallback(function(...n){return t.current&&t.current(...n)},[t])}const Qf=e=>y.forwardRef((t,n)=>v.jsx("div",{...t,ref:n,className:M(t.className,e)})),Kf=Qf("h4");Kf.displayName="DivStyledAsH4";const Gf=y.forwardRef(({className:e,bsPrefix:t,as:n=Kf,...r},l)=>(t=H(t,"alert-heading"),v.jsx(n,{ref:l,className:M(e,t),...r})));Gf.displayName="AlertHeading";const Kh=Gf;function Gh(){return y.useState(null)}function Yh(){const e=y.useRef(!0),t=y.useRef(()=>e.current);return y.useEffect(()=>(e.current=!0,()=>{e.current=!1}),[]),t.current}function Xh(e){const t=y.useRef(null);return y.useEffect(()=>{t.current=e}),t.current}const Zh=typeof global<"u"&&global.navigator&&global.navigator.product==="ReactNative",Jh=typeof document<"u",ca=Jh||Zh?y.useLayoutEffect:y.useEffect,qh=["as","disabled"];function bh(e,t){if(e==null)return{};var n={},r=Object.keys(e),l,o;for(o=0;o=0)&&(n[l]=e[l]);return n}function ev(e){return!e||e.trim()==="#"}function Hu({tagName:e,disabled:t,href:n,target:r,rel:l,role:o,onClick:i,tabIndex:u=0,type:s}){e||(n!=null||r!=null||l!=null?e="a":e="button");const a={tagName:e};if(e==="button")return[{type:s||"button",disabled:t},a];const d=p=>{if((t||e==="a"&&ev(n))&&p.preventDefault(),t){p.stopPropagation();return}i==null||i(p)},m=p=>{p.key===" "&&(p.preventDefault(),d(p))};return e==="a"&&(n||(n="#"),t&&(n=void 0)),[{role:o??"button",disabled:void 0,tabIndex:t?void 0:u,href:n,target:e==="a"?r:void 0,"aria-disabled":t||void 0,rel:e==="a"?l:void 0,onClick:d,onKeyDown:m},a]}const tv=y.forwardRef((e,t)=>{let{as:n,disabled:r}=e,l=bh(e,qh);const[o,{tagName:i}]=Hu(Object.assign({tagName:n,disabled:r},l));return v.jsx(i,Object.assign({},l,o,{ref:t}))});tv.displayName="Button";const nv=["onKeyDown"];function rv(e,t){if(e==null)return{};var n={},r=Object.keys(e),l,o;for(o=0;o=0)&&(n[l]=e[l]);return n}function lv(e){return!e||e.trim()==="#"}const Yf=y.forwardRef((e,t)=>{let{onKeyDown:n}=e,r=rv(e,nv);const[l]=Hu(Object.assign({tagName:"a"},r)),o=Pe(i=>{l.onKeyDown(i),n==null||n(i)});return lv(r.href)||r.role==="button"?v.jsx("a",Object.assign({ref:t},r,l,{onKeyDown:o})):v.jsx("a",Object.assign({ref:t},r,{onKeyDown:n}))});Yf.displayName="Anchor";const ov=Yf,Xf=y.forwardRef(({className:e,bsPrefix:t,as:n=ov,...r},l)=>(t=H(t,"alert-link"),v.jsx(n,{ref:l,className:M(e,t),...r})));Xf.displayName="AlertLink";const iv=Xf,uv={[St]:"show",[Ht]:"show"},Zf=y.forwardRef(({className:e,children:t,transitionClasses:n={},onEnter:r,...l},o)=>{const i={in:!1,timeout:300,mountOnEnter:!1,unmountOnExit:!1,appear:!1,...l},u=y.useCallback((s,a)=>{Bh(s),r==null||r(s,a)},[r]);return v.jsx(Vh,{ref:o,addEndListener:Ah,...i,onEnter:u,childRef:t.ref,children:(s,a)=>y.cloneElement(t,{...a,className:M("fade",e,t.props.className,uv[s],n[s])})})});Zf.displayName="Fade";const Kl=Zf,sv={"aria-label":ot.string,onClick:ot.func,variant:ot.oneOf(["white"])},Wu=y.forwardRef(({className:e,variant:t,"aria-label":n="Close",...r},l)=>v.jsx("button",{ref:l,type:"button",className:M("btn-close",t&&`btn-close-${t}`,e),"aria-label":n,...r}));Wu.displayName="CloseButton";Wu.propTypes=sv;const Jf=Wu,qf=y.forwardRef((e,t)=>{const{bsPrefix:n,show:r=!0,closeLabel:l="Close alert",closeVariant:o,className:i,children:u,variant:s="primary",onClose:a,dismissible:d,transition:m=Kl,...p}=yh(e,{show:"onClose"}),g=H(n,"alert"),w=Pe(f=>{a&&a(!1,f)}),k=m===!0?Kl:m,R=v.jsxs("div",{role:"alert",...k?void 0:p,ref:t,className:M(i,g,s&&`${g}-${s}`,d&&`${g}-dismissible`),children:[d&&v.jsx(Jf,{onClick:w,"aria-label":l,variant:o}),u]});return k?v.jsx(k,{unmountOnExit:!0,...p,ref:void 0,in:r,children:R}):r?R:null});qf.displayName="Alert";const fa=Object.assign(qf,{Link:iv,Heading:Kh}),bf=y.forwardRef(({as:e,bsPrefix:t,variant:n="primary",size:r,active:l=!1,disabled:o=!1,className:i,...u},s)=>{const a=H(t,"btn"),[d,{tagName:m}]=Hu({tagName:e,disabled:o,...u}),p=m;return v.jsx(p,{...d,...u,ref:s,disabled:o,className:M(i,a,l&&"active",n&&`${a}-${n}`,r&&`${a}-${r}`,u.href&&o&&"disabled")})});bf.displayName="Button";const Mr=bf;function av(e){const t=y.useRef(e);return t.current=e,t}function ed(e){const t=av(e);y.useEffect(()=>()=>t.current(),[])}function cv(e,t){let n=0;return y.Children.map(e,r=>y.isValidElement(r)?t(r,n++):r)}function fv(e,t){return y.Children.toArray(e).some(n=>y.isValidElement(n)&&n.type===t)}function dv({as:e,bsPrefix:t,className:n,...r}){t=H(t,"col");const l=Df(),o=If(),i=[],u=[];return l.forEach(s=>{const a=r[s];delete r[s];let d,m,p;typeof a=="object"&&a!=null?{span:d,offset:m,order:p}=a:d=a;const g=s!==o?`-${s}`:"";d&&i.push(d===!0?`${t}${g}`:`${t}${g}-${d}`),p!=null&&u.push(`order${g}-${p}`),m!=null&&u.push(`offset${g}-${m}`)}),[{...r,className:M(n,...i,...u)},{as:e,bsPrefix:t,spans:i}]}const td=y.forwardRef((e,t)=>{const[{className:n,...r},{as:l="div",bsPrefix:o,spans:i}]=dv(e);return v.jsx(l,{...r,ref:t,className:M(n,!i.length&&o)})});td.displayName="Col";const Vu=td,nd=y.forwardRef(({bsPrefix:e,fluid:t=!1,as:n="div",className:r,...l},o)=>{const i=H(e,"container"),u=typeof t=="string"?`-${t}`:"-fluid";return v.jsx(n,{ref:o,...l,className:M(r,t?`${i}${u}`:i)})});nd.displayName="Container";const pv=nd;var mv=Function.prototype.bind.call(Function.prototype.call,[].slice);function cn(e,t){return mv(e.querySelectorAll(t))}function da(e,t){if(e.contains)return e.contains(t);if(e.compareDocumentPosition)return e===t||!!(e.compareDocumentPosition(t)&16)}const hv="data-rr-ui-";function vv(e){return`${hv}${e}`}const rd=y.createContext(Wn?window:void 0);rd.Provider;function Qu(){return y.useContext(rd)}const yv={type:ot.string,tooltip:ot.bool,as:ot.elementType},Ku=y.forwardRef(({as:e="div",className:t,type:n="valid",tooltip:r=!1,...l},o)=>v.jsx(e,{...l,ref:o,className:M(t,`${n}-${r?"tooltip":"feedback"}`)}));Ku.displayName="Feedback";Ku.propTypes=yv;const ld=Ku,gv=y.createContext({}),ct=gv,od=y.forwardRef(({id:e,bsPrefix:t,className:n,type:r="checkbox",isValid:l=!1,isInvalid:o=!1,as:i="input",...u},s)=>{const{controlId:a}=y.useContext(ct);return t=H(t,"form-check-input"),v.jsx(i,{...u,ref:s,type:r,id:e||a,className:M(n,t,l&&"is-valid",o&&"is-invalid")})});od.displayName="FormCheckInput";const id=od,ud=y.forwardRef(({bsPrefix:e,className:t,htmlFor:n,...r},l)=>{const{controlId:o}=y.useContext(ct);return e=H(e,"form-check-label"),v.jsx("label",{...r,ref:l,htmlFor:n||o,className:M(t,e)})});ud.displayName="FormCheckLabel";const Gi=ud,sd=y.forwardRef(({id:e,bsPrefix:t,bsSwitchPrefix:n,inline:r=!1,reverse:l=!1,disabled:o=!1,isValid:i=!1,isInvalid:u=!1,feedbackTooltip:s=!1,feedback:a,feedbackType:d,className:m,style:p,title:g="",type:w="checkbox",label:k,children:R,as:f="input",...c},h)=>{t=H(t,"form-check"),n=H(n,"form-switch");const{controlId:S}=y.useContext(ct),C=y.useMemo(()=>({controlId:e||S}),[S,e]),N=!R&&k!=null&&k!==!1||fv(R,Gi),T=v.jsx(id,{...c,type:w==="switch"?"checkbox":w,ref:h,isValid:i,isInvalid:u,disabled:o,as:f});return v.jsx(ct.Provider,{value:C,children:v.jsx("div",{style:p,className:M(m,N&&t,r&&`${t}-inline`,l&&`${t}-reverse`,w==="switch"&&n),children:R||v.jsxs(v.Fragment,{children:[T,N&&v.jsx(Gi,{title:g,children:k}),a&&v.jsx(ld,{type:d,tooltip:s,children:a})]})})})});sd.displayName="FormCheck";const Gl=Object.assign(sd,{Input:id,Label:Gi}),ad=y.forwardRef(({bsPrefix:e,type:t,size:n,htmlSize:r,id:l,className:o,isValid:i=!1,isInvalid:u=!1,plaintext:s,readOnly:a,as:d="input",...m},p)=>{const{controlId:g}=y.useContext(ct);return e=H(e,"form-control"),v.jsx(d,{...m,type:t,size:r,ref:p,readOnly:a,id:l||g,className:M(o,s?`${e}-plaintext`:e,n&&`${e}-${n}`,t==="color"&&`${e}-color`,i&&"is-valid",u&&"is-invalid")})});ad.displayName="FormControl";const wv=Object.assign(ad,{Feedback:ld}),cd=y.forwardRef(({className:e,bsPrefix:t,as:n="div",...r},l)=>(t=H(t,"form-floating"),v.jsx(n,{ref:l,className:M(e,t),...r})));cd.displayName="FormFloating";const Sv=cd,fd=y.forwardRef(({controlId:e,as:t="div",...n},r)=>{const l=y.useMemo(()=>({controlId:e}),[e]);return v.jsx(ct.Provider,{value:l,children:v.jsx(t,{...n,ref:r})})});fd.displayName="FormGroup";const dd=fd,pd=y.forwardRef(({as:e="label",bsPrefix:t,column:n=!1,visuallyHidden:r=!1,className:l,htmlFor:o,...i},u)=>{const{controlId:s}=y.useContext(ct);t=H(t,"form-label");let a="col-form-label";typeof n=="string"&&(a=`${a} ${a}-${n}`);const d=M(l,t,r&&"visually-hidden",n&&a);return o=o||s,n?v.jsx(Vu,{ref:u,as:"label",className:d,htmlFor:o,...i}):v.jsx(e,{ref:u,className:d,htmlFor:o,...i})});pd.displayName="FormLabel";const kv=pd,md=y.forwardRef(({bsPrefix:e,className:t,id:n,...r},l)=>{const{controlId:o}=y.useContext(ct);return e=H(e,"form-range"),v.jsx("input",{...r,type:"range",ref:l,className:M(t,e),id:n||o})});md.displayName="FormRange";const Ev=md,hd=y.forwardRef(({bsPrefix:e,size:t,htmlSize:n,className:r,isValid:l=!1,isInvalid:o=!1,id:i,...u},s)=>{const{controlId:a}=y.useContext(ct);return e=H(e,"form-select"),v.jsx("select",{...u,size:n,ref:s,className:M(r,e,t&&`${e}-${t}`,l&&"is-valid",o&&"is-invalid"),id:i||a})});hd.displayName="FormSelect";const xv=hd,vd=y.forwardRef(({bsPrefix:e,className:t,as:n="small",muted:r,...l},o)=>(e=H(e,"form-text"),v.jsx(n,{...l,ref:o,className:M(t,e,r&&"text-muted")})));vd.displayName="FormText";const Cv=vd,yd=y.forwardRef((e,t)=>v.jsx(Gl,{...e,ref:t,type:"switch"}));yd.displayName="Switch";const Nv=Object.assign(yd,{Input:Gl.Input,Label:Gl.Label}),gd=y.forwardRef(({bsPrefix:e,className:t,children:n,controlId:r,label:l,...o},i)=>(e=H(e,"form-floating"),v.jsxs(dd,{ref:i,className:M(t,e),controlId:r,...o,children:[n,v.jsx("label",{htmlFor:r,children:l})]})));gd.displayName="FloatingLabel";const Tv=gd,_v={_ref:ot.any,validated:ot.bool,as:ot.elementType},Gu=y.forwardRef(({className:e,validated:t,as:n="form",...r},l)=>v.jsx(n,{...r,ref:l,className:M(e,t&&"was-validated")}));Gu.displayName="Form";Gu.propTypes=_v;const On=Object.assign(Gu,{Group:dd,Control:wv,Floating:Sv,Check:Gl,Switch:Nv,Label:kv,Text:Cv,Range:Ev,Select:xv,FloatingLabel:Tv});var ul;function pa(e){if((!ul&&ul!==0||e)&&Wn){var t=document.createElement("div");t.style.position="absolute",t.style.top="-9999px",t.style.width="50px",t.style.height="50px",t.style.overflow="scroll",document.body.appendChild(t),ul=t.offsetWidth-t.clientWidth,document.body.removeChild(t)}return ul}function Wo(e){e===void 0&&(e=po());try{var t=e.activeElement;return!t||!t.nodeName?null:t}catch{return e.body}}function jv(e=document){const t=e.defaultView;return Math.abs(t.innerWidth-e.documentElement.clientWidth)}const ma=vv("modal-open");class Rv{constructor({ownerDocument:t,handleContainerOverflow:n=!0,isRTL:r=!1}={}){this.handleContainerOverflow=n,this.isRTL=r,this.modals=[],this.ownerDocument=t}getScrollbarWidth(){return jv(this.ownerDocument)}getElement(){return(this.ownerDocument||document).body}setModalAttributes(t){}removeModalAttributes(t){}setContainerStyle(t){const n={overflow:"hidden"},r=this.isRTL?"paddingLeft":"paddingRight",l=this.getElement();t.style={overflow:l.style.overflow,[r]:l.style[r]},t.scrollBarWidth&&(n[r]=`${parseInt(Xt(l,r)||"0",10)+t.scrollBarWidth}px`),l.setAttribute(ma,""),Xt(l,n)}reset(){[...this.modals].forEach(t=>this.remove(t))}removeContainerStyle(t){const n=this.getElement();n.removeAttribute(ma),Object.assign(n.style,t.style)}add(t){let n=this.modals.indexOf(t);return n!==-1||(n=this.modals.length,this.modals.push(t),this.setModalAttributes(t),n!==0)||(this.state={scrollBarWidth:this.getScrollbarWidth(),style:{}},this.handleContainerOverflow&&this.setContainerStyle(this.state)),n}remove(t){const n=this.modals.indexOf(t);n!==-1&&(this.modals.splice(n,1),!this.modals.length&&this.handleContainerOverflow&&this.removeContainerStyle(this.state),this.removeModalAttributes(t))}isTopModal(t){return!!this.modals.length&&this.modals[this.modals.length-1]===t}}const Yu=Rv,Vo=(e,t)=>Wn?e==null?(t||po()).body:(typeof e=="function"&&(e=e()),e&&"current"in e&&(e=e.current),e&&("nodeType"in e||e.getBoundingClientRect)?e:null):null;function Lv(e,t){const n=Qu(),[r,l]=y.useState(()=>Vo(e,n==null?void 0:n.document));if(!r){const o=Vo(e);o&&l(o)}return y.useEffect(()=>{t&&r&&t(r)},[t,r]),y.useEffect(()=>{const o=Vo(e);o!==r&&l(o)},[e,r]),r}function Ov({children:e,in:t,onExited:n,mountOnEnter:r,unmountOnExit:l}){const o=y.useRef(null),i=y.useRef(t),u=Pe(n);y.useEffect(()=>{t?i.current=!0:u(o.current)},[t,u]);const s=mo(o,e.ref),a=y.cloneElement(e,{ref:s});return t?a:l||!i.current&&r?null:a}function Pv({in:e,onTransition:t}){const n=y.useRef(null),r=y.useRef(!0),l=Pe(t);return ca(()=>{if(!n.current)return;let o=!1;return l({in:e,element:n.current,initial:r.current,isStale:()=>o}),()=>{o=!0}},[e,l]),ca(()=>(r.current=!1,()=>{r.current=!0}),[]),n}function Fv({children:e,in:t,onExited:n,onEntered:r,transition:l}){const[o,i]=y.useState(!t);t&&o&&i(!1);const u=Pv({in:!!t,onTransition:a=>{const d=()=>{a.isStale()||(a.in?r==null||r(a.element,a.initial):(i(!0),n==null||n(a.element)))};Promise.resolve(l(a)).then(d,m=>{throw a.in||i(!0),m})}}),s=mo(u,e.ref);return o&&!t?null:y.cloneElement(e,{ref:s})}function ha(e,t,n){return e?v.jsx(e,Object.assign({},n)):t?v.jsx(Fv,Object.assign({},n,{transition:t})):v.jsx(Ov,Object.assign({},n))}function Mv(e){return e.code==="Escape"||e.keyCode===27}const zv=["show","role","className","style","children","backdrop","keyboard","onBackdropClick","onEscapeKeyDown","transition","runTransition","backdropTransition","runBackdropTransition","autoFocus","enforceFocus","restoreFocus","restoreFocusOptions","renderDialog","renderBackdrop","manager","container","onShow","onHide","onExit","onExited","onExiting","onEnter","onEntering","onEntered"];function $v(e,t){if(e==null)return{};var n={},r=Object.keys(e),l,o;for(o=0;o=0)&&(n[l]=e[l]);return n}let Qo;function Dv(e){return Qo||(Qo=new Yu({ownerDocument:e==null?void 0:e.document})),Qo}function Iv(e){const t=Qu(),n=e||Dv(t),r=y.useRef({dialog:null,backdrop:null});return Object.assign(r.current,{add:()=>n.add(r.current),remove:()=>n.remove(r.current),isTopModal:()=>n.isTopModal(r.current),setDialogRef:y.useCallback(l=>{r.current.dialog=l},[]),setBackdropRef:y.useCallback(l=>{r.current.backdrop=l},[])})}const wd=y.forwardRef((e,t)=>{let{show:n=!1,role:r="dialog",className:l,style:o,children:i,backdrop:u=!0,keyboard:s=!0,onBackdropClick:a,onEscapeKeyDown:d,transition:m,runTransition:p,backdropTransition:g,runBackdropTransition:w,autoFocus:k=!0,enforceFocus:R=!0,restoreFocus:f=!0,restoreFocusOptions:c,renderDialog:h,renderBackdrop:S=K=>v.jsx("div",Object.assign({},K)),manager:C,container:N,onShow:T,onHide:j=()=>{},onExit:B,onExited:P,onExiting:ie,onEnter:Ve,onEntering:Qe,onEntered:rn}=e,Qn=$v(e,zv);const _e=Qu(),Ke=Lv(N),x=Iv(C),L=Yh(),O=Xh(n),[D,A]=y.useState(!n),fe=y.useRef(null);y.useImperativeHandle(t,()=>x,[x]),Wn&&!O&&n&&(fe.current=Wo(_e==null?void 0:_e.document)),n&&D&&A(!1);const je=Pe(()=>{if(x.add(),on.current=Ql(document,"keydown",ho),ln.current=Ql(document,"focus",()=>setTimeout(Re),!0),T&&T(),k){var K,Hr;const Yn=Wo((K=(Hr=x.dialog)==null?void 0:Hr.ownerDocument)!=null?K:_e==null?void 0:_e.document);x.dialog&&Yn&&!da(x.dialog,Yn)&&(fe.current=Yn,x.dialog.focus())}}),qe=Pe(()=>{if(x.remove(),on.current==null||on.current(),ln.current==null||ln.current(),f){var K;(K=fe.current)==null||K.focus==null||K.focus(c),fe.current=null}});y.useEffect(()=>{!n||!Ke||je()},[n,Ke,je]),y.useEffect(()=>{D&&qe()},[D,qe]),ed(()=>{qe()});const Re=Pe(()=>{if(!R||!L()||!x.isTopModal())return;const K=Wo(_e==null?void 0:_e.document);x.dialog&&K&&!da(x.dialog,K)&&x.dialog.focus()}),mt=Pe(K=>{K.target===K.currentTarget&&(a==null||a(K),u===!0&&j())}),ho=Pe(K=>{s&&Mv(K)&&x.isTopModal()&&(d==null||d(K),K.defaultPrevented||j())}),ln=y.useRef(),on=y.useRef(),Kn=(...K)=>{A(!0),P==null||P(...K)};if(!Ke)return null;const Ur=Object.assign({role:r,ref:x.setDialogRef,"aria-modal":r==="dialog"?!0:void 0},Qn,{style:o,className:l,tabIndex:-1});let Gn=h?h(Ur):v.jsx("div",Object.assign({},Ur,{children:y.cloneElement(i,{role:"document"})}));Gn=ha(m,p,{unmountOnExit:!0,mountOnEnter:!0,appear:!0,in:!!n,onExit:B,onExiting:ie,onExited:Kn,onEnter:Ve,onEntering:Qe,onEntered:rn,children:Gn});let It=null;return u&&(It=S({ref:x.setBackdropRef,onClick:mt}),It=ha(g,w,{in:!!n,appear:!0,mountOnEnter:!0,unmountOnExit:!0,children:It})),v.jsx(v.Fragment,{children:Cn.createPortal(v.jsxs(v.Fragment,{children:[It,Gn]}),Ke)})});wd.displayName="Modal";const Av=Object.assign(wd,{Manager:Yu});function Bv(e,t){return e.classList?!!t&&e.classList.contains(t):(" "+(e.className.baseVal||e.className)+" ").indexOf(" "+t+" ")!==-1}function Uv(e,t){e.classList?e.classList.add(t):Bv(e,t)||(typeof e.className=="string"?e.className=e.className+" "+t:e.setAttribute("class",(e.className&&e.className.baseVal||"")+" "+t))}function va(e,t){return e.replace(new RegExp("(^|\\s)"+t+"(?:\\s|$)","g"),"$1").replace(/\s+/g," ").replace(/^\s*|\s*$/g,"")}function Hv(e,t){e.classList?e.classList.remove(t):typeof e.className=="string"?e.className=va(e.className,t):e.setAttribute("class",va(e.className&&e.className.baseVal||"",t))}const fn={FIXED_CONTENT:".fixed-top, .fixed-bottom, .is-fixed, .sticky-top",STICKY_CONTENT:".sticky-top",NAVBAR_TOGGLER:".navbar-toggler"};class Wv extends Yu{adjustAndStore(t,n,r){const l=n.style[t];n.dataset[t]=l,Xt(n,{[t]:`${parseFloat(Xt(n,t))+r}px`})}restore(t,n){const r=n.dataset[t];r!==void 0&&(delete n.dataset[t],Xt(n,{[t]:r}))}setContainerStyle(t){super.setContainerStyle(t);const n=this.getElement();if(Uv(n,"modal-open"),!t.scrollBarWidth)return;const r=this.isRTL?"paddingLeft":"paddingRight",l=this.isRTL?"marginLeft":"marginRight";cn(n,fn.FIXED_CONTENT).forEach(o=>this.adjustAndStore(r,o,t.scrollBarWidth)),cn(n,fn.STICKY_CONTENT).forEach(o=>this.adjustAndStore(l,o,-t.scrollBarWidth)),cn(n,fn.NAVBAR_TOGGLER).forEach(o=>this.adjustAndStore(l,o,t.scrollBarWidth))}removeContainerStyle(t){super.removeContainerStyle(t);const n=this.getElement();Hv(n,"modal-open");const r=this.isRTL?"paddingLeft":"paddingRight",l=this.isRTL?"marginLeft":"marginRight";cn(n,fn.FIXED_CONTENT).forEach(o=>this.restore(r,o)),cn(n,fn.STICKY_CONTENT).forEach(o=>this.restore(l,o)),cn(n,fn.NAVBAR_TOGGLER).forEach(o=>this.restore(l,o))}}let Ko;function Vv(e){return Ko||(Ko=new Wv(e)),Ko}const Sd=y.forwardRef(({className:e,bsPrefix:t,as:n="div",...r},l)=>(t=H(t,"modal-body"),v.jsx(n,{ref:l,className:M(e,t),...r})));Sd.displayName="ModalBody";const Qv=Sd,Kv=y.createContext({onHide(){}}),kd=Kv,Ed=y.forwardRef(({bsPrefix:e,className:t,contentClassName:n,centered:r,size:l,fullscreen:o,children:i,scrollable:u,...s},a)=>{e=H(e,"modal");const d=`${e}-dialog`,m=typeof o=="string"?`${e}-fullscreen-${o}`:`${e}-fullscreen`;return v.jsx("div",{...s,ref:a,className:M(d,t,l&&`${e}-${l}`,r&&`${d}-centered`,u&&`${d}-scrollable`,o&&m),children:v.jsx("div",{className:M(`${e}-content`,n),children:i})})});Ed.displayName="ModalDialog";const xd=Ed,Cd=y.forwardRef(({className:e,bsPrefix:t,as:n="div",...r},l)=>(t=H(t,"modal-footer"),v.jsx(n,{ref:l,className:M(e,t),...r})));Cd.displayName="ModalFooter";const Gv=Cd,Yv=y.forwardRef(({closeLabel:e="Close",closeVariant:t,closeButton:n=!1,onHide:r,children:l,...o},i)=>{const u=y.useContext(kd),s=Pe(()=>{u==null||u.onHide(),r==null||r()});return v.jsxs("div",{ref:i,...o,children:[l,n&&v.jsx(Jf,{"aria-label":e,variant:t,onClick:s})]})}),Xv=Yv,Nd=y.forwardRef(({bsPrefix:e,className:t,closeLabel:n="Close",closeButton:r=!1,...l},o)=>(e=H(e,"modal-header"),v.jsx(Xv,{ref:o,...l,className:M(t,e),closeLabel:n,closeButton:r})));Nd.displayName="ModalHeader";const Zv=Nd,Jv=Qf("h4"),Td=y.forwardRef(({className:e,bsPrefix:t,as:n=Jv,...r},l)=>(t=H(t,"modal-title"),v.jsx(n,{ref:l,className:M(e,t),...r})));Td.displayName="ModalTitle";const qv=Td;function bv(e){return v.jsx(Kl,{...e,timeout:null})}function ey(e){return v.jsx(Kl,{...e,timeout:null})}const _d=y.forwardRef(({bsPrefix:e,className:t,style:n,dialogClassName:r,contentClassName:l,children:o,dialogAs:i=xd,"aria-labelledby":u,"aria-describedby":s,"aria-label":a,show:d=!1,animation:m=!0,backdrop:p=!0,keyboard:g=!0,onEscapeKeyDown:w,onShow:k,onHide:R,container:f,autoFocus:c=!0,enforceFocus:h=!0,restoreFocus:S=!0,restoreFocusOptions:C,onEntered:N,onExit:T,onExiting:j,onEnter:B,onEntering:P,onExited:ie,backdropClassName:Ve,manager:Qe,...rn},Qn)=>{const[_e,Ke]=y.useState({}),[x,L]=y.useState(!1),O=y.useRef(!1),D=y.useRef(!1),A=y.useRef(null),[fe,je]=Gh(),qe=mo(Qn,je),Re=Pe(R),mt=kh();e=H(e,"modal");const ho=y.useMemo(()=>({onHide:Re}),[Re]);function ln(){return Qe||Vv({isRTL:mt})}function on($){if(!Wn)return;const un=ln().getScrollbarWidth()>0,Zu=$.scrollHeight>po($).documentElement.clientHeight;Ke({paddingRight:un&&!Zu?pa():void 0,paddingLeft:!un&&Zu?pa():void 0})}const Kn=Pe(()=>{fe&&on(fe.dialog)});ed(()=>{Ki(window,"resize",Kn),A.current==null||A.current()});const Ur=()=>{O.current=!0},Gn=$=>{O.current&&fe&&$.target===fe.dialog&&(D.current=!0),O.current=!1},It=()=>{L(!0),A.current=Vf(fe.dialog,()=>{L(!1)})},K=$=>{$.target===$.currentTarget&&It()},Hr=$=>{if(p==="static"){K($);return}if(D.current||$.target!==$.currentTarget){D.current=!1;return}R==null||R()},Yn=$=>{g?w==null||w($):($.preventDefault(),p==="static"&&It())},Dd=($,un)=>{$&&on($),B==null||B($,un)},Id=$=>{A.current==null||A.current(),T==null||T($)},Ad=($,un)=>{P==null||P($,un),Wf(window,"resize",Kn)},Bd=$=>{$&&($.style.display=""),ie==null||ie($),Ki(window,"resize",Kn)},Ud=y.useCallback($=>v.jsx("div",{...$,className:M(`${e}-backdrop`,Ve,!m&&"show")}),[m,Ve,e]),Xu={...n,..._e};Xu.display="block";const Hd=$=>v.jsx("div",{role:"dialog",...$,style:Xu,className:M(t,e,x&&`${e}-static`,!m&&"show"),onClick:p?Hr:void 0,onMouseUp:Gn,"aria-label":a,"aria-labelledby":u,"aria-describedby":s,children:v.jsx(i,{...rn,onMouseDown:Ur,className:r,contentClassName:l,children:o})});return v.jsx(kd.Provider,{value:ho,children:v.jsx(Av,{show:d,ref:qe,backdrop:p,container:f,keyboard:!0,autoFocus:c,enforceFocus:h,restoreFocus:S,restoreFocusOptions:C,onEscapeKeyDown:Yn,onShow:k,onHide:R,onEnter:Dd,onEntering:Ad,onEntered:N,onExit:Id,onExiting:j,onExited:Bd,manager:ln(),transition:m?bv:void 0,backdropTransition:m?ey:void 0,renderBackdrop:Ud,renderDialog:Hd})})});_d.displayName="Modal";const tt=Object.assign(_d,{Body:Qv,Header:Zv,Title:qv,Footer:Gv,Dialog:xd,TRANSITION_DURATION:300,BACKDROP_TRANSITION_DURATION:150}),ya=1e3;function ty(e,t,n){const r=(e-t)/(n-t)*100;return Math.round(r*ya)/ya}function ga({min:e,now:t,max:n,label:r,visuallyHidden:l,striped:o,animated:i,className:u,style:s,variant:a,bsPrefix:d,...m},p){return v.jsx("div",{ref:p,...m,role:"progressbar",className:M(u,`${d}-bar`,{[`bg-${a}`]:a,[`${d}-bar-animated`]:i,[`${d}-bar-striped`]:i||o}),style:{width:`${ty(t,e,n)}%`,...s},"aria-valuenow":t,"aria-valuemin":e,"aria-valuemax":n,children:l?v.jsx("span",{className:"visually-hidden",children:r}):r})}const jd=y.forwardRef(({isChild:e=!1,...t},n)=>{const r={min:0,max:100,animated:!1,visuallyHidden:!1,striped:!1,...t};if(r.bsPrefix=H(r.bsPrefix,"progress"),e)return ga(r,n);const{min:l,now:o,max:i,label:u,visuallyHidden:s,striped:a,animated:d,bsPrefix:m,variant:p,className:g,children:w,...k}=r;return v.jsx("div",{ref:n,...k,className:M(g,m),children:w?cv(w,R=>y.cloneElement(R,{isChild:!0})):ga({min:l,now:o,max:i,label:u,visuallyHidden:s,striped:a,animated:d,bsPrefix:m,variant:p},n)})});jd.displayName="ProgressBar";const ny=jd,Rd=y.forwardRef(({bsPrefix:e,className:t,as:n="div",...r},l)=>{const o=H(e,"row"),i=Df(),u=If(),s=`${o}-cols`,a=[];return i.forEach(d=>{const m=r[d];delete r[d];let p;m!=null&&typeof m=="object"?{cols:p}=m:p=m;const g=d!==u?`-${d}`:"";p!=null&&a.push(`${s}${g}-${p}`)}),v.jsx(n,{ref:l,...r,className:M(t,o,...a)})});Rd.displayName="Row";const Ld=Rd,Od=y.forwardRef(({bsPrefix:e,variant:t,animation:n="border",size:r,as:l="div",className:o,...i},u)=>{e=H(e,"spinner");const s=`${e}-${n}`;return v.jsx(l,{ref:u,...i,className:M(o,s,r&&`${s}-${r}`,t&&`text-${t}`)})});Od.displayName="Spinner";const An=Od,ry=window.origin==="null"||window.origin==="http://localhost:3031"?"http://localhost:3030":"",Sl="initializing",wa="paused",Pd="live",ly="error",vt=async(e,t,n)=>{console.log(e,t);const r=ry+t,l={method:e,headers:{Accept:"application/json"},body:n};let o={method:e,path:t,text:""},i;try{i=await fetch(r,l)}catch{return o.text="network error",Promise.reject(o)}if(o.status=i.status,o.statusText=i.statusText,!i.ok){const s=await i.text();try{const a=JSON.parse(s);o.text=a.human_readable!==void 0?a.human_readable:JSON.stringify(a,null,2)}catch{o.text=s}return Promise.reject(o)}return await i.json()},ft={listTorrents:()=>vt("GET","/torrents"),getTorrentDetails:e=>vt("GET",`/torrents/${e}`),getTorrentStats:e=>vt("GET",`/torrents/${e}/stats/v1`),uploadTorrent:(e,t)=>{t=t||{};let n="/torrents?&overwrite=true";return t.listOnly&&(n+="&list_only=true"),t.selectedFiles!=null&&(n+=`&only_files=${t.selectedFiles.join(",")}`),vt("POST",n,e)},pause:e=>vt("POST",`/torrents/${e}/pause`),start:e=>vt("POST",`/torrents/${e}/start`),forget:e=>vt("POST",`/torrents/${e}/forget`),delete:e=>vt("POST",`/torrents/${e}/delete`)},Vn=y.createContext(null),Fd=y.createContext(null),Go=({className:e,onClick:t,disabled:n,color:r})=>{const l=o=>{o.stopPropagation(),!n&&t()};return v.jsx("a",{className:`bi ${e} p-1`,onClick:l,href:"#"})},oy=({id:e,show:t,onHide:n})=>{if(!t)return null;const[r,l]=y.useState(!1),[o,i]=y.useState(null),[u,s]=y.useState(!1),a=y.useContext(Vn),d=()=>{l(!1),i(null),s(!1),n()},m=()=>{s(!0),(r?ft.delete:ft.forget)(e).then(()=>{a.refreshTorrents(),d()}).catch(g=>{i({text:`Error deleting torrent id=${e}`,details:g}),s(!1)})};return v.jsxs(tt,{show:t,onHide:d,children:[v.jsx(tt.Header,{closeButton:!0,children:"Delete torrent"}),v.jsxs(tt.Body,{children:[v.jsx(On,{children:v.jsx(On.Group,{controlId:"delete-torrent",children:v.jsx(On.Check,{type:"checkbox",label:"Also delete files",checked:r,onChange:()=>l(!r)})})}),o&&v.jsx(zr,{error:o})]}),v.jsxs(tt.Footer,{children:[u&&v.jsx(An,{}),v.jsx(Mr,{variant:"primary",onClick:m,disabled:u,children:"OK"}),v.jsx(Mr,{variant:"secondary",onClick:d,children:"Cancel"})]})]})},iy=({id:e,statsResponse:t})=>{let n=t.state,[r,l]=y.useState(!1),[o,i]=y.useState(!1),u=y.useContext(Fd);const s=n=="live",a=n=="paused"||n=="error",d=y.useContext(Vn),m=()=>{l(!0),ft.start(e).then(()=>{u.refresh()},k=>{d.setCloseableError({text:`Error starting torrent id=${e}`,details:k})}).finally(()=>l(!1))},p=()=>{l(!0),ft.pause(e).then(()=>{u.refresh()},k=>{d.setCloseableError({text:`Error pausing torrent id=${e}`,details:k})}).finally(()=>l(!1))},g=()=>{l(!0),i(!0)},w=()=>{l(!1),i(!1)};return v.jsx(Ld,{children:v.jsxs(Vu,{children:[a&&v.jsx(Go,{className:"bi-play-circle",onClick:m,disabled:r,color:"success"}),s&&v.jsx(Go,{className:"bi-pause-circle",onClick:p,disabled:r}),v.jsx(Go,{className:"bi-x-circle",onClick:g,disabled:r,color:"danger"}),v.jsx(oy,{id:e,show:o,onHide:w})]})})},uy=({id:e,detailsResponse:t,statsResponse:n})=>{const r=(n==null?void 0:n.state)??"",l=n==null?void 0:n.error,o=(n==null?void 0:n.total_bytes)??1,i=(n==null?void 0:n.progress_bytes)??0,u=(n==null?void 0:n.finished)||!1,s=l?100:i/o*100,a=(r==Sl||r==Pd)&&!u,d=l?"Error":`${s.toFixed(2)}%`,m=l?"danger":u?"success":r==Sl?"warning":"primary",p=()=>{var R;let k=(R=n==null?void 0:n.live)==null?void 0:R.snapshot.peer_stats;return k?`${k.live} / ${k.seen}`:""},g=()=>{var k;if(u)return"Completed";switch(r){case wa:return"Paused";case Sl:return"Checking files";case ly:return"Error"}return((k=n.live)==null?void 0:k.download_speed.human_readable)??"N/A"};let w=[];return l?w.push("bg-warning"):e%2==0&&w.push("bg-light"),v.jsxs(Ld,{className:w.join(" "),children:[v.jsx(yt,{size:3,label:"Name",children:t?v.jsxs(v.Fragment,{children:[v.jsx("div",{className:"text-truncate",children:yy(t)}),l&&v.jsxs("p",{className:"text-danger",children:[v.jsx("strong",{children:"Error:"})," ",l]})]}):v.jsx(An,{})}),n?v.jsxs(v.Fragment,{children:[v.jsx(yt,{label:"Size",children:`${zd(o)} `}),v.jsx(yt,{size:2,label:(r==wa,"Progress"),children:v.jsx(ny,{now:s,label:d,animated:a,variant:m})}),v.jsx(yt,{size:2,label:"Down Speed",children:g()}),v.jsx(yt,{label:"ETA",children:gy(n)}),v.jsx(yt,{size:2,label:"Peers",children:p()}),v.jsx(yt,{label:"Actions",children:v.jsx(iy,{id:e,statsResponse:n})})]}):v.jsx(yt,{label:"Loading stats",size:8,children:v.jsx(An,{})})]})},yt=({size:e,label:t,children:n})=>v.jsxs(Vu,{md:e||1,className:"py-3",children:[v.jsx("div",{className:"fw-bold",children:t}),n]}),sy=({id:e,torrent:t})=>{const[n,r]=y.useState(null),[l,o]=y.useState(null),[i,u]=y.useState(0),s=()=>{u(i+1)};return y.useEffect(()=>{if(n===null)return Sy(async()=>{await ft.getTorrentDetails(t.id).then(r)},1e3)},[n]),y.useEffect(()=>$d(async()=>ft.getTorrentStats(t.id).then(g=>(o(g),g)).then(g=>g.finished?1e4:g.state==Sl||g.state==Pd?1e3:1e4,g=>1e4),0),[i]),v.jsx(Fd.Provider,{value:{refresh:s},children:v.jsx(uy,{id:e,detailsResponse:n,statsResponse:l})})},ay=e=>{if(e.torrents===null&&e.loading)return v.jsx(An,{});if(e.torrents!==null)return e.torrents.length===0?v.jsx("div",{className:"text-center",children:v.jsx("p",{children:"No existing torrents found. Add them through buttons below."})}):v.jsx(v.Fragment,{children:e.torrents.map(t=>v.jsx(sy,{id:t.id,torrent:t},t.id))})},cy=()=>{const[e,t]=y.useState(null),[n,r]=y.useState(null),[l,o]=y.useState(null),[i,u]=y.useState(!1),s=async()=>{u(!0);let d=await ft.listTorrents().finally(()=>u(!1));o(d.torrents)};y.useEffect(()=>$d(async()=>s().then(()=>(r(null),5e3),d=>(r({text:"Error refreshing torrents",details:d}),console.error(d),5e3)),0),[]);const a={setCloseableError:t,refreshTorrents:s};return v.jsx(Vn.Provider,{value:a,children:v.jsxs("div",{className:"text-center",children:[v.jsx("h1",{className:"mt-3 mb-4",children:"rqbit web 4.0.0-beta.0"}),v.jsx(vy,{closeableError:e,otherError:n,torrents:l,torrentsLoading:i})]})})},fy=e=>{let{details:t}=e;return t?v.jsxs(v.Fragment,{children:[t.status&&v.jsxs("strong",{children:[t.status," ",t.statusText,": "]}),t.text]}):null},zr=e=>{let{error:t,remove:n}=e;return t==null?null:v.jsxs(fa,{variant:"danger",onClose:n,dismissible:n!=null,children:[v.jsx(fa.Heading,{children:t.text}),v.jsx(fy,{details:t.details})]})},Md=({buttonText:e,onClick:t,data:n,resetData:r,variant:l})=>{const[o,i]=y.useState(!1),[u,s]=y.useState([]),[a,d]=y.useState(null);y.useContext(Vn);const m=n!==null||a!==null;y.useEffect(()=>{if(n===null)return;let g=setTimeout(async()=>{i(!0);try{const w=await ft.uploadTorrent(n,{listOnly:!0});s(w.details.files)}catch(w){d({text:"Error uploading torrent",details:w})}finally{i(!1)}},0);return()=>clearTimeout(g)},[n]);const p=()=>{r(),d(null),s([]),i(!1)};return v.jsxs(v.Fragment,{children:[v.jsx(Mr,{variant:l,onClick:t,className:"m-1",children:e}),v.jsx(my,{show:m,onHide:p,fileListError:a,fileList:u,data:n,fileListLoading:o})]})},dy=()=>{let[e,t]=y.useState(null);const n=()=>{const r=prompt("Enter magnet link or HTTP(s) URL");t(r===""?null:r)};return v.jsx(Md,{variant:"primary",buttonText:"Add Torrent from Magnet Link",onClick:n,data:e,resetData:()=>t(null)})},py=()=>{const e=y.useRef(),[t,n]=y.useState(null),r=async()=>{const i=e.current.files[0];n(i)},l=()=>{e.current.value="",n(null)},o=()=>{e.current.click()};return v.jsxs(v.Fragment,{children:[v.jsx("input",{type:"file",ref:e,accept:".torrent",onChange:r,className:"d-none"}),v.jsx(Md,{variant:"secondary",buttonText:"Upload .torrent File",onClick:o,data:t,resetData:l})]})},my=e=>{let{show:t,onHide:n,fileList:r,fileListError:l,fileListLoading:o,data:i}=e;const[u,s]=y.useState([]),[a,d]=y.useState(!1),[m,p]=y.useState(null),g=y.useContext(Vn);y.useEffect(()=>{s(r.map((f,c)=>c))},[r]);const w=()=>{n(),s([]),p(null),d(!1)},k=f=>{u.includes(f)?s(u.filter(c=>c!==f)):s([...u,f])},R=async()=>{d(!0),ft.uploadTorrent(i,{selectedFiles:u}).then(()=>{n(),g.refreshTorrents()},f=>{p({text:"Error starting torrent",details:f})}).finally(()=>d(!1))};return v.jsxs(tt,{show:t,onHide:w,size:"lg",children:[v.jsx(tt.Header,{closeButton:!0,children:!!l||v.jsx(tt.Title,{children:"Select Files"})}),v.jsxs(tt.Body,{children:[o?v.jsx(An,{}):l?v.jsx(zr,{error:l}):v.jsx(On,{children:r.map((f,c)=>v.jsx(On.Group,{controlId:`check-${c}`,children:v.jsx(On.Check,{type:"checkbox",label:`${f.name} (${zd(f.length)})`,checked:u.includes(c),onChange:()=>k(c)})},c))}),v.jsx(zr,{error:m})]}),v.jsxs(tt.Footer,{children:[a&&v.jsx(An,{}),v.jsx(Mr,{variant:"primary",onClick:R,disabled:o||a||u.length==0,children:"OK"}),v.jsx(Mr,{variant:"secondary",onClick:w,children:"Cancel"})]})]})},hy=()=>v.jsxs("div",{id:"buttons-container",className:"mt-3",children:[v.jsx(dy,{}),v.jsx(py,{})]}),vy=e=>{let t=y.useContext(Vn);return v.jsxs(pv,{children:[v.jsx(zr,{error:e.closeableError,remove:()=>t.setCloseableError(null)}),v.jsx(zr,{error:e.otherError}),v.jsx(ay,{torrents:e.torrents,loading:e.torrentsLoading}),v.jsx(hy,{})]})};function zd(e){if(e===0)return"0 Bytes";const t=1024,n=["Bytes","KB","MB","GB"],r=Math.floor(Math.log(e)/Math.log(t));return parseFloat((e/Math.pow(t,r)).toFixed(2))+" "+n[r]}function yy(e){return e.files.filter(n=>n.included).reduce((n,r)=>n.length>r.length?n:r).name}function gy(e){var n,r,l;let t=(l=(r=(n=e==null?void 0:e.live)==null?void 0:n.time_remaining)==null?void 0:r.duration)==null?void 0:l.secs;return t==null?"N/A":wy(t)}function wy(e){const t=Math.floor(e/3600),n=Math.floor(e%3600/60),r=e%60,l=(o,i)=>o>0?`${o}${i}`:"";return t>0?`${l(t,"h")} ${l(n,"m")}`.trim():n>0?`${l(n,"m")} ${l(r,"s")}`.trim():`${l(r,"s")}`.trim()}function $d(e,t){let n,r=t;const l=async()=>{if(r=await e(),r==null)throw"asyncCallback returned null or undefined";o()};let o=()=>{n=setTimeout(l,r)};return o(),()=>{clearTimeout(n)}}function Sy(e,t){let n;const r=async()=>{await e().then(()=>!1,()=>!0)&&l()};let l=o=>{n=setTimeout(r,o!==void 0?o:t)};return l(0),()=>clearTimeout(n)}async function ky(){const e=document.getElementById("app");Yo.createRoot(e).render(v.jsx(y.StrictMode,{children:v.jsx(cy,{})}))}document.addEventListener("DOMContentLoaded",ky); +*/(function(e){(function(){var t={}.hasOwnProperty;function n(){for(var r=[],l=0;l=0)&&(n[l]=e[l]);return n}function ia(e){return"default"+e.charAt(0).toUpperCase()+e.substr(1)}function mh(e){var t=hh(e,"string");return typeof t=="symbol"?t:String(t)}function hh(e,t){if(typeof e!="object"||e===null)return e;var n=e[Symbol.toPrimitive];if(n!==void 0){var r=n.call(e,t||"default");if(typeof r!="object")return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return(t==="string"?String:Number)(e)}function vh(e,t,n){var r=y.useRef(e!==void 0),l=y.useState(t),o=l[0],i=l[1],u=e!==void 0,s=r.current;return r.current=u,!u&&s&&o!==t&&i(t),[u?e:o,y.useCallback(function(a){for(var f=arguments.length,h=new Array(f>1?f-1:0),d=1;d{o.target===e&&(l(),t(o))},n+r)}function Uh(e){e.offsetHeight}const aa=e=>!e||typeof e=="function"?e:t=>{e.current=t};function Bh(e,t){const n=aa(e),r=aa(t);return l=>{n&&n(l),r&&r(l)}}function mo(e,t){return y.useMemo(()=>Bh(e,t),[e,t])}function Hh(e){return e&&"setState"in e?Nn.findDOMNode(e):e??null}const Wh=Vt.forwardRef(({onEnter:e,onEntering:t,onEntered:n,onExit:r,onExiting:l,onExited:o,addEndListener:i,children:u,childRef:s,...a},f)=>{const h=y.useRef(null),d=mo(h,s),g=N=>{d(Hh(N))},S=N=>T=>{N&&h.current&&N(h.current,T)},k=y.useCallback(S(e),[e]),R=y.useCallback(S(t),[t]),p=y.useCallback(S(n),[n]),c=y.useCallback(S(r),[r]),m=y.useCallback(S(l),[l]),w=y.useCallback(S(o),[o]),C=y.useCallback(S(i),[i]);return v.jsx(zh,{ref:f,...a,onEnter:k,onEntered:p,onEntering:R,onExit:c,onExited:w,onExiting:m,addEndListener:C,nodeRef:h,children:typeof u=="function"?(N,T)=>u(N,{...T,ref:g}):Vt.cloneElement(u,{ref:g})})}),Vh=Wh;function Qh(e){const t=y.useRef(e);return y.useEffect(()=>{t.current=e},[e]),t}function Pe(e){const t=Qh(e);return y.useCallback(function(...n){return t.current&&t.current(...n)},[t])}const Qf=e=>y.forwardRef((t,n)=>v.jsx("div",{...t,ref:n,className:M(t.className,e)})),Kf=Qf("h4");Kf.displayName="DivStyledAsH4";const Gf=y.forwardRef(({className:e,bsPrefix:t,as:n=Kf,...r},l)=>(t=H(t,"alert-heading"),v.jsx(n,{ref:l,className:M(e,t),...r})));Gf.displayName="AlertHeading";const Kh=Gf;function Gh(){return y.useState(null)}function Yh(){const e=y.useRef(!0),t=y.useRef(()=>e.current);return y.useEffect(()=>(e.current=!0,()=>{e.current=!1}),[]),t.current}function Xh(e){const t=y.useRef(null);return y.useEffect(()=>{t.current=e}),t.current}const Zh=typeof global<"u"&&global.navigator&&global.navigator.product==="ReactNative",Jh=typeof document<"u",ca=Jh||Zh?y.useLayoutEffect:y.useEffect,qh=["as","disabled"];function bh(e,t){if(e==null)return{};var n={},r=Object.keys(e),l,o;for(o=0;o=0)&&(n[l]=e[l]);return n}function ev(e){return!e||e.trim()==="#"}function Hu({tagName:e,disabled:t,href:n,target:r,rel:l,role:o,onClick:i,tabIndex:u=0,type:s}){e||(n!=null||r!=null||l!=null?e="a":e="button");const a={tagName:e};if(e==="button")return[{type:s||"button",disabled:t},a];const f=d=>{if((t||e==="a"&&ev(n))&&d.preventDefault(),t){d.stopPropagation();return}i==null||i(d)},h=d=>{d.key===" "&&(d.preventDefault(),f(d))};return e==="a"&&(n||(n="#"),t&&(n=void 0)),[{role:o??"button",disabled:void 0,tabIndex:t?void 0:u,href:n,target:e==="a"?r:void 0,"aria-disabled":t||void 0,rel:e==="a"?l:void 0,onClick:f,onKeyDown:h},a]}const tv=y.forwardRef((e,t)=>{let{as:n,disabled:r}=e,l=bh(e,qh);const[o,{tagName:i}]=Hu(Object.assign({tagName:n,disabled:r},l));return v.jsx(i,Object.assign({},l,o,{ref:t}))});tv.displayName="Button";const nv=["onKeyDown"];function rv(e,t){if(e==null)return{};var n={},r=Object.keys(e),l,o;for(o=0;o=0)&&(n[l]=e[l]);return n}function lv(e){return!e||e.trim()==="#"}const Yf=y.forwardRef((e,t)=>{let{onKeyDown:n}=e,r=rv(e,nv);const[l]=Hu(Object.assign({tagName:"a"},r)),o=Pe(i=>{l.onKeyDown(i),n==null||n(i)});return lv(r.href)||r.role==="button"?v.jsx("a",Object.assign({ref:t},r,l,{onKeyDown:o})):v.jsx("a",Object.assign({ref:t},r,{onKeyDown:n}))});Yf.displayName="Anchor";const ov=Yf,Xf=y.forwardRef(({className:e,bsPrefix:t,as:n=ov,...r},l)=>(t=H(t,"alert-link"),v.jsx(n,{ref:l,className:M(e,t),...r})));Xf.displayName="AlertLink";const iv=Xf,uv={[St]:"show",[Wt]:"show"},Zf=y.forwardRef(({className:e,children:t,transitionClasses:n={},onEnter:r,...l},o)=>{const i={in:!1,timeout:300,mountOnEnter:!1,unmountOnExit:!1,appear:!1,...l},u=y.useCallback((s,a)=>{Uh(s),r==null||r(s,a)},[r]);return v.jsx(Vh,{ref:o,addEndListener:Ah,...i,onEnter:u,childRef:t.ref,children:(s,a)=>y.cloneElement(t,{...a,className:M("fade",e,t.props.className,uv[s],n[s])})})});Zf.displayName="Fade";const Kl=Zf,sv={"aria-label":ot.string,onClick:ot.func,variant:ot.oneOf(["white"])},Wu=y.forwardRef(({className:e,variant:t,"aria-label":n="Close",...r},l)=>v.jsx("button",{ref:l,type:"button",className:M("btn-close",t&&`btn-close-${t}`,e),"aria-label":n,...r}));Wu.displayName="CloseButton";Wu.propTypes=sv;const Jf=Wu,qf=y.forwardRef((e,t)=>{const{bsPrefix:n,show:r=!0,closeLabel:l="Close alert",closeVariant:o,className:i,children:u,variant:s="primary",onClose:a,dismissible:f,transition:h=Kl,...d}=yh(e,{show:"onClose"}),g=H(n,"alert"),S=Pe(p=>{a&&a(!1,p)}),k=h===!0?Kl:h,R=v.jsxs("div",{role:"alert",...k?void 0:d,ref:t,className:M(i,g,s&&`${g}-${s}`,f&&`${g}-dismissible`),children:[f&&v.jsx(Jf,{onClick:S,"aria-label":l,variant:o}),u]});return k?v.jsx(k,{unmountOnExit:!0,...d,ref:void 0,in:r,children:R}):r?R:null});qf.displayName="Alert";const fa=Object.assign(qf,{Link:iv,Heading:Kh}),bf=y.forwardRef(({as:e,bsPrefix:t,variant:n="primary",size:r,active:l=!1,disabled:o=!1,className:i,...u},s)=>{const a=H(t,"btn"),[f,{tagName:h}]=Hu({tagName:e,disabled:o,...u}),d=h;return v.jsx(d,{...f,...u,ref:s,disabled:o,className:M(i,a,l&&"active",n&&`${a}-${n}`,r&&`${a}-${r}`,u.href&&o&&"disabled")})});bf.displayName="Button";const Mr=bf;function av(e){const t=y.useRef(e);return t.current=e,t}function ed(e){const t=av(e);y.useEffect(()=>()=>t.current(),[])}function cv(e,t){let n=0;return y.Children.map(e,r=>y.isValidElement(r)?t(r,n++):r)}function fv(e,t){return y.Children.toArray(e).some(n=>y.isValidElement(n)&&n.type===t)}function dv({as:e,bsPrefix:t,className:n,...r}){t=H(t,"col");const l=Df(),o=If(),i=[],u=[];return l.forEach(s=>{const a=r[s];delete r[s];let f,h,d;typeof a=="object"&&a!=null?{span:f,offset:h,order:d}=a:f=a;const g=s!==o?`-${s}`:"";f&&i.push(f===!0?`${t}${g}`:`${t}${g}-${f}`),d!=null&&u.push(`order${g}-${d}`),h!=null&&u.push(`offset${g}-${h}`)}),[{...r,className:M(n,...i,...u)},{as:e,bsPrefix:t,spans:i}]}const td=y.forwardRef((e,t)=>{const[{className:n,...r},{as:l="div",bsPrefix:o,spans:i}]=dv(e);return v.jsx(l,{...r,ref:t,className:M(n,!i.length&&o)})});td.displayName="Col";const Vu=td,nd=y.forwardRef(({bsPrefix:e,fluid:t=!1,as:n="div",className:r,...l},o)=>{const i=H(e,"container"),u=typeof t=="string"?`-${t}`:"-fluid";return v.jsx(n,{ref:o,...l,className:M(r,t?`${i}${u}`:i)})});nd.displayName="Container";const pv=nd;var mv=Function.prototype.bind.call(Function.prototype.call,[].slice);function fn(e,t){return mv(e.querySelectorAll(t))}function da(e,t){if(e.contains)return e.contains(t);if(e.compareDocumentPosition)return e===t||!!(e.compareDocumentPosition(t)&16)}const hv="data-rr-ui-";function vv(e){return`${hv}${e}`}const rd=y.createContext(Wn?window:void 0);rd.Provider;function Qu(){return y.useContext(rd)}const yv={type:ot.string,tooltip:ot.bool,as:ot.elementType},Ku=y.forwardRef(({as:e="div",className:t,type:n="valid",tooltip:r=!1,...l},o)=>v.jsx(e,{...l,ref:o,className:M(t,`${n}-${r?"tooltip":"feedback"}`)}));Ku.displayName="Feedback";Ku.propTypes=yv;const ld=Ku,gv=y.createContext({}),ct=gv,od=y.forwardRef(({id:e,bsPrefix:t,className:n,type:r="checkbox",isValid:l=!1,isInvalid:o=!1,as:i="input",...u},s)=>{const{controlId:a}=y.useContext(ct);return t=H(t,"form-check-input"),v.jsx(i,{...u,ref:s,type:r,id:e||a,className:M(n,t,l&&"is-valid",o&&"is-invalid")})});od.displayName="FormCheckInput";const id=od,ud=y.forwardRef(({bsPrefix:e,className:t,htmlFor:n,...r},l)=>{const{controlId:o}=y.useContext(ct);return e=H(e,"form-check-label"),v.jsx("label",{...r,ref:l,htmlFor:n||o,className:M(t,e)})});ud.displayName="FormCheckLabel";const Gi=ud,sd=y.forwardRef(({id:e,bsPrefix:t,bsSwitchPrefix:n,inline:r=!1,reverse:l=!1,disabled:o=!1,isValid:i=!1,isInvalid:u=!1,feedbackTooltip:s=!1,feedback:a,feedbackType:f,className:h,style:d,title:g="",type:S="checkbox",label:k,children:R,as:p="input",...c},m)=>{t=H(t,"form-check"),n=H(n,"form-switch");const{controlId:w}=y.useContext(ct),C=y.useMemo(()=>({controlId:e||w}),[w,e]),N=!R&&k!=null&&k!==!1||fv(R,Gi),T=v.jsx(id,{...c,type:S==="switch"?"checkbox":S,ref:m,isValid:i,isInvalid:u,disabled:o,as:p});return v.jsx(ct.Provider,{value:C,children:v.jsx("div",{style:d,className:M(h,N&&t,r&&`${t}-inline`,l&&`${t}-reverse`,S==="switch"&&n),children:R||v.jsxs(v.Fragment,{children:[T,N&&v.jsx(Gi,{title:g,children:k}),a&&v.jsx(ld,{type:f,tooltip:s,children:a})]})})})});sd.displayName="FormCheck";const Gl=Object.assign(sd,{Input:id,Label:Gi}),ad=y.forwardRef(({bsPrefix:e,type:t,size:n,htmlSize:r,id:l,className:o,isValid:i=!1,isInvalid:u=!1,plaintext:s,readOnly:a,as:f="input",...h},d)=>{const{controlId:g}=y.useContext(ct);return e=H(e,"form-control"),v.jsx(f,{...h,type:t,size:r,ref:d,readOnly:a,id:l||g,className:M(o,s?`${e}-plaintext`:e,n&&`${e}-${n}`,t==="color"&&`${e}-color`,i&&"is-valid",u&&"is-invalid")})});ad.displayName="FormControl";const wv=Object.assign(ad,{Feedback:ld}),cd=y.forwardRef(({className:e,bsPrefix:t,as:n="div",...r},l)=>(t=H(t,"form-floating"),v.jsx(n,{ref:l,className:M(e,t),...r})));cd.displayName="FormFloating";const Sv=cd,fd=y.forwardRef(({controlId:e,as:t="div",...n},r)=>{const l=y.useMemo(()=>({controlId:e}),[e]);return v.jsx(ct.Provider,{value:l,children:v.jsx(t,{...n,ref:r})})});fd.displayName="FormGroup";const dd=fd,pd=y.forwardRef(({as:e="label",bsPrefix:t,column:n=!1,visuallyHidden:r=!1,className:l,htmlFor:o,...i},u)=>{const{controlId:s}=y.useContext(ct);t=H(t,"form-label");let a="col-form-label";typeof n=="string"&&(a=`${a} ${a}-${n}`);const f=M(l,t,r&&"visually-hidden",n&&a);return o=o||s,n?v.jsx(Vu,{ref:u,as:"label",className:f,htmlFor:o,...i}):v.jsx(e,{ref:u,className:f,htmlFor:o,...i})});pd.displayName="FormLabel";const kv=pd,md=y.forwardRef(({bsPrefix:e,className:t,id:n,...r},l)=>{const{controlId:o}=y.useContext(ct);return e=H(e,"form-range"),v.jsx("input",{...r,type:"range",ref:l,className:M(t,e),id:n||o})});md.displayName="FormRange";const xv=md,hd=y.forwardRef(({bsPrefix:e,size:t,htmlSize:n,className:r,isValid:l=!1,isInvalid:o=!1,id:i,...u},s)=>{const{controlId:a}=y.useContext(ct);return e=H(e,"form-select"),v.jsx("select",{...u,size:n,ref:s,className:M(r,e,t&&`${e}-${t}`,l&&"is-valid",o&&"is-invalid"),id:i||a})});hd.displayName="FormSelect";const Ev=hd,vd=y.forwardRef(({bsPrefix:e,className:t,as:n="small",muted:r,...l},o)=>(e=H(e,"form-text"),v.jsx(n,{...l,ref:o,className:M(t,e,r&&"text-muted")})));vd.displayName="FormText";const Cv=vd,yd=y.forwardRef((e,t)=>v.jsx(Gl,{...e,ref:t,type:"switch"}));yd.displayName="Switch";const Nv=Object.assign(yd,{Input:Gl.Input,Label:Gl.Label}),gd=y.forwardRef(({bsPrefix:e,className:t,children:n,controlId:r,label:l,...o},i)=>(e=H(e,"form-floating"),v.jsxs(dd,{ref:i,className:M(t,e),controlId:r,...o,children:[n,v.jsx("label",{htmlFor:r,children:l})]})));gd.displayName="FloatingLabel";const Tv=gd,_v={_ref:ot.any,validated:ot.bool,as:ot.elementType},Gu=y.forwardRef(({className:e,validated:t,as:n="form",...r},l)=>v.jsx(n,{...r,ref:l,className:M(e,t&&"was-validated")}));Gu.displayName="Form";Gu.propTypes=_v;const Et=Object.assign(Gu,{Group:dd,Control:wv,Floating:Sv,Check:Gl,Switch:Nv,Label:kv,Text:Cv,Range:xv,Select:Ev,FloatingLabel:Tv});var ul;function pa(e){if((!ul&&ul!==0||e)&&Wn){var t=document.createElement("div");t.style.position="absolute",t.style.top="-9999px",t.style.width="50px",t.style.height="50px",t.style.overflow="scroll",document.body.appendChild(t),ul=t.offsetWidth-t.clientWidth,document.body.removeChild(t)}return ul}function Wo(e){e===void 0&&(e=po());try{var t=e.activeElement;return!t||!t.nodeName?null:t}catch{return e.body}}function jv(e=document){const t=e.defaultView;return Math.abs(t.innerWidth-e.documentElement.clientWidth)}const ma=vv("modal-open");class Rv{constructor({ownerDocument:t,handleContainerOverflow:n=!0,isRTL:r=!1}={}){this.handleContainerOverflow=n,this.isRTL=r,this.modals=[],this.ownerDocument=t}getScrollbarWidth(){return jv(this.ownerDocument)}getElement(){return(this.ownerDocument||document).body}setModalAttributes(t){}removeModalAttributes(t){}setContainerStyle(t){const n={overflow:"hidden"},r=this.isRTL?"paddingLeft":"paddingRight",l=this.getElement();t.style={overflow:l.style.overflow,[r]:l.style[r]},t.scrollBarWidth&&(n[r]=`${parseInt(Zt(l,r)||"0",10)+t.scrollBarWidth}px`),l.setAttribute(ma,""),Zt(l,n)}reset(){[...this.modals].forEach(t=>this.remove(t))}removeContainerStyle(t){const n=this.getElement();n.removeAttribute(ma),Object.assign(n.style,t.style)}add(t){let n=this.modals.indexOf(t);return n!==-1||(n=this.modals.length,this.modals.push(t),this.setModalAttributes(t),n!==0)||(this.state={scrollBarWidth:this.getScrollbarWidth(),style:{}},this.handleContainerOverflow&&this.setContainerStyle(this.state)),n}remove(t){const n=this.modals.indexOf(t);n!==-1&&(this.modals.splice(n,1),!this.modals.length&&this.handleContainerOverflow&&this.removeContainerStyle(this.state),this.removeModalAttributes(t))}isTopModal(t){return!!this.modals.length&&this.modals[this.modals.length-1]===t}}const Yu=Rv,Vo=(e,t)=>Wn?e==null?(t||po()).body:(typeof e=="function"&&(e=e()),e&&"current"in e&&(e=e.current),e&&("nodeType"in e||e.getBoundingClientRect)?e:null):null;function Lv(e,t){const n=Qu(),[r,l]=y.useState(()=>Vo(e,n==null?void 0:n.document));if(!r){const o=Vo(e);o&&l(o)}return y.useEffect(()=>{t&&r&&t(r)},[t,r]),y.useEffect(()=>{const o=Vo(e);o!==r&&l(o)},[e,r]),r}function Ov({children:e,in:t,onExited:n,mountOnEnter:r,unmountOnExit:l}){const o=y.useRef(null),i=y.useRef(t),u=Pe(n);y.useEffect(()=>{t?i.current=!0:u(o.current)},[t,u]);const s=mo(o,e.ref),a=y.cloneElement(e,{ref:s});return t?a:l||!i.current&&r?null:a}function Pv({in:e,onTransition:t}){const n=y.useRef(null),r=y.useRef(!0),l=Pe(t);return ca(()=>{if(!n.current)return;let o=!1;return l({in:e,element:n.current,initial:r.current,isStale:()=>o}),()=>{o=!0}},[e,l]),ca(()=>(r.current=!1,()=>{r.current=!0}),[]),n}function Fv({children:e,in:t,onExited:n,onEntered:r,transition:l}){const[o,i]=y.useState(!t);t&&o&&i(!1);const u=Pv({in:!!t,onTransition:a=>{const f=()=>{a.isStale()||(a.in?r==null||r(a.element,a.initial):(i(!0),n==null||n(a.element)))};Promise.resolve(l(a)).then(f,h=>{throw a.in||i(!0),h})}}),s=mo(u,e.ref);return o&&!t?null:y.cloneElement(e,{ref:s})}function ha(e,t,n){return e?v.jsx(e,Object.assign({},n)):t?v.jsx(Fv,Object.assign({},n,{transition:t})):v.jsx(Ov,Object.assign({},n))}function Mv(e){return e.code==="Escape"||e.keyCode===27}const zv=["show","role","className","style","children","backdrop","keyboard","onBackdropClick","onEscapeKeyDown","transition","runTransition","backdropTransition","runBackdropTransition","autoFocus","enforceFocus","restoreFocus","restoreFocusOptions","renderDialog","renderBackdrop","manager","container","onShow","onHide","onExit","onExited","onExiting","onEnter","onEntering","onEntered"];function $v(e,t){if(e==null)return{};var n={},r=Object.keys(e),l,o;for(o=0;o=0)&&(n[l]=e[l]);return n}let Qo;function Dv(e){return Qo||(Qo=new Yu({ownerDocument:e==null?void 0:e.document})),Qo}function Iv(e){const t=Qu(),n=e||Dv(t),r=y.useRef({dialog:null,backdrop:null});return Object.assign(r.current,{add:()=>n.add(r.current),remove:()=>n.remove(r.current),isTopModal:()=>n.isTopModal(r.current),setDialogRef:y.useCallback(l=>{r.current.dialog=l},[]),setBackdropRef:y.useCallback(l=>{r.current.backdrop=l},[])})}const wd=y.forwardRef((e,t)=>{let{show:n=!1,role:r="dialog",className:l,style:o,children:i,backdrop:u=!0,keyboard:s=!0,onBackdropClick:a,onEscapeKeyDown:f,transition:h,runTransition:d,backdropTransition:g,runBackdropTransition:S,autoFocus:k=!0,enforceFocus:R=!0,restoreFocus:p=!0,restoreFocusOptions:c,renderDialog:m,renderBackdrop:w=K=>v.jsx("div",Object.assign({},K)),manager:C,container:N,onShow:T,onHide:j=()=>{},onExit:U,onExited:P,onExiting:ie,onEnter:Ve,onEntering:Qe,onEntered:ln}=e,Qn=$v(e,zv);const _e=Qu(),Ke=Lv(N),E=Iv(C),L=Yh(),O=Xh(n),[D,A]=y.useState(!n),fe=y.useRef(null);y.useImperativeHandle(t,()=>E,[E]),Wn&&!O&&n&&(fe.current=Wo(_e==null?void 0:_e.document)),n&&D&&A(!1);const je=Pe(()=>{if(E.add(),un.current=Ql(document,"keydown",ho),on.current=Ql(document,"focus",()=>setTimeout(Re),!0),T&&T(),k){var K,Hr;const Yn=Wo((K=(Hr=E.dialog)==null?void 0:Hr.ownerDocument)!=null?K:_e==null?void 0:_e.document);E.dialog&&Yn&&!da(E.dialog,Yn)&&(fe.current=Yn,E.dialog.focus())}}),qe=Pe(()=>{if(E.remove(),un.current==null||un.current(),on.current==null||on.current(),p){var K;(K=fe.current)==null||K.focus==null||K.focus(c),fe.current=null}});y.useEffect(()=>{!n||!Ke||je()},[n,Ke,je]),y.useEffect(()=>{D&&qe()},[D,qe]),ed(()=>{qe()});const Re=Pe(()=>{if(!R||!L()||!E.isTopModal())return;const K=Wo(_e==null?void 0:_e.document);E.dialog&&K&&!da(E.dialog,K)&&E.dialog.focus()}),mt=Pe(K=>{K.target===K.currentTarget&&(a==null||a(K),u===!0&&j())}),ho=Pe(K=>{s&&Mv(K)&&E.isTopModal()&&(f==null||f(K),K.defaultPrevented||j())}),on=y.useRef(),un=y.useRef(),Kn=(...K)=>{A(!0),P==null||P(...K)};if(!Ke)return null;const Br=Object.assign({role:r,ref:E.setDialogRef,"aria-modal":r==="dialog"?!0:void 0},Qn,{style:o,className:l,tabIndex:-1});let Gn=m?m(Br):v.jsx("div",Object.assign({},Br,{children:y.cloneElement(i,{role:"document"})}));Gn=ha(h,d,{unmountOnExit:!0,mountOnEnter:!0,appear:!0,in:!!n,onExit:U,onExiting:ie,onExited:Kn,onEnter:Ve,onEntering:Qe,onEntered:ln,children:Gn});let At=null;return u&&(At=w({ref:E.setBackdropRef,onClick:mt}),At=ha(g,S,{in:!!n,appear:!0,mountOnEnter:!0,unmountOnExit:!0,children:At})),v.jsx(v.Fragment,{children:Nn.createPortal(v.jsxs(v.Fragment,{children:[At,Gn]}),Ke)})});wd.displayName="Modal";const Av=Object.assign(wd,{Manager:Yu});function Uv(e,t){return e.classList?!!t&&e.classList.contains(t):(" "+(e.className.baseVal||e.className)+" ").indexOf(" "+t+" ")!==-1}function Bv(e,t){e.classList?e.classList.add(t):Uv(e,t)||(typeof e.className=="string"?e.className=e.className+" "+t:e.setAttribute("class",(e.className&&e.className.baseVal||"")+" "+t))}function va(e,t){return e.replace(new RegExp("(^|\\s)"+t+"(?:\\s|$)","g"),"$1").replace(/\s+/g," ").replace(/^\s*|\s*$/g,"")}function Hv(e,t){e.classList?e.classList.remove(t):typeof e.className=="string"?e.className=va(e.className,t):e.setAttribute("class",va(e.className&&e.className.baseVal||"",t))}const dn={FIXED_CONTENT:".fixed-top, .fixed-bottom, .is-fixed, .sticky-top",STICKY_CONTENT:".sticky-top",NAVBAR_TOGGLER:".navbar-toggler"};class Wv extends Yu{adjustAndStore(t,n,r){const l=n.style[t];n.dataset[t]=l,Zt(n,{[t]:`${parseFloat(Zt(n,t))+r}px`})}restore(t,n){const r=n.dataset[t];r!==void 0&&(delete n.dataset[t],Zt(n,{[t]:r}))}setContainerStyle(t){super.setContainerStyle(t);const n=this.getElement();if(Bv(n,"modal-open"),!t.scrollBarWidth)return;const r=this.isRTL?"paddingLeft":"paddingRight",l=this.isRTL?"marginLeft":"marginRight";fn(n,dn.FIXED_CONTENT).forEach(o=>this.adjustAndStore(r,o,t.scrollBarWidth)),fn(n,dn.STICKY_CONTENT).forEach(o=>this.adjustAndStore(l,o,-t.scrollBarWidth)),fn(n,dn.NAVBAR_TOGGLER).forEach(o=>this.adjustAndStore(l,o,t.scrollBarWidth))}removeContainerStyle(t){super.removeContainerStyle(t);const n=this.getElement();Hv(n,"modal-open");const r=this.isRTL?"paddingLeft":"paddingRight",l=this.isRTL?"marginLeft":"marginRight";fn(n,dn.FIXED_CONTENT).forEach(o=>this.restore(r,o)),fn(n,dn.STICKY_CONTENT).forEach(o=>this.restore(l,o)),fn(n,dn.NAVBAR_TOGGLER).forEach(o=>this.restore(l,o))}}let Ko;function Vv(e){return Ko||(Ko=new Wv(e)),Ko}const Sd=y.forwardRef(({className:e,bsPrefix:t,as:n="div",...r},l)=>(t=H(t,"modal-body"),v.jsx(n,{ref:l,className:M(e,t),...r})));Sd.displayName="ModalBody";const Qv=Sd,Kv=y.createContext({onHide(){}}),kd=Kv,xd=y.forwardRef(({bsPrefix:e,className:t,contentClassName:n,centered:r,size:l,fullscreen:o,children:i,scrollable:u,...s},a)=>{e=H(e,"modal");const f=`${e}-dialog`,h=typeof o=="string"?`${e}-fullscreen-${o}`:`${e}-fullscreen`;return v.jsx("div",{...s,ref:a,className:M(f,t,l&&`${e}-${l}`,r&&`${f}-centered`,u&&`${f}-scrollable`,o&&h),children:v.jsx("div",{className:M(`${e}-content`,n),children:i})})});xd.displayName="ModalDialog";const Ed=xd,Cd=y.forwardRef(({className:e,bsPrefix:t,as:n="div",...r},l)=>(t=H(t,"modal-footer"),v.jsx(n,{ref:l,className:M(e,t),...r})));Cd.displayName="ModalFooter";const Gv=Cd,Yv=y.forwardRef(({closeLabel:e="Close",closeVariant:t,closeButton:n=!1,onHide:r,children:l,...o},i)=>{const u=y.useContext(kd),s=Pe(()=>{u==null||u.onHide(),r==null||r()});return v.jsxs("div",{ref:i,...o,children:[l,n&&v.jsx(Jf,{"aria-label":e,variant:t,onClick:s})]})}),Xv=Yv,Nd=y.forwardRef(({bsPrefix:e,className:t,closeLabel:n="Close",closeButton:r=!1,...l},o)=>(e=H(e,"modal-header"),v.jsx(Xv,{ref:o,...l,className:M(t,e),closeLabel:n,closeButton:r})));Nd.displayName="ModalHeader";const Zv=Nd,Jv=Qf("h4"),Td=y.forwardRef(({className:e,bsPrefix:t,as:n=Jv,...r},l)=>(t=H(t,"modal-title"),v.jsx(n,{ref:l,className:M(e,t),...r})));Td.displayName="ModalTitle";const qv=Td;function bv(e){return v.jsx(Kl,{...e,timeout:null})}function ey(e){return v.jsx(Kl,{...e,timeout:null})}const _d=y.forwardRef(({bsPrefix:e,className:t,style:n,dialogClassName:r,contentClassName:l,children:o,dialogAs:i=Ed,"aria-labelledby":u,"aria-describedby":s,"aria-label":a,show:f=!1,animation:h=!0,backdrop:d=!0,keyboard:g=!0,onEscapeKeyDown:S,onShow:k,onHide:R,container:p,autoFocus:c=!0,enforceFocus:m=!0,restoreFocus:w=!0,restoreFocusOptions:C,onEntered:N,onExit:T,onExiting:j,onEnter:U,onEntering:P,onExited:ie,backdropClassName:Ve,manager:Qe,...ln},Qn)=>{const[_e,Ke]=y.useState({}),[E,L]=y.useState(!1),O=y.useRef(!1),D=y.useRef(!1),A=y.useRef(null),[fe,je]=Gh(),qe=mo(Qn,je),Re=Pe(R),mt=kh();e=H(e,"modal");const ho=y.useMemo(()=>({onHide:Re}),[Re]);function on(){return Qe||Vv({isRTL:mt})}function un($){if(!Wn)return;const sn=on().getScrollbarWidth()>0,Zu=$.scrollHeight>po($).documentElement.clientHeight;Ke({paddingRight:sn&&!Zu?pa():void 0,paddingLeft:!sn&&Zu?pa():void 0})}const Kn=Pe(()=>{fe&&un(fe.dialog)});ed(()=>{Ki(window,"resize",Kn),A.current==null||A.current()});const Br=()=>{O.current=!0},Gn=$=>{O.current&&fe&&$.target===fe.dialog&&(D.current=!0),O.current=!1},At=()=>{L(!0),A.current=Vf(fe.dialog,()=>{L(!1)})},K=$=>{$.target===$.currentTarget&&At()},Hr=$=>{if(d==="static"){K($);return}if(D.current||$.target!==$.currentTarget){D.current=!1;return}R==null||R()},Yn=$=>{g?S==null||S($):($.preventDefault(),d==="static"&&At())},Dd=($,sn)=>{$&&un($),U==null||U($,sn)},Id=$=>{A.current==null||A.current(),T==null||T($)},Ad=($,sn)=>{P==null||P($,sn),Wf(window,"resize",Kn)},Ud=$=>{$&&($.style.display=""),ie==null||ie($),Ki(window,"resize",Kn)},Bd=y.useCallback($=>v.jsx("div",{...$,className:M(`${e}-backdrop`,Ve,!h&&"show")}),[h,Ve,e]),Xu={...n,..._e};Xu.display="block";const Hd=$=>v.jsx("div",{role:"dialog",...$,style:Xu,className:M(t,e,E&&`${e}-static`,!h&&"show"),onClick:d?Hr:void 0,onMouseUp:Gn,"aria-label":a,"aria-labelledby":u,"aria-describedby":s,children:v.jsx(i,{...ln,onMouseDown:Br,className:r,contentClassName:l,children:o})});return v.jsx(kd.Provider,{value:ho,children:v.jsx(Av,{show:f,ref:qe,backdrop:d,container:p,keyboard:!0,autoFocus:c,enforceFocus:m,restoreFocus:w,restoreFocusOptions:C,onEscapeKeyDown:Yn,onShow:k,onHide:R,onEnter:Dd,onEntering:Ad,onEntered:N,onExit:Id,onExiting:j,onExited:Ud,manager:on(),transition:h?bv:void 0,backdropTransition:h?ey:void 0,renderBackdrop:Bd,renderDialog:Hd})})});_d.displayName="Modal";const tt=Object.assign(_d,{Body:Qv,Header:Zv,Title:qv,Footer:Gv,Dialog:Ed,TRANSITION_DURATION:300,BACKDROP_TRANSITION_DURATION:150}),ya=1e3;function ty(e,t,n){const r=(e-t)/(n-t)*100;return Math.round(r*ya)/ya}function ga({min:e,now:t,max:n,label:r,visuallyHidden:l,striped:o,animated:i,className:u,style:s,variant:a,bsPrefix:f,...h},d){return v.jsx("div",{ref:d,...h,role:"progressbar",className:M(u,`${f}-bar`,{[`bg-${a}`]:a,[`${f}-bar-animated`]:i,[`${f}-bar-striped`]:i||o}),style:{width:`${ty(t,e,n)}%`,...s},"aria-valuenow":t,"aria-valuemin":e,"aria-valuemax":n,children:l?v.jsx("span",{className:"visually-hidden",children:r}):r})}const jd=y.forwardRef(({isChild:e=!1,...t},n)=>{const r={min:0,max:100,animated:!1,visuallyHidden:!1,striped:!1,...t};if(r.bsPrefix=H(r.bsPrefix,"progress"),e)return ga(r,n);const{min:l,now:o,max:i,label:u,visuallyHidden:s,striped:a,animated:f,bsPrefix:h,variant:d,className:g,children:S,...k}=r;return v.jsx("div",{ref:n,...k,className:M(g,h),children:S?cv(S,R=>y.cloneElement(R,{isChild:!0})):ga({min:l,now:o,max:i,label:u,visuallyHidden:s,striped:a,animated:f,bsPrefix:h,variant:d},n)})});jd.displayName="ProgressBar";const ny=jd,Rd=y.forwardRef(({bsPrefix:e,className:t,as:n="div",...r},l)=>{const o=H(e,"row"),i=Df(),u=If(),s=`${o}-cols`,a=[];return i.forEach(f=>{const h=r[f];delete r[f];let d;h!=null&&typeof h=="object"?{cols:d}=h:d=h;const g=f!==u?`-${f}`:"";d!=null&&a.push(`${s}${g}-${d}`)}),v.jsx(n,{ref:l,...r,className:M(t,o,...a)})});Rd.displayName="Row";const Ld=Rd,Od=y.forwardRef(({bsPrefix:e,variant:t,animation:n="border",size:r,as:l="div",className:o,...i},u)=>{e=H(e,"spinner");const s=`${e}-${n}`;return v.jsx(l,{ref:u,...i,className:M(o,s,r&&`${s}-${r}`,t&&`text-${t}`)})});Od.displayName="Spinner";const An=Od,ry=window.origin==="null"||window.origin==="http://localhost:3031"?"http://localhost:3030":"",Sl="initializing",wa="paused",Pd="live",ly="error",vt=async(e,t,n)=>{console.log(e,t);const r=ry+t,l={method:e,headers:{Accept:"application/json"},body:n};let o={method:e,path:t,text:""},i;try{i=await fetch(r,l)}catch{return o.text="network error",Promise.reject(o)}if(o.status=i.status,o.statusText=i.statusText,!i.ok){const s=await i.text();try{const a=JSON.parse(s);o.text=a.human_readable!==void 0?a.human_readable:JSON.stringify(a,null,2)}catch{o.text=s}return Promise.reject(o)}return await i.json()},ft={listTorrents:()=>vt("GET","/torrents"),getTorrentDetails:e=>vt("GET",`/torrents/${e}`),getTorrentStats:e=>vt("GET",`/torrents/${e}/stats/v1`),uploadTorrent:(e,t)=>{t=t||{};let n="/torrents?&overwrite=true";return t.listOnly&&(n+="&list_only=true"),t.selectedFiles!=null&&(n+=`&only_files=${t.selectedFiles.join(",")}`),t.unpopularTorrent&&(n+="&peer_connect_timeout=20&peer_read_write_timeout=60"),vt("POST",n,e)},pause:e=>vt("POST",`/torrents/${e}/pause`),start:e=>vt("POST",`/torrents/${e}/start`),forget:e=>vt("POST",`/torrents/${e}/forget`),delete:e=>vt("POST",`/torrents/${e}/delete`)},Vn=y.createContext(null),Fd=y.createContext(null),Go=({className:e,onClick:t,disabled:n,color:r})=>{const l=o=>{o.stopPropagation(),!n&&t()};return v.jsx("a",{className:`bi ${e} p-1`,onClick:l,href:"#"})},oy=({id:e,show:t,onHide:n})=>{if(!t)return null;const[r,l]=y.useState(!1),[o,i]=y.useState(null),[u,s]=y.useState(!1),a=y.useContext(Vn),f=()=>{l(!1),i(null),s(!1),n()},h=()=>{s(!0),(r?ft.delete:ft.forget)(e).then(()=>{a.refreshTorrents(),f()}).catch(g=>{i({text:`Error deleting torrent id=${e}`,details:g}),s(!1)})};return v.jsxs(tt,{show:t,onHide:f,children:[v.jsx(tt.Header,{closeButton:!0,children:"Delete torrent"}),v.jsxs(tt.Body,{children:[v.jsx(Et,{children:v.jsx(Et.Group,{controlId:"delete-torrent",children:v.jsx(Et.Check,{type:"checkbox",label:"Also delete files",checked:r,onChange:()=>l(!r)})})}),o&&v.jsx(zr,{error:o})]}),v.jsxs(tt.Footer,{children:[u&&v.jsx(An,{}),v.jsx(Mr,{variant:"primary",onClick:h,disabled:u,children:"OK"}),v.jsx(Mr,{variant:"secondary",onClick:f,children:"Cancel"})]})]})},iy=({id:e,statsResponse:t})=>{let n=t.state,[r,l]=y.useState(!1),[o,i]=y.useState(!1),u=y.useContext(Fd);const s=n=="live",a=n=="paused"||n=="error",f=y.useContext(Vn),h=()=>{l(!0),ft.start(e).then(()=>{u.refresh()},k=>{f.setCloseableError({text:`Error starting torrent id=${e}`,details:k})}).finally(()=>l(!1))},d=()=>{l(!0),ft.pause(e).then(()=>{u.refresh()},k=>{f.setCloseableError({text:`Error pausing torrent id=${e}`,details:k})}).finally(()=>l(!1))},g=()=>{l(!0),i(!0)},S=()=>{l(!1),i(!1)};return v.jsx(Ld,{children:v.jsxs(Vu,{children:[a&&v.jsx(Go,{className:"bi-play-circle",onClick:h,disabled:r,color:"success"}),s&&v.jsx(Go,{className:"bi-pause-circle",onClick:d,disabled:r}),v.jsx(Go,{className:"bi-x-circle",onClick:g,disabled:r,color:"danger"}),v.jsx(oy,{id:e,show:o,onHide:S})]})})},uy=({id:e,detailsResponse:t,statsResponse:n})=>{const r=(n==null?void 0:n.state)??"",l=n==null?void 0:n.error,o=(n==null?void 0:n.total_bytes)??1,i=(n==null?void 0:n.progress_bytes)??0,u=(n==null?void 0:n.finished)||!1,s=l?100:i/o*100,a=(r==Sl||r==Pd)&&!u,f=l?"Error":`${s.toFixed(2)}%`,h=l?"danger":u?"success":r==Sl?"warning":"primary",d=()=>{var R;let k=(R=n==null?void 0:n.live)==null?void 0:R.snapshot.peer_stats;return k?`${k.live} / ${k.seen}`:""},g=()=>{var k;if(u)return"Completed";switch(r){case wa:return"Paused";case Sl:return"Checking files";case ly:return"Error"}return((k=n.live)==null?void 0:k.download_speed.human_readable)??"N/A"};let S=[];return l?S.push("bg-warning"):e%2==0&&S.push("bg-light"),v.jsxs(Ld,{className:S.join(" "),children:[v.jsx(yt,{size:3,label:"Name",children:t?v.jsxs(v.Fragment,{children:[v.jsx("div",{className:"text-truncate",children:yy(t)}),l&&v.jsxs("p",{className:"text-danger",children:[v.jsx("strong",{children:"Error:"})," ",l]})]}):v.jsx(An,{})}),n?v.jsxs(v.Fragment,{children:[v.jsx(yt,{label:"Size",children:`${zd(o)} `}),v.jsx(yt,{size:2,label:(r==wa,"Progress"),children:v.jsx(ny,{now:s,label:f,animated:a,variant:h})}),v.jsx(yt,{size:2,label:"Down Speed",children:g()}),v.jsx(yt,{label:"ETA",children:gy(n)}),v.jsx(yt,{size:2,label:"Peers",children:d()}),v.jsx(yt,{label:"Actions",children:v.jsx(iy,{id:e,statsResponse:n})})]}):v.jsx(yt,{label:"Loading stats",size:8,children:v.jsx(An,{})})]})},yt=({size:e,label:t,children:n})=>v.jsxs(Vu,{md:e||1,className:"py-3",children:[v.jsx("div",{className:"fw-bold",children:t}),n]}),sy=({id:e,torrent:t})=>{const[n,r]=y.useState(null),[l,o]=y.useState(null),[i,u]=y.useState(0),s=()=>{u(i+1)};return y.useEffect(()=>{if(n===null)return Sy(async()=>{await ft.getTorrentDetails(t.id).then(r)},1e3)},[n]),y.useEffect(()=>$d(async()=>ft.getTorrentStats(t.id).then(g=>(o(g),g)).then(g=>g.finished?1e4:g.state==Sl||g.state==Pd?1e3:1e4,g=>1e4),0),[i]),v.jsx(Fd.Provider,{value:{refresh:s},children:v.jsx(uy,{id:e,detailsResponse:n,statsResponse:l})})},ay=e=>{if(e.torrents===null&&e.loading)return v.jsx(An,{});if(e.torrents!==null)return e.torrents.length===0?v.jsx("div",{className:"text-center",children:v.jsx("p",{children:"No existing torrents found. Add them through buttons below."})}):v.jsx(v.Fragment,{children:e.torrents.map(t=>v.jsx(sy,{id:t.id,torrent:t},t.id))})},cy=()=>{const[e,t]=y.useState(null),[n,r]=y.useState(null),[l,o]=y.useState(null),[i,u]=y.useState(!1),s=async()=>{u(!0);let f=await ft.listTorrents().finally(()=>u(!1));o(f.torrents)};y.useEffect(()=>$d(async()=>s().then(()=>(r(null),5e3),f=>(r({text:"Error refreshing torrents",details:f}),console.error(f),5e3)),0),[]);const a={setCloseableError:t,refreshTorrents:s};return v.jsx(Vn.Provider,{value:a,children:v.jsxs("div",{className:"text-center",children:[v.jsx("h1",{className:"mt-3 mb-4",children:"rqbit web 4.0.0-beta.0"}),v.jsx(vy,{closeableError:e,otherError:n,torrents:l,torrentsLoading:i})]})})},fy=e=>{let{details:t}=e;return t?v.jsxs(v.Fragment,{children:[t.status&&v.jsxs("strong",{children:[t.status," ",t.statusText,": "]}),t.text]}):null},zr=e=>{let{error:t,remove:n}=e;return t==null?null:v.jsxs(fa,{variant:"danger",onClose:n,dismissible:n!=null,children:[v.jsx(fa.Heading,{children:t.text}),v.jsx(fy,{details:t.details})]})},Md=({buttonText:e,onClick:t,data:n,resetData:r,variant:l})=>{const[o,i]=y.useState(!1),[u,s]=y.useState([]),[a,f]=y.useState(null);y.useContext(Vn);const h=n!==null||a!==null;y.useEffect(()=>{if(n===null)return;let g=setTimeout(async()=>{i(!0);try{const S=await ft.uploadTorrent(n,{listOnly:!0});s(S.details.files)}catch(S){f({text:"Error uploading torrent",details:S})}finally{i(!1)}},0);return()=>clearTimeout(g)},[n]);const d=()=>{r(),f(null),s([]),i(!1)};return v.jsxs(v.Fragment,{children:[v.jsx(Mr,{variant:l,onClick:t,className:"m-1",children:e}),v.jsx(my,{show:h,onHide:d,fileListError:a,fileList:u,data:n,fileListLoading:o})]})},dy=()=>{let[e,t]=y.useState(null);const n=()=>{const r=prompt("Enter magnet link or HTTP(s) URL");t(r===""?null:r)};return v.jsx(Md,{variant:"primary",buttonText:"Add Torrent from Magnet / URL",onClick:n,data:e,resetData:()=>t(null)})},py=()=>{const e=y.useRef(),[t,n]=y.useState(null),r=async()=>{const i=e.current.files[0];n(i)},l=()=>{e.current.value="",n(null)},o=()=>{e.current.click()};return v.jsxs(v.Fragment,{children:[v.jsx("input",{type:"file",ref:e,accept:".torrent",onChange:r,className:"d-none"}),v.jsx(Md,{variant:"secondary",buttonText:"Upload .torrent File",onClick:o,data:t,resetData:l})]})},my=e=>{let{show:t,onHide:n,fileList:r,fileListError:l,fileListLoading:o,data:i}=e;const[u,s]=y.useState([]),[a,f]=y.useState(!1),[h,d]=y.useState(null),[g,S]=y.useState(!1),k=y.useContext(Vn);y.useEffect(()=>{s(r.map((m,w)=>w))},[r]);const R=()=>{n(),s([]),d(null),f(!1)},p=m=>{u.includes(m)?s(u.filter(w=>w!==m)):s([...u,m])},c=async()=>{f(!0),ft.uploadTorrent(i,{selectedFiles:u,unpopularTorrent:g}).then(()=>{n(),k.refreshTorrents()},m=>{d({text:"Error starting torrent",details:m})}).finally(()=>f(!1))};return v.jsxs(tt,{show:t,onHide:R,size:"lg",children:[v.jsx(tt.Header,{closeButton:!0,children:!!l||v.jsx(tt.Title,{children:"Add torrent"})}),v.jsxs(tt.Body,{children:[v.jsxs(Et,{children:[v.jsxs("fieldset",{className:"mb-5",children:[v.jsx("legend",{children:"Pick the files to download"}),o?v.jsx(An,{}):l?v.jsx(zr,{error:l}):v.jsx(v.Fragment,{children:r.map((m,w)=>v.jsx(Et.Group,{controlId:`check-${w}`,children:v.jsx(Et.Check,{type:"checkbox",label:`${m.name} (${zd(m.length)})`,checked:u.includes(w),onChange:()=>p(w)})},w))})]}),v.jsxs("fieldset",{children:[v.jsx("legend",{children:"Other options"}),v.jsxs(Et.Group,{controlId:"unpopular-torrent",children:[v.jsx(Et.Check,{type:"checkbox",label:"Increase timeouts",checked:g,onChange:()=>S(!g)}),v.jsx("small",{id:"emailHelp",className:"form-text text-muted",children:"This might be useful for unpopular torrents with few peers."})]})]})]}),v.jsx(zr,{error:h})]}),v.jsxs(tt.Footer,{children:[a&&v.jsx(An,{}),v.jsx(Mr,{variant:"primary",onClick:c,disabled:o||a||u.length==0,children:"OK"}),v.jsx(Mr,{variant:"secondary",onClick:R,children:"Cancel"})]})]})},hy=()=>v.jsxs("div",{id:"buttons-container",className:"mt-3",children:[v.jsx(dy,{}),v.jsx(py,{})]}),vy=e=>{let t=y.useContext(Vn);return v.jsxs(pv,{children:[v.jsx(zr,{error:e.closeableError,remove:()=>t.setCloseableError(null)}),v.jsx(zr,{error:e.otherError}),v.jsx(ay,{torrents:e.torrents,loading:e.torrentsLoading}),v.jsx(hy,{})]})};function zd(e){if(e===0)return"0 Bytes";const t=1024,n=["Bytes","KB","MB","GB"],r=Math.floor(Math.log(e)/Math.log(t));return parseFloat((e/Math.pow(t,r)).toFixed(2))+" "+n[r]}function yy(e){return e.files.filter(n=>n.included).reduce((n,r)=>n.length>r.length?n:r).name}function gy(e){var n,r,l;let t=(l=(r=(n=e==null?void 0:e.live)==null?void 0:n.time_remaining)==null?void 0:r.duration)==null?void 0:l.secs;return t==null?"N/A":wy(t)}function wy(e){const t=Math.floor(e/3600),n=Math.floor(e%3600/60),r=e%60,l=(o,i)=>o>0?`${o}${i}`:"";return t>0?`${l(t,"h")} ${l(n,"m")}`.trim():n>0?`${l(n,"m")} ${l(r,"s")}`.trim():`${l(r,"s")}`.trim()}function $d(e,t){let n,r=t;const l=async()=>{if(r=await e(),r==null)throw"asyncCallback returned null or undefined";o()};let o=()=>{n=setTimeout(l,r)};return o(),()=>{clearTimeout(n)}}function Sy(e,t){let n;const r=async()=>{await e().then(()=>!1,()=>!0)&&l()};let l=o=>{n=setTimeout(r,o!==void 0?o:t)};return l(0),()=>clearTimeout(n)}async function ky(){const e=document.getElementById("app");Yo.createRoot(e).render(v.jsx(y.StrictMode,{children:v.jsx(cy,{})}))}document.addEventListener("DOMContentLoaded",ky); diff --git a/crates/librqbit/webui/dist/manifest.json b/crates/librqbit/webui/dist/manifest.json index 5f70725..8d25a85 100644 --- a/crates/librqbit/webui/dist/manifest.json +++ b/crates/librqbit/webui/dist/manifest.json @@ -4,7 +4,7 @@ "src": "assets/logo.svg" }, "index.html": { - "file": "assets/index-1c38bddb.js", + "file": "assets/index-b3c336f4.js", "isEntry": true, "src": "index.html" } diff --git a/crates/librqbit/webui/src/api.ts b/crates/librqbit/webui/src/api.ts index eba0f63..eb8fe4e 100644 --- a/crates/librqbit/webui/src/api.ts +++ b/crates/librqbit/webui/src/api.ts @@ -147,7 +147,7 @@ export const API = { }, uploadTorrent: (data: string | File, opts?: { - listOnly?: boolean, selectedFiles?: Array + listOnly?: boolean, selectedFiles?: Array, unpopularTorrent?: boolean, }): Promise => { opts = opts || {}; let url = '/torrents?&overwrite=true'; @@ -157,6 +157,9 @@ export const API = { if (opts.selectedFiles != null) { url += `&only_files=${opts.selectedFiles.join(',')}`; } + if (opts.unpopularTorrent) { + url += '&peer_connect_timeout=20&peer_read_write_timeout=60'; + } return makeRequest('POST', url, data) }, diff --git a/crates/librqbit/webui/src/index.tsx b/crates/librqbit/webui/src/index.tsx index efa10b0..cdafa9e 100644 --- a/crates/librqbit/webui/src/index.tsx +++ b/crates/librqbit/webui/src/index.tsx @@ -438,7 +438,7 @@ const MagnetInput = () => { }; return ( - setMagnet(null)} /> + setMagnet(null)} /> ); }; @@ -481,6 +481,7 @@ const FileSelectionModal = (props: { const [selectedFiles, setSelectedFiles] = useState([]); const [uploading, setUploading] = useState(false); const [uploadError, setUploadError] = useState(null); + const [unpopularTorrent, setUnpopularTorrent] = useState(false); const ctx = useContext(AppContext); useEffect(() => { @@ -504,7 +505,7 @@ const FileSelectionModal = (props: { const handleUpload = async () => { setUploading(true); - API.uploadTorrent(data, { selectedFiles }).then( + API.uploadTorrent(data, { selectedFiles, unpopularTorrent }).then( () => { onHide(); ctx.refreshTorrents(); @@ -518,24 +519,43 @@ const FileSelectionModal = (props: { return ( - {!!fileListError || Select Files} + {!!fileListError || Add torrent} - {fileListLoading ? - : fileListError ? : -
- {fileList.map((file, index) => ( - - handleToggleFile(index)}> - - - ))} -
- } +
+
+ Pick the files to download + {fileListLoading ? + : fileListError ? : + <> + {fileList.map((file, index) => ( + + handleToggleFile(index)}> + + + ))} + + } +
+
+ Other options + + + setUnpopularTorrent(!unpopularTorrent)}> + + This might be useful for unpopular torrents with few peers. + +
+ +
From 42b1fb09c214b75be53c4f748438771ba4a3b8ad Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Thu, 30 Nov 2023 16:26:57 +0000 Subject: [PATCH 39/51] Merging session and user-provided peer timeouts --- crates/librqbit/src/peer_connection.rs | 2 +- crates/librqbit/src/session.rs | 26 ++++++++++++++++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/librqbit/src/peer_connection.rs b/crates/librqbit/src/peer_connection.rs index 84b3b89..9eb5702 100644 --- a/crates/librqbit/src/peer_connection.rs +++ b/crates/librqbit/src/peer_connection.rs @@ -38,7 +38,7 @@ pub enum WriterRequest { Disconnect, } -#[derive(Default, Copy, Clone)] +#[derive(Default, Debug, Copy, Clone)] pub struct PeerConnectionOptions { pub connect_timeout: Option, pub read_write_timeout: Option, diff --git a/crates/librqbit/src/session.rs b/crates/librqbit/src/session.rs index 4304da2..91fe07e 100644 --- a/crates/librqbit/src/session.rs +++ b/crates/librqbit/src/session.rs @@ -353,6 +353,22 @@ impl Session { self.dht.as_ref() } + fn merge_peer_opts(&self, other: Option) -> PeerConnectionOptions { + let other = match other { + Some(o) => o, + None => self.peer_opts, + }; + PeerConnectionOptions { + connect_timeout: other.connect_timeout.or(self.peer_opts.connect_timeout), + read_write_timeout: other + .read_write_timeout + .or(self.peer_opts.read_write_timeout), + keep_alive_interval: other + .keep_alive_interval + .or(self.peer_opts.keep_alive_interval), + } + } + async fn populate_from_stored(self: &Arc) -> anyhow::Result<()> { let mut rdr = match std::fs::File::open(&self.persistence_filename) { Ok(f) => BufReader::new(f), @@ -459,7 +475,7 @@ impl Session { let opts = opts.unwrap_or_default(); - let (info_hash, info, dht_rx, trackers, initial_peers) = match add.into() { + let (info_hash, info, dht_rx, trackers, initial_peers) = match add { AddTorrent::Url(magnet) if magnet.starts_with("magnet:") => { let Magnet { info_hash, @@ -488,7 +504,7 @@ impl Session { self.peer_id, info_hash, dht_rx, - Some(self.peer_opts), + Some(self.merge_peer_opts(opts.peer_opts)), ) .await { @@ -650,11 +666,13 @@ impl Session { builder.force_tracker_interval(interval); } - if let Some(t) = opts.peer_opts.unwrap_or(self.peer_opts).connect_timeout { + let peer_opts = self.merge_peer_opts(opts.peer_opts); + + if let Some(t) = peer_opts.connect_timeout { builder.peer_connect_timeout(t); } - if let Some(t) = opts.peer_opts.unwrap_or(self.peer_opts).read_write_timeout { + if let Some(t) = peer_opts.read_write_timeout { builder.peer_read_write_timeout(t); } From 558aa3f246eb277f7316b34eb0e7c2aff1329c07 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Thu, 30 Nov 2023 16:52:03 +0000 Subject: [PATCH 40/51] Fix accidentally removed line - sending nodes on getPeersRequest --- crates/dht/src/dht.rs | 2 ++ crates/librqbit/webui/src/index.tsx | 9 ++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index 465555f..5f91aea 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -662,6 +662,7 @@ impl DhtState { } MessageKind::GetPeersRequest(req) => { // TODO: respond with peer info, for now sending an empty response. + let compact_node_info = generate_compact_nodes(req.info_hash); self.routing_table.write().mark_last_query(&req.id); let message = Message { transaction_id: msg.transaction_id, @@ -669,6 +670,7 @@ impl DhtState { ip: None, kind: MessageKind::Response(bprotocol::Response { id: self.id, + nodes: Some(compact_node_info), ..Default::default() }), }; diff --git a/crates/librqbit/webui/src/index.tsx b/crates/librqbit/webui/src/index.tsx index cdafa9e..54e6df4 100644 --- a/crates/librqbit/webui/src/index.tsx +++ b/crates/librqbit/webui/src/index.tsx @@ -505,11 +505,10 @@ const FileSelectionModal = (props: { const handleUpload = async () => { setUploading(true); - API.uploadTorrent(data, { selectedFiles, unpopularTorrent }).then( - () => { - onHide(); - ctx.refreshTorrents(); - }, + API.uploadTorrent(data, { selectedFiles, unpopularTorrent }).then(() => { + onHide(); + ctx.refreshTorrents(); + }, (e) => { setUploadError({ text: 'Error starting torrent', details: e }); } From 32a220f17cc701e0b2096ee588f2f6556a420820 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Thu, 30 Nov 2023 17:28:32 +0000 Subject: [PATCH 41/51] Shorten debug for messages --- crates/dht/src/bprotocol.rs | 34 ++++++++++++++++++++++++++++++---- crates/dht/src/dht.rs | 2 +- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/crates/dht/src/bprotocol.rs b/crates/dht/src/bprotocol.rs index 5db82fe..9e3834d 100644 --- a/crates/dht/src/bprotocol.rs +++ b/crates/dht/src/bprotocol.rs @@ -163,17 +163,27 @@ struct RawMessage { ip: Option, } -#[derive(Debug)] pub struct Node { pub id: Id20, pub addr: SocketAddrV4, } -#[derive(Debug)] +impl core::fmt::Debug for Node { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "<{:?}; {}>", self.id, self.addr) + } +} + pub struct CompactNodeInfo { pub nodes: Vec, } +impl core::fmt::Debug for CompactNodeInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self.nodes) + } +} + impl Serialize for CompactNodeInfo { fn serialize(&self, serializer: S) -> Result where @@ -230,11 +240,16 @@ impl<'de> Deserialize<'de> for CompactNodeInfo { } } -#[derive(Debug)] pub struct CompactPeerInfo { pub addr: SocketAddrV4, } +impl core::fmt::Debug for CompactPeerInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self.addr) + } +} + impl Serialize for CompactPeerInfo { fn serialize(&self, serializer: S) -> Result where @@ -343,7 +358,6 @@ impl Message { } } -#[derive(Debug)] pub enum MessageKind { Error(ErrorDescription), GetPeersRequest(GetPeersRequest), @@ -352,6 +366,18 @@ pub enum MessageKind { PingRequest(PingRequest), } +impl core::fmt::Debug for MessageKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Error(e) => write!(f, "{e:?}"), + Self::GetPeersRequest(r) => write!(f, "{r:?}"), + Self::FindNodeRequest(r) => write!(f, "{r:?}"), + Self::Response(r) => write!(f, "{r:?}"), + Self::PingRequest(r) => write!(f, "{r:?}"), + } + } +} + pub fn serialize_message<'a, W: Write, BufT: Serialize + From<&'a [u8]>>( writer: &mut W, transaction_id: BufT, diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index 5f91aea..6aacea4 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -638,7 +638,7 @@ impl DhtState { _ => {} }; - trace!("received query: {:?}", msg); + trace!("received query from {addr}: {msg:?}"); match &msg.kind { // Otherwise, respond to a query. From 18c845d6f078cb194d793fed8e2044a19bab8015 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Thu, 30 Nov 2023 17:34:39 +0000 Subject: [PATCH 42/51] Shorten debug for messages --- crates/dht/src/bprotocol.rs | 2 +- crates/dht/src/dht.rs | 10 +++++++++- crates/librqbit_core/src/id20.rs | 2 -- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/dht/src/bprotocol.rs b/crates/dht/src/bprotocol.rs index 9e3834d..4f7875e 100644 --- a/crates/dht/src/bprotocol.rs +++ b/crates/dht/src/bprotocol.rs @@ -170,7 +170,7 @@ pub struct Node { impl core::fmt::Debug for Node { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "<{:?}; {}>", self.id, self.addr) + write!(f, "{}={:?}", self.addr, self.id) } } diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index 6aacea4..5f36a45 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -721,12 +721,20 @@ enum Request { Ping, } -#[derive(Debug)] enum ResponseOrError { Response(Response), Error(ErrorDescription), } +impl core::fmt::Debug for ResponseOrError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Response(r) => write!(f, "{r:?}"), + Self::Error(e) => write!(f, "{e:?}"), + } + } +} + struct DhtWorker { socket: UdpSocket, dht: Arc, diff --git a/crates/librqbit_core/src/id20.rs b/crates/librqbit_core/src/id20.rs index 2492f78..f5f0222 100644 --- a/crates/librqbit_core/src/id20.rs +++ b/crates/librqbit_core/src/id20.rs @@ -20,11 +20,9 @@ impl FromStr for Id20 { impl std::fmt::Debug for Id20 { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "<")?; for byte in self.0 { write!(f, "{byte:02x?}")?; } - write!(f, ">")?; Ok(()) } } From 80b153dbca186550e727c43bb4587fd93cd732ca Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Thu, 30 Nov 2023 18:25:38 +0000 Subject: [PATCH 43/51] Print timeout message better --- crates/dht/src/dht.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index 5f36a45..b45f202 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -540,7 +540,7 @@ impl DhtState { } Err(_) => { self.inflight_by_transaction_id.remove(&key); - bail!("timeout") + bail!("timeout ({RESPONSE_TIMEOUT:?})") } } } From 261ad3cc7c57097959878b4cca11edae915042e7 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Thu, 30 Nov 2023 19:33:02 +0000 Subject: [PATCH 44/51] 1/n Added peer store --- TODO.md | 11 ++- crates/dht/src/bprotocol.rs | 49 ++++++++++++++ crates/dht/src/dht.rs | 31 ++++++++- crates/dht/src/lib.rs | 1 + crates/dht/src/peer_store.rs | 126 +++++++++++++++++++++++++++++++++++ 5 files changed, 213 insertions(+), 5 deletions(-) create mode 100644 crates/dht/src/peer_store.rs diff --git a/TODO.md b/TODO.md index 0f929a8..ac2c650 100644 --- a/TODO.md +++ b/TODO.md @@ -18,13 +18,18 @@ - [x] many nodes in "Unknown" status, do smth about it - [x] for torrents with a few seeds might be cool to re-query DHT once in a while. - [x] don't leak memory when deleting torrents (i.e. remove torrent information (seen peers etc) once the torrent is deleted) - - [ ] Routing table - is it balanced properly? - - [ ] Don't query Bad nodes + - [x] Routing table - is it balanced properly? + - [ ] + - [x] Don't query Bad nodes - [-] Buckets that have not been changed in 15 minutes should be "refreshed." (per RFC) - - [ ] Did it, but it's flawed: starts repeating the same queries again as neighboring refreshes + - [x] Did it, but it's flawed: starts repeating the same queries again as neighboring refreshes don't know about the other ones, and DHT returns the same nodes again and again. - [x] it's sending many requests now way too fast, locks up Mac OS UI annoyingly + - [x] store peers sent to us with "announce_peer" + - [ ] announced peers should be persisted - [ ] After the search is exhausted, the client then inserts the peer contact information for itself onto the responding nodes with IDs closest to the infohash of the torrent. + + To do this, a - [x] Ensure that if we query the "returned" nodes, they are even closer to our request than the responding node id was. someday: diff --git a/crates/dht/src/bprotocol.rs b/crates/dht/src/bprotocol.rs index 4f7875e..4e2e8eb 100644 --- a/crates/dht/src/bprotocol.rs +++ b/crates/dht/src/bprotocol.rs @@ -327,6 +327,15 @@ pub struct PingRequest { pub id: Id20, } +#[derive(Debug, Serialize, Deserialize)] +pub struct AnnouncePeer { + pub id: Id20, + pub implied_port: u8, + pub info_hash: Id20, + pub port: u16, + pub token: BufT, +} + #[derive(Debug, Serialize, Deserialize)] #[serde(bound(serialize = "BufT: AsRef<[u8]> + Serialize"))] #[serde(bound(deserialize = "BufT: From<&'de [u8]> + Deserialize<'de>"))] @@ -364,6 +373,7 @@ pub enum MessageKind { FindNodeRequest(FindNodeRequest), Response(Response), PingRequest(PingRequest), + AnnouncePeer(AnnouncePeer), } impl core::fmt::Debug for MessageKind { @@ -374,6 +384,7 @@ impl core::fmt::Debug for MessageKind { Self::FindNodeRequest(r) => write!(f, "{r:?}"), Self::Response(r) => write!(f, "{r:?}"), Self::PingRequest(r) => write!(f, "{r:?}"), + Self::AnnouncePeer(r) => write!(f, "{r:?}"), } } } @@ -452,6 +463,19 @@ pub fn serialize_message<'a, W: Write, BufT: Serialize + From<&'a [u8]>>( }; Ok(bencode::bencode_serialize_to_writer(msg, writer)?) } + MessageKind::AnnouncePeer(announce) => { + let msg: RawMessage = RawMessage { + message_type: MessageType::Request, + transaction_id, + error: None, + response: None, + method_name: Some(BufT::from(b"announce_peer")), + arguments: Some(announce), + ip, + version, + }; + Ok(bencode::bencode_serialize_to_writer(msg, writer)?) + } } } @@ -490,6 +514,15 @@ where kind: MessageKind::PingRequest(de.arguments.unwrap()), }) } + b"announce_peer" => { + let de: RawMessage> = bencode::from_bytes(buf)?; + Ok(Message { + transaction_id: de.transaction_id, + version: de.version, + ip: de.ip.map(|c| c.addr), + kind: MessageKind::AnnouncePeer(de.arguments.unwrap()) + }) + } other => anyhow::bail!("unsupported method {:?}", ByteBuf(other)), }, _ => anyhow::bail!( @@ -652,6 +685,22 @@ mod tests { test_deserialize_then_serialize_hex(WHAT_IS_THAT, "what_is_that") } + #[test] + fn test_announce() { + let ann = b"d1:ad2:id20:abcdefghij012345678912:implied_porti1e9:info_hash20:mnopqrstuvwxyz1234564:porti6881e5:token8:aoeusnthe1:q13:announce_peer1:t2:aa1:y1:qe"; + let msg = bprotocol::deserialize_message::(ann).unwrap(); + match &msg.kind { + bprotocol::MessageKind::AnnouncePeer(ann) => { + dbg!(&ann); + } + _ => panic!("wrong kind"), + } + let mut buf = Vec::new(); + bprotocol::serialize_message(&mut buf, msg.transaction_id, msg.version, msg.ip, msg.kind) + .unwrap(); + assert_eq!(ann[..], buf[..]); + } + #[test] fn deserialize_bencode_packets_captured_from_wireshark() { debug_hex_bencode("req: find_node", FIND_NODE_REQUEST); diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index b45f202..c7a9be3 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -14,6 +14,7 @@ use crate::{ self, CompactNodeInfo, ErrorDescription, FindNodeRequest, GetPeersRequest, Message, MessageKind, Node, PingRequest, Response, }, + peer_store::PeerStore, routing_table::{InsertResult, NodeStatus, RoutingTable}, INACTIVITY_TIMEOUT, REQUERY_INTERVAL, RESPONSE_TIMEOUT, }; @@ -488,6 +489,8 @@ pub struct DhtState { rate_limiter: RateLimiter, // This is to send raw messages worker_sender: UnboundedSender, + + peer_store: PeerStore, } impl DhtState { @@ -506,6 +509,7 @@ impl DhtState { worker_sender: sender, listen_addr, rate_limiter: make_rate_limiter(), + peer_store: PeerStore::new(id), } } @@ -660,9 +664,29 @@ impl DhtState { })?; Ok(()) } + MessageKind::AnnouncePeer(ann) => { + self.routing_table.write().mark_last_query(&ann.id); + let added = self.peer_store.store_peer(ann, addr); + trace!("{addr}: added_peer={added}, announce={ann:?}"); + let message = Message { + transaction_id: msg.transaction_id, + version: None, + ip: None, + kind: MessageKind::Response(bprotocol::Response { + id: self.id, + ..Default::default() + }), + }; + self.worker_sender.send(WorkerSendRequest { + our_tid: None, + message, + addr, + })?; + Ok(()) + } MessageKind::GetPeersRequest(req) => { - // TODO: respond with peer info, for now sending an empty response. let compact_node_info = generate_compact_nodes(req.info_hash); + let compact_peer_info = self.peer_store.get_for_info_hash(req.info_hash); self.routing_table.write().mark_last_query(&req.id); let message = Message { transaction_id: msg.transaction_id, @@ -671,7 +695,10 @@ impl DhtState { kind: MessageKind::Response(bprotocol::Response { id: self.id, nodes: Some(compact_node_info), - ..Default::default() + values: Some(compact_peer_info), + token: Some(ByteString( + self.peer_store.gen_token_for(req.id, addr).to_vec(), + )), }), }; self.worker_sender.send(WorkerSendRequest { diff --git a/crates/dht/src/lib.rs b/crates/dht/src/lib.rs index a7c50cc..94188d0 100644 --- a/crates/dht/src/lib.rs +++ b/crates/dht/src/lib.rs @@ -1,5 +1,6 @@ mod bprotocol; mod dht; +mod peer_store; mod persistence; mod routing_table; mod utils; diff --git a/crates/dht/src/peer_store.rs b/crates/dht/src/peer_store.rs new file mode 100644 index 0000000..b7a5266 --- /dev/null +++ b/crates/dht/src/peer_store.rs @@ -0,0 +1,126 @@ +use std::{ + collections::VecDeque, + net::{SocketAddr, SocketAddrV4}, + str::FromStr, + sync::atomic::AtomicU64, + time::Instant, +}; + +use bencode::ByteString; +use librqbit_core::id20::Id20; +use parking_lot::RwLock; +use rand::RngCore; +use tracing::trace; + +use crate::bprotocol::{AnnouncePeer, CompactPeerInfo, Response}; + +struct StoredToken { + token: [u8; 4], + node_id: Id20, + addr: SocketAddr, +} + +struct StoredPeer { + addr: SocketAddrV4, + time: Instant, +} + +pub struct PeerStore { + self_id: Id20, + max_remembered_tokens: usize, + max_remembered_peers: usize, + max_distance: Id20, + tokens: RwLock>, + peers: dashmap::DashMap>, + peers_len: AtomicU64, +} + +impl PeerStore { + pub fn new(self_id: Id20) -> Self { + Self { + self_id, + max_remembered_tokens: 1000, + max_remembered_peers: 1000, + max_distance: Id20::from_str("00000fffffffffffffffffffffffffffffffffff").unwrap(), + tokens: RwLock::new(VecDeque::new()), + peers: dashmap::DashMap::new(), + peers_len: AtomicU64::new(0), + } + } + + pub fn gen_token_for(&self, node_id: Id20, addr: SocketAddr) -> [u8; 4] { + let mut token = [0u8; 4]; + rand::thread_rng().fill_bytes(&mut token); + let mut tokens = self.tokens.write(); + tokens.push_back(StoredToken { + token, + node_id, + addr, + }); + if tokens.len() > self.max_remembered_tokens { + tokens.pop_front(); + } + token + } + + pub fn store_peer(&self, announce: &AnnouncePeer, addr: SocketAddr) -> bool { + // If the info_hash in announce is too far away from us, don't store it. + // If the token doesn't match, don't store it. + // If we are out of capacity, don't store it. + // Otherwise, store it. + let mut addr = match addr { + SocketAddr::V4(addr) => addr, + SocketAddr::V6(_) => { + trace!("peer store: IPv6 not supported"); + return false; + } + }; + if self.peers_len.load(std::sync::atomic::Ordering::SeqCst) + >= self.max_remembered_peers as u64 + { + trace!("peer store: out of capacity"); + return false; + } + + if announce.info_hash.distance(&self.self_id) > self.max_distance { + trace!("peer store: info_hash too far to store"); + return false; + } + if !self + .tokens + .read() + .iter() + .any(|t| t.token[..] == announce.token[..] && t.addr == std::net::SocketAddr::V4(addr)) + { + trace!("peer store: can't find this token / addr combination"); + return false; + } + if announce.implied_port == 0 { + addr.set_port(announce.port); + } + self.peers + .entry(announce.info_hash) + .or_default() + .push(StoredPeer { + addr, + time: Instant::now(), + }); + self.peers_len + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + true + } + + pub fn get_for_info_hash(&self, info_hash: Id20) -> Vec { + if let Some(stored_peers) = self.peers.get(&info_hash) { + return stored_peers + .iter() + .map(|p| CompactPeerInfo { addr: p.addr }) + .collect(); + } + Vec::new() + } + + pub fn garbage_collect_peers(&self) { + todo!() + } +} From b3ab2c4d4cafad237f30ec66b6b252e91d556274 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Thu, 30 Nov 2023 20:50:49 +0000 Subject: [PATCH 45/51] Code to serialize peer store --- Cargo.lock | 64 +++++++++++++++ crates/dht/Cargo.toml | 5 +- crates/dht/src/dht.rs | 7 +- crates/dht/src/peer_store.rs | 147 +++++++++++++++++++++++++++------- crates/dht/src/persistence.rs | 26 ++++-- 5 files changed, 208 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3460a27..45a2730 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.4" @@ -275,6 +290,21 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-targets", +] + [[package]] name = "clap" version = "4.4.8" @@ -462,6 +492,7 @@ dependencies = [ "lock_api", "once_cell", "parking_lot_core", + "serde", ] [[package]] @@ -886,6 +917,29 @@ dependencies = [ "tokio-native-tls", ] +[[package]] +name = "iana-time-zone" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8326b86b6cff230b97d0d312a6c40a60726df3332e721f72a1b035f451663b20" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "idna" version = "0.4.0" @@ -1090,6 +1144,7 @@ version = "3.2.0" dependencies = [ "anyhow", "backoff", + "chrono", "dashmap", "directories", "futures", @@ -2515,6 +2570,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/crates/dht/Cargo.toml b/crates/dht/Cargo.toml index 81decc1..ff6dfe6 100644 --- a/crates/dht/Cargo.toml +++ b/crates/dht/Cargo.toml @@ -32,10 +32,11 @@ futures = "0.3" rand = "0.8" indexmap = "2" directories = "5" -dashmap = "5.5.3" +dashmap = {version = "5.5.3", features = ["serde"]} clone_to_owned = {path="../clone_to_owned", package="librqbit-clone-to-owned", version = "2.2.1"} librqbit-core = {path="../librqbit_core", version = "3.1.0"} +chrono = {version = "0.4.31", features = ["serde"]} [dev-dependencies] -tracing-subscriber = "0.3" \ No newline at end of file +tracing-subscriber = "0.3" diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index c7a9be3..a9d0594 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -490,7 +490,7 @@ pub struct DhtState { // This is to send raw messages worker_sender: UnboundedSender, - peer_store: PeerStore, + pub(crate) peer_store: PeerStore, } impl DhtState { @@ -499,6 +499,7 @@ impl DhtState { sender: UnboundedSender, routing_table: Option, listen_addr: SocketAddr, + peer_store: PeerStore, ) -> Self { let routing_table = routing_table.unwrap_or_else(|| RoutingTable::new(id, None)); Self { @@ -509,7 +510,7 @@ impl DhtState { worker_sender: sender, listen_addr, rate_limiter: make_rate_limiter(), - peer_store: PeerStore::new(id), + peer_store, } } @@ -1056,6 +1057,7 @@ pub struct DhtConfig { pub bootstrap_addrs: Option>, pub routing_table: Option, pub listen_addr: Option, + pub(crate) peer_store: Option, } impl DhtState { @@ -1089,6 +1091,7 @@ impl DhtState { in_tx, config.routing_table, listen_addr, + config.peer_store.unwrap_or_else(|| PeerStore::new(peer_id)), )); spawn(error_span!("dht"), { diff --git a/crates/dht/src/peer_store.rs b/crates/dht/src/peer_store.rs index b7a5266..259dc23 100644 --- a/crates/dht/src/peer_store.rs +++ b/crates/dht/src/peer_store.rs @@ -2,37 +2,108 @@ use std::{ collections::VecDeque, net::{SocketAddr, SocketAddrV4}, str::FromStr, - sync::atomic::AtomicU64, - time::Instant, + sync::atomic::AtomicU32, }; use bencode::ByteString; +use chrono::{DateTime, Utc}; use librqbit_core::id20::Id20; use parking_lot::RwLock; use rand::RngCore; +use serde::{ + ser::{SerializeMap, SerializeStruct}, + Deserialize, Serialize, +}; use tracing::trace; -use crate::bprotocol::{AnnouncePeer, CompactPeerInfo, Response}; +use crate::bprotocol::{AnnouncePeer, CompactPeerInfo}; +#[derive(Serialize, Deserialize)] struct StoredToken { token: [u8; 4], + #[serde(serialize_with = "crate::utils::serialize_id20")] node_id: Id20, addr: SocketAddr, } +#[derive(Serialize, Deserialize)] struct StoredPeer { addr: SocketAddrV4, - time: Instant, + time: DateTime, } pub struct PeerStore { self_id: Id20, - max_remembered_tokens: usize, - max_remembered_peers: usize, + max_remembered_tokens: u32, + max_remembered_peers: u32, max_distance: Id20, tokens: RwLock>, peers: dashmap::DashMap>, - peers_len: AtomicU64, + peers_len: AtomicU32, +} + +impl Serialize for PeerStore { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + struct SerializePeers<'a> { + peers: &'a dashmap::DashMap>, + } + + impl<'a> Serialize for SerializePeers<'a> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut m = serializer.serialize_map(None)?; + for entry in self.peers.iter() { + m.serialize_entry(&entry.key().as_string(), &entry.value())?; + } + m.end() + } + } + + let mut s = serializer.serialize_struct("PeerStore", 7)?; + s.serialize_field("self_id", &self.self_id.as_string())?; + s.serialize_field("max_remembered_tokens", &self.max_remembered_tokens)?; + s.serialize_field("max_remembered_peers", &self.max_remembered_peers)?; + s.serialize_field("max_distance", &self.max_distance.as_string())?; + s.serialize_field("tokens", &*self.tokens.read())?; + s.serialize_field("peers", &SerializePeers { peers: &self.peers })?; + s.serialize_field( + "peers_len", + &self.peers_len.load(std::sync::atomic::Ordering::SeqCst), + )?; + s.end() + } +} + +impl<'de> Deserialize<'de> for PeerStore { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Tmp { + self_id: Id20, + max_remembered_tokens: u32, + max_remembered_peers: u32, + max_distance: Id20, + tokens: VecDeque, + peers: dashmap::DashMap>, + } + + Tmp::deserialize(deserializer).map(|tmp| Self { + self_id: tmp.self_id, + max_remembered_tokens: tmp.max_remembered_tokens, + max_remembered_peers: tmp.max_remembered_peers, + max_distance: tmp.max_distance, + tokens: RwLock::new(tmp.tokens), + peers_len: AtomicU32::new(tmp.peers.iter().map(|e| e.value().len() as u32).sum()), + peers: tmp.peers, + }) + } } impl PeerStore { @@ -44,7 +115,7 @@ impl PeerStore { max_distance: Id20::from_str("00000fffffffffffffffffffffffffffffffffff").unwrap(), tokens: RwLock::new(VecDeque::new()), peers: dashmap::DashMap::new(), - peers_len: AtomicU64::new(0), + peers_len: AtomicU32::new(0), } } @@ -54,10 +125,10 @@ impl PeerStore { let mut tokens = self.tokens.write(); tokens.push_back(StoredToken { token, - node_id, addr, + node_id, }); - if tokens.len() > self.max_remembered_tokens { + if tokens.len() > self.max_remembered_tokens as usize { tokens.pop_front(); } token @@ -75,36 +146,54 @@ impl PeerStore { return false; } }; - if self.peers_len.load(std::sync::atomic::Ordering::SeqCst) - >= self.max_remembered_peers as u64 - { - trace!("peer store: out of capacity"); - return false; - } if announce.info_hash.distance(&self.self_id) > self.max_distance { trace!("peer store: info_hash too far to store"); return false; } - if !self - .tokens - .read() - .iter() - .any(|t| t.token[..] == announce.token[..] && t.addr == std::net::SocketAddr::V4(addr)) - { + if !self.tokens.read().iter().any(|t| { + t.token[..] == announce.token[..] + && t.addr == std::net::SocketAddr::V4(addr) + && t.node_id == announce.id + }) { trace!("peer store: can't find this token / addr combination"); return false; } + if announce.implied_port == 0 { addr.set_port(announce.port); } - self.peers - .entry(announce.info_hash) - .or_default() - .push(StoredPeer { - addr, - time: Instant::now(), - }); + + use dashmap::mapref::entry::Entry; + let peers_entry = self.peers.entry(announce.info_hash); + let peers_len = self.peers_len.load(std::sync::atomic::Ordering::SeqCst); + match peers_entry { + Entry::Occupied(mut occ) => { + if let Some(s) = occ.get_mut().iter_mut().find(|s| s.addr == addr) { + s.time = Utc::now(); + return true; + } + if peers_len >= self.max_remembered_peers { + trace!("peer store: out of capacity"); + return false; + } + occ.get_mut().push(StoredPeer { + addr, + time: Utc::now(), + }); + } + Entry::Vacant(vac) => { + if peers_len >= self.max_remembered_peers { + trace!("peer store: out of capacity"); + return false; + } + vac.insert(vec![StoredPeer { + addr, + time: Utc::now(), + }]); + } + } + self.peers_len .fetch_add(1, std::sync::atomic::Ordering::SeqCst); true diff --git a/crates/dht/src/persistence.rs b/crates/dht/src/persistence.rs index f74b89f..08bcb8a 100644 --- a/crates/dht/src/persistence.rs +++ b/crates/dht/src/persistence.rs @@ -11,6 +11,7 @@ use std::time::Duration; use anyhow::Context; use tracing::{debug, error, error_span, info, trace, warn}; +use crate::peer_store::PeerStore; use crate::routing_table::RoutingTable; use crate::{Dht, DhtConfig, DhtState}; @@ -21,9 +22,10 @@ pub struct PersistentDhtConfig { } #[derive(Serialize, Deserialize)] -struct DhtSerialize { +struct DhtSerialize { addr: SocketAddr, table: Table, + peer_store: Option, } pub struct PersistentDht { @@ -40,9 +42,16 @@ fn dump_dht(dht: &Dht, filename: &Path, tempfile_name: &Path) -> anyhow::Result< let mut file = BufWriter::new(file); let addr = dht.listen_addr(); - match dht - .with_routing_table(|r| serde_json::to_writer(&mut file, &DhtSerialize { addr, table: r })) - { + match dht.with_routing_table(|r| { + serde_json::to_writer( + &mut file, + &DhtSerialize { + addr, + table: r, + peer_store: Some(&dht.peer_store), + }, + ) + }) { Ok(_) => { trace!("dumped DHT to {:?}", &tempfile_name); } @@ -79,7 +88,7 @@ impl PersistentDht { let de = match OpenOptions::new().read(true).open(&config_filename) { Ok(dht_json) => { let reader = BufReader::new(dht_json); - match serde_json::from_reader::<_, DhtSerialize>(reader) { + match serde_json::from_reader::<_, DhtSerialize>(reader) { Ok(r) => { info!("loaded DHT routing table from {:?}", &config_filename); Some(r) @@ -98,14 +107,15 @@ impl PersistentDht { _ => return Err(e).with_context(|| format!("error reading {config_filename:?}")), }, }; - let (listen_addr, routing_table) = de - .map(|de| (Some(de.addr), Some(de.table))) - .unwrap_or((None, None)); + let (listen_addr, routing_table, peer_store) = de + .map(|de| (Some(de.addr), Some(de.table), de.peer_store)) + .unwrap_or((None, None, None)); let peer_id = routing_table.as_ref().map(|r| r.id()); let dht_config = DhtConfig { peer_id, routing_table, listen_addr, + peer_store, ..Default::default() }; let dht = DhtState::with_config(dht_config).await?; From 21b1bd9e7d278770fd0314381267217d151a1805 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Fri, 1 Dec 2023 08:53:05 +0000 Subject: [PATCH 46/51] A bit finer grained timeout interval in get_peers loop --- crates/dht/src/dht.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/dht/src/dht.rs b/crates/dht/src/dht.rs index a9d0594..c780f1c 100644 --- a/crates/dht/src/dht.rs +++ b/crates/dht/src/dht.rs @@ -287,7 +287,7 @@ impl RecursiveRequest { trace!("iteration {}", iteration); let sleep = match this.get_peers_root() { Ok(0) => Duration::from_secs(1), - Ok(n) if n < 8 => REQUERY_INTERVAL / 2, + Ok(n) if n < 8 => REQUERY_INTERVAL / 8 * (n as u32), Ok(_) => REQUERY_INTERVAL, Err(e) => { error!("error in get_peers_root(): {e:?}"); @@ -313,7 +313,9 @@ impl RecursiveRequest { ); } Some(_) = futs.next(), if !futs.is_empty() => {} - _ = &mut looper => {} + r = &mut looper => { + return r + } } } }, From f337ab1837b22e1f1fd71acf9dbfc6bd584da8e3 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Fri, 1 Dec 2023 09:30:23 +0000 Subject: [PATCH 47/51] Pass back peers from Web UI when adding a magnet in attempt to speed it up --- crates/librqbit/src/http_api.rs | 37 ++++++++++++++++++ crates/librqbit/src/session.rs | 4 +- crates/librqbit/src/torrent_state/mod.rs | 2 + crates/librqbit/webui/dist/assets/index.js | 2 +- crates/librqbit/webui/dist/manifest.json | 2 +- crates/librqbit/webui/src/api.ts | 9 ++++- crates/librqbit/webui/src/index.tsx | 45 +++++++++++----------- crates/rqbit/src/main.rs | 3 +- 8 files changed, 77 insertions(+), 27 deletions(-) diff --git a/crates/librqbit/src/http_api.rs b/crates/librqbit/src/http_api.rs index be3bee4..1608c40 100644 --- a/crates/librqbit/src/http_api.rs +++ b/crates/librqbit/src/http_api.rs @@ -11,6 +11,7 @@ use librqbit_core::id20::Id20; use librqbit_core::torrent_metainfo::TorrentMetaV1Info; use serde::{Deserialize, Serialize}; use std::net::SocketAddr; +use std::str::FromStr; use std::sync::Arc; use std::time::Duration; use tokio::sync::mpsc::UnboundedSender; @@ -281,6 +282,7 @@ pub struct TorrentDetailsResponse { pub struct ApiAddTorrentResponse { pub id: Option, pub details: TorrentDetailsResponse, + pub seen_peers: Option>, } pub struct OnlyFiles(Vec); @@ -324,6 +326,36 @@ impl<'de> Deserialize<'de> for OnlyFiles { } } +pub struct InitialPeers(pub Vec); + +impl<'de> Deserialize<'de> for InitialPeers { + fn deserialize(deserializer: D) -> std::prelude::v1::Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error; + let string = String::deserialize(deserializer)?; + let mut addrs = Vec::new(); + for addr_str in string.split(',') { + addrs.push(SocketAddr::from_str(addr_str).map_err(D::Error::custom)?); + } + Ok(InitialPeers(addrs)) + } +} + +impl Serialize for InitialPeers { + fn serialize(&self, serializer: S) -> std::prelude::v1::Result + where + S: serde::Serializer, + { + self.0 + .iter() + .map(|s| s.to_string()) + .join(",") + .serialize(serializer) + } +} + #[derive(Serialize, Deserialize, Default)] pub struct TorrentAddQueryParams { pub overwrite: Option, @@ -333,6 +365,7 @@ pub struct TorrentAddQueryParams { pub only_files: Option, pub peer_connect_timeout: Option, pub peer_read_write_timeout: Option, + pub initial_peers: Option, pub list_only: Option, } @@ -345,6 +378,7 @@ impl TorrentAddQueryParams { output_folder: self.output_folder, sub_folder: self.sub_folder, list_only: self.list_only.unwrap_or(false), + initial_peers: self.initial_peers.map(|i| i.0), peer_opts: Some(PeerConnectionOptions { connect_timeout: self.peer_connect_timeout.map(Duration::from_secs), read_write_timeout: self.peer_read_write_timeout.map(Duration::from_secs), @@ -471,8 +505,10 @@ impl ApiInternal { info_hash, info, only_files, + seen_peers, }) => ApiAddTorrentResponse { id: None, + seen_peers: Some(seen_peers), details: make_torrent_details(&info_hash, &info, only_files.as_deref()) .context("error making torrent details")?, }, @@ -486,6 +522,7 @@ impl ApiInternal { ApiAddTorrentResponse { id: Some(id), details, + seen_peers: None, } } }; diff --git a/crates/librqbit/src/session.rs b/crates/librqbit/src/session.rs index 91fe07e..2f29302 100644 --- a/crates/librqbit/src/session.rs +++ b/crates/librqbit/src/session.rs @@ -192,7 +192,7 @@ pub struct AddTorrentOptions { pub sub_folder: Option, pub peer_opts: Option, pub force_tracker_interval: Option, - + pub initial_peers: Option>, // This is used to restore the session. pub preferred_id: Option, } @@ -201,6 +201,7 @@ pub struct ListOnlyResponse { pub info_hash: Id20, pub info: TorrentMetaV1Info, pub only_files: Option>, + pub seen_peers: Vec, } pub enum AddTorrentResponse { @@ -642,6 +643,7 @@ impl Session { info_hash, info, only_files, + seen_peers: initial_peers, })); } diff --git a/crates/librqbit/src/torrent_state/mod.rs b/crates/librqbit/src/torrent_state/mod.rs index 28035c4..554514c 100644 --- a/crates/librqbit/src/torrent_state/mod.rs +++ b/crates/librqbit/src/torrent_state/mod.rs @@ -29,6 +29,7 @@ use tracing::debug; use tracing::error; use tracing::error_span; use tracing::warn; +use tracing::trace; use url::Url; use crate::chunk_tracker::ChunkTracker; @@ -207,6 +208,7 @@ impl ManagedTorrent { { let live: Arc = live.upgrade().context("no longer live")?; + trace!("adding {} initial peers", initial_peers.len()); for peer in initial_peers { live.add_peer_if_not_seen(peer).context("torrent closed")?; } diff --git a/crates/librqbit/webui/dist/assets/index.js b/crates/librqbit/webui/dist/assets/index.js index ec39cab..afc5913 100644 --- a/crates/librqbit/webui/dist/assets/index.js +++ b/crates/librqbit/webui/dist/assets/index.js @@ -41,4 +41,4 @@ Error generating stack: `+o.message+` Copyright (c) 2018 Jed Watson. Licensed under the MIT License (MIT), see http://jedwatson.github.io/classnames -*/(function(e){(function(){var t={}.hasOwnProperty;function n(){for(var r=[],l=0;l=0)&&(n[l]=e[l]);return n}function ia(e){return"default"+e.charAt(0).toUpperCase()+e.substr(1)}function mh(e){var t=hh(e,"string");return typeof t=="symbol"?t:String(t)}function hh(e,t){if(typeof e!="object"||e===null)return e;var n=e[Symbol.toPrimitive];if(n!==void 0){var r=n.call(e,t||"default");if(typeof r!="object")return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return(t==="string"?String:Number)(e)}function vh(e,t,n){var r=y.useRef(e!==void 0),l=y.useState(t),o=l[0],i=l[1],u=e!==void 0,s=r.current;return r.current=u,!u&&s&&o!==t&&i(t),[u?e:o,y.useCallback(function(a){for(var f=arguments.length,h=new Array(f>1?f-1:0),d=1;d{o.target===e&&(l(),t(o))},n+r)}function Uh(e){e.offsetHeight}const aa=e=>!e||typeof e=="function"?e:t=>{e.current=t};function Bh(e,t){const n=aa(e),r=aa(t);return l=>{n&&n(l),r&&r(l)}}function mo(e,t){return y.useMemo(()=>Bh(e,t),[e,t])}function Hh(e){return e&&"setState"in e?Nn.findDOMNode(e):e??null}const Wh=Vt.forwardRef(({onEnter:e,onEntering:t,onEntered:n,onExit:r,onExiting:l,onExited:o,addEndListener:i,children:u,childRef:s,...a},f)=>{const h=y.useRef(null),d=mo(h,s),g=N=>{d(Hh(N))},S=N=>T=>{N&&h.current&&N(h.current,T)},k=y.useCallback(S(e),[e]),R=y.useCallback(S(t),[t]),p=y.useCallback(S(n),[n]),c=y.useCallback(S(r),[r]),m=y.useCallback(S(l),[l]),w=y.useCallback(S(o),[o]),C=y.useCallback(S(i),[i]);return v.jsx(zh,{ref:f,...a,onEnter:k,onEntered:p,onEntering:R,onExit:c,onExited:w,onExiting:m,addEndListener:C,nodeRef:h,children:typeof u=="function"?(N,T)=>u(N,{...T,ref:g}):Vt.cloneElement(u,{ref:g})})}),Vh=Wh;function Qh(e){const t=y.useRef(e);return y.useEffect(()=>{t.current=e},[e]),t}function Pe(e){const t=Qh(e);return y.useCallback(function(...n){return t.current&&t.current(...n)},[t])}const Qf=e=>y.forwardRef((t,n)=>v.jsx("div",{...t,ref:n,className:M(t.className,e)})),Kf=Qf("h4");Kf.displayName="DivStyledAsH4";const Gf=y.forwardRef(({className:e,bsPrefix:t,as:n=Kf,...r},l)=>(t=H(t,"alert-heading"),v.jsx(n,{ref:l,className:M(e,t),...r})));Gf.displayName="AlertHeading";const Kh=Gf;function Gh(){return y.useState(null)}function Yh(){const e=y.useRef(!0),t=y.useRef(()=>e.current);return y.useEffect(()=>(e.current=!0,()=>{e.current=!1}),[]),t.current}function Xh(e){const t=y.useRef(null);return y.useEffect(()=>{t.current=e}),t.current}const Zh=typeof global<"u"&&global.navigator&&global.navigator.product==="ReactNative",Jh=typeof document<"u",ca=Jh||Zh?y.useLayoutEffect:y.useEffect,qh=["as","disabled"];function bh(e,t){if(e==null)return{};var n={},r=Object.keys(e),l,o;for(o=0;o=0)&&(n[l]=e[l]);return n}function ev(e){return!e||e.trim()==="#"}function Hu({tagName:e,disabled:t,href:n,target:r,rel:l,role:o,onClick:i,tabIndex:u=0,type:s}){e||(n!=null||r!=null||l!=null?e="a":e="button");const a={tagName:e};if(e==="button")return[{type:s||"button",disabled:t},a];const f=d=>{if((t||e==="a"&&ev(n))&&d.preventDefault(),t){d.stopPropagation();return}i==null||i(d)},h=d=>{d.key===" "&&(d.preventDefault(),f(d))};return e==="a"&&(n||(n="#"),t&&(n=void 0)),[{role:o??"button",disabled:void 0,tabIndex:t?void 0:u,href:n,target:e==="a"?r:void 0,"aria-disabled":t||void 0,rel:e==="a"?l:void 0,onClick:f,onKeyDown:h},a]}const tv=y.forwardRef((e,t)=>{let{as:n,disabled:r}=e,l=bh(e,qh);const[o,{tagName:i}]=Hu(Object.assign({tagName:n,disabled:r},l));return v.jsx(i,Object.assign({},l,o,{ref:t}))});tv.displayName="Button";const nv=["onKeyDown"];function rv(e,t){if(e==null)return{};var n={},r=Object.keys(e),l,o;for(o=0;o=0)&&(n[l]=e[l]);return n}function lv(e){return!e||e.trim()==="#"}const Yf=y.forwardRef((e,t)=>{let{onKeyDown:n}=e,r=rv(e,nv);const[l]=Hu(Object.assign({tagName:"a"},r)),o=Pe(i=>{l.onKeyDown(i),n==null||n(i)});return lv(r.href)||r.role==="button"?v.jsx("a",Object.assign({ref:t},r,l,{onKeyDown:o})):v.jsx("a",Object.assign({ref:t},r,{onKeyDown:n}))});Yf.displayName="Anchor";const ov=Yf,Xf=y.forwardRef(({className:e,bsPrefix:t,as:n=ov,...r},l)=>(t=H(t,"alert-link"),v.jsx(n,{ref:l,className:M(e,t),...r})));Xf.displayName="AlertLink";const iv=Xf,uv={[St]:"show",[Wt]:"show"},Zf=y.forwardRef(({className:e,children:t,transitionClasses:n={},onEnter:r,...l},o)=>{const i={in:!1,timeout:300,mountOnEnter:!1,unmountOnExit:!1,appear:!1,...l},u=y.useCallback((s,a)=>{Uh(s),r==null||r(s,a)},[r]);return v.jsx(Vh,{ref:o,addEndListener:Ah,...i,onEnter:u,childRef:t.ref,children:(s,a)=>y.cloneElement(t,{...a,className:M("fade",e,t.props.className,uv[s],n[s])})})});Zf.displayName="Fade";const Kl=Zf,sv={"aria-label":ot.string,onClick:ot.func,variant:ot.oneOf(["white"])},Wu=y.forwardRef(({className:e,variant:t,"aria-label":n="Close",...r},l)=>v.jsx("button",{ref:l,type:"button",className:M("btn-close",t&&`btn-close-${t}`,e),"aria-label":n,...r}));Wu.displayName="CloseButton";Wu.propTypes=sv;const Jf=Wu,qf=y.forwardRef((e,t)=>{const{bsPrefix:n,show:r=!0,closeLabel:l="Close alert",closeVariant:o,className:i,children:u,variant:s="primary",onClose:a,dismissible:f,transition:h=Kl,...d}=yh(e,{show:"onClose"}),g=H(n,"alert"),S=Pe(p=>{a&&a(!1,p)}),k=h===!0?Kl:h,R=v.jsxs("div",{role:"alert",...k?void 0:d,ref:t,className:M(i,g,s&&`${g}-${s}`,f&&`${g}-dismissible`),children:[f&&v.jsx(Jf,{onClick:S,"aria-label":l,variant:o}),u]});return k?v.jsx(k,{unmountOnExit:!0,...d,ref:void 0,in:r,children:R}):r?R:null});qf.displayName="Alert";const fa=Object.assign(qf,{Link:iv,Heading:Kh}),bf=y.forwardRef(({as:e,bsPrefix:t,variant:n="primary",size:r,active:l=!1,disabled:o=!1,className:i,...u},s)=>{const a=H(t,"btn"),[f,{tagName:h}]=Hu({tagName:e,disabled:o,...u}),d=h;return v.jsx(d,{...f,...u,ref:s,disabled:o,className:M(i,a,l&&"active",n&&`${a}-${n}`,r&&`${a}-${r}`,u.href&&o&&"disabled")})});bf.displayName="Button";const Mr=bf;function av(e){const t=y.useRef(e);return t.current=e,t}function ed(e){const t=av(e);y.useEffect(()=>()=>t.current(),[])}function cv(e,t){let n=0;return y.Children.map(e,r=>y.isValidElement(r)?t(r,n++):r)}function fv(e,t){return y.Children.toArray(e).some(n=>y.isValidElement(n)&&n.type===t)}function dv({as:e,bsPrefix:t,className:n,...r}){t=H(t,"col");const l=Df(),o=If(),i=[],u=[];return l.forEach(s=>{const a=r[s];delete r[s];let f,h,d;typeof a=="object"&&a!=null?{span:f,offset:h,order:d}=a:f=a;const g=s!==o?`-${s}`:"";f&&i.push(f===!0?`${t}${g}`:`${t}${g}-${f}`),d!=null&&u.push(`order${g}-${d}`),h!=null&&u.push(`offset${g}-${h}`)}),[{...r,className:M(n,...i,...u)},{as:e,bsPrefix:t,spans:i}]}const td=y.forwardRef((e,t)=>{const[{className:n,...r},{as:l="div",bsPrefix:o,spans:i}]=dv(e);return v.jsx(l,{...r,ref:t,className:M(n,!i.length&&o)})});td.displayName="Col";const Vu=td,nd=y.forwardRef(({bsPrefix:e,fluid:t=!1,as:n="div",className:r,...l},o)=>{const i=H(e,"container"),u=typeof t=="string"?`-${t}`:"-fluid";return v.jsx(n,{ref:o,...l,className:M(r,t?`${i}${u}`:i)})});nd.displayName="Container";const pv=nd;var mv=Function.prototype.bind.call(Function.prototype.call,[].slice);function fn(e,t){return mv(e.querySelectorAll(t))}function da(e,t){if(e.contains)return e.contains(t);if(e.compareDocumentPosition)return e===t||!!(e.compareDocumentPosition(t)&16)}const hv="data-rr-ui-";function vv(e){return`${hv}${e}`}const rd=y.createContext(Wn?window:void 0);rd.Provider;function Qu(){return y.useContext(rd)}const yv={type:ot.string,tooltip:ot.bool,as:ot.elementType},Ku=y.forwardRef(({as:e="div",className:t,type:n="valid",tooltip:r=!1,...l},o)=>v.jsx(e,{...l,ref:o,className:M(t,`${n}-${r?"tooltip":"feedback"}`)}));Ku.displayName="Feedback";Ku.propTypes=yv;const ld=Ku,gv=y.createContext({}),ct=gv,od=y.forwardRef(({id:e,bsPrefix:t,className:n,type:r="checkbox",isValid:l=!1,isInvalid:o=!1,as:i="input",...u},s)=>{const{controlId:a}=y.useContext(ct);return t=H(t,"form-check-input"),v.jsx(i,{...u,ref:s,type:r,id:e||a,className:M(n,t,l&&"is-valid",o&&"is-invalid")})});od.displayName="FormCheckInput";const id=od,ud=y.forwardRef(({bsPrefix:e,className:t,htmlFor:n,...r},l)=>{const{controlId:o}=y.useContext(ct);return e=H(e,"form-check-label"),v.jsx("label",{...r,ref:l,htmlFor:n||o,className:M(t,e)})});ud.displayName="FormCheckLabel";const Gi=ud,sd=y.forwardRef(({id:e,bsPrefix:t,bsSwitchPrefix:n,inline:r=!1,reverse:l=!1,disabled:o=!1,isValid:i=!1,isInvalid:u=!1,feedbackTooltip:s=!1,feedback:a,feedbackType:f,className:h,style:d,title:g="",type:S="checkbox",label:k,children:R,as:p="input",...c},m)=>{t=H(t,"form-check"),n=H(n,"form-switch");const{controlId:w}=y.useContext(ct),C=y.useMemo(()=>({controlId:e||w}),[w,e]),N=!R&&k!=null&&k!==!1||fv(R,Gi),T=v.jsx(id,{...c,type:S==="switch"?"checkbox":S,ref:m,isValid:i,isInvalid:u,disabled:o,as:p});return v.jsx(ct.Provider,{value:C,children:v.jsx("div",{style:d,className:M(h,N&&t,r&&`${t}-inline`,l&&`${t}-reverse`,S==="switch"&&n),children:R||v.jsxs(v.Fragment,{children:[T,N&&v.jsx(Gi,{title:g,children:k}),a&&v.jsx(ld,{type:f,tooltip:s,children:a})]})})})});sd.displayName="FormCheck";const Gl=Object.assign(sd,{Input:id,Label:Gi}),ad=y.forwardRef(({bsPrefix:e,type:t,size:n,htmlSize:r,id:l,className:o,isValid:i=!1,isInvalid:u=!1,plaintext:s,readOnly:a,as:f="input",...h},d)=>{const{controlId:g}=y.useContext(ct);return e=H(e,"form-control"),v.jsx(f,{...h,type:t,size:r,ref:d,readOnly:a,id:l||g,className:M(o,s?`${e}-plaintext`:e,n&&`${e}-${n}`,t==="color"&&`${e}-color`,i&&"is-valid",u&&"is-invalid")})});ad.displayName="FormControl";const wv=Object.assign(ad,{Feedback:ld}),cd=y.forwardRef(({className:e,bsPrefix:t,as:n="div",...r},l)=>(t=H(t,"form-floating"),v.jsx(n,{ref:l,className:M(e,t),...r})));cd.displayName="FormFloating";const Sv=cd,fd=y.forwardRef(({controlId:e,as:t="div",...n},r)=>{const l=y.useMemo(()=>({controlId:e}),[e]);return v.jsx(ct.Provider,{value:l,children:v.jsx(t,{...n,ref:r})})});fd.displayName="FormGroup";const dd=fd,pd=y.forwardRef(({as:e="label",bsPrefix:t,column:n=!1,visuallyHidden:r=!1,className:l,htmlFor:o,...i},u)=>{const{controlId:s}=y.useContext(ct);t=H(t,"form-label");let a="col-form-label";typeof n=="string"&&(a=`${a} ${a}-${n}`);const f=M(l,t,r&&"visually-hidden",n&&a);return o=o||s,n?v.jsx(Vu,{ref:u,as:"label",className:f,htmlFor:o,...i}):v.jsx(e,{ref:u,className:f,htmlFor:o,...i})});pd.displayName="FormLabel";const kv=pd,md=y.forwardRef(({bsPrefix:e,className:t,id:n,...r},l)=>{const{controlId:o}=y.useContext(ct);return e=H(e,"form-range"),v.jsx("input",{...r,type:"range",ref:l,className:M(t,e),id:n||o})});md.displayName="FormRange";const xv=md,hd=y.forwardRef(({bsPrefix:e,size:t,htmlSize:n,className:r,isValid:l=!1,isInvalid:o=!1,id:i,...u},s)=>{const{controlId:a}=y.useContext(ct);return e=H(e,"form-select"),v.jsx("select",{...u,size:n,ref:s,className:M(r,e,t&&`${e}-${t}`,l&&"is-valid",o&&"is-invalid"),id:i||a})});hd.displayName="FormSelect";const Ev=hd,vd=y.forwardRef(({bsPrefix:e,className:t,as:n="small",muted:r,...l},o)=>(e=H(e,"form-text"),v.jsx(n,{...l,ref:o,className:M(t,e,r&&"text-muted")})));vd.displayName="FormText";const Cv=vd,yd=y.forwardRef((e,t)=>v.jsx(Gl,{...e,ref:t,type:"switch"}));yd.displayName="Switch";const Nv=Object.assign(yd,{Input:Gl.Input,Label:Gl.Label}),gd=y.forwardRef(({bsPrefix:e,className:t,children:n,controlId:r,label:l,...o},i)=>(e=H(e,"form-floating"),v.jsxs(dd,{ref:i,className:M(t,e),controlId:r,...o,children:[n,v.jsx("label",{htmlFor:r,children:l})]})));gd.displayName="FloatingLabel";const Tv=gd,_v={_ref:ot.any,validated:ot.bool,as:ot.elementType},Gu=y.forwardRef(({className:e,validated:t,as:n="form",...r},l)=>v.jsx(n,{...r,ref:l,className:M(e,t&&"was-validated")}));Gu.displayName="Form";Gu.propTypes=_v;const Et=Object.assign(Gu,{Group:dd,Control:wv,Floating:Sv,Check:Gl,Switch:Nv,Label:kv,Text:Cv,Range:xv,Select:Ev,FloatingLabel:Tv});var ul;function pa(e){if((!ul&&ul!==0||e)&&Wn){var t=document.createElement("div");t.style.position="absolute",t.style.top="-9999px",t.style.width="50px",t.style.height="50px",t.style.overflow="scroll",document.body.appendChild(t),ul=t.offsetWidth-t.clientWidth,document.body.removeChild(t)}return ul}function Wo(e){e===void 0&&(e=po());try{var t=e.activeElement;return!t||!t.nodeName?null:t}catch{return e.body}}function jv(e=document){const t=e.defaultView;return Math.abs(t.innerWidth-e.documentElement.clientWidth)}const ma=vv("modal-open");class Rv{constructor({ownerDocument:t,handleContainerOverflow:n=!0,isRTL:r=!1}={}){this.handleContainerOverflow=n,this.isRTL=r,this.modals=[],this.ownerDocument=t}getScrollbarWidth(){return jv(this.ownerDocument)}getElement(){return(this.ownerDocument||document).body}setModalAttributes(t){}removeModalAttributes(t){}setContainerStyle(t){const n={overflow:"hidden"},r=this.isRTL?"paddingLeft":"paddingRight",l=this.getElement();t.style={overflow:l.style.overflow,[r]:l.style[r]},t.scrollBarWidth&&(n[r]=`${parseInt(Zt(l,r)||"0",10)+t.scrollBarWidth}px`),l.setAttribute(ma,""),Zt(l,n)}reset(){[...this.modals].forEach(t=>this.remove(t))}removeContainerStyle(t){const n=this.getElement();n.removeAttribute(ma),Object.assign(n.style,t.style)}add(t){let n=this.modals.indexOf(t);return n!==-1||(n=this.modals.length,this.modals.push(t),this.setModalAttributes(t),n!==0)||(this.state={scrollBarWidth:this.getScrollbarWidth(),style:{}},this.handleContainerOverflow&&this.setContainerStyle(this.state)),n}remove(t){const n=this.modals.indexOf(t);n!==-1&&(this.modals.splice(n,1),!this.modals.length&&this.handleContainerOverflow&&this.removeContainerStyle(this.state),this.removeModalAttributes(t))}isTopModal(t){return!!this.modals.length&&this.modals[this.modals.length-1]===t}}const Yu=Rv,Vo=(e,t)=>Wn?e==null?(t||po()).body:(typeof e=="function"&&(e=e()),e&&"current"in e&&(e=e.current),e&&("nodeType"in e||e.getBoundingClientRect)?e:null):null;function Lv(e,t){const n=Qu(),[r,l]=y.useState(()=>Vo(e,n==null?void 0:n.document));if(!r){const o=Vo(e);o&&l(o)}return y.useEffect(()=>{t&&r&&t(r)},[t,r]),y.useEffect(()=>{const o=Vo(e);o!==r&&l(o)},[e,r]),r}function Ov({children:e,in:t,onExited:n,mountOnEnter:r,unmountOnExit:l}){const o=y.useRef(null),i=y.useRef(t),u=Pe(n);y.useEffect(()=>{t?i.current=!0:u(o.current)},[t,u]);const s=mo(o,e.ref),a=y.cloneElement(e,{ref:s});return t?a:l||!i.current&&r?null:a}function Pv({in:e,onTransition:t}){const n=y.useRef(null),r=y.useRef(!0),l=Pe(t);return ca(()=>{if(!n.current)return;let o=!1;return l({in:e,element:n.current,initial:r.current,isStale:()=>o}),()=>{o=!0}},[e,l]),ca(()=>(r.current=!1,()=>{r.current=!0}),[]),n}function Fv({children:e,in:t,onExited:n,onEntered:r,transition:l}){const[o,i]=y.useState(!t);t&&o&&i(!1);const u=Pv({in:!!t,onTransition:a=>{const f=()=>{a.isStale()||(a.in?r==null||r(a.element,a.initial):(i(!0),n==null||n(a.element)))};Promise.resolve(l(a)).then(f,h=>{throw a.in||i(!0),h})}}),s=mo(u,e.ref);return o&&!t?null:y.cloneElement(e,{ref:s})}function ha(e,t,n){return e?v.jsx(e,Object.assign({},n)):t?v.jsx(Fv,Object.assign({},n,{transition:t})):v.jsx(Ov,Object.assign({},n))}function Mv(e){return e.code==="Escape"||e.keyCode===27}const zv=["show","role","className","style","children","backdrop","keyboard","onBackdropClick","onEscapeKeyDown","transition","runTransition","backdropTransition","runBackdropTransition","autoFocus","enforceFocus","restoreFocus","restoreFocusOptions","renderDialog","renderBackdrop","manager","container","onShow","onHide","onExit","onExited","onExiting","onEnter","onEntering","onEntered"];function $v(e,t){if(e==null)return{};var n={},r=Object.keys(e),l,o;for(o=0;o=0)&&(n[l]=e[l]);return n}let Qo;function Dv(e){return Qo||(Qo=new Yu({ownerDocument:e==null?void 0:e.document})),Qo}function Iv(e){const t=Qu(),n=e||Dv(t),r=y.useRef({dialog:null,backdrop:null});return Object.assign(r.current,{add:()=>n.add(r.current),remove:()=>n.remove(r.current),isTopModal:()=>n.isTopModal(r.current),setDialogRef:y.useCallback(l=>{r.current.dialog=l},[]),setBackdropRef:y.useCallback(l=>{r.current.backdrop=l},[])})}const wd=y.forwardRef((e,t)=>{let{show:n=!1,role:r="dialog",className:l,style:o,children:i,backdrop:u=!0,keyboard:s=!0,onBackdropClick:a,onEscapeKeyDown:f,transition:h,runTransition:d,backdropTransition:g,runBackdropTransition:S,autoFocus:k=!0,enforceFocus:R=!0,restoreFocus:p=!0,restoreFocusOptions:c,renderDialog:m,renderBackdrop:w=K=>v.jsx("div",Object.assign({},K)),manager:C,container:N,onShow:T,onHide:j=()=>{},onExit:U,onExited:P,onExiting:ie,onEnter:Ve,onEntering:Qe,onEntered:ln}=e,Qn=$v(e,zv);const _e=Qu(),Ke=Lv(N),E=Iv(C),L=Yh(),O=Xh(n),[D,A]=y.useState(!n),fe=y.useRef(null);y.useImperativeHandle(t,()=>E,[E]),Wn&&!O&&n&&(fe.current=Wo(_e==null?void 0:_e.document)),n&&D&&A(!1);const je=Pe(()=>{if(E.add(),un.current=Ql(document,"keydown",ho),on.current=Ql(document,"focus",()=>setTimeout(Re),!0),T&&T(),k){var K,Hr;const Yn=Wo((K=(Hr=E.dialog)==null?void 0:Hr.ownerDocument)!=null?K:_e==null?void 0:_e.document);E.dialog&&Yn&&!da(E.dialog,Yn)&&(fe.current=Yn,E.dialog.focus())}}),qe=Pe(()=>{if(E.remove(),un.current==null||un.current(),on.current==null||on.current(),p){var K;(K=fe.current)==null||K.focus==null||K.focus(c),fe.current=null}});y.useEffect(()=>{!n||!Ke||je()},[n,Ke,je]),y.useEffect(()=>{D&&qe()},[D,qe]),ed(()=>{qe()});const Re=Pe(()=>{if(!R||!L()||!E.isTopModal())return;const K=Wo(_e==null?void 0:_e.document);E.dialog&&K&&!da(E.dialog,K)&&E.dialog.focus()}),mt=Pe(K=>{K.target===K.currentTarget&&(a==null||a(K),u===!0&&j())}),ho=Pe(K=>{s&&Mv(K)&&E.isTopModal()&&(f==null||f(K),K.defaultPrevented||j())}),on=y.useRef(),un=y.useRef(),Kn=(...K)=>{A(!0),P==null||P(...K)};if(!Ke)return null;const Br=Object.assign({role:r,ref:E.setDialogRef,"aria-modal":r==="dialog"?!0:void 0},Qn,{style:o,className:l,tabIndex:-1});let Gn=m?m(Br):v.jsx("div",Object.assign({},Br,{children:y.cloneElement(i,{role:"document"})}));Gn=ha(h,d,{unmountOnExit:!0,mountOnEnter:!0,appear:!0,in:!!n,onExit:U,onExiting:ie,onExited:Kn,onEnter:Ve,onEntering:Qe,onEntered:ln,children:Gn});let At=null;return u&&(At=w({ref:E.setBackdropRef,onClick:mt}),At=ha(g,S,{in:!!n,appear:!0,mountOnEnter:!0,unmountOnExit:!0,children:At})),v.jsx(v.Fragment,{children:Nn.createPortal(v.jsxs(v.Fragment,{children:[At,Gn]}),Ke)})});wd.displayName="Modal";const Av=Object.assign(wd,{Manager:Yu});function Uv(e,t){return e.classList?!!t&&e.classList.contains(t):(" "+(e.className.baseVal||e.className)+" ").indexOf(" "+t+" ")!==-1}function Bv(e,t){e.classList?e.classList.add(t):Uv(e,t)||(typeof e.className=="string"?e.className=e.className+" "+t:e.setAttribute("class",(e.className&&e.className.baseVal||"")+" "+t))}function va(e,t){return e.replace(new RegExp("(^|\\s)"+t+"(?:\\s|$)","g"),"$1").replace(/\s+/g," ").replace(/^\s*|\s*$/g,"")}function Hv(e,t){e.classList?e.classList.remove(t):typeof e.className=="string"?e.className=va(e.className,t):e.setAttribute("class",va(e.className&&e.className.baseVal||"",t))}const dn={FIXED_CONTENT:".fixed-top, .fixed-bottom, .is-fixed, .sticky-top",STICKY_CONTENT:".sticky-top",NAVBAR_TOGGLER:".navbar-toggler"};class Wv extends Yu{adjustAndStore(t,n,r){const l=n.style[t];n.dataset[t]=l,Zt(n,{[t]:`${parseFloat(Zt(n,t))+r}px`})}restore(t,n){const r=n.dataset[t];r!==void 0&&(delete n.dataset[t],Zt(n,{[t]:r}))}setContainerStyle(t){super.setContainerStyle(t);const n=this.getElement();if(Bv(n,"modal-open"),!t.scrollBarWidth)return;const r=this.isRTL?"paddingLeft":"paddingRight",l=this.isRTL?"marginLeft":"marginRight";fn(n,dn.FIXED_CONTENT).forEach(o=>this.adjustAndStore(r,o,t.scrollBarWidth)),fn(n,dn.STICKY_CONTENT).forEach(o=>this.adjustAndStore(l,o,-t.scrollBarWidth)),fn(n,dn.NAVBAR_TOGGLER).forEach(o=>this.adjustAndStore(l,o,t.scrollBarWidth))}removeContainerStyle(t){super.removeContainerStyle(t);const n=this.getElement();Hv(n,"modal-open");const r=this.isRTL?"paddingLeft":"paddingRight",l=this.isRTL?"marginLeft":"marginRight";fn(n,dn.FIXED_CONTENT).forEach(o=>this.restore(r,o)),fn(n,dn.STICKY_CONTENT).forEach(o=>this.restore(l,o)),fn(n,dn.NAVBAR_TOGGLER).forEach(o=>this.restore(l,o))}}let Ko;function Vv(e){return Ko||(Ko=new Wv(e)),Ko}const Sd=y.forwardRef(({className:e,bsPrefix:t,as:n="div",...r},l)=>(t=H(t,"modal-body"),v.jsx(n,{ref:l,className:M(e,t),...r})));Sd.displayName="ModalBody";const Qv=Sd,Kv=y.createContext({onHide(){}}),kd=Kv,xd=y.forwardRef(({bsPrefix:e,className:t,contentClassName:n,centered:r,size:l,fullscreen:o,children:i,scrollable:u,...s},a)=>{e=H(e,"modal");const f=`${e}-dialog`,h=typeof o=="string"?`${e}-fullscreen-${o}`:`${e}-fullscreen`;return v.jsx("div",{...s,ref:a,className:M(f,t,l&&`${e}-${l}`,r&&`${f}-centered`,u&&`${f}-scrollable`,o&&h),children:v.jsx("div",{className:M(`${e}-content`,n),children:i})})});xd.displayName="ModalDialog";const Ed=xd,Cd=y.forwardRef(({className:e,bsPrefix:t,as:n="div",...r},l)=>(t=H(t,"modal-footer"),v.jsx(n,{ref:l,className:M(e,t),...r})));Cd.displayName="ModalFooter";const Gv=Cd,Yv=y.forwardRef(({closeLabel:e="Close",closeVariant:t,closeButton:n=!1,onHide:r,children:l,...o},i)=>{const u=y.useContext(kd),s=Pe(()=>{u==null||u.onHide(),r==null||r()});return v.jsxs("div",{ref:i,...o,children:[l,n&&v.jsx(Jf,{"aria-label":e,variant:t,onClick:s})]})}),Xv=Yv,Nd=y.forwardRef(({bsPrefix:e,className:t,closeLabel:n="Close",closeButton:r=!1,...l},o)=>(e=H(e,"modal-header"),v.jsx(Xv,{ref:o,...l,className:M(t,e),closeLabel:n,closeButton:r})));Nd.displayName="ModalHeader";const Zv=Nd,Jv=Qf("h4"),Td=y.forwardRef(({className:e,bsPrefix:t,as:n=Jv,...r},l)=>(t=H(t,"modal-title"),v.jsx(n,{ref:l,className:M(e,t),...r})));Td.displayName="ModalTitle";const qv=Td;function bv(e){return v.jsx(Kl,{...e,timeout:null})}function ey(e){return v.jsx(Kl,{...e,timeout:null})}const _d=y.forwardRef(({bsPrefix:e,className:t,style:n,dialogClassName:r,contentClassName:l,children:o,dialogAs:i=Ed,"aria-labelledby":u,"aria-describedby":s,"aria-label":a,show:f=!1,animation:h=!0,backdrop:d=!0,keyboard:g=!0,onEscapeKeyDown:S,onShow:k,onHide:R,container:p,autoFocus:c=!0,enforceFocus:m=!0,restoreFocus:w=!0,restoreFocusOptions:C,onEntered:N,onExit:T,onExiting:j,onEnter:U,onEntering:P,onExited:ie,backdropClassName:Ve,manager:Qe,...ln},Qn)=>{const[_e,Ke]=y.useState({}),[E,L]=y.useState(!1),O=y.useRef(!1),D=y.useRef(!1),A=y.useRef(null),[fe,je]=Gh(),qe=mo(Qn,je),Re=Pe(R),mt=kh();e=H(e,"modal");const ho=y.useMemo(()=>({onHide:Re}),[Re]);function on(){return Qe||Vv({isRTL:mt})}function un($){if(!Wn)return;const sn=on().getScrollbarWidth()>0,Zu=$.scrollHeight>po($).documentElement.clientHeight;Ke({paddingRight:sn&&!Zu?pa():void 0,paddingLeft:!sn&&Zu?pa():void 0})}const Kn=Pe(()=>{fe&&un(fe.dialog)});ed(()=>{Ki(window,"resize",Kn),A.current==null||A.current()});const Br=()=>{O.current=!0},Gn=$=>{O.current&&fe&&$.target===fe.dialog&&(D.current=!0),O.current=!1},At=()=>{L(!0),A.current=Vf(fe.dialog,()=>{L(!1)})},K=$=>{$.target===$.currentTarget&&At()},Hr=$=>{if(d==="static"){K($);return}if(D.current||$.target!==$.currentTarget){D.current=!1;return}R==null||R()},Yn=$=>{g?S==null||S($):($.preventDefault(),d==="static"&&At())},Dd=($,sn)=>{$&&un($),U==null||U($,sn)},Id=$=>{A.current==null||A.current(),T==null||T($)},Ad=($,sn)=>{P==null||P($,sn),Wf(window,"resize",Kn)},Ud=$=>{$&&($.style.display=""),ie==null||ie($),Ki(window,"resize",Kn)},Bd=y.useCallback($=>v.jsx("div",{...$,className:M(`${e}-backdrop`,Ve,!h&&"show")}),[h,Ve,e]),Xu={...n,..._e};Xu.display="block";const Hd=$=>v.jsx("div",{role:"dialog",...$,style:Xu,className:M(t,e,E&&`${e}-static`,!h&&"show"),onClick:d?Hr:void 0,onMouseUp:Gn,"aria-label":a,"aria-labelledby":u,"aria-describedby":s,children:v.jsx(i,{...ln,onMouseDown:Br,className:r,contentClassName:l,children:o})});return v.jsx(kd.Provider,{value:ho,children:v.jsx(Av,{show:f,ref:qe,backdrop:d,container:p,keyboard:!0,autoFocus:c,enforceFocus:m,restoreFocus:w,restoreFocusOptions:C,onEscapeKeyDown:Yn,onShow:k,onHide:R,onEnter:Dd,onEntering:Ad,onEntered:N,onExit:Id,onExiting:j,onExited:Ud,manager:on(),transition:h?bv:void 0,backdropTransition:h?ey:void 0,renderBackdrop:Bd,renderDialog:Hd})})});_d.displayName="Modal";const tt=Object.assign(_d,{Body:Qv,Header:Zv,Title:qv,Footer:Gv,Dialog:Ed,TRANSITION_DURATION:300,BACKDROP_TRANSITION_DURATION:150}),ya=1e3;function ty(e,t,n){const r=(e-t)/(n-t)*100;return Math.round(r*ya)/ya}function ga({min:e,now:t,max:n,label:r,visuallyHidden:l,striped:o,animated:i,className:u,style:s,variant:a,bsPrefix:f,...h},d){return v.jsx("div",{ref:d,...h,role:"progressbar",className:M(u,`${f}-bar`,{[`bg-${a}`]:a,[`${f}-bar-animated`]:i,[`${f}-bar-striped`]:i||o}),style:{width:`${ty(t,e,n)}%`,...s},"aria-valuenow":t,"aria-valuemin":e,"aria-valuemax":n,children:l?v.jsx("span",{className:"visually-hidden",children:r}):r})}const jd=y.forwardRef(({isChild:e=!1,...t},n)=>{const r={min:0,max:100,animated:!1,visuallyHidden:!1,striped:!1,...t};if(r.bsPrefix=H(r.bsPrefix,"progress"),e)return ga(r,n);const{min:l,now:o,max:i,label:u,visuallyHidden:s,striped:a,animated:f,bsPrefix:h,variant:d,className:g,children:S,...k}=r;return v.jsx("div",{ref:n,...k,className:M(g,h),children:S?cv(S,R=>y.cloneElement(R,{isChild:!0})):ga({min:l,now:o,max:i,label:u,visuallyHidden:s,striped:a,animated:f,bsPrefix:h,variant:d},n)})});jd.displayName="ProgressBar";const ny=jd,Rd=y.forwardRef(({bsPrefix:e,className:t,as:n="div",...r},l)=>{const o=H(e,"row"),i=Df(),u=If(),s=`${o}-cols`,a=[];return i.forEach(f=>{const h=r[f];delete r[f];let d;h!=null&&typeof h=="object"?{cols:d}=h:d=h;const g=f!==u?`-${f}`:"";d!=null&&a.push(`${s}${g}-${d}`)}),v.jsx(n,{ref:l,...r,className:M(t,o,...a)})});Rd.displayName="Row";const Ld=Rd,Od=y.forwardRef(({bsPrefix:e,variant:t,animation:n="border",size:r,as:l="div",className:o,...i},u)=>{e=H(e,"spinner");const s=`${e}-${n}`;return v.jsx(l,{ref:u,...i,className:M(o,s,r&&`${s}-${r}`,t&&`text-${t}`)})});Od.displayName="Spinner";const An=Od,ry=window.origin==="null"||window.origin==="http://localhost:3031"?"http://localhost:3030":"",Sl="initializing",wa="paused",Pd="live",ly="error",vt=async(e,t,n)=>{console.log(e,t);const r=ry+t,l={method:e,headers:{Accept:"application/json"},body:n};let o={method:e,path:t,text:""},i;try{i=await fetch(r,l)}catch{return o.text="network error",Promise.reject(o)}if(o.status=i.status,o.statusText=i.statusText,!i.ok){const s=await i.text();try{const a=JSON.parse(s);o.text=a.human_readable!==void 0?a.human_readable:JSON.stringify(a,null,2)}catch{o.text=s}return Promise.reject(o)}return await i.json()},ft={listTorrents:()=>vt("GET","/torrents"),getTorrentDetails:e=>vt("GET",`/torrents/${e}`),getTorrentStats:e=>vt("GET",`/torrents/${e}/stats/v1`),uploadTorrent:(e,t)=>{t=t||{};let n="/torrents?&overwrite=true";return t.listOnly&&(n+="&list_only=true"),t.selectedFiles!=null&&(n+=`&only_files=${t.selectedFiles.join(",")}`),t.unpopularTorrent&&(n+="&peer_connect_timeout=20&peer_read_write_timeout=60"),vt("POST",n,e)},pause:e=>vt("POST",`/torrents/${e}/pause`),start:e=>vt("POST",`/torrents/${e}/start`),forget:e=>vt("POST",`/torrents/${e}/forget`),delete:e=>vt("POST",`/torrents/${e}/delete`)},Vn=y.createContext(null),Fd=y.createContext(null),Go=({className:e,onClick:t,disabled:n,color:r})=>{const l=o=>{o.stopPropagation(),!n&&t()};return v.jsx("a",{className:`bi ${e} p-1`,onClick:l,href:"#"})},oy=({id:e,show:t,onHide:n})=>{if(!t)return null;const[r,l]=y.useState(!1),[o,i]=y.useState(null),[u,s]=y.useState(!1),a=y.useContext(Vn),f=()=>{l(!1),i(null),s(!1),n()},h=()=>{s(!0),(r?ft.delete:ft.forget)(e).then(()=>{a.refreshTorrents(),f()}).catch(g=>{i({text:`Error deleting torrent id=${e}`,details:g}),s(!1)})};return v.jsxs(tt,{show:t,onHide:f,children:[v.jsx(tt.Header,{closeButton:!0,children:"Delete torrent"}),v.jsxs(tt.Body,{children:[v.jsx(Et,{children:v.jsx(Et.Group,{controlId:"delete-torrent",children:v.jsx(Et.Check,{type:"checkbox",label:"Also delete files",checked:r,onChange:()=>l(!r)})})}),o&&v.jsx(zr,{error:o})]}),v.jsxs(tt.Footer,{children:[u&&v.jsx(An,{}),v.jsx(Mr,{variant:"primary",onClick:h,disabled:u,children:"OK"}),v.jsx(Mr,{variant:"secondary",onClick:f,children:"Cancel"})]})]})},iy=({id:e,statsResponse:t})=>{let n=t.state,[r,l]=y.useState(!1),[o,i]=y.useState(!1),u=y.useContext(Fd);const s=n=="live",a=n=="paused"||n=="error",f=y.useContext(Vn),h=()=>{l(!0),ft.start(e).then(()=>{u.refresh()},k=>{f.setCloseableError({text:`Error starting torrent id=${e}`,details:k})}).finally(()=>l(!1))},d=()=>{l(!0),ft.pause(e).then(()=>{u.refresh()},k=>{f.setCloseableError({text:`Error pausing torrent id=${e}`,details:k})}).finally(()=>l(!1))},g=()=>{l(!0),i(!0)},S=()=>{l(!1),i(!1)};return v.jsx(Ld,{children:v.jsxs(Vu,{children:[a&&v.jsx(Go,{className:"bi-play-circle",onClick:h,disabled:r,color:"success"}),s&&v.jsx(Go,{className:"bi-pause-circle",onClick:d,disabled:r}),v.jsx(Go,{className:"bi-x-circle",onClick:g,disabled:r,color:"danger"}),v.jsx(oy,{id:e,show:o,onHide:S})]})})},uy=({id:e,detailsResponse:t,statsResponse:n})=>{const r=(n==null?void 0:n.state)??"",l=n==null?void 0:n.error,o=(n==null?void 0:n.total_bytes)??1,i=(n==null?void 0:n.progress_bytes)??0,u=(n==null?void 0:n.finished)||!1,s=l?100:i/o*100,a=(r==Sl||r==Pd)&&!u,f=l?"Error":`${s.toFixed(2)}%`,h=l?"danger":u?"success":r==Sl?"warning":"primary",d=()=>{var R;let k=(R=n==null?void 0:n.live)==null?void 0:R.snapshot.peer_stats;return k?`${k.live} / ${k.seen}`:""},g=()=>{var k;if(u)return"Completed";switch(r){case wa:return"Paused";case Sl:return"Checking files";case ly:return"Error"}return((k=n.live)==null?void 0:k.download_speed.human_readable)??"N/A"};let S=[];return l?S.push("bg-warning"):e%2==0&&S.push("bg-light"),v.jsxs(Ld,{className:S.join(" "),children:[v.jsx(yt,{size:3,label:"Name",children:t?v.jsxs(v.Fragment,{children:[v.jsx("div",{className:"text-truncate",children:yy(t)}),l&&v.jsxs("p",{className:"text-danger",children:[v.jsx("strong",{children:"Error:"})," ",l]})]}):v.jsx(An,{})}),n?v.jsxs(v.Fragment,{children:[v.jsx(yt,{label:"Size",children:`${zd(o)} `}),v.jsx(yt,{size:2,label:(r==wa,"Progress"),children:v.jsx(ny,{now:s,label:f,animated:a,variant:h})}),v.jsx(yt,{size:2,label:"Down Speed",children:g()}),v.jsx(yt,{label:"ETA",children:gy(n)}),v.jsx(yt,{size:2,label:"Peers",children:d()}),v.jsx(yt,{label:"Actions",children:v.jsx(iy,{id:e,statsResponse:n})})]}):v.jsx(yt,{label:"Loading stats",size:8,children:v.jsx(An,{})})]})},yt=({size:e,label:t,children:n})=>v.jsxs(Vu,{md:e||1,className:"py-3",children:[v.jsx("div",{className:"fw-bold",children:t}),n]}),sy=({id:e,torrent:t})=>{const[n,r]=y.useState(null),[l,o]=y.useState(null),[i,u]=y.useState(0),s=()=>{u(i+1)};return y.useEffect(()=>{if(n===null)return Sy(async()=>{await ft.getTorrentDetails(t.id).then(r)},1e3)},[n]),y.useEffect(()=>$d(async()=>ft.getTorrentStats(t.id).then(g=>(o(g),g)).then(g=>g.finished?1e4:g.state==Sl||g.state==Pd?1e3:1e4,g=>1e4),0),[i]),v.jsx(Fd.Provider,{value:{refresh:s},children:v.jsx(uy,{id:e,detailsResponse:n,statsResponse:l})})},ay=e=>{if(e.torrents===null&&e.loading)return v.jsx(An,{});if(e.torrents!==null)return e.torrents.length===0?v.jsx("div",{className:"text-center",children:v.jsx("p",{children:"No existing torrents found. Add them through buttons below."})}):v.jsx(v.Fragment,{children:e.torrents.map(t=>v.jsx(sy,{id:t.id,torrent:t},t.id))})},cy=()=>{const[e,t]=y.useState(null),[n,r]=y.useState(null),[l,o]=y.useState(null),[i,u]=y.useState(!1),s=async()=>{u(!0);let f=await ft.listTorrents().finally(()=>u(!1));o(f.torrents)};y.useEffect(()=>$d(async()=>s().then(()=>(r(null),5e3),f=>(r({text:"Error refreshing torrents",details:f}),console.error(f),5e3)),0),[]);const a={setCloseableError:t,refreshTorrents:s};return v.jsx(Vn.Provider,{value:a,children:v.jsxs("div",{className:"text-center",children:[v.jsx("h1",{className:"mt-3 mb-4",children:"rqbit web 4.0.0-beta.0"}),v.jsx(vy,{closeableError:e,otherError:n,torrents:l,torrentsLoading:i})]})})},fy=e=>{let{details:t}=e;return t?v.jsxs(v.Fragment,{children:[t.status&&v.jsxs("strong",{children:[t.status," ",t.statusText,": "]}),t.text]}):null},zr=e=>{let{error:t,remove:n}=e;return t==null?null:v.jsxs(fa,{variant:"danger",onClose:n,dismissible:n!=null,children:[v.jsx(fa.Heading,{children:t.text}),v.jsx(fy,{details:t.details})]})},Md=({buttonText:e,onClick:t,data:n,resetData:r,variant:l})=>{const[o,i]=y.useState(!1),[u,s]=y.useState([]),[a,f]=y.useState(null);y.useContext(Vn);const h=n!==null||a!==null;y.useEffect(()=>{if(n===null)return;let g=setTimeout(async()=>{i(!0);try{const S=await ft.uploadTorrent(n,{listOnly:!0});s(S.details.files)}catch(S){f({text:"Error uploading torrent",details:S})}finally{i(!1)}},0);return()=>clearTimeout(g)},[n]);const d=()=>{r(),f(null),s([]),i(!1)};return v.jsxs(v.Fragment,{children:[v.jsx(Mr,{variant:l,onClick:t,className:"m-1",children:e}),v.jsx(my,{show:h,onHide:d,fileListError:a,fileList:u,data:n,fileListLoading:o})]})},dy=()=>{let[e,t]=y.useState(null);const n=()=>{const r=prompt("Enter magnet link or HTTP(s) URL");t(r===""?null:r)};return v.jsx(Md,{variant:"primary",buttonText:"Add Torrent from Magnet / URL",onClick:n,data:e,resetData:()=>t(null)})},py=()=>{const e=y.useRef(),[t,n]=y.useState(null),r=async()=>{const i=e.current.files[0];n(i)},l=()=>{e.current.value="",n(null)},o=()=>{e.current.click()};return v.jsxs(v.Fragment,{children:[v.jsx("input",{type:"file",ref:e,accept:".torrent",onChange:r,className:"d-none"}),v.jsx(Md,{variant:"secondary",buttonText:"Upload .torrent File",onClick:o,data:t,resetData:l})]})},my=e=>{let{show:t,onHide:n,fileList:r,fileListError:l,fileListLoading:o,data:i}=e;const[u,s]=y.useState([]),[a,f]=y.useState(!1),[h,d]=y.useState(null),[g,S]=y.useState(!1),k=y.useContext(Vn);y.useEffect(()=>{s(r.map((m,w)=>w))},[r]);const R=()=>{n(),s([]),d(null),f(!1)},p=m=>{u.includes(m)?s(u.filter(w=>w!==m)):s([...u,m])},c=async()=>{f(!0),ft.uploadTorrent(i,{selectedFiles:u,unpopularTorrent:g}).then(()=>{n(),k.refreshTorrents()},m=>{d({text:"Error starting torrent",details:m})}).finally(()=>f(!1))};return v.jsxs(tt,{show:t,onHide:R,size:"lg",children:[v.jsx(tt.Header,{closeButton:!0,children:!!l||v.jsx(tt.Title,{children:"Add torrent"})}),v.jsxs(tt.Body,{children:[v.jsxs(Et,{children:[v.jsxs("fieldset",{className:"mb-5",children:[v.jsx("legend",{children:"Pick the files to download"}),o?v.jsx(An,{}):l?v.jsx(zr,{error:l}):v.jsx(v.Fragment,{children:r.map((m,w)=>v.jsx(Et.Group,{controlId:`check-${w}`,children:v.jsx(Et.Check,{type:"checkbox",label:`${m.name} (${zd(m.length)})`,checked:u.includes(w),onChange:()=>p(w)})},w))})]}),v.jsxs("fieldset",{children:[v.jsx("legend",{children:"Other options"}),v.jsxs(Et.Group,{controlId:"unpopular-torrent",children:[v.jsx(Et.Check,{type:"checkbox",label:"Increase timeouts",checked:g,onChange:()=>S(!g)}),v.jsx("small",{id:"emailHelp",className:"form-text text-muted",children:"This might be useful for unpopular torrents with few peers."})]})]})]}),v.jsx(zr,{error:h})]}),v.jsxs(tt.Footer,{children:[a&&v.jsx(An,{}),v.jsx(Mr,{variant:"primary",onClick:c,disabled:o||a||u.length==0,children:"OK"}),v.jsx(Mr,{variant:"secondary",onClick:R,children:"Cancel"})]})]})},hy=()=>v.jsxs("div",{id:"buttons-container",className:"mt-3",children:[v.jsx(dy,{}),v.jsx(py,{})]}),vy=e=>{let t=y.useContext(Vn);return v.jsxs(pv,{children:[v.jsx(zr,{error:e.closeableError,remove:()=>t.setCloseableError(null)}),v.jsx(zr,{error:e.otherError}),v.jsx(ay,{torrents:e.torrents,loading:e.torrentsLoading}),v.jsx(hy,{})]})};function zd(e){if(e===0)return"0 Bytes";const t=1024,n=["Bytes","KB","MB","GB"],r=Math.floor(Math.log(e)/Math.log(t));return parseFloat((e/Math.pow(t,r)).toFixed(2))+" "+n[r]}function yy(e){return e.files.filter(n=>n.included).reduce((n,r)=>n.length>r.length?n:r).name}function gy(e){var n,r,l;let t=(l=(r=(n=e==null?void 0:e.live)==null?void 0:n.time_remaining)==null?void 0:r.duration)==null?void 0:l.secs;return t==null?"N/A":wy(t)}function wy(e){const t=Math.floor(e/3600),n=Math.floor(e%3600/60),r=e%60,l=(o,i)=>o>0?`${o}${i}`:"";return t>0?`${l(t,"h")} ${l(n,"m")}`.trim():n>0?`${l(n,"m")} ${l(r,"s")}`.trim():`${l(r,"s")}`.trim()}function $d(e,t){let n,r=t;const l=async()=>{if(r=await e(),r==null)throw"asyncCallback returned null or undefined";o()};let o=()=>{n=setTimeout(l,r)};return o(),()=>{clearTimeout(n)}}function Sy(e,t){let n;const r=async()=>{await e().then(()=>!1,()=>!0)&&l()};let l=o=>{n=setTimeout(r,o!==void 0?o:t)};return l(0),()=>clearTimeout(n)}async function ky(){const e=document.getElementById("app");Yo.createRoot(e).render(v.jsx(y.StrictMode,{children:v.jsx(cy,{})}))}document.addEventListener("DOMContentLoaded",ky); +*/(function(e){(function(){var t={}.hasOwnProperty;function n(){for(var r=[],l=0;l=0)&&(n[l]=e[l]);return n}function ia(e){return"default"+e.charAt(0).toUpperCase()+e.substr(1)}function mh(e){var t=hh(e,"string");return typeof t=="symbol"?t:String(t)}function hh(e,t){if(typeof e!="object"||e===null)return e;var n=e[Symbol.toPrimitive];if(n!==void 0){var r=n.call(e,t||"default");if(typeof r!="object")return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return(t==="string"?String:Number)(e)}function vh(e,t,n){var r=y.useRef(e!==void 0),l=y.useState(t),o=l[0],i=l[1],u=e!==void 0,s=r.current;return r.current=u,!u&&s&&o!==t&&i(t),[u?e:o,y.useCallback(function(a){for(var f=arguments.length,h=new Array(f>1?f-1:0),d=1;d{o.target===e&&(l(),t(o))},n+r)}function Uh(e){e.offsetHeight}const aa=e=>!e||typeof e=="function"?e:t=>{e.current=t};function Bh(e,t){const n=aa(e),r=aa(t);return l=>{n&&n(l),r&&r(l)}}function mo(e,t){return y.useMemo(()=>Bh(e,t),[e,t])}function Hh(e){return e&&"setState"in e?Nn.findDOMNode(e):e??null}const Wh=Vt.forwardRef(({onEnter:e,onEntering:t,onEntered:n,onExit:r,onExiting:l,onExited:o,addEndListener:i,children:u,childRef:s,...a},f)=>{const h=y.useRef(null),d=mo(h,s),g=N=>{d(Hh(N))},S=N=>T=>{N&&h.current&&N(h.current,T)},k=y.useCallback(S(e),[e]),R=y.useCallback(S(t),[t]),p=y.useCallback(S(n),[n]),c=y.useCallback(S(r),[r]),m=y.useCallback(S(l),[l]),w=y.useCallback(S(o),[o]),C=y.useCallback(S(i),[i]);return v.jsx(zh,{ref:f,...a,onEnter:k,onEntered:p,onEntering:R,onExit:c,onExited:w,onExiting:m,addEndListener:C,nodeRef:h,children:typeof u=="function"?(N,T)=>u(N,{...T,ref:g}):Vt.cloneElement(u,{ref:g})})}),Vh=Wh;function Qh(e){const t=y.useRef(e);return y.useEffect(()=>{t.current=e},[e]),t}function Pe(e){const t=Qh(e);return y.useCallback(function(...n){return t.current&&t.current(...n)},[t])}const Qf=e=>y.forwardRef((t,n)=>v.jsx("div",{...t,ref:n,className:M(t.className,e)})),Kf=Qf("h4");Kf.displayName="DivStyledAsH4";const Gf=y.forwardRef(({className:e,bsPrefix:t,as:n=Kf,...r},l)=>(t=H(t,"alert-heading"),v.jsx(n,{ref:l,className:M(e,t),...r})));Gf.displayName="AlertHeading";const Kh=Gf;function Gh(){return y.useState(null)}function Yh(){const e=y.useRef(!0),t=y.useRef(()=>e.current);return y.useEffect(()=>(e.current=!0,()=>{e.current=!1}),[]),t.current}function Xh(e){const t=y.useRef(null);return y.useEffect(()=>{t.current=e}),t.current}const Zh=typeof global<"u"&&global.navigator&&global.navigator.product==="ReactNative",Jh=typeof document<"u",ca=Jh||Zh?y.useLayoutEffect:y.useEffect,qh=["as","disabled"];function bh(e,t){if(e==null)return{};var n={},r=Object.keys(e),l,o;for(o=0;o=0)&&(n[l]=e[l]);return n}function ev(e){return!e||e.trim()==="#"}function Hu({tagName:e,disabled:t,href:n,target:r,rel:l,role:o,onClick:i,tabIndex:u=0,type:s}){e||(n!=null||r!=null||l!=null?e="a":e="button");const a={tagName:e};if(e==="button")return[{type:s||"button",disabled:t},a];const f=d=>{if((t||e==="a"&&ev(n))&&d.preventDefault(),t){d.stopPropagation();return}i==null||i(d)},h=d=>{d.key===" "&&(d.preventDefault(),f(d))};return e==="a"&&(n||(n="#"),t&&(n=void 0)),[{role:o??"button",disabled:void 0,tabIndex:t?void 0:u,href:n,target:e==="a"?r:void 0,"aria-disabled":t||void 0,rel:e==="a"?l:void 0,onClick:f,onKeyDown:h},a]}const tv=y.forwardRef((e,t)=>{let{as:n,disabled:r}=e,l=bh(e,qh);const[o,{tagName:i}]=Hu(Object.assign({tagName:n,disabled:r},l));return v.jsx(i,Object.assign({},l,o,{ref:t}))});tv.displayName="Button";const nv=["onKeyDown"];function rv(e,t){if(e==null)return{};var n={},r=Object.keys(e),l,o;for(o=0;o=0)&&(n[l]=e[l]);return n}function lv(e){return!e||e.trim()==="#"}const Yf=y.forwardRef((e,t)=>{let{onKeyDown:n}=e,r=rv(e,nv);const[l]=Hu(Object.assign({tagName:"a"},r)),o=Pe(i=>{l.onKeyDown(i),n==null||n(i)});return lv(r.href)||r.role==="button"?v.jsx("a",Object.assign({ref:t},r,l,{onKeyDown:o})):v.jsx("a",Object.assign({ref:t},r,{onKeyDown:n}))});Yf.displayName="Anchor";const ov=Yf,Xf=y.forwardRef(({className:e,bsPrefix:t,as:n=ov,...r},l)=>(t=H(t,"alert-link"),v.jsx(n,{ref:l,className:M(e,t),...r})));Xf.displayName="AlertLink";const iv=Xf,uv={[St]:"show",[Wt]:"show"},Zf=y.forwardRef(({className:e,children:t,transitionClasses:n={},onEnter:r,...l},o)=>{const i={in:!1,timeout:300,mountOnEnter:!1,unmountOnExit:!1,appear:!1,...l},u=y.useCallback((s,a)=>{Uh(s),r==null||r(s,a)},[r]);return v.jsx(Vh,{ref:o,addEndListener:Ah,...i,onEnter:u,childRef:t.ref,children:(s,a)=>y.cloneElement(t,{...a,className:M("fade",e,t.props.className,uv[s],n[s])})})});Zf.displayName="Fade";const Kl=Zf,sv={"aria-label":ot.string,onClick:ot.func,variant:ot.oneOf(["white"])},Wu=y.forwardRef(({className:e,variant:t,"aria-label":n="Close",...r},l)=>v.jsx("button",{ref:l,type:"button",className:M("btn-close",t&&`btn-close-${t}`,e),"aria-label":n,...r}));Wu.displayName="CloseButton";Wu.propTypes=sv;const Jf=Wu,qf=y.forwardRef((e,t)=>{const{bsPrefix:n,show:r=!0,closeLabel:l="Close alert",closeVariant:o,className:i,children:u,variant:s="primary",onClose:a,dismissible:f,transition:h=Kl,...d}=yh(e,{show:"onClose"}),g=H(n,"alert"),S=Pe(p=>{a&&a(!1,p)}),k=h===!0?Kl:h,R=v.jsxs("div",{role:"alert",...k?void 0:d,ref:t,className:M(i,g,s&&`${g}-${s}`,f&&`${g}-dismissible`),children:[f&&v.jsx(Jf,{onClick:S,"aria-label":l,variant:o}),u]});return k?v.jsx(k,{unmountOnExit:!0,...d,ref:void 0,in:r,children:R}):r?R:null});qf.displayName="Alert";const fa=Object.assign(qf,{Link:iv,Heading:Kh}),bf=y.forwardRef(({as:e,bsPrefix:t,variant:n="primary",size:r,active:l=!1,disabled:o=!1,className:i,...u},s)=>{const a=H(t,"btn"),[f,{tagName:h}]=Hu({tagName:e,disabled:o,...u}),d=h;return v.jsx(d,{...f,...u,ref:s,disabled:o,className:M(i,a,l&&"active",n&&`${a}-${n}`,r&&`${a}-${r}`,u.href&&o&&"disabled")})});bf.displayName="Button";const Mr=bf;function av(e){const t=y.useRef(e);return t.current=e,t}function ed(e){const t=av(e);y.useEffect(()=>()=>t.current(),[])}function cv(e,t){let n=0;return y.Children.map(e,r=>y.isValidElement(r)?t(r,n++):r)}function fv(e,t){return y.Children.toArray(e).some(n=>y.isValidElement(n)&&n.type===t)}function dv({as:e,bsPrefix:t,className:n,...r}){t=H(t,"col");const l=Df(),o=If(),i=[],u=[];return l.forEach(s=>{const a=r[s];delete r[s];let f,h,d;typeof a=="object"&&a!=null?{span:f,offset:h,order:d}=a:f=a;const g=s!==o?`-${s}`:"";f&&i.push(f===!0?`${t}${g}`:`${t}${g}-${f}`),d!=null&&u.push(`order${g}-${d}`),h!=null&&u.push(`offset${g}-${h}`)}),[{...r,className:M(n,...i,...u)},{as:e,bsPrefix:t,spans:i}]}const td=y.forwardRef((e,t)=>{const[{className:n,...r},{as:l="div",bsPrefix:o,spans:i}]=dv(e);return v.jsx(l,{...r,ref:t,className:M(n,!i.length&&o)})});td.displayName="Col";const Vu=td,nd=y.forwardRef(({bsPrefix:e,fluid:t=!1,as:n="div",className:r,...l},o)=>{const i=H(e,"container"),u=typeof t=="string"?`-${t}`:"-fluid";return v.jsx(n,{ref:o,...l,className:M(r,t?`${i}${u}`:i)})});nd.displayName="Container";const pv=nd;var mv=Function.prototype.bind.call(Function.prototype.call,[].slice);function fn(e,t){return mv(e.querySelectorAll(t))}function da(e,t){if(e.contains)return e.contains(t);if(e.compareDocumentPosition)return e===t||!!(e.compareDocumentPosition(t)&16)}const hv="data-rr-ui-";function vv(e){return`${hv}${e}`}const rd=y.createContext(Wn?window:void 0);rd.Provider;function Qu(){return y.useContext(rd)}const yv={type:ot.string,tooltip:ot.bool,as:ot.elementType},Ku=y.forwardRef(({as:e="div",className:t,type:n="valid",tooltip:r=!1,...l},o)=>v.jsx(e,{...l,ref:o,className:M(t,`${n}-${r?"tooltip":"feedback"}`)}));Ku.displayName="Feedback";Ku.propTypes=yv;const ld=Ku,gv=y.createContext({}),ct=gv,od=y.forwardRef(({id:e,bsPrefix:t,className:n,type:r="checkbox",isValid:l=!1,isInvalid:o=!1,as:i="input",...u},s)=>{const{controlId:a}=y.useContext(ct);return t=H(t,"form-check-input"),v.jsx(i,{...u,ref:s,type:r,id:e||a,className:M(n,t,l&&"is-valid",o&&"is-invalid")})});od.displayName="FormCheckInput";const id=od,ud=y.forwardRef(({bsPrefix:e,className:t,htmlFor:n,...r},l)=>{const{controlId:o}=y.useContext(ct);return e=H(e,"form-check-label"),v.jsx("label",{...r,ref:l,htmlFor:n||o,className:M(t,e)})});ud.displayName="FormCheckLabel";const Gi=ud,sd=y.forwardRef(({id:e,bsPrefix:t,bsSwitchPrefix:n,inline:r=!1,reverse:l=!1,disabled:o=!1,isValid:i=!1,isInvalid:u=!1,feedbackTooltip:s=!1,feedback:a,feedbackType:f,className:h,style:d,title:g="",type:S="checkbox",label:k,children:R,as:p="input",...c},m)=>{t=H(t,"form-check"),n=H(n,"form-switch");const{controlId:w}=y.useContext(ct),C=y.useMemo(()=>({controlId:e||w}),[w,e]),N=!R&&k!=null&&k!==!1||fv(R,Gi),T=v.jsx(id,{...c,type:S==="switch"?"checkbox":S,ref:m,isValid:i,isInvalid:u,disabled:o,as:p});return v.jsx(ct.Provider,{value:C,children:v.jsx("div",{style:d,className:M(h,N&&t,r&&`${t}-inline`,l&&`${t}-reverse`,S==="switch"&&n),children:R||v.jsxs(v.Fragment,{children:[T,N&&v.jsx(Gi,{title:g,children:k}),a&&v.jsx(ld,{type:f,tooltip:s,children:a})]})})})});sd.displayName="FormCheck";const Gl=Object.assign(sd,{Input:id,Label:Gi}),ad=y.forwardRef(({bsPrefix:e,type:t,size:n,htmlSize:r,id:l,className:o,isValid:i=!1,isInvalid:u=!1,plaintext:s,readOnly:a,as:f="input",...h},d)=>{const{controlId:g}=y.useContext(ct);return e=H(e,"form-control"),v.jsx(f,{...h,type:t,size:r,ref:d,readOnly:a,id:l||g,className:M(o,s?`${e}-plaintext`:e,n&&`${e}-${n}`,t==="color"&&`${e}-color`,i&&"is-valid",u&&"is-invalid")})});ad.displayName="FormControl";const wv=Object.assign(ad,{Feedback:ld}),cd=y.forwardRef(({className:e,bsPrefix:t,as:n="div",...r},l)=>(t=H(t,"form-floating"),v.jsx(n,{ref:l,className:M(e,t),...r})));cd.displayName="FormFloating";const Sv=cd,fd=y.forwardRef(({controlId:e,as:t="div",...n},r)=>{const l=y.useMemo(()=>({controlId:e}),[e]);return v.jsx(ct.Provider,{value:l,children:v.jsx(t,{...n,ref:r})})});fd.displayName="FormGroup";const dd=fd,pd=y.forwardRef(({as:e="label",bsPrefix:t,column:n=!1,visuallyHidden:r=!1,className:l,htmlFor:o,...i},u)=>{const{controlId:s}=y.useContext(ct);t=H(t,"form-label");let a="col-form-label";typeof n=="string"&&(a=`${a} ${a}-${n}`);const f=M(l,t,r&&"visually-hidden",n&&a);return o=o||s,n?v.jsx(Vu,{ref:u,as:"label",className:f,htmlFor:o,...i}):v.jsx(e,{ref:u,className:f,htmlFor:o,...i})});pd.displayName="FormLabel";const kv=pd,md=y.forwardRef(({bsPrefix:e,className:t,id:n,...r},l)=>{const{controlId:o}=y.useContext(ct);return e=H(e,"form-range"),v.jsx("input",{...r,type:"range",ref:l,className:M(t,e),id:n||o})});md.displayName="FormRange";const xv=md,hd=y.forwardRef(({bsPrefix:e,size:t,htmlSize:n,className:r,isValid:l=!1,isInvalid:o=!1,id:i,...u},s)=>{const{controlId:a}=y.useContext(ct);return e=H(e,"form-select"),v.jsx("select",{...u,size:n,ref:s,className:M(r,e,t&&`${e}-${t}`,l&&"is-valid",o&&"is-invalid"),id:i||a})});hd.displayName="FormSelect";const Ev=hd,vd=y.forwardRef(({bsPrefix:e,className:t,as:n="small",muted:r,...l},o)=>(e=H(e,"form-text"),v.jsx(n,{...l,ref:o,className:M(t,e,r&&"text-muted")})));vd.displayName="FormText";const Cv=vd,yd=y.forwardRef((e,t)=>v.jsx(Gl,{...e,ref:t,type:"switch"}));yd.displayName="Switch";const Nv=Object.assign(yd,{Input:Gl.Input,Label:Gl.Label}),gd=y.forwardRef(({bsPrefix:e,className:t,children:n,controlId:r,label:l,...o},i)=>(e=H(e,"form-floating"),v.jsxs(dd,{ref:i,className:M(t,e),controlId:r,...o,children:[n,v.jsx("label",{htmlFor:r,children:l})]})));gd.displayName="FloatingLabel";const Tv=gd,_v={_ref:ot.any,validated:ot.bool,as:ot.elementType},Gu=y.forwardRef(({className:e,validated:t,as:n="form",...r},l)=>v.jsx(n,{...r,ref:l,className:M(e,t&&"was-validated")}));Gu.displayName="Form";Gu.propTypes=_v;const Et=Object.assign(Gu,{Group:dd,Control:wv,Floating:Sv,Check:Gl,Switch:Nv,Label:kv,Text:Cv,Range:xv,Select:Ev,FloatingLabel:Tv});var ul;function pa(e){if((!ul&&ul!==0||e)&&Wn){var t=document.createElement("div");t.style.position="absolute",t.style.top="-9999px",t.style.width="50px",t.style.height="50px",t.style.overflow="scroll",document.body.appendChild(t),ul=t.offsetWidth-t.clientWidth,document.body.removeChild(t)}return ul}function Wo(e){e===void 0&&(e=po());try{var t=e.activeElement;return!t||!t.nodeName?null:t}catch{return e.body}}function jv(e=document){const t=e.defaultView;return Math.abs(t.innerWidth-e.documentElement.clientWidth)}const ma=vv("modal-open");class Rv{constructor({ownerDocument:t,handleContainerOverflow:n=!0,isRTL:r=!1}={}){this.handleContainerOverflow=n,this.isRTL=r,this.modals=[],this.ownerDocument=t}getScrollbarWidth(){return jv(this.ownerDocument)}getElement(){return(this.ownerDocument||document).body}setModalAttributes(t){}removeModalAttributes(t){}setContainerStyle(t){const n={overflow:"hidden"},r=this.isRTL?"paddingLeft":"paddingRight",l=this.getElement();t.style={overflow:l.style.overflow,[r]:l.style[r]},t.scrollBarWidth&&(n[r]=`${parseInt(Zt(l,r)||"0",10)+t.scrollBarWidth}px`),l.setAttribute(ma,""),Zt(l,n)}reset(){[...this.modals].forEach(t=>this.remove(t))}removeContainerStyle(t){const n=this.getElement();n.removeAttribute(ma),Object.assign(n.style,t.style)}add(t){let n=this.modals.indexOf(t);return n!==-1||(n=this.modals.length,this.modals.push(t),this.setModalAttributes(t),n!==0)||(this.state={scrollBarWidth:this.getScrollbarWidth(),style:{}},this.handleContainerOverflow&&this.setContainerStyle(this.state)),n}remove(t){const n=this.modals.indexOf(t);n!==-1&&(this.modals.splice(n,1),!this.modals.length&&this.handleContainerOverflow&&this.removeContainerStyle(this.state),this.removeModalAttributes(t))}isTopModal(t){return!!this.modals.length&&this.modals[this.modals.length-1]===t}}const Yu=Rv,Vo=(e,t)=>Wn?e==null?(t||po()).body:(typeof e=="function"&&(e=e()),e&&"current"in e&&(e=e.current),e&&("nodeType"in e||e.getBoundingClientRect)?e:null):null;function Lv(e,t){const n=Qu(),[r,l]=y.useState(()=>Vo(e,n==null?void 0:n.document));if(!r){const o=Vo(e);o&&l(o)}return y.useEffect(()=>{t&&r&&t(r)},[t,r]),y.useEffect(()=>{const o=Vo(e);o!==r&&l(o)},[e,r]),r}function Ov({children:e,in:t,onExited:n,mountOnEnter:r,unmountOnExit:l}){const o=y.useRef(null),i=y.useRef(t),u=Pe(n);y.useEffect(()=>{t?i.current=!0:u(o.current)},[t,u]);const s=mo(o,e.ref),a=y.cloneElement(e,{ref:s});return t?a:l||!i.current&&r?null:a}function Pv({in:e,onTransition:t}){const n=y.useRef(null),r=y.useRef(!0),l=Pe(t);return ca(()=>{if(!n.current)return;let o=!1;return l({in:e,element:n.current,initial:r.current,isStale:()=>o}),()=>{o=!0}},[e,l]),ca(()=>(r.current=!1,()=>{r.current=!0}),[]),n}function Fv({children:e,in:t,onExited:n,onEntered:r,transition:l}){const[o,i]=y.useState(!t);t&&o&&i(!1);const u=Pv({in:!!t,onTransition:a=>{const f=()=>{a.isStale()||(a.in?r==null||r(a.element,a.initial):(i(!0),n==null||n(a.element)))};Promise.resolve(l(a)).then(f,h=>{throw a.in||i(!0),h})}}),s=mo(u,e.ref);return o&&!t?null:y.cloneElement(e,{ref:s})}function ha(e,t,n){return e?v.jsx(e,Object.assign({},n)):t?v.jsx(Fv,Object.assign({},n,{transition:t})):v.jsx(Ov,Object.assign({},n))}function Mv(e){return e.code==="Escape"||e.keyCode===27}const zv=["show","role","className","style","children","backdrop","keyboard","onBackdropClick","onEscapeKeyDown","transition","runTransition","backdropTransition","runBackdropTransition","autoFocus","enforceFocus","restoreFocus","restoreFocusOptions","renderDialog","renderBackdrop","manager","container","onShow","onHide","onExit","onExited","onExiting","onEnter","onEntering","onEntered"];function $v(e,t){if(e==null)return{};var n={},r=Object.keys(e),l,o;for(o=0;o=0)&&(n[l]=e[l]);return n}let Qo;function Dv(e){return Qo||(Qo=new Yu({ownerDocument:e==null?void 0:e.document})),Qo}function Iv(e){const t=Qu(),n=e||Dv(t),r=y.useRef({dialog:null,backdrop:null});return Object.assign(r.current,{add:()=>n.add(r.current),remove:()=>n.remove(r.current),isTopModal:()=>n.isTopModal(r.current),setDialogRef:y.useCallback(l=>{r.current.dialog=l},[]),setBackdropRef:y.useCallback(l=>{r.current.backdrop=l},[])})}const wd=y.forwardRef((e,t)=>{let{show:n=!1,role:r="dialog",className:l,style:o,children:i,backdrop:u=!0,keyboard:s=!0,onBackdropClick:a,onEscapeKeyDown:f,transition:h,runTransition:d,backdropTransition:g,runBackdropTransition:S,autoFocus:k=!0,enforceFocus:R=!0,restoreFocus:p=!0,restoreFocusOptions:c,renderDialog:m,renderBackdrop:w=K=>v.jsx("div",Object.assign({},K)),manager:C,container:N,onShow:T,onHide:j=()=>{},onExit:U,onExited:P,onExiting:ie,onEnter:Ve,onEntering:Qe,onEntered:ln}=e,Qn=$v(e,zv);const _e=Qu(),Ke=Lv(N),E=Iv(C),L=Yh(),O=Xh(n),[D,A]=y.useState(!n),fe=y.useRef(null);y.useImperativeHandle(t,()=>E,[E]),Wn&&!O&&n&&(fe.current=Wo(_e==null?void 0:_e.document)),n&&D&&A(!1);const je=Pe(()=>{if(E.add(),un.current=Ql(document,"keydown",ho),on.current=Ql(document,"focus",()=>setTimeout(Re),!0),T&&T(),k){var K,Hr;const Yn=Wo((K=(Hr=E.dialog)==null?void 0:Hr.ownerDocument)!=null?K:_e==null?void 0:_e.document);E.dialog&&Yn&&!da(E.dialog,Yn)&&(fe.current=Yn,E.dialog.focus())}}),qe=Pe(()=>{if(E.remove(),un.current==null||un.current(),on.current==null||on.current(),p){var K;(K=fe.current)==null||K.focus==null||K.focus(c),fe.current=null}});y.useEffect(()=>{!n||!Ke||je()},[n,Ke,je]),y.useEffect(()=>{D&&qe()},[D,qe]),ed(()=>{qe()});const Re=Pe(()=>{if(!R||!L()||!E.isTopModal())return;const K=Wo(_e==null?void 0:_e.document);E.dialog&&K&&!da(E.dialog,K)&&E.dialog.focus()}),mt=Pe(K=>{K.target===K.currentTarget&&(a==null||a(K),u===!0&&j())}),ho=Pe(K=>{s&&Mv(K)&&E.isTopModal()&&(f==null||f(K),K.defaultPrevented||j())}),on=y.useRef(),un=y.useRef(),Kn=(...K)=>{A(!0),P==null||P(...K)};if(!Ke)return null;const Br=Object.assign({role:r,ref:E.setDialogRef,"aria-modal":r==="dialog"?!0:void 0},Qn,{style:o,className:l,tabIndex:-1});let Gn=m?m(Br):v.jsx("div",Object.assign({},Br,{children:y.cloneElement(i,{role:"document"})}));Gn=ha(h,d,{unmountOnExit:!0,mountOnEnter:!0,appear:!0,in:!!n,onExit:U,onExiting:ie,onExited:Kn,onEnter:Ve,onEntering:Qe,onEntered:ln,children:Gn});let At=null;return u&&(At=w({ref:E.setBackdropRef,onClick:mt}),At=ha(g,S,{in:!!n,appear:!0,mountOnEnter:!0,unmountOnExit:!0,children:At})),v.jsx(v.Fragment,{children:Nn.createPortal(v.jsxs(v.Fragment,{children:[At,Gn]}),Ke)})});wd.displayName="Modal";const Av=Object.assign(wd,{Manager:Yu});function Uv(e,t){return e.classList?!!t&&e.classList.contains(t):(" "+(e.className.baseVal||e.className)+" ").indexOf(" "+t+" ")!==-1}function Bv(e,t){e.classList?e.classList.add(t):Uv(e,t)||(typeof e.className=="string"?e.className=e.className+" "+t:e.setAttribute("class",(e.className&&e.className.baseVal||"")+" "+t))}function va(e,t){return e.replace(new RegExp("(^|\\s)"+t+"(?:\\s|$)","g"),"$1").replace(/\s+/g," ").replace(/^\s*|\s*$/g,"")}function Hv(e,t){e.classList?e.classList.remove(t):typeof e.className=="string"?e.className=va(e.className,t):e.setAttribute("class",va(e.className&&e.className.baseVal||"",t))}const dn={FIXED_CONTENT:".fixed-top, .fixed-bottom, .is-fixed, .sticky-top",STICKY_CONTENT:".sticky-top",NAVBAR_TOGGLER:".navbar-toggler"};class Wv extends Yu{adjustAndStore(t,n,r){const l=n.style[t];n.dataset[t]=l,Zt(n,{[t]:`${parseFloat(Zt(n,t))+r}px`})}restore(t,n){const r=n.dataset[t];r!==void 0&&(delete n.dataset[t],Zt(n,{[t]:r}))}setContainerStyle(t){super.setContainerStyle(t);const n=this.getElement();if(Bv(n,"modal-open"),!t.scrollBarWidth)return;const r=this.isRTL?"paddingLeft":"paddingRight",l=this.isRTL?"marginLeft":"marginRight";fn(n,dn.FIXED_CONTENT).forEach(o=>this.adjustAndStore(r,o,t.scrollBarWidth)),fn(n,dn.STICKY_CONTENT).forEach(o=>this.adjustAndStore(l,o,-t.scrollBarWidth)),fn(n,dn.NAVBAR_TOGGLER).forEach(o=>this.adjustAndStore(l,o,t.scrollBarWidth))}removeContainerStyle(t){super.removeContainerStyle(t);const n=this.getElement();Hv(n,"modal-open");const r=this.isRTL?"paddingLeft":"paddingRight",l=this.isRTL?"marginLeft":"marginRight";fn(n,dn.FIXED_CONTENT).forEach(o=>this.restore(r,o)),fn(n,dn.STICKY_CONTENT).forEach(o=>this.restore(l,o)),fn(n,dn.NAVBAR_TOGGLER).forEach(o=>this.restore(l,o))}}let Ko;function Vv(e){return Ko||(Ko=new Wv(e)),Ko}const Sd=y.forwardRef(({className:e,bsPrefix:t,as:n="div",...r},l)=>(t=H(t,"modal-body"),v.jsx(n,{ref:l,className:M(e,t),...r})));Sd.displayName="ModalBody";const Qv=Sd,Kv=y.createContext({onHide(){}}),kd=Kv,xd=y.forwardRef(({bsPrefix:e,className:t,contentClassName:n,centered:r,size:l,fullscreen:o,children:i,scrollable:u,...s},a)=>{e=H(e,"modal");const f=`${e}-dialog`,h=typeof o=="string"?`${e}-fullscreen-${o}`:`${e}-fullscreen`;return v.jsx("div",{...s,ref:a,className:M(f,t,l&&`${e}-${l}`,r&&`${f}-centered`,u&&`${f}-scrollable`,o&&h),children:v.jsx("div",{className:M(`${e}-content`,n),children:i})})});xd.displayName="ModalDialog";const Ed=xd,Cd=y.forwardRef(({className:e,bsPrefix:t,as:n="div",...r},l)=>(t=H(t,"modal-footer"),v.jsx(n,{ref:l,className:M(e,t),...r})));Cd.displayName="ModalFooter";const Gv=Cd,Yv=y.forwardRef(({closeLabel:e="Close",closeVariant:t,closeButton:n=!1,onHide:r,children:l,...o},i)=>{const u=y.useContext(kd),s=Pe(()=>{u==null||u.onHide(),r==null||r()});return v.jsxs("div",{ref:i,...o,children:[l,n&&v.jsx(Jf,{"aria-label":e,variant:t,onClick:s})]})}),Xv=Yv,Nd=y.forwardRef(({bsPrefix:e,className:t,closeLabel:n="Close",closeButton:r=!1,...l},o)=>(e=H(e,"modal-header"),v.jsx(Xv,{ref:o,...l,className:M(t,e),closeLabel:n,closeButton:r})));Nd.displayName="ModalHeader";const Zv=Nd,Jv=Qf("h4"),Td=y.forwardRef(({className:e,bsPrefix:t,as:n=Jv,...r},l)=>(t=H(t,"modal-title"),v.jsx(n,{ref:l,className:M(e,t),...r})));Td.displayName="ModalTitle";const qv=Td;function bv(e){return v.jsx(Kl,{...e,timeout:null})}function ey(e){return v.jsx(Kl,{...e,timeout:null})}const _d=y.forwardRef(({bsPrefix:e,className:t,style:n,dialogClassName:r,contentClassName:l,children:o,dialogAs:i=Ed,"aria-labelledby":u,"aria-describedby":s,"aria-label":a,show:f=!1,animation:h=!0,backdrop:d=!0,keyboard:g=!0,onEscapeKeyDown:S,onShow:k,onHide:R,container:p,autoFocus:c=!0,enforceFocus:m=!0,restoreFocus:w=!0,restoreFocusOptions:C,onEntered:N,onExit:T,onExiting:j,onEnter:U,onEntering:P,onExited:ie,backdropClassName:Ve,manager:Qe,...ln},Qn)=>{const[_e,Ke]=y.useState({}),[E,L]=y.useState(!1),O=y.useRef(!1),D=y.useRef(!1),A=y.useRef(null),[fe,je]=Gh(),qe=mo(Qn,je),Re=Pe(R),mt=kh();e=H(e,"modal");const ho=y.useMemo(()=>({onHide:Re}),[Re]);function on(){return Qe||Vv({isRTL:mt})}function un($){if(!Wn)return;const sn=on().getScrollbarWidth()>0,Zu=$.scrollHeight>po($).documentElement.clientHeight;Ke({paddingRight:sn&&!Zu?pa():void 0,paddingLeft:!sn&&Zu?pa():void 0})}const Kn=Pe(()=>{fe&&un(fe.dialog)});ed(()=>{Ki(window,"resize",Kn),A.current==null||A.current()});const Br=()=>{O.current=!0},Gn=$=>{O.current&&fe&&$.target===fe.dialog&&(D.current=!0),O.current=!1},At=()=>{L(!0),A.current=Vf(fe.dialog,()=>{L(!1)})},K=$=>{$.target===$.currentTarget&&At()},Hr=$=>{if(d==="static"){K($);return}if(D.current||$.target!==$.currentTarget){D.current=!1;return}R==null||R()},Yn=$=>{g?S==null||S($):($.preventDefault(),d==="static"&&At())},Dd=($,sn)=>{$&&un($),U==null||U($,sn)},Id=$=>{A.current==null||A.current(),T==null||T($)},Ad=($,sn)=>{P==null||P($,sn),Wf(window,"resize",Kn)},Ud=$=>{$&&($.style.display=""),ie==null||ie($),Ki(window,"resize",Kn)},Bd=y.useCallback($=>v.jsx("div",{...$,className:M(`${e}-backdrop`,Ve,!h&&"show")}),[h,Ve,e]),Xu={...n,..._e};Xu.display="block";const Hd=$=>v.jsx("div",{role:"dialog",...$,style:Xu,className:M(t,e,E&&`${e}-static`,!h&&"show"),onClick:d?Hr:void 0,onMouseUp:Gn,"aria-label":a,"aria-labelledby":u,"aria-describedby":s,children:v.jsx(i,{...ln,onMouseDown:Br,className:r,contentClassName:l,children:o})});return v.jsx(kd.Provider,{value:ho,children:v.jsx(Av,{show:f,ref:qe,backdrop:d,container:p,keyboard:!0,autoFocus:c,enforceFocus:m,restoreFocus:w,restoreFocusOptions:C,onEscapeKeyDown:Yn,onShow:k,onHide:R,onEnter:Dd,onEntering:Ad,onEntered:N,onExit:Id,onExiting:j,onExited:Ud,manager:on(),transition:h?bv:void 0,backdropTransition:h?ey:void 0,renderBackdrop:Bd,renderDialog:Hd})})});_d.displayName="Modal";const tt=Object.assign(_d,{Body:Qv,Header:Zv,Title:qv,Footer:Gv,Dialog:Ed,TRANSITION_DURATION:300,BACKDROP_TRANSITION_DURATION:150}),ya=1e3;function ty(e,t,n){const r=(e-t)/(n-t)*100;return Math.round(r*ya)/ya}function ga({min:e,now:t,max:n,label:r,visuallyHidden:l,striped:o,animated:i,className:u,style:s,variant:a,bsPrefix:f,...h},d){return v.jsx("div",{ref:d,...h,role:"progressbar",className:M(u,`${f}-bar`,{[`bg-${a}`]:a,[`${f}-bar-animated`]:i,[`${f}-bar-striped`]:i||o}),style:{width:`${ty(t,e,n)}%`,...s},"aria-valuenow":t,"aria-valuemin":e,"aria-valuemax":n,children:l?v.jsx("span",{className:"visually-hidden",children:r}):r})}const jd=y.forwardRef(({isChild:e=!1,...t},n)=>{const r={min:0,max:100,animated:!1,visuallyHidden:!1,striped:!1,...t};if(r.bsPrefix=H(r.bsPrefix,"progress"),e)return ga(r,n);const{min:l,now:o,max:i,label:u,visuallyHidden:s,striped:a,animated:f,bsPrefix:h,variant:d,className:g,children:S,...k}=r;return v.jsx("div",{ref:n,...k,className:M(g,h),children:S?cv(S,R=>y.cloneElement(R,{isChild:!0})):ga({min:l,now:o,max:i,label:u,visuallyHidden:s,striped:a,animated:f,bsPrefix:h,variant:d},n)})});jd.displayName="ProgressBar";const ny=jd,Rd=y.forwardRef(({bsPrefix:e,className:t,as:n="div",...r},l)=>{const o=H(e,"row"),i=Df(),u=If(),s=`${o}-cols`,a=[];return i.forEach(f=>{const h=r[f];delete r[f];let d;h!=null&&typeof h=="object"?{cols:d}=h:d=h;const g=f!==u?`-${f}`:"";d!=null&&a.push(`${s}${g}-${d}`)}),v.jsx(n,{ref:l,...r,className:M(t,o,...a)})});Rd.displayName="Row";const Ld=Rd,Od=y.forwardRef(({bsPrefix:e,variant:t,animation:n="border",size:r,as:l="div",className:o,...i},u)=>{e=H(e,"spinner");const s=`${e}-${n}`;return v.jsx(l,{ref:u,...i,className:M(o,s,r&&`${s}-${r}`,t&&`text-${t}`)})});Od.displayName="Spinner";const An=Od,ry=window.origin==="null"||window.origin==="http://localhost:3031"?"http://localhost:3030":"",Sl="initializing",wa="paused",Pd="live",ly="error",vt=async(e,t,n)=>{console.log(e,t);const r=ry+t,l={method:e,headers:{Accept:"application/json"},body:n};let o={method:e,path:t,text:""},i;try{i=await fetch(r,l)}catch{return o.text="network error",Promise.reject(o)}if(o.status=i.status,o.statusText=i.statusText,!i.ok){const s=await i.text();try{const a=JSON.parse(s);o.text=a.human_readable!==void 0?a.human_readable:JSON.stringify(a,null,2)}catch{o.text=s}return Promise.reject(o)}return await i.json()},ft={listTorrents:()=>vt("GET","/torrents"),getTorrentDetails:e=>vt("GET",`/torrents/${e}`),getTorrentStats:e=>vt("GET",`/torrents/${e}/stats/v1`),uploadTorrent:(e,t)=>{t=t||{};let n="/torrents?&overwrite=true";return t.listOnly&&(n+="&list_only=true"),t.selectedFiles!=null&&(n+=`&only_files=${t.selectedFiles.join(",")}`),t.unpopularTorrent&&(n+="&peer_connect_timeout=20&peer_read_write_timeout=60"),t.initialPeers&&(n+=`&initial_peers=${t.initialPeers.join(",")}`),vt("POST",n,e)},pause:e=>vt("POST",`/torrents/${e}/pause`),start:e=>vt("POST",`/torrents/${e}/start`),forget:e=>vt("POST",`/torrents/${e}/forget`),delete:e=>vt("POST",`/torrents/${e}/delete`)},Vn=y.createContext(null),Fd=y.createContext(null),Go=({className:e,onClick:t,disabled:n,color:r})=>{const l=o=>{o.stopPropagation(),!n&&t()};return v.jsx("a",{className:`bi ${e} p-1`,onClick:l,href:"#"})},oy=({id:e,show:t,onHide:n})=>{if(!t)return null;const[r,l]=y.useState(!1),[o,i]=y.useState(null),[u,s]=y.useState(!1),a=y.useContext(Vn),f=()=>{l(!1),i(null),s(!1),n()},h=()=>{s(!0),(r?ft.delete:ft.forget)(e).then(()=>{a.refreshTorrents(),f()}).catch(g=>{i({text:`Error deleting torrent id=${e}`,details:g}),s(!1)})};return v.jsxs(tt,{show:t,onHide:f,children:[v.jsx(tt.Header,{closeButton:!0,children:"Delete torrent"}),v.jsxs(tt.Body,{children:[v.jsx(Et,{children:v.jsx(Et.Group,{controlId:"delete-torrent",children:v.jsx(Et.Check,{type:"checkbox",label:"Also delete files",checked:r,onChange:()=>l(!r)})})}),o&&v.jsx(zr,{error:o})]}),v.jsxs(tt.Footer,{children:[u&&v.jsx(An,{}),v.jsx(Mr,{variant:"primary",onClick:h,disabled:u,children:"OK"}),v.jsx(Mr,{variant:"secondary",onClick:f,children:"Cancel"})]})]})},iy=({id:e,statsResponse:t})=>{let n=t.state,[r,l]=y.useState(!1),[o,i]=y.useState(!1),u=y.useContext(Fd);const s=n=="live",a=n=="paused"||n=="error",f=y.useContext(Vn),h=()=>{l(!0),ft.start(e).then(()=>{u.refresh()},k=>{f.setCloseableError({text:`Error starting torrent id=${e}`,details:k})}).finally(()=>l(!1))},d=()=>{l(!0),ft.pause(e).then(()=>{u.refresh()},k=>{f.setCloseableError({text:`Error pausing torrent id=${e}`,details:k})}).finally(()=>l(!1))},g=()=>{l(!0),i(!0)},S=()=>{l(!1),i(!1)};return v.jsx(Ld,{children:v.jsxs(Vu,{children:[a&&v.jsx(Go,{className:"bi-play-circle",onClick:h,disabled:r,color:"success"}),s&&v.jsx(Go,{className:"bi-pause-circle",onClick:d,disabled:r}),v.jsx(Go,{className:"bi-x-circle",onClick:g,disabled:r,color:"danger"}),v.jsx(oy,{id:e,show:o,onHide:S})]})})},uy=({id:e,detailsResponse:t,statsResponse:n})=>{const r=(n==null?void 0:n.state)??"",l=n==null?void 0:n.error,o=(n==null?void 0:n.total_bytes)??1,i=(n==null?void 0:n.progress_bytes)??0,u=(n==null?void 0:n.finished)||!1,s=l?100:i/o*100,a=(r==Sl||r==Pd)&&!u,f=l?"Error":`${s.toFixed(2)}%`,h=l?"danger":u?"success":r==Sl?"warning":"primary",d=()=>{var R;let k=(R=n==null?void 0:n.live)==null?void 0:R.snapshot.peer_stats;return k?`${k.live} / ${k.seen}`:""},g=()=>{var k;if(u)return"Completed";switch(r){case wa:return"Paused";case Sl:return"Checking files";case ly:return"Error"}return((k=n.live)==null?void 0:k.download_speed.human_readable)??"N/A"};let S=[];return l?S.push("bg-warning"):e%2==0&&S.push("bg-light"),v.jsxs(Ld,{className:S.join(" "),children:[v.jsx(yt,{size:3,label:"Name",children:t?v.jsxs(v.Fragment,{children:[v.jsx("div",{className:"text-truncate",children:yy(t)}),l&&v.jsxs("p",{className:"text-danger",children:[v.jsx("strong",{children:"Error:"})," ",l]})]}):v.jsx(An,{})}),n?v.jsxs(v.Fragment,{children:[v.jsx(yt,{label:"Size",children:`${zd(o)} `}),v.jsx(yt,{size:2,label:(r==wa,"Progress"),children:v.jsx(ny,{now:s,label:f,animated:a,variant:h})}),v.jsx(yt,{size:2,label:"Down Speed",children:g()}),v.jsx(yt,{label:"ETA",children:gy(n)}),v.jsx(yt,{size:2,label:"Peers",children:d()}),v.jsx(yt,{label:"Actions",children:v.jsx(iy,{id:e,statsResponse:n})})]}):v.jsx(yt,{label:"Loading stats",size:8,children:v.jsx(An,{})})]})},yt=({size:e,label:t,children:n})=>v.jsxs(Vu,{md:e||1,className:"py-3",children:[v.jsx("div",{className:"fw-bold",children:t}),n]}),sy=({id:e,torrent:t})=>{const[n,r]=y.useState(null),[l,o]=y.useState(null),[i,u]=y.useState(0),s=()=>{u(i+1)};return y.useEffect(()=>{if(n===null)return Sy(async()=>{await ft.getTorrentDetails(t.id).then(r)},1e3)},[n]),y.useEffect(()=>$d(async()=>ft.getTorrentStats(t.id).then(g=>(o(g),g)).then(g=>g.finished?1e4:g.state==Sl||g.state==Pd?1e3:1e4,g=>1e4),0),[i]),v.jsx(Fd.Provider,{value:{refresh:s},children:v.jsx(uy,{id:e,detailsResponse:n,statsResponse:l})})},ay=e=>{if(e.torrents===null&&e.loading)return v.jsx(An,{});if(e.torrents!==null)return e.torrents.length===0?v.jsx("div",{className:"text-center",children:v.jsx("p",{children:"No existing torrents found. Add them through buttons below."})}):v.jsx(v.Fragment,{children:e.torrents.map(t=>v.jsx(sy,{id:t.id,torrent:t},t.id))})},cy=()=>{const[e,t]=y.useState(null),[n,r]=y.useState(null),[l,o]=y.useState(null),[i,u]=y.useState(!1),s=async()=>{u(!0);let f=await ft.listTorrents().finally(()=>u(!1));o(f.torrents)};y.useEffect(()=>$d(async()=>s().then(()=>(r(null),5e3),f=>(r({text:"Error refreshing torrents",details:f}),console.error(f),5e3)),0),[]);const a={setCloseableError:t,refreshTorrents:s};return v.jsx(Vn.Provider,{value:a,children:v.jsxs("div",{className:"text-center",children:[v.jsx("h1",{className:"mt-3 mb-4",children:"rqbit web 4.0.0-beta.0"}),v.jsx(vy,{closeableError:e,otherError:n,torrents:l,torrentsLoading:i})]})})},fy=e=>{let{details:t}=e;return t?v.jsxs(v.Fragment,{children:[t.status&&v.jsxs("strong",{children:[t.status," ",t.statusText,": "]}),t.text]}):null},zr=e=>{let{error:t,remove:n}=e;return t==null?null:v.jsxs(fa,{variant:"danger",onClose:n,dismissible:n!=null,children:[v.jsx(fa.Heading,{children:t.text}),v.jsx(fy,{details:t.details})]})},Md=({buttonText:e,onClick:t,data:n,resetData:r,variant:l})=>{const[o,i]=y.useState(!1),[u,s]=y.useState(null),[a,f]=y.useState(null);y.useContext(Vn);const h=n!==null||a!==null;y.useEffect(()=>{if(n===null)return;let g=setTimeout(async()=>{i(!0);try{const S=await ft.uploadTorrent(n,{listOnly:!0});s(S)}catch(S){f({text:"Error listing torrent files",details:S})}finally{i(!1)}},0);return()=>clearTimeout(g)},[n]);const d=()=>{r(),f(null),s(null),i(!1)};return v.jsxs(v.Fragment,{children:[v.jsx(Mr,{variant:l,onClick:t,className:"m-1",children:e}),v.jsx(my,{show:h,onHide:d,listTorrentError:a,listTorrentResponse:u,data:n,listTorrentLoading:o})]})},dy=()=>{let[e,t]=y.useState(null);const n=()=>{const r=prompt("Enter magnet link or HTTP(s) URL");t(r===""?null:r)};return v.jsx(Md,{variant:"primary",buttonText:"Add Torrent from Magnet / URL",onClick:n,data:e,resetData:()=>t(null)})},py=()=>{const e=y.useRef(),[t,n]=y.useState(null),r=async()=>{const i=e.current.files[0];n(i)},l=()=>{e.current.value="",n(null)},o=()=>{e.current.click()};return v.jsxs(v.Fragment,{children:[v.jsx("input",{type:"file",ref:e,accept:".torrent",onChange:r,className:"d-none"}),v.jsx(Md,{variant:"secondary",buttonText:"Upload .torrent File",onClick:o,data:t,resetData:l})]})},my=e=>{let{show:t,onHide:n,listTorrentResponse:r,listTorrentError:l,listTorrentLoading:o,data:i}=e;const[u,s]=y.useState([]),[a,f]=y.useState(!1),[h,d]=y.useState(null),[g,S]=y.useState(!1),k=y.useContext(Vn);y.useEffect(()=>{s(r?r.details.files.map((m,w)=>w):[])},[r]);const R=()=>{n(),s([]),d(null),f(!1)},p=m=>{u.includes(m)?s(u.filter(w=>w!==m)):s([...u,m])},c=async()=>{f(!0);let m=r.seen_peers?r.seen_peers.slice(0,32):null;ft.uploadTorrent(i,{selectedFiles:u,unpopularTorrent:g,initialPeers:m}).then(()=>{n(),k.refreshTorrents()},w=>{d({text:"Error starting torrent",details:w})}).finally(()=>f(!1))};return v.jsxs(tt,{show:t,onHide:R,size:"lg",children:[v.jsx(tt.Header,{closeButton:!0,children:v.jsx(tt.Title,{children:"Add torrent"})}),v.jsxs(tt.Body,{children:[v.jsxs(Et,{children:[v.jsxs("fieldset",{className:"mb-5",children:[v.jsx("legend",{children:"Pick the files to download"}),o?v.jsx(An,{}):l?v.jsx(zr,{error:l}):v.jsx(v.Fragment,{children:r==null?void 0:r.details.files.map((m,w)=>v.jsx(Et.Group,{controlId:`check-${w}`,children:v.jsx(Et.Check,{type:"checkbox",label:`${m.name} (${zd(m.length)})`,checked:u.includes(w),onChange:()=>p(w)})},w))})]}),v.jsxs("fieldset",{children:[v.jsx("legend",{children:"Other options"}),v.jsxs(Et.Group,{controlId:"unpopular-torrent",children:[v.jsx(Et.Check,{type:"checkbox",label:"Increase timeouts",checked:g,onChange:()=>S(!g)}),v.jsx("small",{id:"emailHelp",className:"form-text text-muted",children:"This might be useful for unpopular torrents with few peers."})]})]})]}),v.jsx(zr,{error:h})]}),v.jsxs(tt.Footer,{children:[a&&v.jsx(An,{}),v.jsx(Mr,{variant:"primary",onClick:c,disabled:o||a||u.length==0,children:"OK"}),v.jsx(Mr,{variant:"secondary",onClick:R,children:"Cancel"})]})]})},hy=()=>v.jsxs("div",{id:"buttons-container",className:"mt-3",children:[v.jsx(dy,{}),v.jsx(py,{})]}),vy=e=>{let t=y.useContext(Vn);return v.jsxs(pv,{children:[v.jsx(zr,{error:e.closeableError,remove:()=>t.setCloseableError(null)}),v.jsx(zr,{error:e.otherError}),v.jsx(ay,{torrents:e.torrents,loading:e.torrentsLoading}),v.jsx(hy,{})]})};function zd(e){if(e===0)return"0 Bytes";const t=1024,n=["Bytes","KB","MB","GB"],r=Math.floor(Math.log(e)/Math.log(t));return parseFloat((e/Math.pow(t,r)).toFixed(2))+" "+n[r]}function yy(e){return e.files.filter(n=>n.included).reduce((n,r)=>n.length>r.length?n:r).name}function gy(e){var n,r,l;let t=(l=(r=(n=e==null?void 0:e.live)==null?void 0:n.time_remaining)==null?void 0:r.duration)==null?void 0:l.secs;return t==null?"N/A":wy(t)}function wy(e){const t=Math.floor(e/3600),n=Math.floor(e%3600/60),r=e%60,l=(o,i)=>o>0?`${o}${i}`:"";return t>0?`${l(t,"h")} ${l(n,"m")}`.trim():n>0?`${l(n,"m")} ${l(r,"s")}`.trim():`${l(r,"s")}`.trim()}function $d(e,t){let n,r=t;const l=async()=>{if(r=await e(),r==null)throw"asyncCallback returned null or undefined";o()};let o=()=>{n=setTimeout(l,r)};return o(),()=>{clearTimeout(n)}}function Sy(e,t){let n;const r=async()=>{await e().then(()=>!1,()=>!0)&&l()};let l=o=>{n=setTimeout(r,o!==void 0?o:t)};return l(0),()=>clearTimeout(n)}async function ky(){const e=document.getElementById("app");Yo.createRoot(e).render(v.jsx(y.StrictMode,{children:v.jsx(cy,{})}))}document.addEventListener("DOMContentLoaded",ky); diff --git a/crates/librqbit/webui/dist/manifest.json b/crates/librqbit/webui/dist/manifest.json index 8d25a85..c3b3044 100644 --- a/crates/librqbit/webui/dist/manifest.json +++ b/crates/librqbit/webui/dist/manifest.json @@ -4,7 +4,7 @@ "src": "assets/logo.svg" }, "index.html": { - "file": "assets/index-b3c336f4.js", + "file": "assets/index-75fed916.js", "isEntry": true, "src": "index.html" } diff --git a/crates/librqbit/webui/src/api.ts b/crates/librqbit/webui/src/api.ts index eb8fe4e..dd8772c 100644 --- a/crates/librqbit/webui/src/api.ts +++ b/crates/librqbit/webui/src/api.ts @@ -22,6 +22,7 @@ export interface TorrentDetails { export interface AddTorrentResponse { id: number | null; details: TorrentDetails; + seen_peers?: Array; } export interface ListTorrentsResponse { @@ -147,7 +148,10 @@ export const API = { }, uploadTorrent: (data: string | File, opts?: { - listOnly?: boolean, selectedFiles?: Array, unpopularTorrent?: boolean, + listOnly?: boolean, + selectedFiles?: Array, + unpopularTorrent?: boolean, + initialPeers?: Array, }): Promise => { opts = opts || {}; let url = '/torrents?&overwrite=true'; @@ -160,6 +164,9 @@ export const API = { if (opts.unpopularTorrent) { url += '&peer_connect_timeout=20&peer_read_write_timeout=60'; } + if (opts.initialPeers) { + url += `&initial_peers=${opts.initialPeers.join(',')}`; + } return makeRequest('POST', url, data) }, diff --git a/crates/librqbit/webui/src/index.tsx b/crates/librqbit/webui/src/index.tsx index 54e6df4..c787629 100644 --- a/crates/librqbit/webui/src/index.tsx +++ b/crates/librqbit/webui/src/index.tsx @@ -378,11 +378,11 @@ const ErrorComponent = (props: { error: Error, remove?: () => void }) => { const UploadButton = ({ buttonText, onClick, data, resetData, variant }) => { const [loading, setLoading] = useState(false); - const [fileList, setFileList] = useState([]); - const [fileListError, setFileListError] = useState(null); + const [listTorrentResponse, setListTorrentResponse] = useState(null); + const [listTorrentError, setListTorrentError] = useState(null); const ctx = useContext(AppContext); - const showModal = data !== null || fileListError !== null; + const showModal = data !== null || listTorrentError !== null; // Get the torrent file list if there's data. useEffect(() => { @@ -394,9 +394,9 @@ const UploadButton = ({ buttonText, onClick, data, resetData, variant }) => { setLoading(true); try { const response = await API.uploadTorrent(data, { listOnly: true }); - setFileList(response.details.files); + setListTorrentResponse(response); } catch (e) { - setFileListError({ text: 'Error uploading torrent', details: e }); + setListTorrentError({ text: 'Error listing torrent files', details: e }); } finally { setLoading(false); } @@ -406,8 +406,8 @@ const UploadButton = ({ buttonText, onClick, data, resetData, variant }) => { const clear = () => { resetData(); - setFileListError(null); - setFileList([]); + setListTorrentError(null); + setListTorrentResponse(null); setLoading(false); } @@ -420,10 +420,10 @@ const UploadButton = ({ buttonText, onClick, data, resetData, variant }) => { ); @@ -471,12 +471,12 @@ const FileInput = () => { const FileSelectionModal = (props: { show: boolean, onHide: () => void, - fileList: Array, - fileListError: Error, - fileListLoading: boolean, + listTorrentResponse: AddTorrentResponse, + listTorrentError: Error, + listTorrentLoading: boolean, data: string | File }) => { - let { show, onHide, fileList, fileListError, fileListLoading, data } = props; + let { show, onHide, listTorrentResponse, listTorrentError, listTorrentLoading, data } = props; const [selectedFiles, setSelectedFiles] = useState([]); const [uploading, setUploading] = useState(false); @@ -485,8 +485,8 @@ const FileSelectionModal = (props: { const ctx = useContext(AppContext); useEffect(() => { - setSelectedFiles(fileList.map((_, id) => id)); - }, [fileList]); + setSelectedFiles(listTorrentResponse ? listTorrentResponse.details.files.map((_, id) => id) : []); + }, [listTorrentResponse]); const clear = () => { onHide(); @@ -505,7 +505,8 @@ const FileSelectionModal = (props: { const handleUpload = async () => { setUploading(true); - API.uploadTorrent(data, { selectedFiles, unpopularTorrent }).then(() => { + let initialPeers = listTorrentResponse.seen_peers ? listTorrentResponse.seen_peers.slice(0, 32) : null; + API.uploadTorrent(data, { selectedFiles, unpopularTorrent, initialPeers }).then(() => { onHide(); ctx.refreshTorrents(); }, @@ -518,16 +519,16 @@ const FileSelectionModal = (props: { return ( - {!!fileListError || Add torrent} + Add torrent
Pick the files to download - {fileListLoading ? - : fileListError ? : + {listTorrentLoading ? + : listTorrentError ? : <> - {fileList.map((file, index) => ( + {listTorrentResponse?.details.files.map((file, index) => ( {uploading && } -