Selective download modal
This commit is contained in:
parent
0574888ef2
commit
7d6ed06166
4 changed files with 200 additions and 62 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -849,6 +849,7 @@ dependencies = [
|
||||||
"futures",
|
"futures",
|
||||||
"hex 0.4.3",
|
"hex 0.4.3",
|
||||||
"http",
|
"http",
|
||||||
|
"itertools",
|
||||||
"librqbit-bencode",
|
"librqbit-bencode",
|
||||||
"librqbit-buffers",
|
"librqbit-buffers",
|
||||||
"librqbit-clone-to-owned",
|
"librqbit-clone-to-owned",
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ serde = {version = "1", features=["derive"]}
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_urlencoded = "0.7"
|
serde_urlencoded = "0.7"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
itertools = "0.12"
|
||||||
http = "0.2"
|
http = "0.2"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
reqwest = {version="0.11.22", default-features=false}
|
reqwest = {version="0.11.22", default-features=false}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ use axum::routing::get;
|
||||||
use buffers::ByteString;
|
use buffers::ByteString;
|
||||||
use dht::{Dht, DhtStats};
|
use dht::{Dht, DhtStats};
|
||||||
use http::StatusCode;
|
use http::StatusCode;
|
||||||
|
use itertools::Itertools;
|
||||||
use librqbit_core::id20::Id20;
|
use librqbit_core::id20::Id20;
|
||||||
use librqbit_core::torrent_metainfo::TorrentMetaV1Info;
|
use librqbit_core::torrent_metainfo::TorrentMetaV1Info;
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
|
|
@ -263,37 +264,45 @@ pub struct ApiAddTorrentResponse {
|
||||||
pub details: TorrentDetailsResponse,
|
pub details: TorrentDetailsResponse,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn deserialize_only_files<'de, D>(
|
pub struct OnlyFiles(Vec<usize>);
|
||||||
deserializer: D,
|
|
||||||
) -> core::result::Result<Option<Vec<usize>>, D::Error>
|
|
||||||
where
|
|
||||||
D: serde::Deserializer<'de>,
|
|
||||||
{
|
|
||||||
use serde::de::Error;
|
|
||||||
|
|
||||||
let s = Option::<String>::deserialize(deserializer)?;
|
impl Serialize for OnlyFiles {
|
||||||
let s = match s {
|
fn serialize<S>(&self, serializer: S) -> core::result::Result<S::Ok, S::Error>
|
||||||
Some(s) => s,
|
where
|
||||||
None => return Ok(None),
|
S: serde::Serializer,
|
||||||
};
|
{
|
||||||
let list = s
|
let s = self.0.iter().map(|id| id.to_string()).join(",");
|
||||||
.split(',')
|
s.serialize(serializer)
|
||||||
.try_fold(Vec::<usize>::new(), |mut acc, c| match c.parse() {
|
}
|
||||||
Ok(i) => {
|
}
|
||||||
acc.push(i);
|
|
||||||
Ok(acc)
|
impl<'de> Deserialize<'de> for OnlyFiles {
|
||||||
}
|
fn deserialize<D>(deserializer: D) -> core::result::Result<Self, D::Error>
|
||||||
Err(_) => Err(D::Error::custom(format!(
|
where
|
||||||
"only_files: failed to parse {:?} as integer",
|
D: serde::Deserializer<'de>,
|
||||||
c
|
{
|
||||||
))),
|
use serde::de::Error;
|
||||||
})?;
|
|
||||||
if list.is_empty() {
|
let s = String::deserialize(deserializer)?;
|
||||||
return Err(D::Error::custom(
|
let list = s
|
||||||
"only_files: should contain at least one file id",
|
.split(',')
|
||||||
));
|
.try_fold(Vec::<usize>::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)]
|
#[derive(Serialize, Deserialize)]
|
||||||
|
|
@ -302,8 +311,7 @@ pub struct TorrentAddQueryParams {
|
||||||
pub output_folder: Option<String>,
|
pub output_folder: Option<String>,
|
||||||
pub sub_folder: Option<String>,
|
pub sub_folder: Option<String>,
|
||||||
pub only_files_regex: Option<String>,
|
pub only_files_regex: Option<String>,
|
||||||
#[serde(deserialize_with = "deserialize_only_files")]
|
pub only_files: Option<OnlyFiles>,
|
||||||
pub only_files: Option<Vec<usize>>,
|
|
||||||
pub list_only: Option<bool>,
|
pub list_only: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -312,7 +320,7 @@ impl TorrentAddQueryParams {
|
||||||
AddTorrentOptions {
|
AddTorrentOptions {
|
||||||
overwrite: self.overwrite.unwrap_or(false),
|
overwrite: self.overwrite.unwrap_or(false),
|
||||||
only_files_regex: self.only_files_regex,
|
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,
|
output_folder: self.output_folder,
|
||||||
sub_folder: self.sub_folder,
|
sub_folder: self.sub_folder,
|
||||||
list_only: self.list_only.unwrap_or(false),
|
list_only: self.list_only.unwrap_or(false),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { StrictMode, createContext, memo, useContext, useEffect, useRef, useState } from 'react';
|
import { StrictMode, createContext, memo, useContext, useEffect, useRef, useState } from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
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
|
// Define API URL and base path
|
||||||
const apiUrl = (window.origin === 'null' || window.origin === 'http://localhost:3031') ? 'http://localhost:3030' : '';
|
const apiUrl = (window.origin === 'null' || window.origin === 'http://localhost:3031') ? 'http://localhost:3030' : '';
|
||||||
|
|
@ -33,13 +33,21 @@ interface TorrentId {
|
||||||
info_hash: string;
|
info_hash: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TorrentFile {
|
||||||
|
name: string;
|
||||||
|
length: number;
|
||||||
|
included: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// Interface for the Torrent Details API response
|
// Interface for the Torrent Details API response
|
||||||
interface TorrentDetails {
|
interface TorrentDetails {
|
||||||
files: {
|
info_hash: string,
|
||||||
name: string;
|
files: Array<TorrentFile>;
|
||||||
length: number;
|
}
|
||||||
included: boolean;
|
|
||||||
}[];
|
interface AddTorrentResponse {
|
||||||
|
id: number | null;
|
||||||
|
details: TorrentDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interface for the Torrent Stats API response
|
// Interface for the Torrent Stats API response
|
||||||
|
|
@ -113,6 +121,7 @@ const ColumnWithProgressBar = ({ label, percentage }) => (
|
||||||
|
|
||||||
const Torrent = ({ torrent }) => {
|
const Torrent = ({ torrent }) => {
|
||||||
const defaultDetails: TorrentDetails = {
|
const defaultDetails: TorrentDetails = {
|
||||||
|
info_hash: '',
|
||||||
files: []
|
files: []
|
||||||
};
|
};
|
||||||
const defaultStats: TorrentStats = {
|
const defaultStats: TorrentStats = {
|
||||||
|
|
@ -188,12 +197,6 @@ const Torrent = ({ torrent }) => {
|
||||||
return <TorrentRow detailsResponse={detailsResponse} statsResponse={statsResponse} />
|
return <TorrentRow detailsResponse={detailsResponse} statsResponse={statsResponse} />
|
||||||
}
|
}
|
||||||
|
|
||||||
const Spinner = () => (
|
|
||||||
<div className="spinner-border" role="status">
|
|
||||||
<span className="sr-only">Loading...</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const TorrentsList = (props: { torrents: Array<TorrentId>, loading: boolean }) => {
|
const TorrentsList = (props: { torrents: Array<TorrentId>, loading: boolean }) => {
|
||||||
if (props.torrents === null && props.loading) {
|
if (props.torrents === null && props.loading) {
|
||||||
return <Spinner />
|
return <Spinner />
|
||||||
|
|
@ -347,44 +350,169 @@ const Error = (props: { error: ErrorType, remove?: () => void }) => {
|
||||||
</Alert>);
|
</Alert>);
|
||||||
};
|
};
|
||||||
|
|
||||||
const MagnetInput = () => {
|
const UploadButton = ({ buttonText, onClick, data, setData, variant }) => {
|
||||||
let ctx = useContext(AppContext);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [fileList, setFileList] = useState(null);
|
||||||
|
const ctx = useContext(AppContext);
|
||||||
|
const showModal = data !== null;
|
||||||
|
|
||||||
async function addTorrentFromMagnet(): Promise<void> {
|
// Get the torrent file list if there's data.
|
||||||
const magnetLink = prompt('Enter magnet link:');
|
useEffect(() => {
|
||||||
if (magnetLink) {
|
if (data === null) {
|
||||||
await ctx.makeRequest('POST', '/torrents?overwrite=true', magnetLink, true);
|
return;
|
||||||
ctx.refreshTorrents();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 <Button variant="primary" className="mr-2" onClick={addTorrentFromMagnet}>
|
return (
|
||||||
Add Torrent from Magnet Link
|
<>
|
||||||
</Button>;
|
<Button variant={variant} onClick={onClick}>
|
||||||
|
{buttonText}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<FileSelectionModal
|
||||||
|
show={showModal}
|
||||||
|
onHide={clear}
|
||||||
|
fileList={fileList}
|
||||||
|
data={data}
|
||||||
|
fileListLoading={loading}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MagnetInput = () => {
|
||||||
|
let [magnet, setMagnet] = useState(null);
|
||||||
|
|
||||||
|
const onClick = () => {
|
||||||
|
const m = prompt('Enter magnet link or HTTP(s) URL');
|
||||||
|
setMagnet(m === '' ? null : m);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UploadButton variant='primary' buttonText="Add Torrent from Magnet Link" onClick={onClick} data={magnet} setData={setMagnet} />
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const FileInput = () => {
|
const FileInput = () => {
|
||||||
const inputRef = useRef<HTMLInputElement>();
|
const inputRef = useRef<HTMLInputElement>();
|
||||||
let ctx = useContext(AppContext);
|
const [file, setFile] = useState(null);
|
||||||
|
|
||||||
const inputOnChange = async (e) => {
|
const onFileChange = async () => {
|
||||||
let file = e.target.files[0];
|
const file = inputRef.current.files[0];
|
||||||
await ctx.makeRequest('POST', '/torrents?overwrite=true', file, true);
|
setFile(file);
|
||||||
ctx.refreshTorrents();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
inputRef.current.click();
|
inputRef.current.click();
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<input type="file" ref={inputRef} id="file-input" accept=".torrent" onChange={inputOnChange} className='d-none' />
|
<input type="file" ref={inputRef} accept=".torrent" onChange={onFileChange} className='d-none' />
|
||||||
<Button id="upload-file-button" variant="secondary" onClick={onClick}>Upload .torrent File</Button>
|
<UploadButton variant='secondary' buttonText="Upload .torrent File" onClick={onClick} data={file} setData={setFile} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FileSelectionModal = (props: { show: boolean, onHide, fileList: Array<TorrentFile> | 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 (
|
||||||
|
<Modal show={show} onHide={onHide}>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>Select Files</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{fileListLoading ? (
|
||||||
|
<Spinner animation="border" role="status">
|
||||||
|
<span className="sr-only">Loading...</span>
|
||||||
|
</Spinner>
|
||||||
|
) : (
|
||||||
|
<Container>
|
||||||
|
{fileList.map((file, index) => (
|
||||||
|
<Row key={index}>
|
||||||
|
<Col>
|
||||||
|
<Form.Check
|
||||||
|
type="checkbox"
|
||||||
|
label={`${file.name} ${formatBytesToGB(file.length)}`}
|
||||||
|
checked={selectedFiles.includes(index)}
|
||||||
|
onChange={() => handleToggleFile(index)}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
<Error error={uploadError} />
|
||||||
|
</Container>
|
||||||
|
)}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="secondary" onClick={onHide}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={handleUpload} disabled={fileListLoading || uploading || selectedFiles.length == 0}>
|
||||||
|
OK
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const Buttons = () => {
|
const Buttons = () => {
|
||||||
return (
|
return (
|
||||||
<div id="buttons-container" className="mt-3">
|
<div id="buttons-container" className="mt-3">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue