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]
resolver = "2"
members = [
"crates/librqbit",
"crates/rqbit",

View file

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

View file

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

View file

@ -1,7 +1,7 @@
[package]
name = "librqbit-clone-to-owned"
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."
license = "Apache-2.0"
documentation = "https://docs.rs/librqbit-clone-to-owned"

View file

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

View file

@ -2,7 +2,7 @@
name = "librqbit"
version = "3.3.0"
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."
license = "Apache-2.0"
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 { ProgressBar, Button, Container, Row, Col, Alert, Modal, Form, Spinner, Table } from 'react-bootstrap';
// 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,
};
import { AddTorrentResponse, TorrentDetails, TorrentFile, TorrentId, TorrentStats, ErrorDetails, API } from './api';
interface Error {
text: string,
@ -24,110 +10,42 @@ interface Error {
interface ContextType {
setCloseableError: (error: Error) => void,
setOtherError: (error: Error) => void,
makeRequest: (method: string, path: string, data: any) => Promise<any>,
requests: {
getTorrentDetails: any,
getTorrentStats: any,
},
refreshTorrents: () => void,
}
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<{
id: number, detailsResponse: TorrentDetails, statsResponse: TorrentStats
}> = ({ id, detailsResponse, statsResponse }) => {
const totalBytes = statsResponse.snapshot.total_bytes;
const downloadedBytes = statsResponse.snapshot.have_bytes;
const totalBytes = statsResponse?.snapshot?.total_bytes ?? 1;
const downloadedBytes = statsResponse?.snapshot?.have_bytes ?? 0;
const finished = totalBytes == downloadedBytes;
const downloadPercentage = (downloadedBytes / totalBytes) * 100;
let classes = [
];
if (id % 2 == 0) {
classes.push('bg-light');
}
return (
<Row className={classes.join(' ')}>
<Row className={`${id % 2 == 0 ? 'bg-light' : ''}`}>
<Column size={4} label="Name">
<div className='text-truncate'>
{getLargestFileName(detailsResponse)}
</div>
{detailsResponse ?
<div className='text-truncate'>
{getLargestFileName(detailsResponse)}
</div>
: <Spinner />}
</Column>
<Column label="Size">{`${formatBytes(totalBytes)}`}</Column>
<Column size={2} label="Progress">
<ProgressBar now={downloadPercentage} label={`${downloadPercentage.toFixed(2)}%`} animated={!finished} />
</Column>
<Column size={2} label="Down Speed">{statsResponse.download_speed.human_readable}</Column>
<Column label="ETA">{getCompletionETA(statsResponse)}</Column>
<Column size={2} label="Peers">{`${statsResponse.snapshot.peer_stats.live} / ${statsResponse.snapshot.peer_stats.seen}`}</Column>
</Row>
{statsResponse ?
<>
<Column label="Size">{`${formatBytes(totalBytes)} `}</Column>
<Column size={2} label="Progress">
<ProgressBar now={downloadPercentage} label={`${downloadPercentage.toFixed(2)}% `} animated={!finished} />
</Column>
<Column size={2} label="Down Speed">{statsResponse.download_speed.human_readable}</Column>
<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 defaultDetails: TorrentDetails = {
info_hash: '',
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<TorrentDetails>(null);
const [statsResponse, updateStatsResponse] = useState<TorrentStats>(null);
const [detailsResponse, updateDetailsResponse] = useState(defaultDetails);
const [statsResponse, updateStatsResponse] = useState(defaultStats);
let ctx = useContext(AppContext);
// Update details once
// Update details once.
useEffect(() => {
if (detailsResponse === defaultDetails) {
if (detailsResponse === null) {
return loopUntilSuccess(async () => {
await ctx.requests.getTorrentDetails(torrent.id).then(updateDetailsResponse);
await API.getTorrentDetails(torrent.id).then(updateDetailsResponse);
}, 1000);
}
}, [detailsResponse]);
// Update stats forever.
const update = async () => {
// Update stats once then forever.
useEffect(() => customSetInterval((async () => {
const errorInterval = 10000;
const liveInterval = 500;
const finishedInterval = 5000;
return ctx.requests.getTorrentStats(torrent.id).then((stats) => {
return API.getTorrentStats(torrent.id).then((stats) => {
updateStatsResponse(stats);
return torrentIsDone(stats) ? finishedInterval : liveInterval;
}, (e) => {
return errorInterval
})
};
useEffect(() => {
let clear = customSetInterval(update, 0);
return clear;
}, []);
return errorInterval;
});
}), 0), []);
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.
if (props.torrents === null) {
return <></>
return;
}
if (props.torrents.length === 0) {
return (
<div className="text-center">
<p>No existing torrents found. Add them through buttons below.</p>
</div>
)
return <div className="text-center">
<p>No existing torrents found. Add them through buttons below.</p>
</div>;
}
return (
<>
{props.torrents.map((t: TorrentId) =>
<Torrent id={t.id} key={t.id} torrent={t} />
)}
</>
)
return <>
{props.torrents.map((t: TorrentId) =>
<Torrent id={t.id} key={t.id} torrent={t} />
)}
</>;
};
const Root = () => {
@ -252,86 +118,26 @@ const Root = () => {
const [torrents, setTorrents] = useState<Array<TorrentId>>(null);
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 () => {
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);
return torrents;
};
useEffect(() => {
let interval = 500;
let clear = customSetInterval(async () => {
try {
await refreshTorrents();
return customSetInterval(async () =>
refreshTorrents().then(() => {
setOtherError(null);
return interval;
} catch (e) {
return 5000;
}, (e) => {
setOtherError({ text: 'Error refreshing torrents', details: e });
console.error(e);
return 5000;
}
}, interval);
return clear;
}), 0);
}, []);
const context: ContextType = {
setCloseableError,
setOtherError,
makeRequest,
requests,
refreshTorrents,
}
@ -353,11 +159,7 @@ const ErrorDetails = (props: { details: ErrorDetails }) => {
return null;
}
return <>
{
details.status && (
<strong>{details.status} {details.statusText}: </strong>
)
}
{details.status && <strong>{details.status} {details.statusText}: </strong>}
{details.text}
</>
@ -370,7 +172,7 @@ const ErrorComponent = (props: { error: Error, remove?: () => void }) => {
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>
<ErrorDetails details={error.details} />
@ -379,9 +181,11 @@ const ErrorComponent = (props: { error: Error, remove?: () => void }) => {
const UploadButton = ({ buttonText, onClick, data, resetData, variant }) => {
const [loading, setLoading] = useState(false);
const [fileList, setFileList] = useState(null);
const [fileList, setFileList] = useState([]);
const [fileListError, setFileListError] = useState(null);
const ctx = useContext(AppContext);
const showModal = data !== null;
const showModal = data !== null || fileListError !== null;
// Get the torrent file list if there's data.
useEffect(() => {
@ -392,12 +196,10 @@ const UploadButton = ({ buttonText, onClick, data, resetData, variant }) => {
let t = setTimeout(async () => {
setLoading(true);
try {
const response: AddTorrentResponse = await ctx.makeRequest('POST', `/torrents?list_only=true&overwrite=true`, data);
console.log(response);
const response = await API.uploadTorrent(data, { listOnly: true });
setFileList(response.details.files);
} catch (e) {
ctx.setCloseableError({ text: 'Error listing torrent', details: e });
clear();
setFileListError({ text: 'Error listing torrent', details: e });
} finally {
setLoading(false);
}
@ -407,7 +209,8 @@ const UploadButton = ({ buttonText, onClick, data, resetData, variant }) => {
const clear = () => {
resetData();
setFileList(null);
setFileListError(null);
setFileList([]);
setLoading(false);
}
@ -420,6 +223,7 @@ const UploadButton = ({ buttonText, onClick, data, resetData, variant }) => {
<FileSelectionModal
show={showModal}
onHide={clear}
fileListError={fileListError}
fileList={fileList}
data={data}
fileListLoading={loading}
@ -467,21 +271,25 @@ const FileInput = () => {
);
};
const FileSelectionModal = (props: { show: boolean, onHide, fileList: Array<TorrentFile> | null, fileListLoading: boolean, data }) => {
let { show, onHide, fileList, fileListLoading, data } = props;
const FileSelectionModal = (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 [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<Error>(null);
const ctx = useContext(AppContext);
useEffect(() => {
setSelectedFiles((fileList || []).map((_, id) => id));
setSelectedFiles(fileList.map((_, id) => id));
}, [fileList]);
fileList = fileList || [];
let ctx = useContext(AppContext);
const clear = () => {
onHide();
setSelectedFiles([]);
@ -489,47 +297,39 @@ const FileSelectionModal = (props: { show: boolean, onHide, fileList: Array<Torr
setUploading(false);
}
const handleToggleFile = (fileIndex: number) => {
if (selectedFiles.includes(fileIndex)) {
setSelectedFiles(selectedFiles.filter((index) => index !== fileIndex));
const handleToggleFile = (toggledId: number) => {
if (selectedFiles.includes(toggledId)) {
setSelectedFiles(selectedFiles.filter((i) => i !== toggledId));
} else {
setSelectedFiles([...selectedFiles, fileIndex]);
setSelectedFiles([...selectedFiles, toggledId]);
}
};
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);
ctx.makeRequest('POST', url, data).then(() => { onHide() }, (e) => {
setUploadError({ text: 'Error starting torrent', details: e });
}).finally(() => setUploading(false));
API.uploadTorrent(data, { selectedFiles }).then(
() => {
onHide();
ctx.refreshTorrents();
},
(e) => {
setUploadError({ text: 'Error starting torrent', details: e });
}
).finally(() => setUploading(false));
};
return (
<Modal show={show} onHide={clear} size='lg'>
<Modal.Header closeButton>
<Modal.Title>Select Files</Modal.Title>
{!!fileListError || <Modal.Title>Select Files</Modal.Title>}
</Modal.Header>
<Modal.Body>
{fileListLoading ? (
<Spinner />
) : (
<>
{fileListLoading ? <Spinner />
: fileListError ? <ErrorComponent error={fileListError}></ErrorComponent> :
<Form>
{fileList.map((file, index) => (
<Form.Group key={index} controlId={`check-${index}`}>
<Form.Check
type="checkbox"
label={`${file.name} (${formatBytes(file.length)})`}
checked={selectedFiles.includes(index)}
@ -537,21 +337,18 @@ const FileSelectionModal = (props: { show: boolean, onHide, fileList: Array<Torr
</Form.Check>
</Form.Group>
))}
</Form>
<ErrorComponent error={uploadError} />
</>
)}
}
<ErrorComponent error={uploadError} />
</Modal.Body>
<Modal.Footer>
{uploading && <Spinner />}
<Button variant="secondary" onClick={clear}>
Cancel
</Button>
<Button variant="primary" onClick={handleUpload} disabled={fileListLoading || uploading || selectedFiles.length == 0}>
OK
</Button>
<Button variant="secondary" onClick={clear}>
Cancel
</Button>
</Modal.Footer>
</Modal >
);
@ -580,35 +377,26 @@ function torrentIsDone(stats: TorrentStats): boolean {
return stats.snapshot.have_bytes == stats.snapshot.total_bytes;
}
// Render function to display all torrents
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) {
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
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));
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 {
if (torrentDetails.files.length == 0) {
return 'Loading...';
}
const largestFile = torrentDetails.files.reduce((prev: any, current: any) => (prev.length > current.length) ? prev : current);
const largestFile = torrentDetails.files.filter(
(f) => f.included
).reduce(
(prev: any, current: any) => (prev.length > current.length) ? prev : current
);
return largestFile.name;
}
// Function to get the completion ETA of a torrent
function getCompletionETA(stats: TorrentStats): string {
if (stats.time_remaining && stats.time_remaining.duration) {
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 minutes = Math.floor((seconds % 3600) / 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) {
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 currentInterval: number = interval;
let currentInterval: number = initialInterval;
const executeCallback = async () => {
currentInterval = await asyncCallback();
@ -651,39 +442,34 @@ function customSetInterval(asyncCallback: any, interval: number) {
scheduleNext();
let clearCustomInterval = () => {
return () => {
clearTimeout(timeoutId);
}
return clearCustomInterval;
};
}
function loopUntilSuccess(callback, interval: number) {
function loopUntilSuccess<T>(callback: () => Promise<T>, interval: number): () => void {
let timeoutId: number;
const executeCallback = async () => {
let retry = await callback().then(() => { false }, () => { true });
let retry = await callback().then(() => false, () => true);
if (retry) {
scheduleNext();
}
}
let scheduleNext = (i?: number) => {
timeoutId = setTimeout(executeCallback, i !== undefined ? i : interval);
let scheduleNext = (overrideInterval?: number) => {
timeoutId = setTimeout(executeCallback, overrideInterval !== undefined ? overrideInterval : interval);
}
scheduleNext(0);
let clearCustomInterval = () => {
clearTimeout(timeoutId);
}
return clearCustomInterval;
return () => clearTimeout(timeoutId);
}
// List all torrents on page load and set up auto-refresh
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

View file

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

View file

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

View file

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