From 5eb01ac226f8b7afb4ad6909e39349e607101ba5 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Sat, 6 Apr 2024 09:20:03 +0100 Subject: [PATCH] Ability to change the list of files at any time, including through UI (#115) * Now can update the list of files without pausing/unpausing * Shrink a few functions * Reopen write when updating files * Todos * opened_file abstraction * iter_pieces_within iterator * Simplify iter_pieces_within * Simplify iter_pieces_within * Add "iter_file_details" * temporarily broken: readonly by default * Live torrent - reopen files * Reopen files after changing the list * Now reopening files read only when they are completed * Fix a bug in opened_file.rs * update todos * update help * Reconnect all peers that are idling * Add a couple fields to OpenedFile * Add a couple fields to OpenedFile * Small cleanups - use the new iterator where possible * size_of_piece_in_file function * Updating have * Include file progress * Almost nothing * ugly progress bars * bad UI, saving * its not so bad * Works now * update progress bar a bit * Reopen read-only on pause * Zero bytes isnt too bad! Doesnt break anything * fix per file progress bars * progress bar not as ugly anymore? * ui tweaks * fix a react bug * TODO.md update * Fix js + TODOs * Compute per-file progress on init * Fix stats updating live * Nothing * Nothing * cleanup ui a bit * Nothing * Final fixes * Trying to fix rust 1.73 * Sorting filenames * remove unnecessary indentation * Remove unnecessary comment --- TODO.md | 15 ++ crates/librqbit/src/chunk_tracker.rs | 74 ++++-- crates/librqbit/src/file_ops.rs | 78 +++--- crates/librqbit/src/lib.rs | 1 + crates/librqbit/src/opened_file.rs | 97 +++++++ crates/librqbit/src/session.rs | 13 +- crates/librqbit/src/tests/e2e.rs | 3 +- .../src/torrent_state/initializing.rs | 133 +++++----- crates/librqbit/src/torrent_state/live/mod.rs | 240 ++++++++++-------- .../src/torrent_state/live/peer/mod.rs | 17 +- .../src/torrent_state/live/peers/mod.rs | 2 +- crates/librqbit/src/torrent_state/mod.rs | 49 ++-- crates/librqbit/src/torrent_state/paused.rs | 21 +- crates/librqbit/src/torrent_state/stats.rs | 1 + crates/librqbit/src/type_aliases.rs | 3 + .../webui/node_modules/.package-lock.json | 20 +- crates/librqbit/webui/package-lock.json | 22 +- crates/librqbit/webui/package.json | 2 + crates/librqbit/webui/src/api-types.ts | 1 + .../webui/src/components/FileListInput.tsx | 94 +++++-- .../webui/src/components/ProgressBar.tsx | 21 +- .../webui/src/components/TorrentRow.tsx | 193 +++++++++----- .../src/components/buttons/IconButton.tsx | 2 +- .../src/components/buttons/TorrentActions.tsx | 26 +- .../src/components/forms/FormCheckbox.tsx | 16 +- .../components/modal/FileSelectionModal.tsx | 1 + .../components/modal/TorrentSettingsModal.tsx | 90 ------- crates/librqbit_core/src/lengths.rs | 99 ++++++++ crates/librqbit_core/src/torrent_metainfo.rs | 35 ++- desktop/package-lock.json | 2 + desktop/src/configure.tsx | 6 +- 31 files changed, 865 insertions(+), 512 deletions(-) create mode 100644 crates/librqbit/src/opened_file.rs delete mode 100644 crates/librqbit/webui/src/components/modal/TorrentSettingsModal.tsx diff --git a/TODO.md b/TODO.md index 2daacd9..5d44f10 100644 --- a/TODO.md +++ b/TODO.md @@ -68,3 +68,18 @@ refactor: - [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 + +- reopen: + + - [x] in general, the only time the file should be write-only, is when it's live and not yet fully downloaded + - [x] initializing: open read-only if file has all pieces + - [x] on piece validated open read-only all files that were copleted + - [x] would be nice to have some abstraction that walks files and their pieces + - [ ] nit: optimize open write/read/write right away on first start + - [x] peers: if finished they are all paused forever, but if we change the list of files, we need to restart them + +- [x] opened_files: track HAVE progress + - [x] actually track + - [x] show in API and UI + - [x] refresh when downloading (it doesn't somehow) + - [x] on restart, this is not computed, compute diff --git a/crates/librqbit/src/chunk_tracker.rs b/crates/librqbit/src/chunk_tracker.rs index 1d304b2..c229a74 100644 --- a/crates/librqbit/src/chunk_tracker.rs +++ b/crates/librqbit/src/chunk_tracker.rs @@ -33,9 +33,13 @@ pub struct ChunkTracker { // What pieces to download first. priority_piece_ids: Vec, + + // Quick to retrieve stats, that MUST be in sync with the BFs + // above (have/selected). + hns: HaveNeededSelected, } -#[derive(Debug, PartialEq, Eq, Clone, Copy)] +#[derive(Default, Debug, PartialEq, Eq, Clone, Copy)] pub struct HaveNeededSelected { // How many bytes we have downloaded and verified. pub have_bytes: u64, @@ -146,7 +150,7 @@ impl ChunkTracker { // E.g. if it's a video file, than the last piece often contains some index, or just // players look into it, and it's better be there. let priority_piece_ids = last_needed_piece_id.into_iter().collect(); - Ok(Self { + let mut ct = Self { chunk_status: compute_chunk_have_status(&lengths, &have_pieces) .context("error computing chunk status")?, queue_pieces: needed_pieces, @@ -154,7 +158,10 @@ impl ChunkTracker { lengths, have: have_pieces, priority_piece_ids, - }) + hns: HaveNeededSelected::default(), + }; + ct.hns = ct.calc_hns(); + Ok(ct) } pub fn get_lengths(&self) -> &Lengths { @@ -164,34 +171,31 @@ impl ChunkTracker { pub fn get_have_pieces(&self) -> &BF { &self.have } + + pub fn get_selected_pieces(&self) -> &BF { + &self.selected + } pub fn reserve_needed_piece(&mut self, index: ValidPieceIndex) { self.queue_pieces.set(index.get() as usize, false) } - pub fn calc_have_bytes(&self) -> u64 { - self.have - .iter_ones() - .filter_map(|piece_id| { - let piece_id = self.lengths.validate_piece_index(piece_id as u32)?; - Some(self.lengths.piece_length(piece_id) as u64) - }) - .sum() + pub fn get_hns(&self) -> &HaveNeededSelected { + &self.hns } - pub fn calc_needed_bytes(&self) -> u64 { - self.have - .iter() - .zip(self.selected.iter()) - .enumerate() - .filter_map(|(piece_id, (have, selected))| { - if *selected && !*have { - let piece_id = self.lengths.validate_piece_index(piece_id as u32)?; - Some(self.lengths.piece_length(piece_id) as u64) - } else { - None - } - }) - .sum() + fn calc_hns(&self) -> HaveNeededSelected { + let mut hns = HaveNeededSelected::default(); + for piece in self.lengths.iter_piece_infos() { + let id = piece.piece_index.get() as usize; + let len = piece.len as u64; + let is_have = self.have[id]; + let is_selected = self.selected[id]; + let is_needed = is_selected && !is_have; + hns.have_bytes += len * (is_have as u64); + hns.selected_bytes += len * (is_selected as u64); + hns.needed_bytes += len * (is_needed as u64); + } + hns } pub fn iter_queued_pieces(&self) -> impl Iterator + '_ { @@ -242,7 +246,15 @@ impl ChunkTracker { } pub fn mark_piece_downloaded(&mut self, idx: ValidPieceIndex) { - self.have.set(idx.get() as usize, true); + let id = idx.get() as usize; + if !self.have[id] { + self.have.set(id, true); + let len = self.lengths.piece_length(idx) as u64; + self.hns.have_bytes += len; + if self.selected[id] { + self.hns.needed_bytes -= len; + } + } } pub fn is_chunk_ready_to_upload(&self, chunk: &ChunkInfo) -> bool { @@ -252,6 +264,10 @@ impl ChunkTracker { .unwrap_or(false) } + pub fn get_remaining_bytes(&self) -> u64 { + self.hns.needed_bytes + } + // return true if the whole piece is marked downloaded pub fn mark_chunk_downloaded( &mut self, @@ -356,11 +372,13 @@ impl ChunkTracker { } } - Ok(HaveNeededSelected { + let res = HaveNeededSelected { have_bytes, needed_bytes, selected_bytes, - }) + }; + self.hns = res; + Ok(res) } } diff --git a/crates/librqbit/src/file_ops.rs b/crates/librqbit/src/file_ops.rs index f395218..15ab97e 100644 --- a/crates/librqbit/src/file_ops.rs +++ b/crates/librqbit/src/file_ops.rs @@ -2,24 +2,23 @@ use std::{ fs::File, io::{Read, Seek, SeekFrom, Write}, marker::PhantomData, - sync::{ - atomic::{AtomicU64, Ordering}, - Arc, - }, + sync::atomic::{AtomicU64, Ordering}, }; use anyhow::Context; use buffers::ByteBufOwned; use librqbit_core::{ lengths::{ChunkInfo, Lengths, ValidPieceIndex}, - torrent_metainfo::{FileIteratorName, TorrentMetaV1Info}, + torrent_metainfo::TorrentMetaV1Info, }; -use parking_lot::Mutex; use peer_binary_protocol::Piece; use sha1w::{ISha1, Sha1}; use tracing::{debug, trace, warn}; -use crate::type_aliases::{PeerHandle, BF}; +use crate::{ + opened_file::OpenedFile, + type_aliases::{OpenedFiles, PeerHandle, BF}, +}; pub(crate) struct InitialCheckResults { // A piece as flags based on these dimensions: @@ -64,7 +63,7 @@ pub fn update_hash_from_file( pub(crate) struct FileOps<'a> { torrent: &'a TorrentMetaV1Info, - files: &'a [Arc>], + files: &'a OpenedFiles, lengths: &'a Lengths, phantom_data: PhantomData, } @@ -72,7 +71,7 @@ pub(crate) struct FileOps<'a> { impl<'a> FileOps<'a> { pub fn new( torrent: &'a TorrentMetaV1Info, - files: &'a [Arc>], + files: &'a OpenedFiles, lengths: &'a Lengths, ) -> Self { Self { @@ -86,6 +85,8 @@ impl<'a> FileOps<'a> { pub fn initial_check( &self, only_files: Option<&[usize]>, + opened_files: &OpenedFiles, + lengths: &Lengths, progress: &AtomicU64, ) -> anyhow::Result { let mut needed_pieces = @@ -96,46 +97,38 @@ impl<'a> FileOps<'a> { let mut have_bytes = 0u64; let mut needed_bytes = 0u64; let mut total_selected_bytes = 0u64; + let mut piece_files = Vec::::new(); #[derive(Debug)] struct CurrentFile<'a> { index: usize, - fd: &'a Arc>, - len: u64, - name: FileIteratorName<'a, ByteBufOwned>, + fd: &'a OpenedFile, full_file_required: bool, processed_bytes: u64, is_broken: bool, } impl<'a> CurrentFile<'a> { fn remaining(&self) -> u64 { - self.len - self.processed_bytes + self.fd.len - self.processed_bytes } fn mark_processed_bytes(&mut self, bytes: u64) { self.processed_bytes += bytes } } - let mut file_iterator = self - .files - .iter() - .zip(self.torrent.iter_filenames_and_lengths()?) - .enumerate() - .map(|(idx, (fd, (name, len)))| { - let full_file_required = if let Some(only_files) = only_files { - only_files.contains(&idx) - } else { - true - }; - CurrentFile { - index: idx, - fd, - len, - name, - full_file_required, - processed_bytes: 0, - is_broken: false, - } - }); + let mut file_iterator = self.files.iter().enumerate().map(|(idx, fd)| { + let full_file_required = if let Some(only_files) = only_files { + only_files.contains(&idx) + } else { + true + }; + CurrentFile { + index: idx, + fd, + full_file_required, + processed_bytes: 0, + is_broken: false, + } + }); let mut current_file = file_iterator .next() @@ -144,6 +137,7 @@ impl<'a> FileOps<'a> { let mut read_buffer = vec![0u8; 65536]; for piece_info in self.lengths.iter_piece_infos() { + piece_files.clear(); let mut computed_hash = Sha1::new(); let mut piece_remaining = piece_info.len as usize; let mut some_files_broken = false; @@ -166,6 +160,8 @@ impl<'a> FileOps<'a> { std::cmp::min(current_file.remaining(), piece_remaining as u64) as usize; } + piece_files.push(current_file.index); + let pos = current_file.processed_bytes; piece_remaining -= to_read_in_file; current_file.mark_processed_bytes(to_read_in_file as u64); @@ -175,7 +171,7 @@ impl<'a> FileOps<'a> { continue; } - let mut fd = current_file.fd.lock(); + let mut fd = current_file.fd.file.lock(); fd.seek(SeekFrom::Start(pos)) .context("bug? error seeking")?; @@ -187,7 +183,7 @@ impl<'a> FileOps<'a> { ) { debug!( "error reading from file {} ({:?}) at {}: {:#}", - current_file.index, current_file.name, pos, &err + current_file.index, current_file.fd.filename, pos, &err ); current_file.is_broken = true; some_files_broken = true; @@ -219,6 +215,10 @@ impl<'a> FileOps<'a> { piece_info.piece_index ); have_bytes += piece_info.len as u64; + for file_id in piece_files.drain(..) { + opened_files[file_id] + .update_have_on_piece_completed(piece_info.piece_index.get(), lengths); + } have_pieces.set(piece_info.piece_index.get() as usize, true); } else if piece_selected { trace!( @@ -266,7 +266,7 @@ impl<'a> FileOps<'a> { 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(); + let mut file_g = self.files[file_idx].file.lock(); trace!( "piece={}, handle={}, file_idx={}, seeking to {}. Last received chunk: {:?}", piece_index, @@ -334,7 +334,7 @@ impl<'a> FileOps<'a> { let file_remaining_len = file_len - absolute_offset; 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(); + let mut file_g = self.files[file_idx].file.lock(); trace!( "piece={}, handle={}, file_idx={}, seeking to {}. To read chunk: {:?}", chunk_info.piece_index, @@ -387,7 +387,7 @@ impl<'a> FileOps<'a> { let remaining_len = file_len - absolute_offset; let to_write = std::cmp::min(buf.len(), remaining_len as usize); - let mut file_g = self.files[file_idx].lock(); + let mut file_g = self.files[file_idx].file.lock(); trace!( "piece={}, chunk={:?}, handle={}, begin={}, file={}, writing {} bytes at {}", chunk_info.piece_index, diff --git a/crates/librqbit/src/lib.rs b/crates/librqbit/src/lib.rs index e2727a4..47bbb7a 100644 --- a/crates/librqbit/src/lib.rs +++ b/crates/librqbit/src/lib.rs @@ -31,6 +31,7 @@ mod dht_utils; mod file_ops; pub mod http_api; pub mod http_api_client; +mod opened_file; mod peer_connection; mod peer_info_reader; mod read_buf; diff --git a/crates/librqbit/src/opened_file.rs b/crates/librqbit/src/opened_file.rs new file mode 100644 index 0000000..915ce58 --- /dev/null +++ b/crates/librqbit/src/opened_file.rs @@ -0,0 +1,97 @@ +use std::{ + fs::File, + path::PathBuf, + sync::atomic::{AtomicU64, Ordering}, +}; + +use anyhow::Context; +use librqbit_core::lengths::Lengths; +use parking_lot::Mutex; +use tracing::debug; + +#[derive(Debug)] +pub(crate) struct OpenedFile { + pub file: Mutex, + pub filename: PathBuf, + pub offset_in_torrent: u64, + pub have: AtomicU64, + pub piece_range: std::ops::Range, + pub len: u64, +} + +pub(crate) fn dummy_file() -> anyhow::Result { + #[cfg(target_os = "windows")] + const DEVNULL: &str = "NUL"; + #[cfg(not(target_os = "windows"))] + const DEVNULL: &str = "/dev/null"; + + std::fs::OpenOptions::new() + .read(true) + .open(DEVNULL) + .with_context(|| format!("error opening {}", DEVNULL)) +} + +impl OpenedFile { + pub fn new( + f: File, + filename: PathBuf, + have: u64, + len: u64, + offset_in_torrent: u64, + piece_range: std::ops::Range, + ) -> Self { + Self { + file: Mutex::new(f), + filename, + have: AtomicU64::new(have), + len, + offset_in_torrent, + piece_range, + } + } + pub fn reopen(&self, read_only: bool) -> anyhow::Result<()> { + let log_suffix = if read_only { " read only" } else { "" }; + + let mut open_opts = std::fs::OpenOptions::new(); + open_opts.read(true); + if !read_only { + open_opts.write(true).create(false); + } + + let mut g = self.file.lock(); + *g = open_opts + .open(&self.filename) + .with_context(|| format!("error re-opening {:?}{log_suffix}", self.filename))?; + debug!("reopened {:?}{log_suffix}", self.filename); + Ok(()) + } + + pub fn take(&self) -> anyhow::Result { + let mut f = self.file.lock(); + let dummy = dummy_file()?; + let f = std::mem::replace(&mut *f, dummy); + Ok(f) + } + + pub fn take_clone(&self) -> anyhow::Result { + let f = self.take()?; + Ok(Self { + file: Mutex::new(f), + filename: self.filename.clone(), + offset_in_torrent: self.offset_in_torrent, + have: AtomicU64::new(self.have.load(Ordering::Relaxed)), + len: self.len, + piece_range: self.piece_range.clone(), + }) + } + + pub fn piece_range_usize(&self) -> std::ops::Range { + self.piece_range.start as usize..self.piece_range.end as usize + } + + pub fn update_have_on_piece_completed(&self, piece_id: u32, lengths: &Lengths) -> u64 { + let size = lengths.size_of_piece_in_file(piece_id, self.offset_in_torrent, self.len); + self.have.fetch_add(size, Ordering::Relaxed); + size + } +} diff --git a/crates/librqbit/src/session.rs b/crates/librqbit/src/session.rs index 7698dc7..d078818 100644 --- a/crates/librqbit/src/session.rs +++ b/crates/librqbit/src/session.rs @@ -1083,10 +1083,10 @@ impl Session { warn!(error=?e, "error deleting torrent cleanly"); } (Ok(Some(paused)), true) => { - drop(paused.files); - for file in paused.filenames { - if let Err(e) = std::fs::remove_file(&file) { - warn!(?file, error=?e, "could not delete file"); + for file in paused.files.iter() { + drop(file.take()?); + if let Err(e) = std::fs::remove_file(&file.filename) { + warn!(?file.filename, error=?e, "could not delete file"); } } } @@ -1142,10 +1142,7 @@ impl Session { handle: &ManagedTorrentHandle, only_files: &HashSet, ) -> anyhow::Result<()> { - let need_to_unpause = handle.update_only_files(only_files)?; - if need_to_unpause { - self.unpause(handle)?; - } + handle.update_only_files(only_files)?; Ok(()) } diff --git a/crates/librqbit/src/tests/e2e.rs b/crates/librqbit/src/tests/e2e.rs index 8498920..938ca11 100644 --- a/crates/librqbit/src/tests/e2e.rs +++ b/crates/librqbit/src/tests/e2e.rs @@ -163,6 +163,7 @@ async fn test_e2e() { crate::AddTorrent::TorrentFileBytes(Cow::Owned(torrent_file_bytes.clone())), Some(AddTorrentOptions { initial_peers: Some(peers.clone()), + // only_files: Some(vec![0]), overwrite: false, ..Default::default() }), @@ -253,7 +254,7 @@ async fn test_e2e() { .with_state(|s| match s { crate::ManagedTorrentState::Initializing(_) => Ok(false), crate::ManagedTorrentState::Paused(p) => { - assert_eq!(p.hns.needed_bytes, 0); + assert_eq!(p.chunk_tracker.get_hns().needed_bytes, 0); Ok(true) } _ => bail!("bugged state"), diff --git a/crates/librqbit/src/torrent_state/initializing.rs b/crates/librqbit/src/torrent_state/initializing.rs index b4aa62b..c405e56 100644 --- a/crates/librqbit/src/torrent_state/initializing.rs +++ b/crates/librqbit/src/torrent_state/initializing.rs @@ -6,14 +6,12 @@ use std::{ use anyhow::Context; -use parking_lot::Mutex; - use size_format::SizeFormatterBinary as SF; use tracing::{debug, info, warn}; use crate::{ - chunk_tracker::{ChunkTracker, HaveNeededSelected}, - file_ops::FileOps, + chunk_tracker::ChunkTracker, file_ops::FileOps, opened_file::OpenedFile, + type_aliases::OpenedFiles, }; use super::{paused::TorrentStatePaused, ManagedTorrentInfo}; @@ -43,48 +41,52 @@ impl TorrentStateInitializing { } pub async fn check(&self) -> anyhow::Result { - let (files, filenames) = { - let mut files = - Vec::>>::with_capacity(self.meta.info.iter_file_lengths()?.count()); - let mut filenames = Vec::new(); - for (path_bits, _) in self.meta.info.iter_filenames_and_lengths()? { - let mut full_path = self.meta.out_dir.clone(); - let relative_path = path_bits - .to_pathbuf() - .context("error converting file to path")?; - full_path.push(relative_path); + let mut files = OpenedFiles::new(); + for file_details in self.meta.info.iter_file_details(&self.meta.lengths)? { + let mut full_path = self.meta.out_dir.clone(); + let relative_path = file_details + .filename + .to_pathbuf() + .context("error converting file to path")?; + full_path.push(relative_path); - std::fs::create_dir_all(full_path.parent().unwrap())?; - let file = if self.meta.options.overwrite { - OpenOptions::new() - .create(true) - .read(true) - .write(true) - .open(&full_path) - .with_context(|| { - format!("error opening {full_path:?} in read/write mode") - })? - } else { - // TODO: create_new does not seem to work with read(true), so calling this twice. - OpenOptions::new() - .create_new(true) - .write(true) - .open(&full_path) - .with_context(|| format!("error creating {:?}", &full_path))?; - OpenOptions::new().read(true).write(true).open(&full_path)? - }; - filenames.push(full_path); - files.push(Arc::new(Mutex::new(file))) - } - (files, filenames) - }; + std::fs::create_dir_all(full_path.parent().context("bug: no parent")?)?; + let file = if self.meta.options.overwrite { + OpenOptions::new() + .create(true) + .read(true) + .write(true) + .open(&full_path) + .with_context(|| format!("error opening {full_path:?} in read/write mode"))? + } else { + // TODO: create_new does not seem to work with read(true), so calling this twice. + OpenOptions::new() + .create_new(true) + .write(true) + .open(&full_path) + .with_context(|| format!("error creating {:?}", &full_path))?; + OpenOptions::new().read(true).write(true).open(&full_path)? + }; + files.push(OpenedFile::new( + file, + full_path, + 0, + file_details.len, + file_details.offset, + file_details.pieces, + )); + } debug!("computed lengths: {:?}", &self.meta.lengths); info!("Doing initial checksum validation, this might take a while..."); let initial_check_results = self.meta.spawner.spawn_block_in_place(|| { - FileOps::new(&self.meta.info, &files, &self.meta.lengths) - .initial_check(self.only_files.as_deref(), &self.checked_bytes) + FileOps::new(&self.meta.info, &files, &self.meta.lengths).initial_check( + self.only_files.as_deref(), + &files, + &self.meta.lengths, + &self.checked_bytes, + ) })?; info!( @@ -94,36 +96,35 @@ impl TorrentStateInitializing { SF::new(initial_check_results.selected_bytes) ); + // Ensure file lenghts are correct, and reopen read-only. self.meta.spawner.spawn_block_in_place(|| { - for (idx, (file, (name, length))) in files - .iter() - .zip(self.meta.info.iter_filenames_and_lengths().unwrap()) - .enumerate() - { + for (idx, file) in files.iter().enumerate() { if self .only_files .as_ref() - .map(|v| !v.contains(&idx)) - .unwrap_or(false) + .map(|v| v.contains(&idx)) + .unwrap_or(true) { - continue; - } - let now = Instant::now(); - if let Err(err) = ensure_file_length(&file.lock(), length) { - warn!( - "Error setting length for file {:?} to {}: {:#?}", - name, length, err - ); - } else { - debug!( - "Set length for file {:?} to {} in {:?}", - name, - SF::new(length), - now.elapsed() - ); + let now = Instant::now(); + if let Err(err) = ensure_file_length(&file.file.lock(), file.len) { + warn!( + "Error setting length for file {:?} to {}: {:#?}", + file.filename, file.len, err + ); + } else { + debug!( + "Set length for file {:?} to {} in {:?}", + file.filename, + SF::new(file.len), + now.elapsed() + ); + } } + + file.reopen(true)?; } - }); + Ok::<_, anyhow::Error>(()) + })?; let chunk_tracker = ChunkTracker::new( initial_check_results.have_pieces, @@ -135,13 +136,7 @@ impl TorrentStateInitializing { let paused = TorrentStatePaused { info: self.meta.clone(), files, - filenames, chunk_tracker, - hns: HaveNeededSelected { - have_bytes: initial_check_results.have_bytes, - needed_bytes: initial_check_results.needed_bytes, - selected_bytes: initial_check_results.selected_bytes, - }, }; Ok(paused) } diff --git a/crates/librqbit/src/torrent_state/live/mod.rs b/crates/librqbit/src/torrent_state/live/mod.rs index a9611ee..ece1090 100644 --- a/crates/librqbit/src/torrent_state/live/mod.rs +++ b/crates/librqbit/src/torrent_state/live/mod.rs @@ -44,10 +44,8 @@ pub mod peers; pub mod stats; use std::{ - collections::HashMap, - fs::File, + collections::{HashMap, HashSet}, net::SocketAddr, - path::PathBuf, sync::{ atomic::{AtomicU64, Ordering}, Arc, @@ -60,7 +58,6 @@ use backoff::backoff::Backoff; use buffers::{ByteBuf, ByteBufOwned}; use clone_to_owned::CloneToOwned; use futures::{stream::FuturesUnordered, StreamExt}; -use itertools::Itertools; use librqbit_core::{ hash_id::Id20, lengths::{ChunkInfo, Lengths, ValidPieceIndex}, @@ -68,7 +65,7 @@ use librqbit_core::{ speed_estimator::SpeedEstimator, torrent_metainfo::TorrentMetaV1Info, }; -use parking_lot::{Mutex, RwLock, RwLockReadGuard, RwLockWriteGuard}; +use parking_lot::{RwLock, RwLockReadGuard, RwLockWriteGuard}; use peer_binary_protocol::{ extended::handshake::ExtendedHandshake, Handshake, Message, MessageOwned, Piece, Request, }; @@ -90,7 +87,7 @@ use crate::{ }, session::CheckedIncomingConnection, torrent_state::{peer::Peer, utils::atomic_inc}, - type_aliases::{PeerHandle, BF}, + type_aliases::{OpenedFiles, PeerHandle, BF}, }; use self::{ @@ -116,18 +113,6 @@ struct InflightPiece { started: Instant, } -fn dummy_file() -> anyhow::Result { - #[cfg(target_os = "windows")] - const DEVNULL: &str = "NUL"; - #[cfg(not(target_os = "windows"))] - const DEVNULL: &str = "/dev/null"; - - std::fs::OpenOptions::new() - .read(true) - .open(DEVNULL) - .with_context(|| format!("error opening {}", DEVNULL)) -} - fn make_piece_bitfield(lengths: &Lengths) -> BF { BF::from_boxed_slice(vec![0; lengths.piece_bitfield_bytes()].into_boxed_slice()) } @@ -170,11 +155,7 @@ pub struct TorrentStateLive { meta: Arc, locked: RwLock, - files: Vec>>, - filenames: Vec, - - initially_needed_bytes: u64, - total_selected_bytes: u64, + files: OpenedFiles, stats: AtomicStats, lengths: Lengths, @@ -192,22 +173,48 @@ pub struct TorrentStateLive { cancellation_token: CancellationToken, } +fn reopen_necessary_files_for_write(ct: &ChunkTracker, files: &OpenedFiles) -> anyhow::Result<()> { + // Reopen files that we don't have, but have selected in write-only mode. + for opened_file in files.iter() { + let prange = opened_file.piece_range_usize(); + if prange.is_empty() { + continue; + } + let selected = ct + .get_selected_pieces() + .get(prange.clone()) + .with_context(|| format!("bug: bad range get_selected_pieces(), {prange:?}"))?; + let have = ct + .get_have_pieces() + .get(prange.clone()) + .with_context(|| format!("bug: bad range get_have_pieces(), {prange:?}"))?; + let need_write = selected + .iter() + .zip(have.iter()) + .any(|(selected, have)| *selected && !*have); + if need_write { + opened_file.reopen(false)?; + } + } + Ok(()) +} + impl TorrentStateLive { pub(crate) fn new( paused: TorrentStatePaused, fatal_errors_tx: tokio::sync::oneshot::Sender, cancellation_token: CancellationToken, - ) -> Arc { + ) -> anyhow::Result> { let (peer_queue_tx, peer_queue_rx) = unbounded_channel(); let down_speed_estimator = SpeedEstimator::new(5); let up_speed_estimator = SpeedEstimator::new(5); - let have_bytes = paused.hns.have_bytes; - let needed_bytes = paused.hns.needed_bytes; - let total_selected_bytes = paused.hns.selected_bytes; + let have_bytes = paused.chunk_tracker.get_hns().have_bytes; let lengths = *paused.chunk_tracker.get_lengths(); + reopen_necessary_files_for_write(&paused.chunk_tracker, &paused.files)?; + let state = Arc::new(TorrentStateLive { meta: paused.info.clone(), peers: Default::default(), @@ -217,14 +224,11 @@ impl TorrentStateLive { fatal_errors_tx: Some(fatal_errors_tx), }), files: paused.files, - filenames: paused.filenames, stats: AtomicStats { have_bytes: AtomicU64::new(have_bytes), ..Default::default() }, - initially_needed_bytes: needed_bytes, lengths, - total_selected_bytes, peer_semaphore: Arc::new(Semaphore::new(128)), peer_queue_tx, finished_notify: Notify::new(), @@ -246,9 +250,7 @@ impl TorrentStateLive { let now = Instant::now(); let stats = state.stats_snapshot(); let fetched = stats.fetched_bytes; - let needed = state.initially_needed(); - // TODO: this is too coarse. - let remaining = needed - stats.downloaded_and_checked_bytes; + let remaining = state.locked.read().get_chunks()?.get_remaining_bytes(); state .down_speed_estimator .add_snapshot(fetched, Some(remaining), now); @@ -265,7 +267,7 @@ impl TorrentStateLive { error_span!(parent: state.meta.span.clone(), "peer_adder"), state.clone().task_peer_adder(peer_queue_rx), ); - state + Ok(state) } pub(crate) fn spawn( @@ -494,9 +496,6 @@ impl TorrentStateLive { pub(crate) fn file_ops(&self) -> FileOps<'_> { FileOps::new(&self.meta.info, &self.files, &self.lengths) } - pub fn initially_needed(&self) -> u64 { - self.initially_needed_bytes - } pub(crate) fn lock_read( &self, @@ -518,10 +517,6 @@ impl TorrentStateLive { }); } - pub fn get_total_selected_bytes(&self) -> u64 { - self.total_selected_bytes - } - pub fn get_uploaded_bytes(&self) -> u64 { self.stats.uploaded_bytes.load(Ordering::Relaxed) } @@ -535,12 +530,11 @@ impl TorrentStateLive { self.stats.have_bytes.load(Ordering::Relaxed) } - pub fn is_finished(&self) -> bool { - self.get_left_to_download_bytes() == 0 - } - - pub fn get_left_to_download_bytes(&self) -> u64 { - self.initially_needed_bytes - self.get_downloaded_bytes() + pub fn get_hns(&self) -> Option { + self.lock_read("get_hns") + .get_chunks() + .ok() + .map(|c| *c.get_hns()) } fn maybe_transmit_haves(&self, index: ValidPieceIndex) { @@ -653,16 +647,11 @@ impl TorrentStateLive { let files = self .files .iter() - .map(|f| { - let mut f = f.lock(); - let dummy = dummy_file()?; - let f = std::mem::replace(&mut *f, dummy); - Ok::<_, anyhow::Error>(Arc::new(Mutex::new(f))) - }) - .try_collect()?; - - let filenames = self.filenames.clone(); - + .map(|f| f.take_clone()) + .collect::>>()?; + for file in files.iter() { + file.reopen(true)?; + } let mut chunk_tracker = g .chunks .take() @@ -670,20 +659,12 @@ impl TorrentStateLive { for piece_id in g.inflight_pieces.keys().copied() { chunk_tracker.mark_piece_broken_if_not_have(piece_id); } - let have_bytes = chunk_tracker.calc_have_bytes(); - let needed_bytes = chunk_tracker.calc_needed_bytes(); // g.chunks; Ok(TorrentStatePaused { info: self.meta.clone(), files, - filenames, chunk_tracker, - hns: HaveNeededSelected { - have_bytes, - needed_bytes, - selected_bytes: self.total_selected_bytes, - }, }) } @@ -699,6 +680,92 @@ impl TorrentStateLive { } Err(res) } + + pub(crate) fn update_only_files(&self, only_files: &HashSet) -> anyhow::Result<()> { + let mut g = self.lock_write("update_only_files"); + let ct = g.get_chunks_mut()?; + let hns = ct.update_only_files(self.files.iter().map(|f| f.len), only_files)?; + reopen_necessary_files_for_write(ct, &self.files)?; + if !hns.finished() { + self.reconnect_all_not_needed_peers(); + } + Ok(()) + } + + pub(crate) fn is_finished(&self) -> bool { + self.get_hns().map(|h| h.finished()).unwrap_or_default() + } + + fn on_piece_completed(&self, id: ValidPieceIndex) -> anyhow::Result<()> { + // if we have all the pieces of the file, reopen it read only + for (idx, opened_file) in self + .files + .iter() + .enumerate() + .skip_while(|fd| !fd.1.piece_range.contains(&id.get())) + .take_while(|fd| fd.1.piece_range.contains(&id.get())) + { + let bytes = opened_file.update_have_on_piece_completed(id.get(), &self.lengths); + if bytes == 0 { + warn!(file_id=idx, piece_id=id.get(), "bug: update_have_on_piece_completed() returned 0, although this piece is present in the file"); + } + + let have_all = self + .lock_read("on_piece_completed_reopen") + .get_chunks()? + .get_have_pieces() + .get(opened_file.piece_range_usize()) + .with_context(|| { + format!("bug: invalid range {:?}", opened_file.piece_range_usize()) + })? + .all(); + if have_all { + opened_file.reopen(true)?; + } + } + + if self.is_finished() { + info!("torrent finished downloading"); + self.finished_notify.notify_waiters(); + + // There is not poing being connected to peers that have all the torrent, when + // we don't need anything from them, and they don't need anything from us. + self.disconnect_all_peers_that_have_full_torrent(); + } + Ok(()) + } + + fn disconnect_all_peers_that_have_full_torrent(&self) { + for mut pe in self.peers.states.iter_mut() { + if let PeerState::Live(l) = pe.value().state.get() { + if l.has_full_torrent(self.lengths.total_pieces() as usize) { + let prev = pe.value_mut().state.set_not_needed(&self.peers.stats); + let _ = prev + .take_live_no_counters() + .unwrap() + .tx + .send(WriterRequest::Disconnect); + } + } + } + } + + fn reconnect_all_not_needed_peers(&self) { + for pe in self.peers.states.iter() { + if let PeerState::NotNeeded = pe.value().state.get() { + if self.peer_queue_tx.send(*pe.key()).is_err() { + return; + } + } + } + } + + pub(crate) fn get_file_progress(&self) -> Vec { + self.files + .iter() + .map(|fd| fd.have.load(Ordering::Relaxed)) + .collect() + } } struct PeerHandlerLocked { @@ -1202,27 +1269,6 @@ impl PeerHandler { self.state.peers.mark_peer_interested(self.addr, true); } - fn reopen_read_only(&self) -> anyhow::Result<()> { - // Lock exclusive just in case to ensure in-flight operations finish.?? - let _guard = self.state.lock_write("reopen_read_only"); - - for (file, filename) in self.state.files.iter().zip(self.state.filenames.iter()) { - let mut g = file.lock(); - // this should close the original file - // putting in a block just in case to guarantee drop. - { - *g = dummy_file()?; - } - *g = std::fs::OpenOptions::new() - .read(true) - .open(filename) - .with_context(|| format!("error re-opening {:?} readonly", filename))?; - debug!("reopened {:?} read-only", filename); - } - info!("reopened all torrent files in read-only mode"); - Ok(()) - } - fn on_i_am_unchoked(&self) { trace!("we are unchoked"); self.locked.write().i_am_choked = false; @@ -1398,12 +1444,7 @@ impl PeerHandler { debug!("piece={} successfully downloaded and verified", index); - if self.state.is_finished() { - info!("torrent finished downloading"); - self.state.finished_notify.notify_waiters(); - self.disconnect_all_peers_that_have_full_torrent(); - self.reopen_read_only()?; - } + self.state.on_piece_completed(chunk_info.piece_index)?; self.state.maybe_transmit_haves(chunk_info.piece_index); } @@ -1424,19 +1465,4 @@ impl PeerHandler { .with_context(|| format!("error processing received chunk {chunk_info:?}"))?; Ok(()) } - - fn disconnect_all_peers_that_have_full_torrent(&self) { - for mut pe in self.state.peers.states.iter_mut() { - if let PeerState::Live(l) = pe.value().state.get() { - if l.has_full_torrent(self.state.lengths.total_pieces() as usize) { - let prev = pe.value_mut().state.set_not_needed(&self.state.peers.stats); - let _ = prev - .take_live_no_counters() - .unwrap() - .tx - .send(WriterRequest::Disconnect); - } - } - } - } } diff --git a/crates/librqbit/src/torrent_state/live/peer/mod.rs b/crates/librqbit/src/torrent_state/live/peer/mod.rs index 34c0448..50c95da 100644 --- a/crates/librqbit/src/torrent_state/live/peer/mod.rs +++ b/crates/librqbit/src/torrent_state/live/peer/mod.rs @@ -122,17 +122,18 @@ impl PeerStateNoMut { } } - pub fn queued_to_connecting( + pub fn idle_to_connecting( &mut self, counters: &AggregatePeerStatsAtomic, ) -> Option<(PeerRx, PeerTx)> { - if let PeerState::Queued = &self.0 { - let (tx, rx) = unbounded_channel(); - let tx_2 = tx.clone(); - self.set(PeerState::Connecting(tx), counters); - Some((rx, tx_2)) - } else { - None + match &self.0 { + PeerState::Queued | PeerState::NotNeeded => { + let (tx, rx) = unbounded_channel(); + let tx_2 = tx.clone(); + self.set(PeerState::Connecting(tx), counters); + Some((rx, tx_2)) + } + _ => None, } } diff --git a/crates/librqbit/src/torrent_state/live/peers/mod.rs b/crates/librqbit/src/torrent_state/live/peers/mod.rs index f290986..4e51d7b 100644 --- a/crates/librqbit/src/torrent_state/live/peers/mod.rs +++ b/crates/librqbit/src/torrent_state/live/peers/mod.rs @@ -90,7 +90,7 @@ impl PeerStates { let rx = self .with_peer_mut(h, "mark_peer_connecting", |peer| { peer.state - .queued_to_connecting(&self.stats) + .idle_to_connecting(&self.stats) .context("invalid peer state") }) .context("peer not found in states")??; diff --git a/crates/librqbit/src/torrent_state/mod.rs b/crates/librqbit/src/torrent_state/mod.rs index eaeab1c..adb2d62 100644 --- a/crates/librqbit/src/torrent_state/mod.rs +++ b/crates/librqbit/src/torrent_state/mod.rs @@ -268,7 +268,7 @@ impl ManagedTorrent { let (tx, rx) = tokio::sync::oneshot::channel(); let live = - TorrentStateLive::new(paused, tx, live_cancellation_token); + TorrentStateLive::new(paused, tx, live_cancellation_token)?; g.state = ManagedTorrentState::Live(live.clone()); spawn_fatal_errors_receiver(&t, rx, token); @@ -289,7 +289,7 @@ impl ManagedTorrent { ManagedTorrentState::Paused(_) => { let paused = g.state.take().assert_paused(); let (tx, rx) = tokio::sync::oneshot::channel(); - let live = TorrentStateLive::new(paused, tx, live_cancellation_token.clone()); + let live = TorrentStateLive::new(paused, tx, live_cancellation_token.clone())?; g.state = ManagedTorrentState::Live(live.clone()); spawn_fatal_errors_receiver(self, rx, live_cancellation_token); spawn_peer_adder(&live, peer_rx); @@ -337,6 +337,7 @@ impl ManagedTorrent { use stats::TorrentStatsState as S; let mut resp = TorrentStats { total_bytes: self.info().lengths.total_length(), + file_progress: Vec::new(), state: S::Error, error: None, progress_bytes: 0, @@ -353,21 +354,25 @@ impl ManagedTorrent { } ManagedTorrentState::Paused(p) => { resp.state = S::Paused; - resp.total_bytes = p.hns.total(); - resp.progress_bytes = p.hns.progress(); - resp.finished = p.hns.finished(); + let hns = p.hns(); + resp.total_bytes = hns.total(); + resp.progress_bytes = hns.progress(); + resp.finished = hns.finished(); + resp.file_progress = p + .files + .iter() + .map(|f| f.have.load(Ordering::Relaxed)) + .collect(); } ManagedTorrentState::Live(l) => { resp.state = S::Live; let live_stats = LiveStats::from(l.as_ref()); - let total = l.get_total_selected_bytes(); - let remaining = l.get_left_to_download_bytes(); - let progress = total - remaining; - - resp.progress_bytes = progress; - resp.total_bytes = total; - resp.finished = remaining == 0; + let hns = l.get_hns().unwrap_or_default(); + resp.total_bytes = hns.total(); + resp.progress_bytes = hns.progress(); + resp.finished = hns.finished(); resp.uploaded_bytes = l.get_uploaded_bytes(); + resp.file_progress = l.get_file_progress(); resp.live = Some(live_stats); } ManagedTorrentState::Error(e) => { @@ -410,10 +415,7 @@ impl ManagedTorrent { // Returns true if needed to unpause torrent. // This is just implementation detail - it's easier to pause/unpause than to tinker with internals. - pub(crate) fn update_only_files(&self, only_files: &HashSet) -> anyhow::Result { - if only_files.is_empty() { - anyhow::bail!("you need to select at least one file"); - } + pub(crate) fn update_only_files(&self, only_files: &HashSet) -> anyhow::Result<()> { let file_count = self.info().info.iter_file_lengths()?.count(); for f in only_files.iter().copied() { if f >= file_count { @@ -426,25 +428,20 @@ impl ManagedTorrent { // if paused, need to update chunk tracker let mut g = self.locked.write(); - let need_to_unpause = match &mut g.state { + match &mut g.state { ManagedTorrentState::Initializing(_) => bail!("can't update initializing torrent"), - ManagedTorrentState::Error(_) => false, - ManagedTorrentState::None => false, + ManagedTorrentState::Error(_) => {} + ManagedTorrentState::None => {} ManagedTorrentState::Paused(p) => { p.update_only_files(only_files)?; - false } ManagedTorrentState::Live(l) => { - let mut p = l.pause()?; - let e = p.update_only_files(only_files); - g.state = ManagedTorrentState::Paused(p); - e?; - true + l.update_only_files(only_files)?; } }; g.only_files = Some(only_files.iter().copied().collect()); - Ok(need_to_unpause) + Ok(()) } } diff --git a/crates/librqbit/src/torrent_state/paused.rs b/crates/librqbit/src/torrent_state/paused.rs index c5c079e..23d8fd3 100644 --- a/crates/librqbit/src/torrent_state/paused.rs +++ b/crates/librqbit/src/torrent_state/paused.rs @@ -1,25 +1,26 @@ -use std::{collections::HashSet, fs::File, path::PathBuf, sync::Arc}; +use std::{collections::HashSet, sync::Arc}; -use parking_lot::Mutex; - -use crate::chunk_tracker::{ChunkTracker, HaveNeededSelected}; +use crate::{ + chunk_tracker::{ChunkTracker, HaveNeededSelected}, + type_aliases::OpenedFiles, +}; use super::ManagedTorrentInfo; pub struct TorrentStatePaused { pub(crate) info: Arc, - pub(crate) files: Vec>>, - pub(crate) filenames: Vec, + pub(crate) files: OpenedFiles, pub(crate) chunk_tracker: ChunkTracker, - pub(crate) hns: HaveNeededSelected, } impl TorrentStatePaused { pub(crate) fn update_only_files(&mut self, only_files: &HashSet) -> anyhow::Result<()> { - let hns = self - .chunk_tracker + self.chunk_tracker .update_only_files(self.info.info.iter_file_lengths()?, only_files)?; - self.hns = hns; Ok(()) } + + pub(crate) fn hns(&self) -> &HaveNeededSelected { + self.chunk_tracker.get_hns() + } } diff --git a/crates/librqbit/src/torrent_state/stats.rs b/crates/librqbit/src/torrent_state/stats.rs index 0b85e59..ea982e9 100644 --- a/crates/librqbit/src/torrent_state/stats.rs +++ b/crates/librqbit/src/torrent_state/stats.rs @@ -69,6 +69,7 @@ impl std::fmt::Display for TorrentStatsState { #[derive(Serialize, Debug)] pub struct TorrentStats { pub state: TorrentStatsState, + pub file_progress: Vec, pub error: Option, pub progress_bytes: u64, pub uploaded_bytes: u64, diff --git a/crates/librqbit/src/type_aliases.rs b/crates/librqbit/src/type_aliases.rs index 4d5bda6..76c194b 100644 --- a/crates/librqbit/src/type_aliases.rs +++ b/crates/librqbit/src/type_aliases.rs @@ -2,7 +2,10 @@ use std::net::SocketAddr; use futures::stream::BoxStream; +use crate::opened_file::OpenedFile; + pub type BF = bitvec::boxed::BitBox; pub type PeerHandle = SocketAddr; pub type PeerStream = BoxStream<'static, SocketAddr>; +pub(crate) type OpenedFiles = Vec; diff --git a/crates/librqbit/webui/node_modules/.package-lock.json b/crates/librqbit/webui/node_modules/.package-lock.json index 1c87633..d41ce10 100644 --- a/crates/librqbit/webui/node_modules/.package-lock.json +++ b/crates/librqbit/webui/node_modules/.package-lock.json @@ -832,6 +832,15 @@ "@types/lodash": "*" } }, + "node_modules/@types/lodash.sortby": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/@types/lodash.sortby/-/lodash.sortby-4.7.9.tgz", + "integrity": "sha512-PDmjHnOlndLS59GofH0pnxIs+n9i4CWeXGErSB5JyNFHu2cmvW6mQOaUKjG8EDPkni14IgF8NsRW8bKvFzTm9A==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/prop-types": { "version": "15.7.11", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", @@ -1693,6 +1702,11 @@ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -2500,9 +2514,9 @@ "dev": true }, "node_modules/vite": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.2.tgz", - "integrity": "sha512-tBCZBNSBbHQkaGyhGCDUGqeo2ph8Fstyp6FMSvTtsXeZSPpSMGlviAOav2hxVTqFcx8Hj/twtWKsMJXNY0xI8w==", + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.3.tgz", + "integrity": "sha512-kQL23kMeX92v3ph7IauVkXkikdDRsYMGTVl5KY2E9OY4ONLvkHf04MDTbnfo6NKxZiDLWzVpP5oTa8hQD8U3dg==", "dev": true, "dependencies": { "esbuild": "^0.18.10", diff --git a/crates/librqbit/webui/package-lock.json b/crates/librqbit/webui/package-lock.json index bad6159..2b7fad5 100644 --- a/crates/librqbit/webui/package-lock.json +++ b/crates/librqbit/webui/package-lock.json @@ -8,6 +8,7 @@ "dependencies": { "@restart/ui": "^1.6.6", "lodash.debounce": "^4.0.8", + "lodash.sortby": "^4.7", "react": "^18.2.0", "react-dom": "^18.2.0", "react-icons": "^4.12.0", @@ -15,6 +16,7 @@ }, "devDependencies": { "@types/lodash.debounce": "^4.0.9", + "@types/lodash.sortby": "^4.7.9", "@types/react": "^18.2.38", "@types/react-dom": "^18.2.16", "@vitejs/plugin-react": "^4.2.1", @@ -1192,6 +1194,15 @@ "@types/lodash": "*" } }, + "node_modules/@types/lodash.sortby": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/@types/lodash.sortby/-/lodash.sortby-4.7.9.tgz", + "integrity": "sha512-PDmjHnOlndLS59GofH0pnxIs+n9i4CWeXGErSB5JyNFHu2cmvW6mQOaUKjG8EDPkni14IgF8NsRW8bKvFzTm9A==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/prop-types": { "version": "15.7.11", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", @@ -2053,6 +2064,11 @@ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -2860,9 +2876,9 @@ "dev": true }, "node_modules/vite": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.2.tgz", - "integrity": "sha512-tBCZBNSBbHQkaGyhGCDUGqeo2ph8Fstyp6FMSvTtsXeZSPpSMGlviAOav2hxVTqFcx8Hj/twtWKsMJXNY0xI8w==", + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.3.tgz", + "integrity": "sha512-kQL23kMeX92v3ph7IauVkXkikdDRsYMGTVl5KY2E9OY4ONLvkHf04MDTbnfo6NKxZiDLWzVpP5oTa8hQD8U3dg==", "dev": true, "dependencies": { "esbuild": "^0.18.10", diff --git a/crates/librqbit/webui/package.json b/crates/librqbit/webui/package.json index 9b636a3..01feead 100644 --- a/crates/librqbit/webui/package.json +++ b/crates/librqbit/webui/package.json @@ -10,6 +10,7 @@ "dependencies": { "@restart/ui": "^1.6.6", "lodash.debounce": "^4.0.8", + "lodash.sortby": "^4.7", "react": "^18.2.0", "react-dom": "^18.2.0", "react-icons": "^4.12.0", @@ -17,6 +18,7 @@ }, "devDependencies": { "@types/lodash.debounce": "^4.0.9", + "@types/lodash.sortby": "^4.7.9", "@types/react": "^18.2.38", "@types/react-dom": "^18.2.16", "@vitejs/plugin-react": "^4.2.1", diff --git a/crates/librqbit/webui/src/api-types.ts b/crates/librqbit/webui/src/api-types.ts index 633a073..56edd05 100644 --- a/crates/librqbit/webui/src/api-types.ts +++ b/crates/librqbit/webui/src/api-types.ts @@ -81,6 +81,7 @@ export const STATE_ERROR = "error"; export interface TorrentStats { state: "initializing" | "paused" | "live" | "error"; error: string | null; + file_progress: number[]; progress_bytes: number; finished: boolean; total_bytes: number; diff --git a/crates/librqbit/webui/src/components/FileListInput.tsx b/crates/librqbit/webui/src/components/FileListInput.tsx index 0b124ed..36146c9 100644 --- a/crates/librqbit/webui/src/components/FileListInput.tsx +++ b/crates/librqbit/webui/src/components/FileListInput.tsx @@ -1,15 +1,18 @@ import { useMemo, useState } from "react"; -import { TorrentDetails } from "../api-types"; +import { TorrentDetails, TorrentStats } from "../api-types"; import { FormCheckbox } from "./forms/FormCheckbox"; import { CiSquarePlus, CiSquareMinus } from "react-icons/ci"; import { IconButton } from "./buttons/IconButton"; import { formatBytes } from "../helper/formatBytes"; +import { ProgressBar } from "./ProgressBar"; +import sortBy from "lodash.sortby"; type TorrentFileForCheckbox = { id: number; filename: string; pathComponents: string[]; length: number; + have_bytes: number; }; type FileTree = { @@ -19,7 +22,10 @@ type FileTree = { files: TorrentFileForCheckbox[]; }; -const newFileTree = (torrentDetails: TorrentDetails): FileTree => { +const newFileTree = ( + torrentDetails: TorrentDetails, + stats: TorrentStats | null, +): FileTree => { const newFileTreeInner = ( name: string, id: string, @@ -43,8 +49,15 @@ const newFileTree = (torrentDetails: TorrentDetails): FileTree => { getGroup(file.pathComponents[0]).push(file); }); + directFiles = sortBy(directFiles, (f) => f.filename); + + let sortedGroupsByName = sortBy( + Object.entries(groupsByName), + ([k, _]) => k, + ); + let childId = 0; - for (const [key, value] of Object.entries(groupsByName)) { + for (const [key, value] of sortedGroupsByName) { groups.push(newFileTreeInner(key, id + "." + childId, value, depth + 1)); childId += 1; } @@ -65,6 +78,7 @@ const newFileTree = (torrentDetails: TorrentDetails): FileTree => { filename: file.components[file.components.length - 1], pathComponents: file.components, length: file.length, + have_bytes: stats ? stats.file_progress[id] ?? 0 : 0, }; }), 0, @@ -74,15 +88,21 @@ const newFileTree = (torrentDetails: TorrentDetails): FileTree => { const FileTreeComponent: React.FC<{ tree: FileTree; torrentDetails: TorrentDetails; + torrentStats: TorrentStats | null; selectedFiles: Set; - setSelectedFiles: React.Dispatch>>; + setSelectedFiles: (_: Set) => void; initialExpanded: boolean; + showProgressBar?: boolean; + disabled?: boolean; }> = ({ tree, selectedFiles, setSelectedFiles, initialExpanded, torrentDetails, + torrentStats, + showProgressBar, + disabled, }) => { let [expanded, setExpanded] = useState(initialExpanded); let children = useMemo(() => { @@ -151,6 +171,7 @@ const FileTreeComponent: React.FC<{ {tree.dirs.map((dir) => ( {tree.files.map((file) => ( - handleToggleFile(file.id)} - > + className={`${ + showProgressBar + ? "grid grid-cols-1 gap-1 items-start lg:grid-cols-2 mb-2 lg:mb-0" + : "" + }`} + > + handleToggleFile(file.id)} + > + {showProgressBar && ( + + )} + ))} @@ -176,20 +212,34 @@ const FileTreeComponent: React.FC<{ export const FileListInput: React.FC<{ torrentDetails: TorrentDetails; + torrentStats: TorrentStats | null; selectedFiles: Set; - setSelectedFiles: React.Dispatch>>; -}> = ({ torrentDetails, selectedFiles, setSelectedFiles }) => { - let fileTree = useMemo(() => newFileTree(torrentDetails), [torrentDetails]); + setSelectedFiles: (_: Set) => void; + showProgressBar?: boolean; + disabled?: boolean; +}> = ({ + torrentDetails, + selectedFiles, + setSelectedFiles, + torrentStats, + showProgressBar, + disabled, +}) => { + let fileTree = useMemo( + () => newFileTree(torrentDetails, torrentStats), + [torrentDetails, torrentStats], + ); return ( - <> - - + ); }; diff --git a/crates/librqbit/webui/src/components/ProgressBar.tsx b/crates/librqbit/webui/src/components/ProgressBar.tsx index 27d471b..f15e3e6 100644 --- a/crates/librqbit/webui/src/components/ProgressBar.tsx +++ b/crates/librqbit/webui/src/components/ProgressBar.tsx @@ -1,9 +1,3 @@ -type Props = { - now: number; - label?: string | null; - variant?: "warn" | "info" | "success" | "error"; -}; - const variantClassNames = { warn: "bg-amber-500 text-white", info: "bg-blue-500 text-white", @@ -11,16 +5,25 @@ const variantClassNames = { error: "bg-red-500 text-white", }; -export const ProgressBar = ({ now, variant, label }: Props) => { +export const ProgressBar: React.FC<{ + now: number; + label?: string | null; + variant?: "warn" | "info" | "success" | "error"; + classNames?: string; +}> = ({ now, variant, label, classNames }) => { const progressLabel = label ?? `${now.toFixed(2)}%`; const variantClassName = variantClassNames[variant ?? "info"] ?? variantClassNames["info"]; return ( -
+
{progressLabel} diff --git a/crates/librqbit/webui/src/components/TorrentRow.tsx b/crates/librqbit/webui/src/components/TorrentRow.tsx index f8e4305..09af1ae 100644 --- a/crates/librqbit/webui/src/components/TorrentRow.tsx +++ b/crates/librqbit/webui/src/components/TorrentRow.tsx @@ -3,7 +3,7 @@ import { TorrentDetails, TorrentStats, STATE_INITIALIZING, - STATE_LIVE, + ErrorDetails, } from "../api-types"; import { TorrentActions } from "./buttons/TorrentActions"; import { ProgressBar } from "./ProgressBar"; @@ -12,6 +12,10 @@ import { formatBytes } from "../helper/formatBytes"; import { torrentDisplayName } from "../helper/getTorrentDisplayName"; import { getCompletionETA } from "../helper/getCompletionETA"; import { StatusIcon } from "./StatusIcon"; +import { FileListInput } from "./FileListInput"; +import { useContext, useEffect, useState } from "react"; +import { APIContext, RefreshTorrentStatsContext } from "../context"; +import { useErrorStore } from "../stores/errorStore"; export const TorrentRow: React.FC<{ id: number; @@ -23,7 +27,11 @@ export const TorrentRow: React.FC<{ const totalBytes = statsResponse?.total_bytes ?? 1; const progressBytes = statsResponse?.progress_bytes ?? 0; const finished = statsResponse?.finished || false; - const progressPercentage = error ? 100 : (progressBytes / totalBytes) * 100; + const progressPercentage = error + ? 100 + : totalBytes == 0 + ? 100 + : (progressBytes / totalBytes) * 100; const formatPeersString = () => { let peer_stats = statsResponse?.live?.snapshot.peer_stats; @@ -44,74 +52,133 @@ export const TorrentRow: React.FC<{ ); }; + const [selectedFiles, setSelectedFiles] = useState>(new Set()); + + // Update selected files whenever details are updated. + useEffect(() => { + setSelectedFiles( + new Set( + detailsResponse?.files + .map((f, id) => ({ f, id })) + .filter(({ f }) => f.included) + .map(({ id }) => id) ?? [], + ), + ); + }, [detailsResponse]); + + const API = useContext(APIContext); + + const refreshCtx = useContext(RefreshTorrentStatsContext); + + const [savingSelectedFiles, setSavingSelectedFiles] = useState(false); + + let setCloseableError = useErrorStore((state) => state.setCloseableError); + + const updateSelectedFiles = (selectedFiles: Set) => { + setSavingSelectedFiles(true); + API.updateOnlyFiles(id, Array.from(selectedFiles)) + .then( + () => { + refreshCtx.refresh(); + setCloseableError(null); + }, + (e) => { + setCloseableError({ + text: "Error configuring torrent", + details: e as ErrorDetails, + }); + }, + ) + .finally(() => setSavingSelectedFiles(false)); + }; + + const [extendedView, setExtendedView] = useState(false); + return ( -
- {/* Icon */} -
{statusIcon("w-10 h-10")}
- {/* Name, progress, stats */} -
- {detailsResponse && ( -
-
{statusIcon("w-5 h-5")}
-
- {torrentDisplayName(detailsResponse)} +
+
+ {/* Icon */} +
{statusIcon("w-10 h-10")}
+ {/* Name, progress, stats */} +
+ {detailsResponse && ( +
+
{statusIcon("w-5 h-5")}
+
+ {torrentDisplayName(detailsResponse)} +
+ )} + {error ? ( +

+ Error: {error} +

+ ) : ( + <> +
+ +
+
+
+ {formatPeersString().toString()} +
+
+ +
+ {formatBytes(progressBytes)}/{formatBytes(totalBytes)} +
+
+ {statsResponse && ( + <> +
+ + {getCompletionETA(statsResponse)} +
+
+ +
+ + )} +
+ + )} +
+ {/* Actions */} + {statsResponse && ( +
+
)} - {error ? ( -

- Error: {error} -

- ) : ( - <> -
- -
-
-
- {formatPeersString().toString()} -
-
- -
- {formatBytes(progressBytes)}/{formatBytes(totalBytes)} -
-
- {statsResponse && ( - <> -
- - {getCompletionETA(statsResponse)} -
-
- -
- - )} -
- - )} -
- {/* Actions */} - {statsResponse && ( +
+ + {/* extended view */} + {detailsResponse && extendedView && (
-
)} - +
); }; diff --git a/crates/librqbit/webui/src/components/buttons/IconButton.tsx b/crates/librqbit/webui/src/components/buttons/IconButton.tsx index a77a79a..a5d3cd8 100644 --- a/crates/librqbit/webui/src/components/buttons/IconButton.tsx +++ b/crates/librqbit/webui/src/components/buttons/IconButton.tsx @@ -19,7 +19,7 @@ export const IconButton: React.FC<{ const colorClassName = color ? `text-${color}` : ""; return ( = ({ id, detailsResponse, statsResponse }) => { + extendedView: boolean; + setExtendedView: (extendedView: boolean) => void; +}> = ({ id, statsResponse, extendedView, setExtendedView }) => { let state = statsResponse.state; let [disabled, setDisabled] = useState(false); let [deleting, setDeleting] = useState(false); - let [configuring, setConfiguring] = useState(false); let refreshCtx = useContext(RefreshTorrentStatsContext); @@ -62,10 +61,6 @@ export const TorrentActions: React.FC<{ .finally(() => setDisabled(false)); }; - const openConfigureModal = () => { - setConfiguring(true); - }; - const startDeleting = () => { setDisabled(true); setDeleting(true); @@ -89,7 +84,10 @@ export const TorrentActions: React.FC<{ )} {canConfigure && ( - + setExtendedView(!extendedView)} + disabled={disabled} + > )} @@ -97,14 +95,6 @@ export const TorrentActions: React.FC<{ - {detailsResponse && configuring && ( - setConfiguring(false)} - /> - )}
); }; diff --git a/crates/librqbit/webui/src/components/forms/FormCheckbox.tsx b/crates/librqbit/webui/src/components/forms/FormCheckbox.tsx index 32fedf1..0cdc3d4 100644 --- a/crates/librqbit/webui/src/components/forms/FormCheckbox.tsx +++ b/crates/librqbit/webui/src/components/forms/FormCheckbox.tsx @@ -8,9 +8,20 @@ export const FormCheckbox: React.FC<{ disabled?: boolean; inputType?: "checkbox" | "switch"; onChange?: ChangeEventHandler; -}> = ({ checked, name, disabled, onChange, label, help, inputType }) => { + children?: React.ReactNode; + classNames?: string; +}> = ({ + checked, + name, + disabled, + onChange, + label, + help, + inputType, + children, +}) => { return ( -
+
)}
+ {children}
); }; diff --git a/crates/librqbit/webui/src/components/modal/FileSelectionModal.tsx b/crates/librqbit/webui/src/components/modal/FileSelectionModal.tsx index 94957c0..0a41f33 100644 --- a/crates/librqbit/webui/src/components/modal/FileSelectionModal.tsx +++ b/crates/librqbit/webui/src/components/modal/FileSelectionModal.tsx @@ -105,6 +105,7 @@ export const FileSelectionModal = (props: { selectedFiles={selectedFiles} setSelectedFiles={setSelectedFiles} torrentDetails={listTorrentResponse.details} + torrentStats={null} /> diff --git a/crates/librqbit/webui/src/components/modal/TorrentSettingsModal.tsx b/crates/librqbit/webui/src/components/modal/TorrentSettingsModal.tsx deleted file mode 100644 index 97a1e2e..0000000 --- a/crates/librqbit/webui/src/components/modal/TorrentSettingsModal.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import React, { useContext, useState } from "react"; -import { - AddTorrentResponse, - ErrorDetails, - TorrentDetails, -} from "../../api-types"; -import { FileListInput } from "../FileListInput"; -import { Modal } from "./Modal"; -import { ModalBody } from "./ModalBody"; -import { ModalFooter } from "./ModalFooter"; -import { Button } from "../buttons/Button"; -import { Spinner } from "../Spinner"; -import { APIContext, RefreshTorrentStatsContext } from "../../context"; -import { ErrorComponent } from "../ErrorComponent"; -import { ErrorWithLabel } from "../../stores/errorStore"; - -export const TorrentSettingsModal: React.FC<{ - id: number; - show: boolean; - onHide: () => void; - details: TorrentDetails; -}> = ({ id, show, onHide, details }) => { - let initialSelectedFiles = new Set(); - - let refreshCtx = useContext(RefreshTorrentStatsContext); - - details.files.forEach((f, i) => { - if (f.included) { - initialSelectedFiles.add(i); - } - }); - - const API = useContext(APIContext); - - const [selectedFiles, setSelectedFiles] = - useState>(initialSelectedFiles); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - - const close = () => { - setSelectedFiles(initialSelectedFiles); - onHide(); - }; - - const handleSave = () => { - setSaving(true); - API.updateOnlyFiles(id, Array.from(selectedFiles)).then( - () => { - setSaving(false); - refreshCtx.refresh(); - close(); - setError(null); - }, - (e) => { - setSaving(false); - setError({ - text: "Error configuring torrent", - details: e as ErrorDetails, - }); - }, - ); - }; - - return ( - - - - - - - - {saving && } - - - - - ); -}; diff --git a/crates/librqbit_core/src/lengths.rs b/crates/librqbit_core/src/lengths.rs index 72aaf4d..3ebab9b 100644 --- a/crates/librqbit_core/src/lengths.rs +++ b/crates/librqbit_core/src/lengths.rs @@ -153,6 +153,25 @@ impl Lengths { }) } + // A helper to iterate over pieces in a file. + pub(crate) fn iter_pieces_within_offset( + &self, + offset_bytes: u64, + len: u64, + ) -> std::ops::Range { + // Validation and correction + let offset_bytes = offset_bytes.min(self.total_length); + let end_bytes = (offset_bytes + len).min(self.total_length); + + let start_piece_id = (offset_bytes / self.piece_length as u64) as u32; + let end_piece_id = if end_bytes == offset_bytes { + start_piece_id + } else { + end_bytes.div_ceil(self.piece_length as u64) as u32 + }; + start_piece_id..end_piece_id + } + pub fn iter_chunk_infos(&self, index: ValidPieceIndex) -> impl Iterator { let mut remaining = self.piece_length(index); let absolute_offset = index.0 * self.chunks_per_piece; @@ -230,6 +249,19 @@ impl Lengths { } return None; } + + // How many bytes out of the given piece are present in the given file (by offset and len). + pub fn size_of_piece_in_file(&self, piece_id: u32, file_offset: u64, file_len: u64) -> u64 { + let piece_offset = piece_id as u64 * self.default_piece_length() as u64; + let piece_end = piece_offset + self.default_piece_length() as u64; + + let file_end = file_offset + file_len; + + let offset = file_offset.max(piece_offset); + let end = file_end.min(piece_end); + + end.saturating_sub(offset) + } } #[cfg(test)] @@ -535,4 +567,71 @@ mod tests { assert_eq!(l.chunks_per_piece(l.last_piece_id()), 1); } + + #[test] + fn test_iter_pieces_within() { + // Macro to preserve line numbers + macro_rules! check { + ($l:expr, $offset:expr, $len:expr, $expected:expr) => { + let e: &[u32] = $expected; + println!("case: offset={}, len={}, expected={:?}", $offset, $len, e); + assert_eq!( + &$l.iter_pieces_within_offset($offset, $len) + .collect::>()[..], + $expected + ); + }; + } + + let l = Lengths::new(21, 10).unwrap(); + check!(&l, 0, 5, &[0]); + check!(&l, 0, 10, &[0]); + check!(&l, 0, 11, &[0, 1]); + check!(&l, 0, 0, &[]); + check!(&l, 10, 0, &[]); + check!(&l, 10, 1, &[1]); + check!(&l, 10, 10, &[1]); + check!(&l, 10, 11, &[1, 2]); + + check!(&l, 5, 5, &[0]); + check!(&l, 5, 6, &[0, 1]); + check!(&l, 5, 15, &[0, 1]); + check!(&l, 5, 16, &[0, 1, 2]); + + check!(&l, 20, 1, &[2]); + check!(&l, 20, 2, &[2]); + check!(&l, 20, 1000, &[2]); + check!(&l, 21, 0, &[]); + check!(&l, 21, 1, &[]); + check!(&l, 22, 0, &[]); + check!(&l, 22, 1, &[]); + } + + #[test] + fn test_size_of_piece_in_file() { + let l = Lengths::new(10, 5).unwrap(); + + assert_eq!(l.size_of_piece_in_file(0, 0, 10), 5); + assert_eq!(l.size_of_piece_in_file(0, 1, 10), 4); + assert_eq!(l.size_of_piece_in_file(0, 5, 10), 0); + assert_eq!(l.size_of_piece_in_file(0, 6, 10), 0); + + assert_eq!(l.size_of_piece_in_file(0, 0, 0), 0); + assert_eq!(l.size_of_piece_in_file(0, 1, 0), 0); + assert_eq!(l.size_of_piece_in_file(0, 5, 0), 0); + assert_eq!(l.size_of_piece_in_file(0, 6, 0), 0); + + assert_eq!(l.size_of_piece_in_file(1, 0, 10), 5); + assert_eq!(l.size_of_piece_in_file(1, 4, 10), 5); + assert_eq!(l.size_of_piece_in_file(1, 5, 10), 5); + assert_eq!(l.size_of_piece_in_file(1, 6, 10), 4); + assert_eq!(l.size_of_piece_in_file(1, 9, 10), 1); + assert_eq!(l.size_of_piece_in_file(1, 10, 10), 0); + + // garbage data + assert_eq!(l.size_of_piece_in_file(2, 0, 10), 0); + assert_eq!(l.size_of_piece_in_file(3, 0, 10), 0); + assert_eq!(l.size_of_piece_in_file(0, 10, 0), 0); + assert_eq!(l.size_of_piece_in_file(0, 10, 5), 0); + } } diff --git a/crates/librqbit_core/src/torrent_metainfo.rs b/crates/librqbit_core/src/torrent_metainfo.rs index 6dc4069..9933f67 100644 --- a/crates/librqbit_core/src/torrent_metainfo.rs +++ b/crates/librqbit_core/src/torrent_metainfo.rs @@ -7,7 +7,7 @@ use clone_to_owned::CloneToOwned; use itertools::Either; use serde::{Deserialize, Serialize}; -use crate::hash_id::Id20; +use crate::{hash_id::Id20, lengths::Lengths}; pub type TorrentMetaV1Borrowed<'a> = TorrentMetaV1>; pub type TorrentMetaV1Owned = TorrentMetaV1; @@ -151,6 +151,19 @@ where } } +pub struct FileDetails<'a, BufType> { + pub filename: FileIteratorName<'a, BufType>, + pub offset: u64, + pub len: u64, + pub pieces: std::ops::Range, +} + +impl<'a, BufType> FileDetails<'a, BufType> { + pub fn pieces_usize(&self) -> std::ops::Range { + self.pieces.start as usize..self.pieces.end as usize + } +} + impl> TorrentMetaV1Info { pub fn get_hash(&self, piece: u32) -> Option<&[u8]> { let start = piece as usize * 20; @@ -195,6 +208,26 @@ impl> TorrentMetaV1Info { pub fn iter_file_lengths(&self) -> anyhow::Result + '_> { Ok(self.iter_filenames_and_lengths()?.map(|(_, l)| l)) } + + // NOTE: lenghts MUST be construced with Lenghts::from_torrent, otherwise + // the yielded results will be garbage. + pub fn iter_file_details<'a>( + &'a self, + lengths: &'a Lengths, + ) -> anyhow::Result> + 'a> { + Ok(self + .iter_filenames_and_lengths()? + .scan(0u64, |acc_offset, (filename, len)| { + let offset = *acc_offset; + *acc_offset += len; + Some(FileDetails { + filename, + pieces: lengths.iter_pieces_within_offset(offset, len), + offset, + len, + }) + })) + } } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 2d46843..4511e8c 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -32,6 +32,7 @@ "dependencies": { "@restart/ui": "^1.6.6", "lodash.debounce": "^4.0.8", + "lodash.sortby": "^4.7", "react": "^18.2.0", "react-dom": "^18.2.0", "react-icons": "^4.12.0", @@ -39,6 +40,7 @@ }, "devDependencies": { "@types/lodash.debounce": "^4.0.9", + "@types/lodash.sortby": "^4.7.9", "@types/react": "^18.2.38", "@types/react-dom": "^18.2.16", "@vitejs/plugin-react": "^4.2.1", diff --git a/desktop/src/configure.tsx b/desktop/src/configure.tsx index eb92b44..55a1012 100644 --- a/desktop/src/configure.tsx +++ b/desktop/src/configure.tsx @@ -130,7 +130,7 @@ export const ConfigModal: React.FC<{ }; const handleToggleChange: React.ChangeEventHandler = ( - e + e, ) => { const name: string = e.target.name; const [mainField, subField] = name.split(".", 2); @@ -166,7 +166,7 @@ export const ConfigModal: React.FC<{ text: "Error saving configuration", details: e, }); - } + }, ); }; @@ -179,7 +179,7 @@ export const ConfigModal: React.FC<{ > -
+
{TABS.map((t, i) => { const isActive = t === tab; let classNames = "text-slate-300";