From 7d6ed0616676d875e4d8630951a3de4956f9b74d Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Wed, 22 Nov 2023 17:19:35 +0000 Subject: [PATCH] Selective download modal --- Cargo.lock | 1 + crates/librqbit/Cargo.toml | 1 + crates/librqbit/src/http_api.rs | 72 ++++++----- crates/librqbit/webui/src/index.tsx | 188 +++++++++++++++++++++++----- 4 files changed, 200 insertions(+), 62 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6e754b5..f4b031b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -849,6 +849,7 @@ dependencies = [ "futures", "hex 0.4.3", "http", + "itertools", "librqbit-bencode", "librqbit-buffers", "librqbit-clone-to-owned", diff --git a/crates/librqbit/Cargo.toml b/crates/librqbit/Cargo.toml index 1c55f3b..25d50f0 100644 --- a/crates/librqbit/Cargo.toml +++ b/crates/librqbit/Cargo.toml @@ -38,6 +38,7 @@ serde = {version = "1", features=["derive"]} serde_json = "1" serde_urlencoded = "0.7" anyhow = "1" +itertools = "0.12" http = "0.2" regex = "1" reqwest = {version="0.11.22", default-features=false} diff --git a/crates/librqbit/src/http_api.rs b/crates/librqbit/src/http_api.rs index 7b15f04..81ad43e 100644 --- a/crates/librqbit/src/http_api.rs +++ b/crates/librqbit/src/http_api.rs @@ -6,6 +6,7 @@ use axum::routing::get; use buffers::ByteString; use dht::{Dht, DhtStats}; use http::StatusCode; +use itertools::Itertools; use librqbit_core::id20::Id20; use librqbit_core::torrent_metainfo::TorrentMetaV1Info; use parking_lot::RwLock; @@ -263,37 +264,45 @@ pub struct ApiAddTorrentResponse { pub details: TorrentDetailsResponse, } -fn deserialize_only_files<'de, D>( - deserializer: D, -) -> core::result::Result>, D::Error> -where - D: serde::Deserializer<'de>, -{ - use serde::de::Error; +pub struct OnlyFiles(Vec); - let s = Option::::deserialize(deserializer)?; - let s = match s { - Some(s) => s, - None => return Ok(None), - }; - let list = s - .split(',') - .try_fold(Vec::::new(), |mut acc, c| match c.parse() { - Ok(i) => { - acc.push(i); - Ok(acc) - } - Err(_) => Err(D::Error::custom(format!( - "only_files: failed to parse {:?} as integer", - c - ))), - })?; - if list.is_empty() { - return Err(D::Error::custom( - "only_files: should contain at least one file id", - )); +impl Serialize for OnlyFiles { + fn serialize(&self, serializer: S) -> core::result::Result + where + S: serde::Serializer, + { + let s = self.0.iter().map(|id| id.to_string()).join(","); + s.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for OnlyFiles { + fn deserialize(deserializer: D) -> core::result::Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error; + + let s = String::deserialize(deserializer)?; + let list = s + .split(',') + .try_fold(Vec::::new(), |mut acc, c| match c.parse() { + Ok(i) => { + acc.push(i); + Ok(acc) + } + Err(_) => Err(D::Error::custom(format!( + "only_files: failed to parse {:?} as integer", + c + ))), + })?; + if list.is_empty() { + return Err(D::Error::custom( + "only_files: should contain at least one file id", + )); + } + Ok(OnlyFiles(list)) } - Ok(Some(list)) } #[derive(Serialize, Deserialize)] @@ -302,8 +311,7 @@ pub struct TorrentAddQueryParams { pub output_folder: Option, pub sub_folder: Option, pub only_files_regex: Option, - #[serde(deserialize_with = "deserialize_only_files")] - pub only_files: Option>, + pub only_files: Option, pub list_only: Option, } @@ -312,7 +320,7 @@ impl TorrentAddQueryParams { AddTorrentOptions { overwrite: self.overwrite.unwrap_or(false), only_files_regex: self.only_files_regex, - only_files: self.only_files, + only_files: self.only_files.map(|o| o.0), output_folder: self.output_folder, sub_folder: self.sub_folder, list_only: self.list_only.unwrap_or(false), diff --git a/crates/librqbit/webui/src/index.tsx b/crates/librqbit/webui/src/index.tsx index 73b3748..448727f 100644 --- a/crates/librqbit/webui/src/index.tsx +++ b/crates/librqbit/webui/src/index.tsx @@ -1,6 +1,6 @@ import { StrictMode, createContext, memo, useContext, useEffect, useRef, useState } from 'react'; import ReactDOM from 'react-dom/client'; -import { ProgressBar, Button, Container, Row, Col, Alert } from 'react-bootstrap'; +import { ProgressBar, Button, Container, Row, Col, Alert, Modal, Form, Spinner } from 'react-bootstrap'; // Define API URL and base path const apiUrl = (window.origin === 'null' || window.origin === 'http://localhost:3031') ? 'http://localhost:3030' : ''; @@ -33,13 +33,21 @@ interface TorrentId { info_hash: string; } +interface TorrentFile { + name: string; + length: number; + included: boolean; +} + // Interface for the Torrent Details API response interface TorrentDetails { - files: { - name: string; - length: number; - included: boolean; - }[]; + info_hash: string, + files: Array; +} + +interface AddTorrentResponse { + id: number | null; + details: TorrentDetails; } // Interface for the Torrent Stats API response @@ -113,6 +121,7 @@ const ColumnWithProgressBar = ({ label, percentage }) => ( const Torrent = ({ torrent }) => { const defaultDetails: TorrentDetails = { + info_hash: '', files: [] }; const defaultStats: TorrentStats = { @@ -188,12 +197,6 @@ const Torrent = ({ torrent }) => { return } -const Spinner = () => ( -
- Loading... -
-); - const TorrentsList = (props: { torrents: Array, loading: boolean }) => { if (props.torrents === null && props.loading) { return @@ -347,44 +350,169 @@ const Error = (props: { error: ErrorType, remove?: () => void }) => { ); }; -const MagnetInput = () => { - let ctx = useContext(AppContext); +const UploadButton = ({ buttonText, onClick, data, setData, variant }) => { + const [loading, setLoading] = useState(false); + const [fileList, setFileList] = useState(null); + const ctx = useContext(AppContext); + const showModal = data !== null; - async function addTorrentFromMagnet(): Promise { - const magnetLink = prompt('Enter magnet link:'); - if (magnetLink) { - await ctx.makeRequest('POST', '/torrents?overwrite=true', magnetLink, true); - ctx.refreshTorrents(); + // Get the torrent file list if there's data. + useEffect(() => { + if (data === null) { + return; } + + let t = setTimeout(async () => { + try { + const response: AddTorrentResponse = await ctx.makeRequest('POST', `/torrents?list_only=true&overwrite=true`, data, true); + console.log(response); + setFileList(response.details.files); + } catch (e) { + clear(); + } finally { + setLoading(false); + } + }, 0); + return () => clearTimeout(t); + }, [data]); + + const clear = () => { + setData(null); + setFileList(null); + setLoading(false); } - return ; + return ( + <> + + + + + ); +}; + +const MagnetInput = () => { + let [magnet, setMagnet] = useState(null); + + const onClick = () => { + const m = prompt('Enter magnet link or HTTP(s) URL'); + setMagnet(m === '' ? null : m); + }; + + return ( + + ); }; const FileInput = () => { const inputRef = useRef(); - let ctx = useContext(AppContext); + const [file, setFile] = useState(null); - const inputOnChange = async (e) => { - let file = e.target.files[0]; - await ctx.makeRequest('POST', '/torrents?overwrite=true', file, true); - ctx.refreshTorrents(); + const onFileChange = async () => { + const file = inputRef.current.files[0]; + setFile(file); }; const onClick = () => { inputRef.current.click(); - }; + } return ( <> - - + + ); }; +const FileSelectionModal = (props: { show: boolean, onHide, fileList: Array | null, fileListLoading: boolean, data }) => { + let { show, onHide, fileList, fileListLoading, data } = props; + + + const [selectedFiles, setSelectedFiles] = useState([]); + const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); + + useEffect(() => { + setSelectedFiles((fileList || []).map((_, id) => id)); + }, [fileList]); + + fileList = fileList || []; + + let ctx = useContext(AppContext); + + const handleToggleFile = (fileIndex: number) => { + if (selectedFiles.includes(fileIndex)) { + setSelectedFiles(selectedFiles.filter((index) => index !== fileIndex)); + } else { + setSelectedFiles([...selectedFiles, fileIndex]); + } + }; + + const handleUpload = async () => { + const getSelectedFilesQueryParam = () => { + let allPresent = true; + fileList.map((_, id) => { + allPresent = allPresent && selectedFiles.includes(id); + }); + return allPresent ? '' : '&only_files=' + selectedFiles.join(','); + }; + + let url = `/torrents?overwrite=true${getSelectedFilesQueryParam()}`; + + ctx.makeRequest('POST', url, data, false).then(() => { onHide() }, (e) => { + setUploadError(e); + }) + }; + + return ( + + + Select Files + + + {fileListLoading ? ( + + Loading... + + ) : ( + + {fileList.map((file, index) => ( + + + handleToggleFile(index)} + /> + + + ))} + + + )} + + + + + + + ); +}; + const Buttons = () => { return (