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

View file

@ -33,9 +33,13 @@ pub struct ChunkTracker {
// What pieces to download first.
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 {
// 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<Item = usize> + '_ {
@ -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<ByteBuf>(
&mut self,
@ -356,11 +372,13 @@ impl ChunkTracker {
}
}
Ok(HaveNeededSelected {
let res = HaveNeededSelected {
have_bytes,
needed_bytes,
selected_bytes,
})
};
self.hns = res;
Ok(res)
}
}

View file

@ -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<Sha1: ISha1>(
pub(crate) struct FileOps<'a> {
torrent: &'a TorrentMetaV1Info<ByteBufOwned>,
files: &'a [Arc<Mutex<File>>],
files: &'a OpenedFiles,
lengths: &'a Lengths,
phantom_data: PhantomData<Sha1>,
}
@ -72,7 +71,7 @@ pub(crate) struct FileOps<'a> {
impl<'a> FileOps<'a> {
pub fn new(
torrent: &'a TorrentMetaV1Info<ByteBufOwned>,
files: &'a [Arc<Mutex<File>>],
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<InitialCheckResults> {
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::<usize>::new();
#[derive(Debug)]
struct CurrentFile<'a> {
index: usize,
fd: &'a Arc<Mutex<File>>,
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,

View file

@ -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;

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");
}
(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<usize>,
) -> 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(())
}

View file

@ -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"),

View file

@ -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<TorrentStatePaused> {
let (files, filenames) = {
let mut files =
Vec::<Arc<Mutex<File>>>::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)
}

View file

@ -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<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 {
BF::from_boxed_slice(vec![0; lengths.piece_bitfield_bytes()].into_boxed_slice())
}
@ -170,11 +155,7 @@ pub struct TorrentStateLive {
meta: Arc<ManagedTorrentInfo>,
locked: RwLock<TorrentStateLocked>,
files: Vec<Arc<Mutex<File>>>,
filenames: Vec<PathBuf>,
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<anyhow::Error>,
cancellation_token: CancellationToken,
) -> Arc<Self> {
) -> anyhow::Result<Arc<Self>> {
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<HaveNeededSelected> {
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::<anyhow::Result<Vec<_>>>()?;
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<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 {
@ -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);
}
}
}
}
}

View file

@ -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,
}
}

View file

@ -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")??;

View file

@ -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<usize>) -> anyhow::Result<bool> {
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<usize>) -> 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(())
}
}

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;
pub struct TorrentStatePaused {
pub(crate) info: Arc<ManagedTorrentInfo>,
pub(crate) files: Vec<Arc<Mutex<File>>>,
pub(crate) filenames: Vec<PathBuf>,
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<usize>) -> 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()
}
}

View file

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

View file

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

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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;

View file

@ -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<number>;
setSelectedFiles: React.Dispatch<React.SetStateAction<Set<number>>>;
setSelectedFiles: (_: Set<number>) => 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) => (
<FileTreeComponent
torrentDetails={torrentDetails}
torrentStats={torrentStats}
key={dir.name}
tree={dir}
selectedFiles={selectedFiles}
@ -160,13 +181,28 @@ const FileTreeComponent: React.FC<{
))}
<div className="pl-1">
{tree.files.map((file) => (
<FormCheckbox
checked={selectedFiles.has(file.id)}
<div
key={file.id}
label={`${file.filename} (${formatBytes(file.length)})`}
name={`file-${file.id}`}
onChange={() => handleToggleFile(file.id)}
></FormCheckbox>
className={`${
showProgressBar
? "grid grid-cols-1 gap-1 items-start lg:grid-cols-2 mb-2 lg:mb-0"
: ""
}`}
>
<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>
@ -176,20 +212,34 @@ const FileTreeComponent: React.FC<{
export const FileListInput: React.FC<{
torrentDetails: TorrentDetails;
torrentStats: TorrentStats | null;
selectedFiles: Set<number>;
setSelectedFiles: React.Dispatch<React.SetStateAction<Set<number>>>;
}> = ({ torrentDetails, selectedFiles, setSelectedFiles }) => {
let fileTree = useMemo(() => newFileTree(torrentDetails), [torrentDetails]);
setSelectedFiles: (_: Set<number>) => void;
showProgressBar?: boolean;
disabled?: boolean;
}> = ({
torrentDetails,
selectedFiles,
setSelectedFiles,
torrentStats,
showProgressBar,
disabled,
}) => {
let fileTree = useMemo(
() => newFileTree(torrentDetails, torrentStats),
[torrentDetails, torrentStats],
);
return (
<>
<FileTreeComponent
torrentDetails={torrentDetails}
tree={fileTree}
selectedFiles={selectedFiles}
setSelectedFiles={setSelectedFiles}
initialExpanded={true}
/>
</>
<FileTreeComponent
torrentDetails={torrentDetails}
torrentStats={torrentStats}
tree={fileTree}
selectedFiles={selectedFiles}
setSelectedFiles={setSelectedFiles}
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 = {
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 (
<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
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}%` }}
>
{progressLabel}

View file

@ -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<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 (
<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">
{/* Icon */}
<div className="hidden md:block">{statusIcon("w-10 h-10")}</div>
{/* Name, progress, stats */}
<div className="w-full flex flex-col gap-2">
{detailsResponse && (
<div className="flex items-center gap-2">
<div className="md:hidden">{statusIcon("w-5 h-5")}</div>
<div className="text-left text-lg text-gray-900 text-ellipsis break-all dark:text-slate-200">
{torrentDisplayName(detailsResponse)}
<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">
<section className="flex flex-col lg:flex-row items-center gap-2">
{/* Icon */}
<div className="hidden md:block">{statusIcon("w-10 h-10")}</div>
{/* Name, progress, stats */}
<div className="w-full flex flex-col gap-2">
{detailsResponse && (
<div className="flex items-center gap-2">
<div className="md:hidden">{statusIcon("w-5 h-5")}</div>
<div className="text-left text-lg text-gray-900 text-ellipsis break-all dark:text-slate-200">
{torrentDisplayName(detailsResponse)}
</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>
)}
{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 && (
</section>
{/* extended view */}
{detailsResponse && extendedView && (
<div className="">
<TorrentActions
id={id}
detailsResponse={detailsResponse}
statsResponse={statsResponse}
<FileListInput
torrentDetails={detailsResponse}
torrentStats={statsResponse}
selectedFiles={selectedFiles}
setSelectedFiles={updateSelectedFiles}
disabled={savingSelectedFiles}
showProgressBar
/>
</div>
)}
</section>
</div>
);
};

View file

@ -19,7 +19,7 @@ export const IconButton: React.FC<{
const colorClassName = color ? `text-${color}` : "";
return (
<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}
href="#"
{...otherProps}

View file

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

View file

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

View file

@ -105,6 +105,7 @@ export const FileSelectionModal = (props: {
selectedFiles={selectedFiles}
setSelectedFiles={setSelectedFiles}
torrentDetails={listTorrentResponse.details}
torrentStats={null}
/>
</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> {
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::<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 serde::{Deserialize, Serialize};
use crate::hash_id::Id20;
use crate::{hash_id::Id20, lengths::Lengths};
pub type TorrentMetaV1Borrowed<'a> = TorrentMetaV1<ByteBuf<'a>>;
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> {
pub fn get_hash(&self, piece: u32) -> Option<&[u8]> {
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> + '_> {
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)]