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:
parent
d7380217f6
commit
5eb01ac226
31 changed files with 865 additions and 512 deletions
15
TODO.md
15
TODO.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
97
crates/librqbit/src/opened_file.rs
Normal file
97
crates/librqbit/src/opened_file.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")??;
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
20
crates/librqbit/webui/node_modules/.package-lock.json
generated
vendored
20
crates/librqbit/webui/node_modules/.package-lock.json
generated
vendored
|
|
@ -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",
|
||||||
|
|
|
||||||
22
crates/librqbit/webui/package-lock.json
generated
22
crates/librqbit/webui/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
2
desktop/package-lock.json
generated
2
desktop/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue