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",
|
||||
"hex 0.4.3",
|
||||
"http",
|
||||
"itertools",
|
||||
"librqbit-bencode",
|
||||
"librqbit-buffers",
|
||||
"librqbit-clone-to-owned",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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<Option<Vec<usize>>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de::Error;
|
||||
pub struct OnlyFiles(Vec<usize>);
|
||||
|
||||
let s = Option::<String>::deserialize(deserializer)?;
|
||||
let s = match s {
|
||||
Some(s) => s,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let list = s
|
||||
.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",
|
||||
));
|
||||
impl Serialize for OnlyFiles {
|
||||
fn serialize<S>(&self, serializer: S) -> core::result::Result<S::Ok, S::Error>
|
||||
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<D>(deserializer: D) -> core::result::Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de::Error;
|
||||
|
||||
let s = String::deserialize(deserializer)?;
|
||||
let list = s
|
||||
.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)]
|
||||
|
|
@ -302,8 +311,7 @@ pub struct TorrentAddQueryParams {
|
|||
pub output_folder: Option<String>,
|
||||
pub sub_folder: Option<String>,
|
||||
pub only_files_regex: Option<String>,
|
||||
#[serde(deserialize_with = "deserialize_only_files")]
|
||||
pub only_files: Option<Vec<usize>>,
|
||||
pub only_files: Option<OnlyFiles>,
|
||||
pub list_only: Option<bool>,
|
||||
}
|
||||
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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<TorrentFile>;
|
||||
}
|
||||
|
||||
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 <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 }) => {
|
||||
if (props.torrents === null && props.loading) {
|
||||
return <Spinner />
|
||||
|
|
@ -347,44 +350,169 @@ const Error = (props: { error: ErrorType, remove?: () => void }) => {
|
|||
</Alert>);
|
||||
};
|
||||
|
||||
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<void> {
|
||||
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 <Button variant="primary" className="mr-2" onClick={addTorrentFromMagnet}>
|
||||
Add Torrent from Magnet Link
|
||||
</Button>;
|
||||
return (
|
||||
<>
|
||||
<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 inputRef = useRef<HTMLInputElement>();
|
||||
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 (
|
||||
<>
|
||||
<input type="file" ref={inputRef} id="file-input" accept=".torrent" onChange={inputOnChange} className='d-none' />
|
||||
<Button id="upload-file-button" variant="secondary" onClick={onClick}>Upload .torrent File</Button>
|
||||
<input type="file" ref={inputRef} accept=".torrent" onChange={onFileChange} className='d-none' />
|
||||
<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 = () => {
|
||||
return (
|
||||
<div id="buttons-container" className="mt-3">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue