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
This commit is contained in:
Igor Katson 2024-04-06 09:20:03 +01:00 committed by GitHub
parent d7380217f6
commit 5eb01ac226
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 865 additions and 512 deletions

15
TODO.md
View file

@ -68,3 +68,18 @@ refactor:
- [x] checking is very slow on raspberry - [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) 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 - [ ] .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

View file

@ -33,9 +33,13 @@ pub struct ChunkTracker {
// What pieces to download first. // What pieces to download first.
priority_piece_ids: Vec<usize>, priority_piece_ids: Vec<usize>,
// 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 { pub struct HaveNeededSelected {
// How many bytes we have downloaded and verified. // How many bytes we have downloaded and verified.
pub have_bytes: u64, 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 // 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. // players look into it, and it's better be there.
let priority_piece_ids = last_needed_piece_id.into_iter().collect(); 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) chunk_status: compute_chunk_have_status(&lengths, &have_pieces)
.context("error computing chunk status")?, .context("error computing chunk status")?,
queue_pieces: needed_pieces, queue_pieces: needed_pieces,
@ -154,7 +158,10 @@ impl ChunkTracker {
lengths, lengths,
have: have_pieces, have: have_pieces,
priority_piece_ids, priority_piece_ids,
}) hns: HaveNeededSelected::default(),
};
ct.hns = ct.calc_hns();
Ok(ct)
} }
pub fn get_lengths(&self) -> &Lengths { pub fn get_lengths(&self) -> &Lengths {
@ -164,34 +171,31 @@ impl ChunkTracker {
pub fn get_have_pieces(&self) -> &BF { pub fn get_have_pieces(&self) -> &BF {
&self.have &self.have
} }
pub fn get_selected_pieces(&self) -> &BF {
&self.selected
}
pub fn reserve_needed_piece(&mut self, index: ValidPieceIndex) { pub fn reserve_needed_piece(&mut self, index: ValidPieceIndex) {
self.queue_pieces.set(index.get() as usize, false) self.queue_pieces.set(index.get() as usize, false)
} }
pub fn calc_have_bytes(&self) -> u64 { pub fn get_hns(&self) -> &HaveNeededSelected {
self.have &self.hns
.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 calc_needed_bytes(&self) -> u64 { fn calc_hns(&self) -> HaveNeededSelected {
self.have let mut hns = HaveNeededSelected::default();
.iter() for piece in self.lengths.iter_piece_infos() {
.zip(self.selected.iter()) let id = piece.piece_index.get() as usize;
.enumerate() let len = piece.len as u64;
.filter_map(|(piece_id, (have, selected))| { let is_have = self.have[id];
if *selected && !*have { let is_selected = self.selected[id];
let piece_id = self.lengths.validate_piece_index(piece_id as u32)?; let is_needed = is_selected && !is_have;
Some(self.lengths.piece_length(piece_id) as u64) hns.have_bytes += len * (is_have as u64);
} else { hns.selected_bytes += len * (is_selected as u64);
None hns.needed_bytes += len * (is_needed as u64);
} }
}) hns
.sum()
} }
pub fn iter_queued_pieces(&self) -> impl Iterator<Item = usize> + '_ { pub fn iter_queued_pieces(&self) -> impl Iterator<Item = usize> + '_ {
@ -242,7 +246,15 @@ impl ChunkTracker {
} }
pub fn mark_piece_downloaded(&mut self, idx: ValidPieceIndex) { 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 { pub fn is_chunk_ready_to_upload(&self, chunk: &ChunkInfo) -> bool {
@ -252,6 +264,10 @@ impl ChunkTracker {
.unwrap_or(false) .unwrap_or(false)
} }
pub fn get_remaining_bytes(&self) -> u64 {
self.hns.needed_bytes
}
// return true if the whole piece is marked downloaded // return true if the whole piece is marked downloaded
pub fn mark_chunk_downloaded<ByteBuf>( pub fn mark_chunk_downloaded<ByteBuf>(
&mut self, &mut self,
@ -356,11 +372,13 @@ impl ChunkTracker {
} }
} }
Ok(HaveNeededSelected { let res = HaveNeededSelected {
have_bytes, have_bytes,
needed_bytes, needed_bytes,
selected_bytes, selected_bytes,
}) };
self.hns = res;
Ok(res)
} }
} }

View file

@ -2,24 +2,23 @@ use std::{
fs::File, fs::File,
io::{Read, Seek, SeekFrom, Write}, io::{Read, Seek, SeekFrom, Write},
marker::PhantomData, marker::PhantomData,
sync::{ sync::atomic::{AtomicU64, Ordering},
atomic::{AtomicU64, Ordering},
Arc,
},
}; };
use anyhow::Context; use anyhow::Context;
use buffers::ByteBufOwned; use buffers::ByteBufOwned;
use librqbit_core::{ use librqbit_core::{
lengths::{ChunkInfo, Lengths, ValidPieceIndex}, lengths::{ChunkInfo, Lengths, ValidPieceIndex},
torrent_metainfo::{FileIteratorName, TorrentMetaV1Info}, torrent_metainfo::TorrentMetaV1Info,
}; };
use parking_lot::Mutex;
use peer_binary_protocol::Piece; use peer_binary_protocol::Piece;
use sha1w::{ISha1, Sha1}; use sha1w::{ISha1, Sha1};
use tracing::{debug, trace, warn}; 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 { pub(crate) struct InitialCheckResults {
// A piece as flags based on these dimensions: // A piece as flags based on these dimensions:
@ -64,7 +63,7 @@ pub fn update_hash_from_file<Sha1: ISha1>(
pub(crate) struct FileOps<'a> { pub(crate) struct FileOps<'a> {
torrent: &'a TorrentMetaV1Info<ByteBufOwned>, torrent: &'a TorrentMetaV1Info<ByteBufOwned>,
files: &'a [Arc<Mutex<File>>], files: &'a OpenedFiles,
lengths: &'a Lengths, lengths: &'a Lengths,
phantom_data: PhantomData<Sha1>, phantom_data: PhantomData<Sha1>,
} }
@ -72,7 +71,7 @@ pub(crate) struct FileOps<'a> {
impl<'a> FileOps<'a> { impl<'a> FileOps<'a> {
pub fn new( pub fn new(
torrent: &'a TorrentMetaV1Info<ByteBufOwned>, torrent: &'a TorrentMetaV1Info<ByteBufOwned>,
files: &'a [Arc<Mutex<File>>], files: &'a OpenedFiles,
lengths: &'a Lengths, lengths: &'a Lengths,
) -> Self { ) -> Self {
Self { Self {
@ -86,6 +85,8 @@ impl<'a> FileOps<'a> {
pub fn initial_check( pub fn initial_check(
&self, &self,
only_files: Option<&[usize]>, only_files: Option<&[usize]>,
opened_files: &OpenedFiles,
lengths: &Lengths,
progress: &AtomicU64, progress: &AtomicU64,
) -> anyhow::Result<InitialCheckResults> { ) -> anyhow::Result<InitialCheckResults> {
let mut needed_pieces = let mut needed_pieces =
@ -96,46 +97,38 @@ impl<'a> FileOps<'a> {
let mut have_bytes = 0u64; let mut have_bytes = 0u64;
let mut needed_bytes = 0u64; let mut needed_bytes = 0u64;
let mut total_selected_bytes = 0u64; let mut total_selected_bytes = 0u64;
let mut piece_files = Vec::<usize>::new();
#[derive(Debug)] #[derive(Debug)]
struct CurrentFile<'a> { struct CurrentFile<'a> {
index: usize, index: usize,
fd: &'a Arc<Mutex<File>>, fd: &'a OpenedFile,
len: u64,
name: FileIteratorName<'a, ByteBufOwned>,
full_file_required: bool, full_file_required: bool,
processed_bytes: u64, processed_bytes: u64,
is_broken: bool, is_broken: bool,
} }
impl<'a> CurrentFile<'a> { impl<'a> CurrentFile<'a> {
fn remaining(&self) -> u64 { fn remaining(&self) -> u64 {
self.len - self.processed_bytes self.fd.len - self.processed_bytes
} }
fn mark_processed_bytes(&mut self, bytes: u64) { fn mark_processed_bytes(&mut self, bytes: u64) {
self.processed_bytes += bytes self.processed_bytes += bytes
} }
} }
let mut file_iterator = self let mut file_iterator = self.files.iter().enumerate().map(|(idx, fd)| {
.files let full_file_required = if let Some(only_files) = only_files {
.iter() only_files.contains(&idx)
.zip(self.torrent.iter_filenames_and_lengths()?) } else {
.enumerate() true
.map(|(idx, (fd, (name, len)))| { };
let full_file_required = if let Some(only_files) = only_files { CurrentFile {
only_files.contains(&idx) index: idx,
} else { fd,
true full_file_required,
}; processed_bytes: 0,
CurrentFile { is_broken: false,
index: idx, }
fd, });
len,
name,
full_file_required,
processed_bytes: 0,
is_broken: false,
}
});
let mut current_file = file_iterator let mut current_file = file_iterator
.next() .next()
@ -144,6 +137,7 @@ impl<'a> FileOps<'a> {
let mut read_buffer = vec![0u8; 65536]; let mut read_buffer = vec![0u8; 65536];
for piece_info in self.lengths.iter_piece_infos() { for piece_info in self.lengths.iter_piece_infos() {
piece_files.clear();
let mut computed_hash = Sha1::new(); let mut computed_hash = Sha1::new();
let mut piece_remaining = piece_info.len as usize; let mut piece_remaining = piece_info.len as usize;
let mut some_files_broken = false; 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; std::cmp::min(current_file.remaining(), piece_remaining as u64) as usize;
} }
piece_files.push(current_file.index);
let pos = current_file.processed_bytes; let pos = current_file.processed_bytes;
piece_remaining -= to_read_in_file; piece_remaining -= to_read_in_file;
current_file.mark_processed_bytes(to_read_in_file as u64); current_file.mark_processed_bytes(to_read_in_file as u64);
@ -175,7 +171,7 @@ impl<'a> FileOps<'a> {
continue; continue;
} }
let mut fd = current_file.fd.lock(); let mut fd = current_file.fd.file.lock();
fd.seek(SeekFrom::Start(pos)) fd.seek(SeekFrom::Start(pos))
.context("bug? error seeking")?; .context("bug? error seeking")?;
@ -187,7 +183,7 @@ impl<'a> FileOps<'a> {
) { ) {
debug!( debug!(
"error reading from file {} ({:?}) at {}: {:#}", "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; current_file.is_broken = true;
some_files_broken = true; some_files_broken = true;
@ -219,6 +215,10 @@ impl<'a> FileOps<'a> {
piece_info.piece_index piece_info.piece_index
); );
have_bytes += piece_info.len as u64; 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); have_pieces.set(piece_info.piece_index.get() as usize, true);
} else if piece_selected { } else if piece_selected {
trace!( trace!(
@ -266,7 +266,7 @@ impl<'a> FileOps<'a> {
let to_read_in_file = let to_read_in_file =
std::cmp::min(file_remaining_len, piece_remaining_bytes as u64) as usize; 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!( trace!(
"piece={}, handle={}, file_idx={}, seeking to {}. Last received chunk: {:?}", "piece={}, handle={}, file_idx={}, seeking to {}. Last received chunk: {:?}",
piece_index, piece_index,
@ -334,7 +334,7 @@ impl<'a> FileOps<'a> {
let file_remaining_len = file_len - absolute_offset; 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 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!( trace!(
"piece={}, handle={}, file_idx={}, seeking to {}. To read chunk: {:?}", "piece={}, handle={}, file_idx={}, seeking to {}. To read chunk: {:?}",
chunk_info.piece_index, chunk_info.piece_index,
@ -387,7 +387,7 @@ impl<'a> FileOps<'a> {
let remaining_len = file_len - absolute_offset; let remaining_len = file_len - absolute_offset;
let to_write = std::cmp::min(buf.len(), remaining_len as usize); 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!( trace!(
"piece={}, chunk={:?}, handle={}, begin={}, file={}, writing {} bytes at {}", "piece={}, chunk={:?}, handle={}, begin={}, file={}, writing {} bytes at {}",
chunk_info.piece_index, chunk_info.piece_index,

View file

@ -31,6 +31,7 @@ mod dht_utils;
mod file_ops; mod file_ops;
pub mod http_api; pub mod http_api;
pub mod http_api_client; pub mod http_api_client;
mod opened_file;
mod peer_connection; mod peer_connection;
mod peer_info_reader; mod peer_info_reader;
mod read_buf; mod read_buf;

View file

@ -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<File>,
pub filename: PathBuf,
pub offset_in_torrent: u64,
pub have: AtomicU64,
pub piece_range: std::ops::Range<u32>,
pub len: u64,
}
pub(crate) fn dummy_file() -> anyhow::Result<std::fs::File> {
#[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<u32>,
) -> 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<File> {
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<Self> {
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<usize> {
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
}
}

View file

@ -1083,10 +1083,10 @@ impl Session {
warn!(error=?e, "error deleting torrent cleanly"); warn!(error=?e, "error deleting torrent cleanly");
} }
(Ok(Some(paused)), true) => { (Ok(Some(paused)), true) => {
drop(paused.files); for file in paused.files.iter() {
for file in paused.filenames { drop(file.take()?);
if let Err(e) = std::fs::remove_file(&file) { if let Err(e) = std::fs::remove_file(&file.filename) {
warn!(?file, error=?e, "could not delete file"); warn!(?file.filename, error=?e, "could not delete file");
} }
} }
} }
@ -1142,10 +1142,7 @@ impl Session {
handle: &ManagedTorrentHandle, handle: &ManagedTorrentHandle,
only_files: &HashSet<usize>, only_files: &HashSet<usize>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let need_to_unpause = handle.update_only_files(only_files)?; handle.update_only_files(only_files)?;
if need_to_unpause {
self.unpause(handle)?;
}
Ok(()) Ok(())
} }

View file

@ -163,6 +163,7 @@ async fn test_e2e() {
crate::AddTorrent::TorrentFileBytes(Cow::Owned(torrent_file_bytes.clone())), crate::AddTorrent::TorrentFileBytes(Cow::Owned(torrent_file_bytes.clone())),
Some(AddTorrentOptions { Some(AddTorrentOptions {
initial_peers: Some(peers.clone()), initial_peers: Some(peers.clone()),
// only_files: Some(vec![0]),
overwrite: false, overwrite: false,
..Default::default() ..Default::default()
}), }),
@ -253,7 +254,7 @@ async fn test_e2e() {
.with_state(|s| match s { .with_state(|s| match s {
crate::ManagedTorrentState::Initializing(_) => Ok(false), crate::ManagedTorrentState::Initializing(_) => Ok(false),
crate::ManagedTorrentState::Paused(p) => { crate::ManagedTorrentState::Paused(p) => {
assert_eq!(p.hns.needed_bytes, 0); assert_eq!(p.chunk_tracker.get_hns().needed_bytes, 0);
Ok(true) Ok(true)
} }
_ => bail!("bugged state"), _ => bail!("bugged state"),

View file

@ -6,14 +6,12 @@ use std::{
use anyhow::Context; use anyhow::Context;
use parking_lot::Mutex;
use size_format::SizeFormatterBinary as SF; use size_format::SizeFormatterBinary as SF;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use crate::{ use crate::{
chunk_tracker::{ChunkTracker, HaveNeededSelected}, chunk_tracker::ChunkTracker, file_ops::FileOps, opened_file::OpenedFile,
file_ops::FileOps, type_aliases::OpenedFiles,
}; };
use super::{paused::TorrentStatePaused, ManagedTorrentInfo}; use super::{paused::TorrentStatePaused, ManagedTorrentInfo};
@ -43,48 +41,52 @@ impl TorrentStateInitializing {
} }
pub async fn check(&self) -> anyhow::Result<TorrentStatePaused> { pub async fn check(&self) -> anyhow::Result<TorrentStatePaused> {
let (files, filenames) = { let mut files = OpenedFiles::new();
let mut files = for file_details in self.meta.info.iter_file_details(&self.meta.lengths)? {
Vec::<Arc<Mutex<File>>>::with_capacity(self.meta.info.iter_file_lengths()?.count()); let mut full_path = self.meta.out_dir.clone();
let mut filenames = Vec::new(); let relative_path = file_details
for (path_bits, _) in self.meta.info.iter_filenames_and_lengths()? { .filename
let mut full_path = self.meta.out_dir.clone(); .to_pathbuf()
let relative_path = path_bits .context("error converting file to path")?;
.to_pathbuf() full_path.push(relative_path);
.context("error converting file to path")?;
full_path.push(relative_path);
std::fs::create_dir_all(full_path.parent().unwrap())?; std::fs::create_dir_all(full_path.parent().context("bug: no parent")?)?;
let file = if self.meta.options.overwrite { let file = if self.meta.options.overwrite {
OpenOptions::new() OpenOptions::new()
.create(true) .create(true)
.read(true) .read(true)
.write(true) .write(true)
.open(&full_path) .open(&full_path)
.with_context(|| { .with_context(|| format!("error opening {full_path:?} in read/write mode"))?
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.
} else { OpenOptions::new()
// TODO: create_new does not seem to work with read(true), so calling this twice. .create_new(true)
OpenOptions::new() .write(true)
.create_new(true) .open(&full_path)
.write(true) .with_context(|| format!("error creating {:?}", &full_path))?;
.open(&full_path) OpenOptions::new().read(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,
filenames.push(full_path); full_path,
files.push(Arc::new(Mutex::new(file))) 0,
} file_details.len,
(files, filenames) file_details.offset,
}; file_details.pieces,
));
}
debug!("computed lengths: {:?}", &self.meta.lengths); debug!("computed lengths: {:?}", &self.meta.lengths);
info!("Doing initial checksum validation, this might take a while..."); info!("Doing initial checksum validation, this might take a while...");
let initial_check_results = self.meta.spawner.spawn_block_in_place(|| { let initial_check_results = self.meta.spawner.spawn_block_in_place(|| {
FileOps::new(&self.meta.info, &files, &self.meta.lengths) FileOps::new(&self.meta.info, &files, &self.meta.lengths).initial_check(
.initial_check(self.only_files.as_deref(), &self.checked_bytes) self.only_files.as_deref(),
&files,
&self.meta.lengths,
&self.checked_bytes,
)
})?; })?;
info!( info!(
@ -94,36 +96,35 @@ impl TorrentStateInitializing {
SF::new(initial_check_results.selected_bytes) SF::new(initial_check_results.selected_bytes)
); );
// Ensure file lenghts are correct, and reopen read-only.
self.meta.spawner.spawn_block_in_place(|| { self.meta.spawner.spawn_block_in_place(|| {
for (idx, (file, (name, length))) in files for (idx, file) in files.iter().enumerate() {
.iter()
.zip(self.meta.info.iter_filenames_and_lengths().unwrap())
.enumerate()
{
if self if self
.only_files .only_files
.as_ref() .as_ref()
.map(|v| !v.contains(&idx)) .map(|v| v.contains(&idx))
.unwrap_or(false) .unwrap_or(true)
{ {
continue; let now = Instant::now();
} if let Err(err) = ensure_file_length(&file.file.lock(), file.len) {
let now = Instant::now(); warn!(
if let Err(err) = ensure_file_length(&file.lock(), length) { "Error setting length for file {:?} to {}: {:#?}",
warn!( file.filename, file.len, err
"Error setting length for file {:?} to {}: {:#?}", );
name, length, err } else {
); debug!(
} else { "Set length for file {:?} to {} in {:?}",
debug!( file.filename,
"Set length for file {:?} to {} in {:?}", SF::new(file.len),
name, now.elapsed()
SF::new(length), );
now.elapsed() }
);
} }
file.reopen(true)?;
} }
}); Ok::<_, anyhow::Error>(())
})?;
let chunk_tracker = ChunkTracker::new( let chunk_tracker = ChunkTracker::new(
initial_check_results.have_pieces, initial_check_results.have_pieces,
@ -135,13 +136,7 @@ impl TorrentStateInitializing {
let paused = TorrentStatePaused { let paused = TorrentStatePaused {
info: self.meta.clone(), info: self.meta.clone(),
files, files,
filenames,
chunk_tracker, 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) Ok(paused)
} }

View file

@ -44,10 +44,8 @@ pub mod peers;
pub mod stats; pub mod stats;
use std::{ use std::{
collections::HashMap, collections::{HashMap, HashSet},
fs::File,
net::SocketAddr, net::SocketAddr,
path::PathBuf,
sync::{ sync::{
atomic::{AtomicU64, Ordering}, atomic::{AtomicU64, Ordering},
Arc, Arc,
@ -60,7 +58,6 @@ use backoff::backoff::Backoff;
use buffers::{ByteBuf, ByteBufOwned}; use buffers::{ByteBuf, ByteBufOwned};
use clone_to_owned::CloneToOwned; use clone_to_owned::CloneToOwned;
use futures::{stream::FuturesUnordered, StreamExt}; use futures::{stream::FuturesUnordered, StreamExt};
use itertools::Itertools;
use librqbit_core::{ use librqbit_core::{
hash_id::Id20, hash_id::Id20,
lengths::{ChunkInfo, Lengths, ValidPieceIndex}, lengths::{ChunkInfo, Lengths, ValidPieceIndex},
@ -68,7 +65,7 @@ use librqbit_core::{
speed_estimator::SpeedEstimator, speed_estimator::SpeedEstimator,
torrent_metainfo::TorrentMetaV1Info, torrent_metainfo::TorrentMetaV1Info,
}; };
use parking_lot::{Mutex, RwLock, RwLockReadGuard, RwLockWriteGuard}; use parking_lot::{RwLock, RwLockReadGuard, RwLockWriteGuard};
use peer_binary_protocol::{ use peer_binary_protocol::{
extended::handshake::ExtendedHandshake, Handshake, Message, MessageOwned, Piece, Request, extended::handshake::ExtendedHandshake, Handshake, Message, MessageOwned, Piece, Request,
}; };
@ -90,7 +87,7 @@ use crate::{
}, },
session::CheckedIncomingConnection, session::CheckedIncomingConnection,
torrent_state::{peer::Peer, utils::atomic_inc}, torrent_state::{peer::Peer, utils::atomic_inc},
type_aliases::{PeerHandle, BF}, type_aliases::{OpenedFiles, PeerHandle, BF},
}; };
use self::{ use self::{
@ -116,18 +113,6 @@ struct InflightPiece {
started: Instant, started: Instant,
} }
fn dummy_file() -> anyhow::Result<std::fs::File> {
#[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 { fn make_piece_bitfield(lengths: &Lengths) -> BF {
BF::from_boxed_slice(vec![0; lengths.piece_bitfield_bytes()].into_boxed_slice()) BF::from_boxed_slice(vec![0; lengths.piece_bitfield_bytes()].into_boxed_slice())
} }
@ -170,11 +155,7 @@ pub struct TorrentStateLive {
meta: Arc<ManagedTorrentInfo>, meta: Arc<ManagedTorrentInfo>,
locked: RwLock<TorrentStateLocked>, locked: RwLock<TorrentStateLocked>,
files: Vec<Arc<Mutex<File>>>, files: OpenedFiles,
filenames: Vec<PathBuf>,
initially_needed_bytes: u64,
total_selected_bytes: u64,
stats: AtomicStats, stats: AtomicStats,
lengths: Lengths, lengths: Lengths,
@ -192,22 +173,48 @@ pub struct TorrentStateLive {
cancellation_token: CancellationToken, 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 { impl TorrentStateLive {
pub(crate) fn new( pub(crate) fn new(
paused: TorrentStatePaused, paused: TorrentStatePaused,
fatal_errors_tx: tokio::sync::oneshot::Sender<anyhow::Error>, fatal_errors_tx: tokio::sync::oneshot::Sender<anyhow::Error>,
cancellation_token: CancellationToken, cancellation_token: CancellationToken,
) -> Arc<Self> { ) -> anyhow::Result<Arc<Self>> {
let (peer_queue_tx, peer_queue_rx) = unbounded_channel(); let (peer_queue_tx, peer_queue_rx) = unbounded_channel();
let down_speed_estimator = SpeedEstimator::new(5); let down_speed_estimator = SpeedEstimator::new(5);
let up_speed_estimator = SpeedEstimator::new(5); let up_speed_estimator = SpeedEstimator::new(5);
let have_bytes = paused.hns.have_bytes; let have_bytes = paused.chunk_tracker.get_hns().have_bytes;
let needed_bytes = paused.hns.needed_bytes;
let total_selected_bytes = paused.hns.selected_bytes;
let lengths = *paused.chunk_tracker.get_lengths(); let lengths = *paused.chunk_tracker.get_lengths();
reopen_necessary_files_for_write(&paused.chunk_tracker, &paused.files)?;
let state = Arc::new(TorrentStateLive { let state = Arc::new(TorrentStateLive {
meta: paused.info.clone(), meta: paused.info.clone(),
peers: Default::default(), peers: Default::default(),
@ -217,14 +224,11 @@ impl TorrentStateLive {
fatal_errors_tx: Some(fatal_errors_tx), fatal_errors_tx: Some(fatal_errors_tx),
}), }),
files: paused.files, files: paused.files,
filenames: paused.filenames,
stats: AtomicStats { stats: AtomicStats {
have_bytes: AtomicU64::new(have_bytes), have_bytes: AtomicU64::new(have_bytes),
..Default::default() ..Default::default()
}, },
initially_needed_bytes: needed_bytes,
lengths, lengths,
total_selected_bytes,
peer_semaphore: Arc::new(Semaphore::new(128)), peer_semaphore: Arc::new(Semaphore::new(128)),
peer_queue_tx, peer_queue_tx,
finished_notify: Notify::new(), finished_notify: Notify::new(),
@ -246,9 +250,7 @@ impl TorrentStateLive {
let now = Instant::now(); let now = Instant::now();
let stats = state.stats_snapshot(); let stats = state.stats_snapshot();
let fetched = stats.fetched_bytes; let fetched = stats.fetched_bytes;
let needed = state.initially_needed(); let remaining = state.locked.read().get_chunks()?.get_remaining_bytes();
// TODO: this is too coarse.
let remaining = needed - stats.downloaded_and_checked_bytes;
state state
.down_speed_estimator .down_speed_estimator
.add_snapshot(fetched, Some(remaining), now); .add_snapshot(fetched, Some(remaining), now);
@ -265,7 +267,7 @@ impl TorrentStateLive {
error_span!(parent: state.meta.span.clone(), "peer_adder"), error_span!(parent: state.meta.span.clone(), "peer_adder"),
state.clone().task_peer_adder(peer_queue_rx), state.clone().task_peer_adder(peer_queue_rx),
); );
state Ok(state)
} }
pub(crate) fn spawn( pub(crate) fn spawn(
@ -494,9 +496,6 @@ impl TorrentStateLive {
pub(crate) fn file_ops(&self) -> FileOps<'_> { pub(crate) fn file_ops(&self) -> FileOps<'_> {
FileOps::new(&self.meta.info, &self.files, &self.lengths) FileOps::new(&self.meta.info, &self.files, &self.lengths)
} }
pub fn initially_needed(&self) -> u64 {
self.initially_needed_bytes
}
pub(crate) fn lock_read( pub(crate) fn lock_read(
&self, &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 { pub fn get_uploaded_bytes(&self) -> u64 {
self.stats.uploaded_bytes.load(Ordering::Relaxed) self.stats.uploaded_bytes.load(Ordering::Relaxed)
} }
@ -535,12 +530,11 @@ impl TorrentStateLive {
self.stats.have_bytes.load(Ordering::Relaxed) self.stats.have_bytes.load(Ordering::Relaxed)
} }
pub fn is_finished(&self) -> bool { pub fn get_hns(&self) -> Option<HaveNeededSelected> {
self.get_left_to_download_bytes() == 0 self.lock_read("get_hns")
} .get_chunks()
.ok()
pub fn get_left_to_download_bytes(&self) -> u64 { .map(|c| *c.get_hns())
self.initially_needed_bytes - self.get_downloaded_bytes()
} }
fn maybe_transmit_haves(&self, index: ValidPieceIndex) { fn maybe_transmit_haves(&self, index: ValidPieceIndex) {
@ -653,16 +647,11 @@ impl TorrentStateLive {
let files = self let files = self
.files .files
.iter() .iter()
.map(|f| { .map(|f| f.take_clone())
let mut f = f.lock(); .collect::<anyhow::Result<Vec<_>>>()?;
let dummy = dummy_file()?; for file in files.iter() {
let f = std::mem::replace(&mut *f, dummy); file.reopen(true)?;
Ok::<_, anyhow::Error>(Arc::new(Mutex::new(f))) }
})
.try_collect()?;
let filenames = self.filenames.clone();
let mut chunk_tracker = g let mut chunk_tracker = g
.chunks .chunks
.take() .take()
@ -670,20 +659,12 @@ impl TorrentStateLive {
for piece_id in g.inflight_pieces.keys().copied() { for piece_id in g.inflight_pieces.keys().copied() {
chunk_tracker.mark_piece_broken_if_not_have(piece_id); 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; // g.chunks;
Ok(TorrentStatePaused { Ok(TorrentStatePaused {
info: self.meta.clone(), info: self.meta.clone(),
files, files,
filenames,
chunk_tracker, chunk_tracker,
hns: HaveNeededSelected {
have_bytes,
needed_bytes,
selected_bytes: self.total_selected_bytes,
},
}) })
} }
@ -699,6 +680,92 @@ impl TorrentStateLive {
} }
Err(res) Err(res)
} }
pub(crate) fn update_only_files(&self, only_files: &HashSet<usize>) -> 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<u64> {
self.files
.iter()
.map(|fd| fd.have.load(Ordering::Relaxed))
.collect()
}
} }
struct PeerHandlerLocked { struct PeerHandlerLocked {
@ -1202,27 +1269,6 @@ impl PeerHandler {
self.state.peers.mark_peer_interested(self.addr, true); 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) { fn on_i_am_unchoked(&self) {
trace!("we are unchoked"); trace!("we are unchoked");
self.locked.write().i_am_choked = false; self.locked.write().i_am_choked = false;
@ -1398,12 +1444,7 @@ impl PeerHandler {
debug!("piece={} successfully downloaded and verified", index); debug!("piece={} successfully downloaded and verified", index);
if self.state.is_finished() { self.state.on_piece_completed(chunk_info.piece_index)?;
info!("torrent finished downloading");
self.state.finished_notify.notify_waiters();
self.disconnect_all_peers_that_have_full_torrent();
self.reopen_read_only()?;
}
self.state.maybe_transmit_haves(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:?}"))?; .with_context(|| format!("error processing received chunk {chunk_info:?}"))?;
Ok(()) 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);
}
}
}
}
} }

View file

@ -122,17 +122,18 @@ impl PeerStateNoMut {
} }
} }
pub fn queued_to_connecting( pub fn idle_to_connecting(
&mut self, &mut self,
counters: &AggregatePeerStatsAtomic, counters: &AggregatePeerStatsAtomic,
) -> Option<(PeerRx, PeerTx)> { ) -> Option<(PeerRx, PeerTx)> {
if let PeerState::Queued = &self.0 { match &self.0 {
let (tx, rx) = unbounded_channel(); PeerState::Queued | PeerState::NotNeeded => {
let tx_2 = tx.clone(); let (tx, rx) = unbounded_channel();
self.set(PeerState::Connecting(tx), counters); let tx_2 = tx.clone();
Some((rx, tx_2)) self.set(PeerState::Connecting(tx), counters);
} else { Some((rx, tx_2))
None }
_ => None,
} }
} }

View file

@ -90,7 +90,7 @@ impl PeerStates {
let rx = self let rx = self
.with_peer_mut(h, "mark_peer_connecting", |peer| { .with_peer_mut(h, "mark_peer_connecting", |peer| {
peer.state peer.state
.queued_to_connecting(&self.stats) .idle_to_connecting(&self.stats)
.context("invalid peer state") .context("invalid peer state")
}) })
.context("peer not found in states")??; .context("peer not found in states")??;

View file

@ -268,7 +268,7 @@ impl ManagedTorrent {
let (tx, rx) = tokio::sync::oneshot::channel(); let (tx, rx) = tokio::sync::oneshot::channel();
let live = let live =
TorrentStateLive::new(paused, tx, live_cancellation_token); TorrentStateLive::new(paused, tx, live_cancellation_token)?;
g.state = ManagedTorrentState::Live(live.clone()); g.state = ManagedTorrentState::Live(live.clone());
spawn_fatal_errors_receiver(&t, rx, token); spawn_fatal_errors_receiver(&t, rx, token);
@ -289,7 +289,7 @@ impl ManagedTorrent {
ManagedTorrentState::Paused(_) => { ManagedTorrentState::Paused(_) => {
let paused = g.state.take().assert_paused(); let paused = g.state.take().assert_paused();
let (tx, rx) = tokio::sync::oneshot::channel(); 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()); g.state = ManagedTorrentState::Live(live.clone());
spawn_fatal_errors_receiver(self, rx, live_cancellation_token); spawn_fatal_errors_receiver(self, rx, live_cancellation_token);
spawn_peer_adder(&live, peer_rx); spawn_peer_adder(&live, peer_rx);
@ -337,6 +337,7 @@ impl ManagedTorrent {
use stats::TorrentStatsState as S; use stats::TorrentStatsState as S;
let mut resp = TorrentStats { let mut resp = TorrentStats {
total_bytes: self.info().lengths.total_length(), total_bytes: self.info().lengths.total_length(),
file_progress: Vec::new(),
state: S::Error, state: S::Error,
error: None, error: None,
progress_bytes: 0, progress_bytes: 0,
@ -353,21 +354,25 @@ impl ManagedTorrent {
} }
ManagedTorrentState::Paused(p) => { ManagedTorrentState::Paused(p) => {
resp.state = S::Paused; resp.state = S::Paused;
resp.total_bytes = p.hns.total(); let hns = p.hns();
resp.progress_bytes = p.hns.progress(); resp.total_bytes = hns.total();
resp.finished = p.hns.finished(); 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) => { ManagedTorrentState::Live(l) => {
resp.state = S::Live; resp.state = S::Live;
let live_stats = LiveStats::from(l.as_ref()); let live_stats = LiveStats::from(l.as_ref());
let total = l.get_total_selected_bytes(); let hns = l.get_hns().unwrap_or_default();
let remaining = l.get_left_to_download_bytes(); resp.total_bytes = hns.total();
let progress = total - remaining; resp.progress_bytes = hns.progress();
resp.finished = hns.finished();
resp.progress_bytes = progress;
resp.total_bytes = total;
resp.finished = remaining == 0;
resp.uploaded_bytes = l.get_uploaded_bytes(); resp.uploaded_bytes = l.get_uploaded_bytes();
resp.file_progress = l.get_file_progress();
resp.live = Some(live_stats); resp.live = Some(live_stats);
} }
ManagedTorrentState::Error(e) => { ManagedTorrentState::Error(e) => {
@ -410,10 +415,7 @@ impl ManagedTorrent {
// Returns true if needed to unpause torrent. // Returns true if needed to unpause torrent.
// This is just implementation detail - it's easier to pause/unpause than to tinker with internals. // 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<usize>) -> anyhow::Result<bool> { pub(crate) fn update_only_files(&self, only_files: &HashSet<usize>) -> anyhow::Result<()> {
if only_files.is_empty() {
anyhow::bail!("you need to select at least one file");
}
let file_count = self.info().info.iter_file_lengths()?.count(); let file_count = self.info().info.iter_file_lengths()?.count();
for f in only_files.iter().copied() { for f in only_files.iter().copied() {
if f >= file_count { if f >= file_count {
@ -426,25 +428,20 @@ impl ManagedTorrent {
// if paused, need to update chunk tracker // if paused, need to update chunk tracker
let mut g = self.locked.write(); 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::Initializing(_) => bail!("can't update initializing torrent"),
ManagedTorrentState::Error(_) => false, ManagedTorrentState::Error(_) => {}
ManagedTorrentState::None => false, ManagedTorrentState::None => {}
ManagedTorrentState::Paused(p) => { ManagedTorrentState::Paused(p) => {
p.update_only_files(only_files)?; p.update_only_files(only_files)?;
false
} }
ManagedTorrentState::Live(l) => { ManagedTorrentState::Live(l) => {
let mut p = l.pause()?; l.update_only_files(only_files)?;
let e = p.update_only_files(only_files);
g.state = ManagedTorrentState::Paused(p);
e?;
true
} }
}; };
g.only_files = Some(only_files.iter().copied().collect()); g.only_files = Some(only_files.iter().copied().collect());
Ok(need_to_unpause) Ok(())
} }
} }

View file

@ -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; use super::ManagedTorrentInfo;
pub struct TorrentStatePaused { pub struct TorrentStatePaused {
pub(crate) info: Arc<ManagedTorrentInfo>, pub(crate) info: Arc<ManagedTorrentInfo>,
pub(crate) files: Vec<Arc<Mutex<File>>>, pub(crate) files: OpenedFiles,
pub(crate) filenames: Vec<PathBuf>,
pub(crate) chunk_tracker: ChunkTracker, pub(crate) chunk_tracker: ChunkTracker,
pub(crate) hns: HaveNeededSelected,
} }
impl TorrentStatePaused { impl TorrentStatePaused {
pub(crate) fn update_only_files(&mut self, only_files: &HashSet<usize>) -> anyhow::Result<()> { pub(crate) fn update_only_files(&mut self, only_files: &HashSet<usize>) -> anyhow::Result<()> {
let hns = self self.chunk_tracker
.chunk_tracker
.update_only_files(self.info.info.iter_file_lengths()?, only_files)?; .update_only_files(self.info.info.iter_file_lengths()?, only_files)?;
self.hns = hns;
Ok(()) Ok(())
} }
pub(crate) fn hns(&self) -> &HaveNeededSelected {
self.chunk_tracker.get_hns()
}
} }

View file

@ -69,6 +69,7 @@ impl std::fmt::Display for TorrentStatsState {
#[derive(Serialize, Debug)] #[derive(Serialize, Debug)]
pub struct TorrentStats { pub struct TorrentStats {
pub state: TorrentStatsState, pub state: TorrentStatsState,
pub file_progress: Vec<u64>,
pub error: Option<String>, pub error: Option<String>,
pub progress_bytes: u64, pub progress_bytes: u64,
pub uploaded_bytes: u64, pub uploaded_bytes: u64,

View file

@ -2,7 +2,10 @@ use std::net::SocketAddr;
use futures::stream::BoxStream; use futures::stream::BoxStream;
use crate::opened_file::OpenedFile;
pub type BF = bitvec::boxed::BitBox<u8, bitvec::order::Msb0>; pub type BF = bitvec::boxed::BitBox<u8, bitvec::order::Msb0>;
pub type PeerHandle = SocketAddr; pub type PeerHandle = SocketAddr;
pub type PeerStream = BoxStream<'static, SocketAddr>; pub type PeerStream = BoxStream<'static, SocketAddr>;
pub(crate) type OpenedFiles = Vec<OpenedFile>;

View file

@ -832,6 +832,15 @@
"@types/lodash": "*" "@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": { "node_modules/@types/prop-types": {
"version": "15.7.11", "version": "15.7.11",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", "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", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" "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": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@ -2500,9 +2514,9 @@
"dev": true "dev": true
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "4.5.2", "version": "4.5.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.3.tgz",
"integrity": "sha512-tBCZBNSBbHQkaGyhGCDUGqeo2ph8Fstyp6FMSvTtsXeZSPpSMGlviAOav2hxVTqFcx8Hj/twtWKsMJXNY0xI8w==", "integrity": "sha512-kQL23kMeX92v3ph7IauVkXkikdDRsYMGTVl5KY2E9OY4ONLvkHf04MDTbnfo6NKxZiDLWzVpP5oTa8hQD8U3dg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"esbuild": "^0.18.10", "esbuild": "^0.18.10",

View file

@ -8,6 +8,7 @@
"dependencies": { "dependencies": {
"@restart/ui": "^1.6.6", "@restart/ui": "^1.6.6",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"lodash.sortby": "^4.7",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-icons": "^4.12.0", "react-icons": "^4.12.0",
@ -15,6 +16,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/lodash.debounce": "^4.0.9", "@types/lodash.debounce": "^4.0.9",
"@types/lodash.sortby": "^4.7.9",
"@types/react": "^18.2.38", "@types/react": "^18.2.38",
"@types/react-dom": "^18.2.16", "@types/react-dom": "^18.2.16",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
@ -1192,6 +1194,15 @@
"@types/lodash": "*" "@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": { "node_modules/@types/prop-types": {
"version": "15.7.11", "version": "15.7.11",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", "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", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" "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": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@ -2860,9 +2876,9 @@
"dev": true "dev": true
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "4.5.2", "version": "4.5.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.3.tgz",
"integrity": "sha512-tBCZBNSBbHQkaGyhGCDUGqeo2ph8Fstyp6FMSvTtsXeZSPpSMGlviAOav2hxVTqFcx8Hj/twtWKsMJXNY0xI8w==", "integrity": "sha512-kQL23kMeX92v3ph7IauVkXkikdDRsYMGTVl5KY2E9OY4ONLvkHf04MDTbnfo6NKxZiDLWzVpP5oTa8hQD8U3dg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"esbuild": "^0.18.10", "esbuild": "^0.18.10",

View file

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@restart/ui": "^1.6.6", "@restart/ui": "^1.6.6",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"lodash.sortby": "^4.7",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-icons": "^4.12.0", "react-icons": "^4.12.0",
@ -17,6 +18,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/lodash.debounce": "^4.0.9", "@types/lodash.debounce": "^4.0.9",
"@types/lodash.sortby": "^4.7.9",
"@types/react": "^18.2.38", "@types/react": "^18.2.38",
"@types/react-dom": "^18.2.16", "@types/react-dom": "^18.2.16",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",

View file

@ -81,6 +81,7 @@ export const STATE_ERROR = "error";
export interface TorrentStats { export interface TorrentStats {
state: "initializing" | "paused" | "live" | "error"; state: "initializing" | "paused" | "live" | "error";
error: string | null; error: string | null;
file_progress: number[];
progress_bytes: number; progress_bytes: number;
finished: boolean; finished: boolean;
total_bytes: number; total_bytes: number;

View file

@ -1,15 +1,18 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { TorrentDetails } from "../api-types"; import { TorrentDetails, TorrentStats } from "../api-types";
import { FormCheckbox } from "./forms/FormCheckbox"; import { FormCheckbox } from "./forms/FormCheckbox";
import { CiSquarePlus, CiSquareMinus } from "react-icons/ci"; import { CiSquarePlus, CiSquareMinus } from "react-icons/ci";
import { IconButton } from "./buttons/IconButton"; import { IconButton } from "./buttons/IconButton";
import { formatBytes } from "../helper/formatBytes"; import { formatBytes } from "../helper/formatBytes";
import { ProgressBar } from "./ProgressBar";
import sortBy from "lodash.sortby";
type TorrentFileForCheckbox = { type TorrentFileForCheckbox = {
id: number; id: number;
filename: string; filename: string;
pathComponents: string[]; pathComponents: string[];
length: number; length: number;
have_bytes: number;
}; };
type FileTree = { type FileTree = {
@ -19,7 +22,10 @@ type FileTree = {
files: TorrentFileForCheckbox[]; files: TorrentFileForCheckbox[];
}; };
const newFileTree = (torrentDetails: TorrentDetails): FileTree => { const newFileTree = (
torrentDetails: TorrentDetails,
stats: TorrentStats | null,
): FileTree => {
const newFileTreeInner = ( const newFileTreeInner = (
name: string, name: string,
id: string, id: string,
@ -43,8 +49,15 @@ const newFileTree = (torrentDetails: TorrentDetails): FileTree => {
getGroup(file.pathComponents[0]).push(file); getGroup(file.pathComponents[0]).push(file);
}); });
directFiles = sortBy(directFiles, (f) => f.filename);
let sortedGroupsByName = sortBy(
Object.entries(groupsByName),
([k, _]) => k,
);
let childId = 0; 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)); groups.push(newFileTreeInner(key, id + "." + childId, value, depth + 1));
childId += 1; childId += 1;
} }
@ -65,6 +78,7 @@ const newFileTree = (torrentDetails: TorrentDetails): FileTree => {
filename: file.components[file.components.length - 1], filename: file.components[file.components.length - 1],
pathComponents: file.components, pathComponents: file.components,
length: file.length, length: file.length,
have_bytes: stats ? stats.file_progress[id] ?? 0 : 0,
}; };
}), }),
0, 0,
@ -74,15 +88,21 @@ const newFileTree = (torrentDetails: TorrentDetails): FileTree => {
const FileTreeComponent: React.FC<{ const FileTreeComponent: React.FC<{
tree: FileTree; tree: FileTree;
torrentDetails: TorrentDetails; torrentDetails: TorrentDetails;
torrentStats: TorrentStats | null;
selectedFiles: Set<number>; selectedFiles: Set<number>;
setSelectedFiles: React.Dispatch<React.SetStateAction<Set<number>>>; setSelectedFiles: (_: Set<number>) => void;
initialExpanded: boolean; initialExpanded: boolean;
showProgressBar?: boolean;
disabled?: boolean;
}> = ({ }> = ({
tree, tree,
selectedFiles, selectedFiles,
setSelectedFiles, setSelectedFiles,
initialExpanded, initialExpanded,
torrentDetails, torrentDetails,
torrentStats,
showProgressBar,
disabled,
}) => { }) => {
let [expanded, setExpanded] = useState(initialExpanded); let [expanded, setExpanded] = useState(initialExpanded);
let children = useMemo(() => { let children = useMemo(() => {
@ -151,6 +171,7 @@ const FileTreeComponent: React.FC<{
{tree.dirs.map((dir) => ( {tree.dirs.map((dir) => (
<FileTreeComponent <FileTreeComponent
torrentDetails={torrentDetails} torrentDetails={torrentDetails}
torrentStats={torrentStats}
key={dir.name} key={dir.name}
tree={dir} tree={dir}
selectedFiles={selectedFiles} selectedFiles={selectedFiles}
@ -160,13 +181,28 @@ const FileTreeComponent: React.FC<{
))} ))}
<div className="pl-1"> <div className="pl-1">
{tree.files.map((file) => ( {tree.files.map((file) => (
<FormCheckbox <div
checked={selectedFiles.has(file.id)}
key={file.id} key={file.id}
label={`${file.filename} (${formatBytes(file.length)})`} className={`${
name={`file-${file.id}`} showProgressBar
onChange={() => handleToggleFile(file.id)} ? "grid grid-cols-1 gap-1 items-start lg:grid-cols-2 mb-2 lg:mb-0"
></FormCheckbox> : ""
}`}
>
<FormCheckbox
checked={selectedFiles.has(file.id)}
label={`${file.filename} (${formatBytes(file.length)})`}
name={`file-${file.id}`}
disabled={disabled}
onChange={() => handleToggleFile(file.id)}
></FormCheckbox>
{showProgressBar && (
<ProgressBar
now={(file.have_bytes / file.length) * 100}
variant={file.have_bytes == file.length ? "success" : "info"}
/>
)}
</div>
))} ))}
</div> </div>
</div> </div>
@ -176,20 +212,34 @@ const FileTreeComponent: React.FC<{
export const FileListInput: React.FC<{ export const FileListInput: React.FC<{
torrentDetails: TorrentDetails; torrentDetails: TorrentDetails;
torrentStats: TorrentStats | null;
selectedFiles: Set<number>; selectedFiles: Set<number>;
setSelectedFiles: React.Dispatch<React.SetStateAction<Set<number>>>; setSelectedFiles: (_: Set<number>) => void;
}> = ({ torrentDetails, selectedFiles, setSelectedFiles }) => { showProgressBar?: boolean;
let fileTree = useMemo(() => newFileTree(torrentDetails), [torrentDetails]); disabled?: boolean;
}> = ({
torrentDetails,
selectedFiles,
setSelectedFiles,
torrentStats,
showProgressBar,
disabled,
}) => {
let fileTree = useMemo(
() => newFileTree(torrentDetails, torrentStats),
[torrentDetails, torrentStats],
);
return ( return (
<> <FileTreeComponent
<FileTreeComponent torrentDetails={torrentDetails}
torrentDetails={torrentDetails} torrentStats={torrentStats}
tree={fileTree} tree={fileTree}
selectedFiles={selectedFiles} selectedFiles={selectedFiles}
setSelectedFiles={setSelectedFiles} setSelectedFiles={setSelectedFiles}
initialExpanded={true} initialExpanded={true}
/> showProgressBar={showProgressBar}
</> disabled={disabled}
/>
); );
}; };

View file

@ -1,9 +1,3 @@
type Props = {
now: number;
label?: string | null;
variant?: "warn" | "info" | "success" | "error";
};
const variantClassNames = { const variantClassNames = {
warn: "bg-amber-500 text-white", warn: "bg-amber-500 text-white",
info: "bg-blue-500 text-white", info: "bg-blue-500 text-white",
@ -11,16 +5,25 @@ const variantClassNames = {
error: "bg-red-500 text-white", 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 progressLabel = label ?? `${now.toFixed(2)}%`;
const variantClassName = const variantClassName =
variantClassNames[variant ?? "info"] ?? variantClassNames["info"]; variantClassNames[variant ?? "info"] ?? variantClassNames["info"];
return ( return (
<div className={"w-full bg-gray-200 rounded-full dark:bg-gray-500"}> <div
className={`w-full bg-gray-200 rounded-full mb-1 dark:bg-gray-500 ${classNames}`}
>
<div <div
className={`text-xs font-medium transition-all text-center p-0.5 leading-none rounded-full ${variantClassName}`} className={`text-xs font-medium transition-all text-center leading-none py-0.5 px-2 rounded-full ${variantClassName} ${
now < 1 && "bg-transparent"
}`}
style={{ width: `${now}%` }} style={{ width: `${now}%` }}
> >
{progressLabel} {progressLabel}

View file

@ -3,7 +3,7 @@ import {
TorrentDetails, TorrentDetails,
TorrentStats, TorrentStats,
STATE_INITIALIZING, STATE_INITIALIZING,
STATE_LIVE, ErrorDetails,
} from "../api-types"; } from "../api-types";
import { TorrentActions } from "./buttons/TorrentActions"; import { TorrentActions } from "./buttons/TorrentActions";
import { ProgressBar } from "./ProgressBar"; import { ProgressBar } from "./ProgressBar";
@ -12,6 +12,10 @@ import { formatBytes } from "../helper/formatBytes";
import { torrentDisplayName } from "../helper/getTorrentDisplayName"; import { torrentDisplayName } from "../helper/getTorrentDisplayName";
import { getCompletionETA } from "../helper/getCompletionETA"; import { getCompletionETA } from "../helper/getCompletionETA";
import { StatusIcon } from "./StatusIcon"; 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<{ export const TorrentRow: React.FC<{
id: number; id: number;
@ -23,7 +27,11 @@ export const TorrentRow: React.FC<{
const totalBytes = statsResponse?.total_bytes ?? 1; const totalBytes = statsResponse?.total_bytes ?? 1;
const progressBytes = statsResponse?.progress_bytes ?? 0; const progressBytes = statsResponse?.progress_bytes ?? 0;
const finished = statsResponse?.finished || false; const finished = statsResponse?.finished || false;
const progressPercentage = error ? 100 : (progressBytes / totalBytes) * 100; const progressPercentage = error
? 100
: totalBytes == 0
? 100
: (progressBytes / totalBytes) * 100;
const formatPeersString = () => { const formatPeersString = () => {
let peer_stats = statsResponse?.live?.snapshot.peer_stats; let peer_stats = statsResponse?.live?.snapshot.peer_stats;
@ -44,74 +52,133 @@ export const TorrentRow: React.FC<{
); );
}; };
const [selectedFiles, setSelectedFiles] = useState<Set<number>>(new Set());
// Update selected files whenever details are updated.
useEffect(() => {
setSelectedFiles(
new Set<number>(
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<number>) => {
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 ( return (
<section className="flex flex-col sm:flex-row items-center gap-2 border p-2 border-gray-200 rounded-xl shadow-xs hover:drop-shadow-sm dark:bg-slate-800 dark:border-slate-900"> <div className="flex flex-col border p-2 border-gray-200 rounded-xl shadow-xs hover:drop-shadow-sm dark:bg-slate-800 dark:border-slate-900">
{/* Icon */} <section className="flex flex-col lg:flex-row items-center gap-2">
<div className="hidden md:block">{statusIcon("w-10 h-10")}</div> {/* Icon */}
{/* Name, progress, stats */} <div className="hidden md:block">{statusIcon("w-10 h-10")}</div>
<div className="w-full flex flex-col gap-2"> {/* Name, progress, stats */}
{detailsResponse && ( <div className="w-full flex flex-col gap-2">
<div className="flex items-center gap-2"> {detailsResponse && (
<div className="md:hidden">{statusIcon("w-5 h-5")}</div> <div className="flex items-center gap-2">
<div className="text-left text-lg text-gray-900 text-ellipsis break-all dark:text-slate-200"> <div className="md:hidden">{statusIcon("w-5 h-5")}</div>
{torrentDisplayName(detailsResponse)} <div className="text-left text-lg text-gray-900 text-ellipsis break-all dark:text-slate-200">
{torrentDisplayName(detailsResponse)}
</div>
</div> </div>
)}
{error ? (
<p className="text-red-500 text-sm">
<strong>Error:</strong> {error}
</p>
) : (
<>
<div>
<ProgressBar
now={progressPercentage}
label={error}
variant={
state == STATE_INITIALIZING
? "warn"
: finished
? "success"
: "info"
}
/>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 sm:flex-wrap items-center text-sm text-nowrap font-medium text-gray-500">
<div className="flex gap-2 items-center">
<GoPeople /> {formatPeersString().toString()}
</div>
<div className="flex gap-2 items-center">
<GoFile />
<div>
{formatBytes(progressBytes)}/{formatBytes(totalBytes)}
</div>
</div>
{statsResponse && (
<>
<div className="flex gap-2 items-center">
<GoClock />
{getCompletionETA(statsResponse)}
</div>
<div className="flex gap-2 items-center">
<Speed statsResponse={statsResponse} />
</div>
</>
)}
</div>
</>
)}
</div>
{/* Actions */}
{statsResponse && (
<div className="">
<TorrentActions
id={id}
statsResponse={statsResponse}
extendedView={extendedView}
setExtendedView={setExtendedView}
/>
</div> </div>
)} )}
{error ? ( </section>
<p className="text-red-500 text-sm">
<strong>Error:</strong> {error} {/* extended view */}
</p> {detailsResponse && extendedView && (
) : (
<>
<div>
<ProgressBar
now={progressPercentage}
label={error}
variant={
state == STATE_INITIALIZING
? "warn"
: finished
? "success"
: "info"
}
/>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 sm:flex-wrap items-center text-sm text-nowrap font-medium text-gray-500">
<div className="flex gap-2 items-center">
<GoPeople /> {formatPeersString().toString()}
</div>
<div className="flex gap-2 items-center">
<GoFile />
<div>
{formatBytes(progressBytes)}/{formatBytes(totalBytes)}
</div>
</div>
{statsResponse && (
<>
<div className="flex gap-2 items-center">
<GoClock />
{getCompletionETA(statsResponse)}
</div>
<div className="flex gap-2 items-center">
<Speed statsResponse={statsResponse} />
</div>
</>
)}
</div>
</>
)}
</div>
{/* Actions */}
{statsResponse && (
<div className=""> <div className="">
<TorrentActions <FileListInput
id={id} torrentDetails={detailsResponse}
detailsResponse={detailsResponse} torrentStats={statsResponse}
statsResponse={statsResponse} selectedFiles={selectedFiles}
setSelectedFiles={updateSelectedFiles}
disabled={savingSelectedFiles}
showProgressBar
/> />
</div> </div>
)} )}
</section> </div>
); );
}; };

View file

@ -19,7 +19,7 @@ export const IconButton: React.FC<{
const colorClassName = color ? `text-${color}` : ""; const colorClassName = color ? `text-${color}` : "";
return ( return (
<a <a
className={`block p-1 text-blue-500 flex items-center justify-center ${colorClassName} ${className}`} className={`p-1 text-blue-500 flex items-center justify-center ${colorClassName} ${className}`}
onClick={onClickStopPropagation} onClick={onClickStopPropagation}
href="#" href="#"
{...otherProps} {...otherProps}

View file

@ -1,22 +1,21 @@
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import { TorrentDetails, TorrentStats } from "../../api-types"; import { TorrentStats } from "../../api-types";
import { APIContext, RefreshTorrentStatsContext } from "../../context"; import { APIContext, RefreshTorrentStatsContext } from "../../context";
import { IconButton } from "./IconButton"; import { IconButton } from "./IconButton";
import { DeleteTorrentModal } from "../modal/DeleteTorrentModal"; import { DeleteTorrentModal } from "../modal/DeleteTorrentModal";
import { TorrentSettingsModal } from "../modal/TorrentSettingsModal";
import { FaCog, FaPause, FaPlay, FaTrash } from "react-icons/fa"; import { FaCog, FaPause, FaPlay, FaTrash } from "react-icons/fa";
import { useErrorStore } from "../../stores/errorStore"; import { useErrorStore } from "../../stores/errorStore";
export const TorrentActions: React.FC<{ export const TorrentActions: React.FC<{
id: number; id: number;
detailsResponse: TorrentDetails | null;
statsResponse: TorrentStats; statsResponse: TorrentStats;
}> = ({ id, detailsResponse, statsResponse }) => { extendedView: boolean;
setExtendedView: (extendedView: boolean) => void;
}> = ({ id, statsResponse, extendedView, setExtendedView }) => {
let state = statsResponse.state; let state = statsResponse.state;
let [disabled, setDisabled] = useState<boolean>(false); let [disabled, setDisabled] = useState<boolean>(false);
let [deleting, setDeleting] = useState<boolean>(false); let [deleting, setDeleting] = useState<boolean>(false);
let [configuring, setConfiguring] = useState<boolean>(false);
let refreshCtx = useContext(RefreshTorrentStatsContext); let refreshCtx = useContext(RefreshTorrentStatsContext);
@ -62,10 +61,6 @@ export const TorrentActions: React.FC<{
.finally(() => setDisabled(false)); .finally(() => setDisabled(false));
}; };
const openConfigureModal = () => {
setConfiguring(true);
};
const startDeleting = () => { const startDeleting = () => {
setDisabled(true); setDisabled(true);
setDeleting(true); setDeleting(true);
@ -89,7 +84,10 @@ export const TorrentActions: React.FC<{
</IconButton> </IconButton>
)} )}
{canConfigure && ( {canConfigure && (
<IconButton onClick={openConfigureModal} disabled={disabled}> <IconButton
onClick={() => setExtendedView(!extendedView)}
disabled={disabled}
>
<FaCog className="hover:text-green-600" /> <FaCog className="hover:text-green-600" />
</IconButton> </IconButton>
)} )}
@ -97,14 +95,6 @@ export const TorrentActions: React.FC<{
<FaTrash className="hover:text-red-500" /> <FaTrash className="hover:text-red-500" />
</IconButton> </IconButton>
<DeleteTorrentModal id={id} show={deleting} onHide={cancelDeleting} /> <DeleteTorrentModal id={id} show={deleting} onHide={cancelDeleting} />
{detailsResponse && configuring && (
<TorrentSettingsModal
id={id}
show={configuring}
details={detailsResponse}
onHide={() => setConfiguring(false)}
/>
)}
</div> </div>
); );
}; };

View file

@ -8,9 +8,20 @@ export const FormCheckbox: React.FC<{
disabled?: boolean; disabled?: boolean;
inputType?: "checkbox" | "switch"; inputType?: "checkbox" | "switch";
onChange?: ChangeEventHandler<HTMLInputElement>; onChange?: ChangeEventHandler<HTMLInputElement>;
}> = ({ checked, name, disabled, onChange, label, help, inputType }) => { children?: React.ReactNode;
classNames?: string;
}> = ({
checked,
name,
disabled,
onChange,
label,
help,
inputType,
children,
}) => {
return ( return (
<div className="flex gap-3 items-start"> <div className={`flex gap-3 items-start`}>
<div className="flex"> <div className="flex">
<input <input
type={inputType || "checkbox"} type={inputType || "checkbox"}
@ -30,6 +41,7 @@ export const FormCheckbox: React.FC<{
</div> </div>
)} )}
</div> </div>
{children}
</div> </div>
); );
}; };

View file

@ -105,6 +105,7 @@ export const FileSelectionModal = (props: {
selectedFiles={selectedFiles} selectedFiles={selectedFiles}
setSelectedFiles={setSelectedFiles} setSelectedFiles={setSelectedFiles}
torrentDetails={listTorrentResponse.details} torrentDetails={listTorrentResponse.details}
torrentStats={null}
/> />
</Fieldset> </Fieldset>

View file

@ -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<number>();
let refreshCtx = useContext(RefreshTorrentStatsContext);
details.files.forEach((f, i) => {
if (f.included) {
initialSelectedFiles.add(i);
}
});
const API = useContext(APIContext);
const [selectedFiles, setSelectedFiles] =
useState<Set<number>>(initialSelectedFiles);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<ErrorWithLabel | null>(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 (
<Modal isOpen={show} onClose={close} title="Configure torrent">
<ModalBody>
<ErrorComponent error={error}></ErrorComponent>
<FileListInput
torrentDetails={details}
selectedFiles={selectedFiles}
setSelectedFiles={setSelectedFiles}
/>
</ModalBody>
<ModalFooter>
{saving && <Spinner />}
<Button onClick={close} variant="cancel">
Cancel
</Button>
<Button
onClick={handleSave}
variant="primary"
disabled={saving || selectedFiles.size == 0}
>
OK
</Button>
</ModalFooter>
</Modal>
);
};

View file

@ -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<u32> {
// 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<Item = ChunkInfo> { pub fn iter_chunk_infos(&self, index: ValidPieceIndex) -> impl Iterator<Item = ChunkInfo> {
let mut remaining = self.piece_length(index); let mut remaining = self.piece_length(index);
let absolute_offset = index.0 * self.chunks_per_piece; let absolute_offset = index.0 * self.chunks_per_piece;
@ -230,6 +249,19 @@ impl Lengths {
} }
return None; 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)] #[cfg(test)]
@ -535,4 +567,71 @@ mod tests {
assert_eq!(l.chunks_per_piece(l.last_piece_id()), 1); 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::<Vec<_>>()[..],
$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);
}
} }

View file

@ -7,7 +7,7 @@ use clone_to_owned::CloneToOwned;
use itertools::Either; use itertools::Either;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::hash_id::Id20; use crate::{hash_id::Id20, lengths::Lengths};
pub type TorrentMetaV1Borrowed<'a> = TorrentMetaV1<ByteBuf<'a>>; pub type TorrentMetaV1Borrowed<'a> = TorrentMetaV1<ByteBuf<'a>>;
pub type TorrentMetaV1Owned = TorrentMetaV1<ByteBufOwned>; pub type TorrentMetaV1Owned = TorrentMetaV1<ByteBufOwned>;
@ -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<u32>,
}
impl<'a, BufType> FileDetails<'a, BufType> {
pub fn pieces_usize(&self) -> std::ops::Range<usize> {
self.pieces.start as usize..self.pieces.end as usize
}
}
impl<BufType: AsRef<[u8]>> TorrentMetaV1Info<BufType> { impl<BufType: AsRef<[u8]>> TorrentMetaV1Info<BufType> {
pub fn get_hash(&self, piece: u32) -> Option<&[u8]> { pub fn get_hash(&self, piece: u32) -> Option<&[u8]> {
let start = piece as usize * 20; let start = piece as usize * 20;
@ -195,6 +208,26 @@ impl<BufType: AsRef<[u8]>> TorrentMetaV1Info<BufType> {
pub fn iter_file_lengths(&self) -> anyhow::Result<impl Iterator<Item = u64> + '_> { pub fn iter_file_lengths(&self) -> anyhow::Result<impl Iterator<Item = u64> + '_> {
Ok(self.iter_filenames_and_lengths()?.map(|(_, l)| l)) 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<impl Iterator<Item = FileDetails<'a, BufType>> + '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)] #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]

View file

@ -32,6 +32,7 @@
"dependencies": { "dependencies": {
"@restart/ui": "^1.6.6", "@restart/ui": "^1.6.6",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"lodash.sortby": "^4.7",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-icons": "^4.12.0", "react-icons": "^4.12.0",
@ -39,6 +40,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/lodash.debounce": "^4.0.9", "@types/lodash.debounce": "^4.0.9",
"@types/lodash.sortby": "^4.7.9",
"@types/react": "^18.2.38", "@types/react": "^18.2.38",
"@types/react-dom": "^18.2.16", "@types/react-dom": "^18.2.16",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",

View file

@ -130,7 +130,7 @@ export const ConfigModal: React.FC<{
}; };
const handleToggleChange: React.ChangeEventHandler<HTMLInputElement> = ( const handleToggleChange: React.ChangeEventHandler<HTMLInputElement> = (
e e,
) => { ) => {
const name: string = e.target.name; const name: string = e.target.name;
const [mainField, subField] = name.split(".", 2); const [mainField, subField] = name.split(".", 2);
@ -166,7 +166,7 @@ export const ConfigModal: React.FC<{
text: "Error saving configuration", text: "Error saving configuration",
details: e, details: e,
}); });
} },
); );
}; };
@ -179,7 +179,7 @@ export const ConfigModal: React.FC<{
> >
<ModalBody> <ModalBody>
<ErrorComponent error={error}></ErrorComponent> <ErrorComponent error={error}></ErrorComponent>
<div className="flex border-b mb-4"> <div className="mb-4 flex border-b">
{TABS.map((t, i) => { {TABS.map((t, i) => {
const isActive = t === tab; const isActive = t === tab;
let classNames = "text-slate-300"; let classNames = "text-slate-300";