Small js (webui) cleanups

This commit is contained in:
Igor Katson 2023-11-22 23:13:27 +00:00
parent 56311fb4df
commit d4e29171b9
No known key found for this signature in database
GPG key ID: B4EC22B66D61A3F5
14 changed files with 420 additions and 1771 deletions

View file

@ -1,4 +1,5 @@
[workspace] [workspace]
resolver = "2"
members = [ members = [
"crates/librqbit", "crates/librqbit",
"crates/rqbit", "crates/rqbit",

View file

@ -1,7 +1,7 @@
[package] [package]
name = "librqbit-bencode" name = "librqbit-bencode"
version = "2.2.1" version = "2.2.1"
edition = "2018" edition = "2021"
description = "Bencode serialization and deserialization using Serde" description = "Bencode serialization and deserialization using Serde"
license = "Apache-2.0" license = "Apache-2.0"
documentation = "https://docs.rs/librqbit-bencode" documentation = "https://docs.rs/librqbit-bencode"

View file

@ -1,7 +1,7 @@
[package] [package]
name = "librqbit-buffers" name = "librqbit-buffers"
version = "2.2.1" version = "2.2.1"
edition = "2018" edition = "2021"
description = "Utils to work with &[u8] and Vec<u8> in librqbit source code." description = "Utils to work with &[u8] and Vec<u8> in librqbit source code."
license = "Apache-2.0" license = "Apache-2.0"
documentation = "https://docs.rs/librqbit-buffers" documentation = "https://docs.rs/librqbit-buffers"

View file

@ -1,7 +1,7 @@
[package] [package]
name = "librqbit-clone-to-owned" name = "librqbit-clone-to-owned"
version = "2.2.1" version = "2.2.1"
edition = "2018" edition = "2021"
description = "Util traits to represent something that can be made owned and change type at the same time." description = "Util traits to represent something that can be made owned and change type at the same time."
license = "Apache-2.0" license = "Apache-2.0"
documentation = "https://docs.rs/librqbit-clone-to-owned" documentation = "https://docs.rs/librqbit-clone-to-owned"

View file

@ -1,7 +1,7 @@
[package] [package]
name = "librqbit-dht" name = "librqbit-dht"
version = "3.1.0" version = "3.1.0"
edition = "2018" edition = "2021"
description = "DHT implementation, used in rqbit torrent client." description = "DHT implementation, used in rqbit torrent client."
license = "Apache-2.0" license = "Apache-2.0"
documentation = "https://docs.rs/librqbit-dht" documentation = "https://docs.rs/librqbit-dht"

View file

@ -2,7 +2,7 @@
name = "librqbit" name = "librqbit"
version = "3.3.0" version = "3.3.0"
authors = ["Igor Katson <igor.katson@gmail.com>"] authors = ["Igor Katson <igor.katson@gmail.com>"]
edition = "2018" edition = "2021"
description = "The main library used by rqbit torrent client. The binary is just a small wrapper on top of it." description = "The main library used by rqbit torrent client. The binary is just a small wrapper on top of it."
license = "Apache-2.0" license = "Apache-2.0"
documentation = "https://docs.rs/librqbit" documentation = "https://docs.rs/librqbit"

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,148 @@
// Define API URL and base path
const apiUrl = (window.origin === 'null' || window.origin === 'http://localhost:3031') ? 'http://localhost:3030' : '';
// Interface for the Torrent API response
export interface TorrentId {
id: number;
info_hash: string;
}
export interface TorrentFile {
name: string;
length: number;
included: boolean;
}
// Interface for the Torrent Details API response
export interface TorrentDetails {
info_hash: string,
files: Array<TorrentFile>;
}
export interface AddTorrentResponse {
id: number | null;
details: TorrentDetails;
}
export interface ListTorrentsResponse {
torrents: Array<TorrentId>;
}
// Interface for the Torrent Stats API response
export interface TorrentStats {
snapshot: {
have_bytes: number;
downloaded_and_checked_bytes: number;
downloaded_and_checked_pieces: number;
fetched_bytes: number;
uploaded_bytes: number;
initially_needed_bytes: number;
remaining_bytes: number;
total_bytes: number;
total_piece_download_ms: number;
peer_stats: {
queued: number;
connecting: number;
live: number;
seen: number;
dead: number;
not_needed: number;
};
};
average_piece_download_time: {
secs: number;
nanos: number;
};
download_speed: {
mbps: number;
human_readable: string;
};
all_time_download_speed: {
mbps: number;
human_readable: string;
};
time_remaining: {
human_readable: string;
duration?: {
secs: number,
}
} | null;
}
export interface ErrorDetails {
id?: number,
method?: string,
path?: string,
status?: number,
statusText?: string,
text: string,
};
const makeRequest = async (method: string, path: string, data?: any): Promise<any> => {
console.log(method, path);
const url = apiUrl + path;
const options: RequestInit = {
method,
headers: {
'Accept': 'application/json',
},
body: data,
};
let error: ErrorDetails = {
method: method,
path: path,
text: ''
};
let response: Response;
try {
response = await fetch(url, options);
} catch (e) {
error.text = 'network error';
return Promise.reject(error);
}
error.status = response.status;
error.statusText = response.statusText;
if (!response.ok) {
const errorBody = await response.text();
try {
const json = JSON.parse(errorBody);
error.text = json.human_readable !== undefined ? json.human_readable : JSON.stringify(json, null, 2);
} catch (e) {
error.text = errorBody;
}
return Promise.reject(error);
}
const result = await response.json();
return result;
}
export const API = {
listTorrents: (): Promise<ListTorrentsResponse> => makeRequest('GET', '/torrents'),
getTorrentDetails: (index: number): Promise<TorrentDetails> => {
return makeRequest('GET', `/torrents/${index}`);
},
getTorrentStats: (index: number): Promise<TorrentStats> => {
return makeRequest('GET', `/torrents/${index}/stats`);
},
uploadTorrent: (data: string | File, opts?: {
listOnly?: boolean, selectedFiles?: Array<number>
}): Promise<AddTorrentResponse> => {
opts = opts || {};
let url = '/torrents?&overwrite=true';
if (opts.listOnly) {
url += '&list_only=true';
}
if (opts.selectedFiles != null) {
url += `&only_files=${opts.selectedFiles.join(',')}`;
}
return makeRequest('POST', url, data)
}
}

View file

@ -1,21 +1,7 @@
import { StrictMode, createContext, memo, useContext, useEffect, useRef, useState } from 'react'; import { StrictMode, createContext, 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, Modal, Form, Spinner, Table } from 'react-bootstrap'; import { ProgressBar, Button, Container, Row, Col, Alert, Modal, Form, Spinner, Table } from 'react-bootstrap';
import { AddTorrentResponse, TorrentDetails, TorrentFile, TorrentId, TorrentStats, ErrorDetails, API } from './api';
// import 'bootstrap/dist/css/bootstrap.min.css';
// import './styles.scss';
// Define API URL and base path
const apiUrl = (window.origin === 'null' || window.origin === 'http://localhost:3031') ? 'http://localhost:3030' : '';
interface ErrorDetails {
id?: number,
method?: string,
path?: string,
status?: number,
statusText?: string,
text: string,
};
interface Error { interface Error {
text: string, text: string,
@ -24,110 +10,42 @@ interface Error {
interface ContextType { interface ContextType {
setCloseableError: (error: Error) => void, setCloseableError: (error: Error) => void,
setOtherError: (error: Error) => void,
makeRequest: (method: string, path: string, data: any) => Promise<any>,
requests: {
getTorrentDetails: any,
getTorrentStats: any,
},
refreshTorrents: () => void, refreshTorrents: () => void,
} }
const AppContext = createContext<ContextType>(null); const AppContext = createContext<ContextType>(null);
// Interface for the Torrent API response
interface TorrentId {
id: number;
info_hash: string;
}
interface TorrentFile {
name: string;
length: number;
included: boolean;
}
// Interface for the Torrent Details API response
interface TorrentDetails {
info_hash: string,
files: Array<TorrentFile>;
}
interface AddTorrentResponse {
id: number | null;
details: TorrentDetails;
}
// Interface for the Torrent Stats API response
interface TorrentStats {
snapshot: {
have_bytes: number;
downloaded_and_checked_bytes: number;
downloaded_and_checked_pieces: number;
fetched_bytes: number;
uploaded_bytes: number;
initially_needed_bytes: number;
remaining_bytes: number;
total_bytes: number;
total_piece_download_ms: number;
peer_stats: {
queued: number;
connecting: number;
live: number;
seen: number;
dead: number;
not_needed: number;
};
};
average_piece_download_time: {
secs: number;
nanos: number;
};
download_speed: {
mbps: number;
human_readable: string;
};
all_time_download_speed: {
mbps: number;
human_readable: string;
};
time_remaining: {
human_readable: string;
duration?: {
secs: number,
}
} | null;
}
const TorrentRow: React.FC<{ const TorrentRow: React.FC<{
id: number, detailsResponse: TorrentDetails, statsResponse: TorrentStats id: number, detailsResponse: TorrentDetails, statsResponse: TorrentStats
}> = ({ id, detailsResponse, statsResponse }) => { }> = ({ id, detailsResponse, statsResponse }) => {
const totalBytes = statsResponse.snapshot.total_bytes; const totalBytes = statsResponse?.snapshot?.total_bytes ?? 1;
const downloadedBytes = statsResponse.snapshot.have_bytes; const downloadedBytes = statsResponse?.snapshot?.have_bytes ?? 0;
const finished = totalBytes == downloadedBytes; const finished = totalBytes == downloadedBytes;
const downloadPercentage = (downloadedBytes / totalBytes) * 100; const downloadPercentage = (downloadedBytes / totalBytes) * 100;
let classes = [
];
if (id % 2 == 0) {
classes.push('bg-light');
}
return ( return (
<Row className={classes.join(' ')}> <Row className={`${id % 2 == 0 ? 'bg-light' : ''}`}>
<Column size={4} label="Name"> <Column size={4} label="Name">
<div className='text-truncate'> {detailsResponse ?
{getLargestFileName(detailsResponse)} <div className='text-truncate'>
</div> {getLargestFileName(detailsResponse)}
</div>
: <Spinner />}
</Column> </Column>
<Column label="Size">{`${formatBytes(totalBytes)}`}</Column> {statsResponse ?
<Column size={2} label="Progress"> <>
<ProgressBar now={downloadPercentage} label={`${downloadPercentage.toFixed(2)}%`} animated={!finished} /> <Column label="Size">{`${formatBytes(totalBytes)} `}</Column>
</Column> <Column size={2} label="Progress">
<Column size={2} label="Down Speed">{statsResponse.download_speed.human_readable}</Column> <ProgressBar now={downloadPercentage} label={`${downloadPercentage.toFixed(2)}% `} animated={!finished} />
<Column label="ETA">{getCompletionETA(statsResponse)}</Column> </Column>
<Column size={2} label="Peers">{`${statsResponse.snapshot.peer_stats.live} / ${statsResponse.snapshot.peer_stats.seen}`}</Column> <Column size={2} label="Down Speed">{statsResponse.download_speed.human_readable}</Column>
</Row> <Column label="ETA">{getCompletionETA(statsResponse)}</Column>
<Column size={2} label="Peers">{`${statsResponse.snapshot.peer_stats.live} / ${statsResponse.snapshot.peer_stats.seen}`}</Column >
</>
: <Column label="Loading stats" size={8}><Spinner /></Column>
}
</Row >
); );
} }
@ -143,79 +61,31 @@ const Column: React.FC<{
); );
const Torrent = ({ id, torrent }) => { const Torrent = ({ id, torrent }) => {
const defaultDetails: TorrentDetails = { const [detailsResponse, updateDetailsResponse] = useState<TorrentDetails>(null);
info_hash: '', const [statsResponse, updateStatsResponse] = useState<TorrentStats>(null);
files: []
};
const defaultStats: TorrentStats = {
snapshot: {
have_bytes: 0,
downloaded_and_checked_bytes: 0,
downloaded_and_checked_pieces: 0,
fetched_bytes: 0,
uploaded_bytes: 0,
initially_needed_bytes: 0,
remaining_bytes: 0,
total_bytes: 0,
total_piece_download_ms: 0,
peer_stats: {
queued: 0,
connecting: 0,
live: 0,
seen: 0,
dead: 0,
not_needed: 0
}
},
average_piece_download_time: {
secs: 0,
nanos: 0
},
download_speed: {
mbps: 0,
human_readable: ''
},
all_time_download_speed: {
mbps: 0,
human_readable: ''
},
time_remaining: {
human_readable: ''
}
};
const [detailsResponse, updateDetailsResponse] = useState(defaultDetails); // Update details once.
const [statsResponse, updateStatsResponse] = useState(defaultStats);
let ctx = useContext(AppContext);
// Update details once
useEffect(() => { useEffect(() => {
if (detailsResponse === defaultDetails) { if (detailsResponse === null) {
return loopUntilSuccess(async () => { return loopUntilSuccess(async () => {
await ctx.requests.getTorrentDetails(torrent.id).then(updateDetailsResponse); await API.getTorrentDetails(torrent.id).then(updateDetailsResponse);
}, 1000); }, 1000);
} }
}, [detailsResponse]); }, [detailsResponse]);
// Update stats forever. // Update stats once then forever.
const update = async () => { useEffect(() => customSetInterval((async () => {
const errorInterval = 10000; const errorInterval = 10000;
const liveInterval = 500; const liveInterval = 500;
const finishedInterval = 5000; const finishedInterval = 5000;
return ctx.requests.getTorrentStats(torrent.id).then((stats) => { return API.getTorrentStats(torrent.id).then((stats) => {
updateStatsResponse(stats); updateStatsResponse(stats);
return torrentIsDone(stats) ? finishedInterval : liveInterval; return torrentIsDone(stats) ? finishedInterval : liveInterval;
}, (e) => { }, (e) => {
return errorInterval return errorInterval;
}) });
}; }), 0), []);
useEffect(() => {
let clear = customSetInterval(update, 0);
return clear;
}, []);
return <TorrentRow id={id} detailsResponse={detailsResponse} statsResponse={statsResponse} /> return <TorrentRow id={id} detailsResponse={detailsResponse} statsResponse={statsResponse} />
} }
@ -226,23 +96,19 @@ const TorrentsList = (props: { torrents: Array<TorrentId>, loading: boolean }) =
} }
// The app either just started, or there was an error loading torrents. // The app either just started, or there was an error loading torrents.
if (props.torrents === null) { if (props.torrents === null) {
return <></> return;
} }
if (props.torrents.length === 0) { if (props.torrents.length === 0) {
return ( return <div className="text-center">
<div className="text-center"> <p>No existing torrents found. Add them through buttons below.</p>
<p>No existing torrents found. Add them through buttons below.</p> </div>;
</div>
)
} }
return ( return <>
<> {props.torrents.map((t: TorrentId) =>
{props.torrents.map((t: TorrentId) => <Torrent id={t.id} key={t.id} torrent={t} />
<Torrent id={t.id} key={t.id} torrent={t} /> )}
)} </>;
</>
)
}; };
const Root = () => { const Root = () => {
@ -252,86 +118,26 @@ const Root = () => {
const [torrents, setTorrents] = useState<Array<TorrentId>>(null); const [torrents, setTorrents] = useState<Array<TorrentId>>(null);
const [torrentsLoading, setTorrentsLoading] = useState(false); const [torrentsLoading, setTorrentsLoading] = useState(false);
const makeRequest = async (method: string, path: string, data: any): Promise<any> => {
console.log(method, path);
const url = apiUrl + path;
const options: RequestInit = {
method,
headers: {
'Accept': 'application/json',
},
body: data,
};
let error: ErrorDetails = {
method: method,
path: path,
text: ''
};
let response: Response;
try {
response = await fetch(url, options);
} catch (e) {
error.text = 'network error';
return Promise.reject(error);
}
error.status = response.status;
error.statusText = response.statusText;
if (!response.ok) {
const errorBody = await response.text();
try {
const json = JSON.parse(errorBody);
error.text = json.human_readable !== undefined ? json.human_readable : JSON.stringify(json, null, 2);
} catch (e) {
error.text = errorBody;
}
return Promise.reject(error);
}
const result = await response.json();
return result;
}
const requests = {
getTorrentDetails: (index: number): Promise<TorrentDetails> => {
return makeRequest('GET', `/torrents/${index}`, null);
},
getTorrentStats: (index: number): Promise<TorrentStats> => {
return makeRequest('GET', `/torrents/${index}/stats`, null);
}
};
const refreshTorrents = async () => { const refreshTorrents = async () => {
setTorrentsLoading(true); setTorrentsLoading(true);
let torrents: { torrents: Array<TorrentId> } = await makeRequest('GET', '/torrents', null).finally(() => setTorrentsLoading(false)); let torrents = await API.listTorrents().finally(() => setTorrentsLoading(false));
setTorrents(torrents.torrents); setTorrents(torrents.torrents);
return torrents;
}; };
useEffect(() => { useEffect(() => {
let interval = 500; return customSetInterval(async () =>
let clear = customSetInterval(async () => { refreshTorrents().then(() => {
try {
await refreshTorrents();
setOtherError(null); setOtherError(null);
return interval; return 5000;
} catch (e) { }, (e) => {
setOtherError({ text: 'Error refreshing torrents', details: e }); setOtherError({ text: 'Error refreshing torrents', details: e });
console.error(e); console.error(e);
return 5000; return 5000;
} }), 0);
}, interval);
return clear;
}, []); }, []);
const context: ContextType = { const context: ContextType = {
setCloseableError, setCloseableError,
setOtherError,
makeRequest,
requests,
refreshTorrents, refreshTorrents,
} }
@ -353,11 +159,7 @@ const ErrorDetails = (props: { details: ErrorDetails }) => {
return null; return null;
} }
return <> return <>
{ {details.status && <strong>{details.status} {details.statusText}: </strong>}
details.status && (
<strong>{details.status} {details.statusText}: </strong>
)
}
{details.text} {details.text}
</> </>
@ -370,7 +172,7 @@ const ErrorComponent = (props: { error: Error, remove?: () => void }) => {
return null; return null;
} }
return (<Alert variant='danger' onClose={remove} dismissible={!!remove}> return (<Alert variant='danger' onClose={remove} dismissible={remove != null}>
<Alert.Heading>{error.text}</Alert.Heading> <Alert.Heading>{error.text}</Alert.Heading>
<ErrorDetails details={error.details} /> <ErrorDetails details={error.details} />
@ -379,9 +181,11 @@ const ErrorComponent = (props: { error: Error, remove?: () => void }) => {
const UploadButton = ({ buttonText, onClick, data, resetData, variant }) => { const UploadButton = ({ buttonText, onClick, data, resetData, variant }) => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [fileList, setFileList] = useState(null); const [fileList, setFileList] = useState([]);
const [fileListError, setFileListError] = useState(null);
const ctx = useContext(AppContext); const ctx = useContext(AppContext);
const showModal = data !== null;
const showModal = data !== null || fileListError !== null;
// Get the torrent file list if there's data. // Get the torrent file list if there's data.
useEffect(() => { useEffect(() => {
@ -392,12 +196,10 @@ const UploadButton = ({ buttonText, onClick, data, resetData, variant }) => {
let t = setTimeout(async () => { let t = setTimeout(async () => {
setLoading(true); setLoading(true);
try { try {
const response: AddTorrentResponse = await ctx.makeRequest('POST', `/torrents?list_only=true&overwrite=true`, data); const response = await API.uploadTorrent(data, { listOnly: true });
console.log(response);
setFileList(response.details.files); setFileList(response.details.files);
} catch (e) { } catch (e) {
ctx.setCloseableError({ text: 'Error listing torrent', details: e }); setFileListError({ text: 'Error listing torrent', details: e });
clear();
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -407,7 +209,8 @@ const UploadButton = ({ buttonText, onClick, data, resetData, variant }) => {
const clear = () => { const clear = () => {
resetData(); resetData();
setFileList(null); setFileListError(null);
setFileList([]);
setLoading(false); setLoading(false);
} }
@ -420,6 +223,7 @@ const UploadButton = ({ buttonText, onClick, data, resetData, variant }) => {
<FileSelectionModal <FileSelectionModal
show={showModal} show={showModal}
onHide={clear} onHide={clear}
fileListError={fileListError}
fileList={fileList} fileList={fileList}
data={data} data={data}
fileListLoading={loading} fileListLoading={loading}
@ -467,21 +271,25 @@ const FileInput = () => {
); );
}; };
const FileSelectionModal = (props: { show: boolean, onHide, fileList: Array<TorrentFile> | null, fileListLoading: boolean, data }) => { const FileSelectionModal = (props: {
let { show, onHide, fileList, fileListLoading, data } = props; show: boolean,
onHide: () => void,
fileList: Array<TorrentFile>,
fileListError: Error,
fileListLoading: boolean,
data: string | File
}) => {
let { show, onHide, fileList, fileListError, fileListLoading, data } = props;
const [selectedFiles, setSelectedFiles] = useState([]); const [selectedFiles, setSelectedFiles] = useState([]);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<Error>(null); const [uploadError, setUploadError] = useState<Error>(null);
const ctx = useContext(AppContext);
useEffect(() => { useEffect(() => {
setSelectedFiles((fileList || []).map((_, id) => id)); setSelectedFiles(fileList.map((_, id) => id));
}, [fileList]); }, [fileList]);
fileList = fileList || [];
let ctx = useContext(AppContext);
const clear = () => { const clear = () => {
onHide(); onHide();
setSelectedFiles([]); setSelectedFiles([]);
@ -489,47 +297,39 @@ const FileSelectionModal = (props: { show: boolean, onHide, fileList: Array<Torr
setUploading(false); setUploading(false);
} }
const handleToggleFile = (fileIndex: number) => { const handleToggleFile = (toggledId: number) => {
if (selectedFiles.includes(fileIndex)) { if (selectedFiles.includes(toggledId)) {
setSelectedFiles(selectedFiles.filter((index) => index !== fileIndex)); setSelectedFiles(selectedFiles.filter((i) => i !== toggledId));
} else { } else {
setSelectedFiles([...selectedFiles, fileIndex]); setSelectedFiles([...selectedFiles, toggledId]);
} }
}; };
const handleUpload = async () => { 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()}`;
setUploading(true); setUploading(true);
ctx.makeRequest('POST', url, data).then(() => { onHide() }, (e) => { API.uploadTorrent(data, { selectedFiles }).then(
setUploadError({ text: 'Error starting torrent', details: e }); () => {
}).finally(() => setUploading(false)); onHide();
ctx.refreshTorrents();
},
(e) => {
setUploadError({ text: 'Error starting torrent', details: e });
}
).finally(() => setUploading(false));
}; };
return ( return (
<Modal show={show} onHide={clear} size='lg'> <Modal show={show} onHide={clear} size='lg'>
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title>Select Files</Modal.Title> {!!fileListError || <Modal.Title>Select Files</Modal.Title>}
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
{fileListLoading ? ( {fileListLoading ? <Spinner />
<Spinner /> : fileListError ? <ErrorComponent error={fileListError}></ErrorComponent> :
) : (
<>
<Form> <Form>
{fileList.map((file, index) => ( {fileList.map((file, index) => (
<Form.Group key={index} controlId={`check-${index}`}> <Form.Group key={index} controlId={`check-${index}`}>
<Form.Check <Form.Check
type="checkbox" type="checkbox"
label={`${file.name} (${formatBytes(file.length)})`} label={`${file.name} (${formatBytes(file.length)})`}
checked={selectedFiles.includes(index)} checked={selectedFiles.includes(index)}
@ -537,21 +337,18 @@ const FileSelectionModal = (props: { show: boolean, onHide, fileList: Array<Torr
</Form.Check> </Form.Check>
</Form.Group> </Form.Group>
))} ))}
</Form> </Form>
<ErrorComponent error={uploadError} /> }
</> <ErrorComponent error={uploadError} />
)}
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>
{uploading && <Spinner />} {uploading && <Spinner />}
<Button variant="secondary" onClick={clear}>
Cancel
</Button>
<Button variant="primary" onClick={handleUpload} disabled={fileListLoading || uploading || selectedFiles.length == 0}> <Button variant="primary" onClick={handleUpload} disabled={fileListLoading || uploading || selectedFiles.length == 0}>
OK OK
</Button> </Button>
<Button variant="secondary" onClick={clear}>
Cancel
</Button>
</Modal.Footer> </Modal.Footer>
</Modal > </Modal >
); );
@ -580,35 +377,26 @@ function torrentIsDone(stats: TorrentStats): boolean {
return stats.snapshot.have_bytes == stats.snapshot.total_bytes; return stats.snapshot.have_bytes == stats.snapshot.total_bytes;
} }
// Render function to display all torrents function formatBytes(bytes: number): string {
async function displayTorrents() {
// Get the torrents container
const torrentsContainer = document.getElementById('app');
ReactDOM.createRoot(torrentsContainer).render(<StrictMode><Root /></StrictMode>);
}
// Function to format bytes to GB
function formatBytes(bytes) {
if (bytes === 0) return '0 Bytes'; if (bytes === 0) return '0 Bytes';
const k = 1024; const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
} }
// Function to get the name of the largest file in a torrent
function getLargestFileName(torrentDetails: TorrentDetails): string { function getLargestFileName(torrentDetails: TorrentDetails): string {
if (torrentDetails.files.length == 0) { const largestFile = torrentDetails.files.filter(
return 'Loading...'; (f) => f.included
} ).reduce(
const largestFile = torrentDetails.files.reduce((prev: any, current: any) => (prev.length > current.length) ? prev : current); (prev: any, current: any) => (prev.length > current.length) ? prev : current
);
return largestFile.name; return largestFile.name;
} }
// Function to get the completion ETA of a torrent
function getCompletionETA(stats: TorrentStats): string { function getCompletionETA(stats: TorrentStats): string {
if (stats.time_remaining && stats.time_remaining.duration) { if (stats.time_remaining && stats.time_remaining.duration) {
return formatSecondsToTime(stats.time_remaining.duration.secs); return formatSecondsToTime(stats.time_remaining.duration.secs);
@ -617,12 +405,12 @@ function getCompletionETA(stats: TorrentStats): string {
} }
} }
function formatSecondsToTime(seconds: number) { function formatSecondsToTime(seconds: number): string {
const hours = Math.floor(seconds / 3600); const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60); const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60; const remainingSeconds = seconds % 60;
const formatUnit = (value, unit) => (value > 0 ? `${value}${unit}` : ''); const formatUnit = (value: number, unit: string) => (value > 0 ? `${value}${unit}` : '');
if (hours > 0) { if (hours > 0) {
return `${formatUnit(hours, 'h')} ${formatUnit(minutes, 'm')}`.trim(); return `${formatUnit(hours, 'h')} ${formatUnit(minutes, 'm')}`.trim();
@ -633,9 +421,12 @@ function formatSecondsToTime(seconds: number) {
} }
} }
function customSetInterval(asyncCallback: any, interval: number) { // Run a function with initial interval, then run it forever with the interval that the
// callback returns.
// Returns a callback to clear it.
function customSetInterval(asyncCallback: () => Promise<number>, initialInterval: number): () => void {
let timeoutId: number; let timeoutId: number;
let currentInterval: number = interval; let currentInterval: number = initialInterval;
const executeCallback = async () => { const executeCallback = async () => {
currentInterval = await asyncCallback(); currentInterval = await asyncCallback();
@ -651,39 +442,34 @@ function customSetInterval(asyncCallback: any, interval: number) {
scheduleNext(); scheduleNext();
let clearCustomInterval = () => { return () => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
} };
return clearCustomInterval;
} }
function loopUntilSuccess(callback, interval: number) { function loopUntilSuccess<T>(callback: () => Promise<T>, interval: number): () => void {
let timeoutId: number; let timeoutId: number;
const executeCallback = async () => { const executeCallback = async () => {
let retry = await callback().then(() => { false }, () => { true }); let retry = await callback().then(() => false, () => true);
if (retry) { if (retry) {
scheduleNext(); scheduleNext();
} }
} }
let scheduleNext = (i?: number) => { let scheduleNext = (overrideInterval?: number) => {
timeoutId = setTimeout(executeCallback, i !== undefined ? i : interval); timeoutId = setTimeout(executeCallback, overrideInterval !== undefined ? overrideInterval : interval);
} }
scheduleNext(0); scheduleNext(0);
let clearCustomInterval = () => { return () => clearTimeout(timeoutId);
clearTimeout(timeoutId);
}
return clearCustomInterval;
} }
// List all torrents on page load and set up auto-refresh // List all torrents on page load and set up auto-refresh
async function init(): Promise<void> { async function init(): Promise<void> {
await displayTorrents(); const torrentsContainer = document.getElementById('app');
ReactDOM.createRoot(torrentsContainer).render(<StrictMode><Root /></StrictMode>);
} }
// Call init function on page load // Call init function on page load

View file

@ -1,7 +1,7 @@
[package] [package]
name = "librqbit-core" name = "librqbit-core"
version = "3.0.0" version = "3.0.0"
edition = "2018" edition = "2021"
description = "Important utilities used throughout librqbit useful for working with torrents." description = "Important utilities used throughout librqbit useful for working with torrents."
license = "Apache-2.0" license = "Apache-2.0"
documentation = "https://docs.rs/librqbit-core" documentation = "https://docs.rs/librqbit-core"

View file

@ -1,7 +1,7 @@
[package] [package]
name = "librqbit-peer-protocol" name = "librqbit-peer-protocol"
version = "3.0.0" version = "3.0.0"
edition = "2018" edition = "2021"
description = "Protocol for working with torrent peers. Used in rqbit torrent client." description = "Protocol for working with torrent peers. Used in rqbit torrent client."
license = "Apache-2.0" license = "Apache-2.0"
documentation = "https://docs.rs/librqbit-peer-protocol" documentation = "https://docs.rs/librqbit-peer-protocol"

View file

@ -2,7 +2,7 @@
name = "rqbit" name = "rqbit"
version = "3.3.0" version = "3.3.0"
authors = ["Igor Katson <igor.katson@gmail.com>"] authors = ["Igor Katson <igor.katson@gmail.com>"]
edition = "2018" edition = "2021"
description = "A bittorrent command line client and server." description = "A bittorrent command line client and server."
license = "Apache-2.0" license = "Apache-2.0"
documentation = "https://github.com/ikatson/rqbit" documentation = "https://github.com/ikatson/rqbit"