diff --git a/crates/librqbit/webui/src/components/Speed.tsx b/crates/librqbit/webui/src/components/Speed.tsx
index 19f6f2a..e1fb6f9 100644
--- a/crates/librqbit/webui/src/components/Speed.tsx
+++ b/crates/librqbit/webui/src/components/Speed.tsx
@@ -26,11 +26,11 @@ export const Speed: React.FC<{ statsResponse: TorrentStats }> = ({
<>
{!statsResponse.finished && (
- ↓ {statsResponse.live.download_speed.human_readable}
+ ↓ {statsResponse.live.download_speed?.human_readable}
)}
- ↑ {statsResponse.live.upload_speed.human_readable}
+ ↑ {statsResponse.live.upload_speed?.human_readable}
{statsResponse.live.snapshot.uploaded_bytes > 0 && (
{" "}
diff --git a/crates/librqbit/webui/src/main.tsx b/crates/librqbit/webui/src/main.tsx
index 07aeeff..ecea3a1 100644
--- a/crates/librqbit/webui/src/main.tsx
+++ b/crates/librqbit/webui/src/main.tsx
@@ -1,27 +1,35 @@
import { StrictMode, useEffect, useState } from "react";
-import ReactDOM from 'react-dom/client';
-import { RqbitWebUI, APIContext, customSetInterval } from "./rqbit-web";
+import ReactDOM from "react-dom/client";
+import { RqbitWebUI } from "./rqbit-web";
+import { customSetInterval } from "./helper/customSetInterval";
+import { APIContext } from "./context";
import { API } from "./http-api";
const RootWithVersion = () => {
- let [title, setTitle] = useState("rqbit web UI");
- useEffect(() => {
- const refreshVersion = () => API.getVersion().then((version) => {
- setTitle(`rqbit web UI - v${version}`);
- return 10000;
- }, (e) => {
- return 1000;
- });
- return customSetInterval(refreshVersion, 0)
- }, [])
+ let [title, setTitle] = useState("rqbit web UI");
+ useEffect(() => {
+ const refreshVersion = () =>
+ API.getVersion().then(
+ (version) => {
+ setTitle(`rqbit web UI - v${version}`);
+ return 10000;
+ },
+ (e) => {
+ return 1000;
+ }
+ );
+ return customSetInterval(refreshVersion, 0);
+ }, []);
- return
-
-
-
- ;
-}
+ return (
+
+
+
+
+
+ );
+};
-ReactDOM.createRoot(document.getElementById('app') as HTMLInputElement).render(
-
+ReactDOM.createRoot(document.getElementById("app") as HTMLInputElement).render(
+
);
diff --git a/crates/librqbit/webui/src/rqbit-web.tsx b/crates/librqbit/webui/src/rqbit-web.tsx
index 846dc26..c77d428 100644
--- a/crates/librqbit/webui/src/rqbit-web.tsx
+++ b/crates/librqbit/webui/src/rqbit-web.tsx
@@ -1,829 +1,69 @@
-import { MouseEventHandler, RefObject, createContext, useContext, useEffect, useRef, useState } from 'react';
-import { ProgressBar, Button, Container, Row, Col, Alert, Modal, Form, Spinner } from 'react-bootstrap';
-import { AddTorrentResponse, TorrentDetails, TorrentId, TorrentStats, ErrorDetails as ApiErrorDetails, STATE_INITIALIZING, STATE_LIVE, STATE_PAUSED, STATE_ERROR, RqbitAPI, AddTorrentOptions } from './api-types';
+import { useContext, useEffect, useState } from "react";
+import { TorrentId, ErrorDetails as ApiErrorDetails } from "./api-types";
+import { AppContext, APIContext } from "./context";
+import { RootContent } from "./components/RootContent";
+import { customSetInterval } from "./helper/customSetInterval";
export interface Error {
- text: string,
- details?: ApiErrorDetails,
+ text: string;
+ details?: ApiErrorDetails;
}
-interface ContextType {
- setCloseableError: (error: Error | null) => void,
- refreshTorrents: () => void,
+export interface ContextType {
+ setCloseableError: (error: Error | null) => void;
+ refreshTorrents: () => void;
}
-export const APIContext = createContext({
- listTorrents: () => {
- throw new Error('Function not implemented.');
- },
- getTorrentDetails: () => {
- throw new Error('Function not implemented.');
- },
- getTorrentStats: () => {
- throw new Error('Function not implemented.');
- },
- uploadTorrent: () => {
- throw new Error('Function not implemented.');
- },
- pause: () => {
- throw new Error('Function not implemented.');
- },
- start: () => {
- throw new Error('Function not implemented.');
- },
- forget: () => {
- throw new Error('Function not implemented.');
- },
- delete: () => {
- throw new Error('Function not implemented.');
- }
-});
-
-const AppContext = createContext({
- setCloseableError: (_) => { },
- refreshTorrents: () => { },
-});
-const RefreshTorrentStatsContext = createContext({ refresh: () => { } });
-
-const IconButton: React.FC<{
- className: string,
- onClick: () => void,
- disabled?: boolean,
- color?: string,
-}> = ({ className, onClick, disabled, color }) => {
- const onClickStopPropagation: MouseEventHandler = (e) => {
- e.stopPropagation();
- if (disabled) {
- return;
- }
- onClick();
- }
- return
-}
-
-const DeleteTorrentModal: React.FC<{
- id: number,
- show: boolean,
- onHide: () => void
-}> = ({ id, show, onHide }) => {
- if (!show) {
- return null;
- }
- const [deleteFiles, setDeleteFiles] = useState(false);
- const [error, setError] = useState(null);
- const [deleting, setDeleting] = useState(false);
-
- const ctx = useContext(AppContext);
- const API = useContext(APIContext);
-
- const close = () => {
- setDeleteFiles(false);
- setError(null);
- setDeleting(false);
- onHide();
- }
-
- const deleteTorrent = () => {
- setDeleting(true);
-
- const call = deleteFiles ? API.delete : API.forget;
-
- call(id).then(() => {
- ctx.refreshTorrents();
- close();
- }).catch((e) => {
- setError({
- text: `Error deleting torrent id=${id}`,
- details: e,
- });
- setDeleting(false);
- })
- }
-
- return
-
- Delete torrent
-
-
-
- setDeleteFiles(!deleteFiles)}>
-
-
-
- {error && }
-
-
- {deleting && }
-
-
-
-
-}
-
-const TorrentActions: React.FC<{
- id: number, statsResponse: TorrentStats
-}> = ({ id, statsResponse }) => {
- let state = statsResponse.state;
-
- let [disabled, setDisabled] = useState(false);
- let [deleting, setDeleting] = useState(false);
-
- let refreshCtx = useContext(RefreshTorrentStatsContext);
-
- const canPause = state == 'live';
- const canUnpause = state == 'paused' || state == 'error';
-
- const ctx = useContext(AppContext);
- const API = useContext(APIContext);
-
- const unpause = () => {
- setDisabled(true);
- API.start(id).then(() => { refreshCtx.refresh() }, (e) => {
- ctx.setCloseableError({
- text: `Error starting torrent id=${id}`,
- details: e,
- });
- }).finally(() => setDisabled(false))
- };
-
- const pause = () => {
- setDisabled(true);
- API.pause(id).then(() => { refreshCtx.refresh() }, (e) => {
- ctx.setCloseableError({
- text: `Error pausing torrent id=${id}`,
- details: e,
- });
- }).finally(() => setDisabled(false))
- };
-
- const startDeleting = () => {
- setDisabled(true);
- setDeleting(true);
- }
-
- const cancelDeleting = () => {
- setDisabled(false);
- setDeleting(false);
- }
-
- return
-
- {canUnpause && }
- {canPause && }
-
-
-
-
-}
-
-const Speed: React.FC<{ statsResponse: TorrentStats }> = ({ statsResponse }) => {
- switch (statsResponse.state) {
- case STATE_PAUSED: return 'Paused';
- case STATE_INITIALIZING: return 'Checking files';
- case STATE_ERROR: return 'Error';
- }
- // Unknown state
- if (statsResponse.state != 'live' || statsResponse.live === null) {
- return statsResponse.state;
- }
-
- return <>
- {!statsResponse.finished &&
- ↓ {statsResponse.live.download_speed.human_readable}
}
-
- ↑ {statsResponse.live.upload_speed.human_readable}
- {statsResponse.live.snapshot.uploaded_bytes > 0 &&
- ({formatBytes(statsResponse.live.snapshot.uploaded_bytes)})}
- >
-}
-
-const TorrentRow: React.FC<{
- id: number,
- detailsResponse: TorrentDetails | null,
- statsResponse: TorrentStats | null
-}> = ({ id, detailsResponse, statsResponse }) => {
- const state = statsResponse?.state ?? "";
- const error = statsResponse?.error;
- const totalBytes = statsResponse?.total_bytes ?? 1;
- const progressBytes = statsResponse?.progress_bytes ?? 0;
- const finished = statsResponse?.finished || false;
- const progressPercentage = error ? 100 : (progressBytes / totalBytes) * 100;
- const isAnimated = (state == STATE_INITIALIZING || state == STATE_LIVE) && !finished;
- const progressLabel = error ? 'Error' : `${progressPercentage.toFixed(2)}%`;
- const progressBarVariant = error ? 'danger' : finished ? 'success' : state == STATE_INITIALIZING ? 'warning' : 'primary';
-
- const formatPeersString = () => {
- let peer_stats = statsResponse?.live?.snapshot.peer_stats;
- if (!peer_stats) {
- return '';
- }
- return `${peer_stats.live} / ${peer_stats.seen}`;
- }
-
- let classNames = [];
-
- if (error) {
- classNames.push('bg-warning');
- } else {
- if (id % 2 == 0) {
- classNames.push('bg-light');
- }
- }
-
- return (
-
-
- {detailsResponse ?
- <>
-
- {getLargestFileName(detailsResponse)}
-
- {error && Error: {error}
}
- >
- : }
-
- {statsResponse ?
- <>
- {`${formatBytes(totalBytes)} `}
-
-
-
-
-
-
- {getCompletionETA(statsResponse)}
- {formatPeersString()}
-
-
-
- >
- :
- }
-
-
- );
-}
-
-const Column: React.FC<{
- label: string,
- size?: number,
- children?: any
-}> = ({ size, label, children }) => (
-
- {label}
- {children}
-
-);
-
-const Torrent: React.FC<{
- id: number,
- torrent: TorrentId
-}> = ({ id, torrent }) => {
- const [detailsResponse, updateDetailsResponse] = useState(null);
- const [statsResponse, updateStatsResponse] = useState(null);
- const [forceStatsRefresh, setForceStatsRefresh] = useState(0);
- const API = useContext(APIContext);
-
- const forceStatsRefreshCallback = () => {
- setForceStatsRefresh(forceStatsRefresh + 1);
- }
-
- // Update details once.
- useEffect(() => {
- if (detailsResponse === null) {
- return loopUntilSuccess(async () => {
- await API.getTorrentDetails(torrent.id).then(updateDetailsResponse);
- }, 1000);
- }
- }, [detailsResponse]);
-
- // Update stats once then forever.
- useEffect(() => customSetInterval((async () => {
- const errorInterval = 10000;
- const liveInterval = 1000;
- const nonLiveInterval = 10000;
-
- return API.getTorrentStats(torrent.id).then((stats) => {
- updateStatsResponse(stats);
- return stats;
- }).then((stats) => {
- if (stats.state == STATE_INITIALIZING || stats.state == STATE_LIVE) {
- return liveInterval;
- }
- return nonLiveInterval;
- }, () => {
- return errorInterval;
- });
- }), 0), [forceStatsRefresh]);
-
- return
-
-
-}
-
-const TorrentsList = (props: { torrents: Array | null, loading: boolean }) => {
- if (props.torrents === null && props.loading) {
- return
- }
- // The app either just started, or there was an error loading torrents.
- if (props.torrents === null) {
- return;
- }
-
- if (props.torrents.length === 0) {
- return
-
No existing torrents found. Add them through buttons below.
-
;
- }
- return
- {props.torrents.map((t: TorrentId) =>
-
- )}
-
;
-};
-
export const RqbitWebUI = (props: { title: string }) => {
- const [closeableError, setCloseableError] = useState(null);
- const [otherError, setOtherError] = useState(null);
+ const [closeableError, setCloseableError] = useState(null);
+ const [otherError, setOtherError] = useState(null);
- const [torrents, setTorrents] = useState | null>(null);
- const [torrentsLoading, setTorrentsLoading] = useState(false);
- const API = useContext(APIContext);
+ const [torrents, setTorrents] = useState | null>(null);
+ const [torrentsLoading, setTorrentsLoading] = useState(false);
+ const API = useContext(APIContext);
- const refreshTorrents = async () => {
- setTorrentsLoading(true);
- let torrents = await API.listTorrents().finally(() => setTorrentsLoading(false));
- setTorrents(torrents.torrents);
- };
-
- useEffect(() => {
- return customSetInterval(async () =>
- refreshTorrents().then(() => {
- setOtherError(null);
- return 5000;
- }, (e) => {
- setOtherError({ text: 'Error refreshing torrents', details: e });
- console.error(e);
- return 5000;
- }), 0);
- }, []);
-
- const context: ContextType = {
- setCloseableError,
- refreshTorrents,
- }
-
- return
-
-
{props.title}
-
-
-
-}
-
-const ErrorDetails = (props: { details: ApiErrorDetails | null | undefined }) => {
- let { details } = props;
- if (!details) {
- return null;
- }
- return <>
- {details.statusText && {details.statusText}
}
- {details.text}
- >
-}
-
-export const ErrorComponent = (props: { error: Error | null, remove?: () => void }) => {
- let { error, remove } = props;
-
- if (error == null) {
- return null;
- }
-
- return (
- {error.text}
-
-
- );
-};
-
-const UploadButton: React.FC<{
- buttonText: string,
- onClick: () => void,
- data: string | File | null,
- resetData: () => void,
- variant: string,
-}> = ({ buttonText, onClick, data, resetData, variant }) => {
- const [loading, setLoading] = useState(false);
- const [listTorrentResponse, setListTorrentResponse] = useState(null);
- const [listTorrentError, setListTorrentError] = useState(null);
- const API = useContext(APIContext);
-
- // Get the torrent file list if there's data.
- useEffect(() => {
- if (data === null) {
- return;
- }
-
- let t = setTimeout(async () => {
- setLoading(true);
- try {
- const response = await API.uploadTorrent(data, { list_only: true });
- setListTorrentResponse(response);
- } catch (e) {
- setListTorrentError({ text: 'Error listing torrent files', details: e as ApiErrorDetails });
- } finally {
- setLoading(false);
- }
- }, 0);
- return () => clearTimeout(t);
- }, [data]);
-
- const clear = () => {
- resetData();
- setListTorrentError(null);
- setListTorrentResponse(null);
- setLoading(false);
- }
-
- return (
- <>
-
-
- {data && }
- >
+ const refreshTorrents = async () => {
+ setTorrentsLoading(true);
+ let torrents = await API.listTorrents().finally(() =>
+ setTorrentsLoading(false)
);
-};
+ setTorrents(torrents.torrents);
+ };
-const UrlPromptModal: React.FC<{
- show: boolean,
- setUrl: (_: string) => void,
- cancel: () => void,
-}> = ({ show, setUrl, cancel }) => {
- let [inputValue, setInputValue] = useState('');
- return
-
- Add torrent
-
-
-
- Enter magnet or HTTP(S) URL to the .torrent
- { setInputValue(u.target.value) }} />
-
-
-
-
-
-
-
-
-}
-
-const MagnetInput = () => {
- let [magnet, setMagnet] = useState(null);
-
- let [showModal, setShowModal] = useState(false);
-
- return (
- <>
- {
- setShowModal(true);
- }}
- data={magnet}
- resetData={() => setMagnet(null)}
- />
-
- {
- setShowModal(false);
- setMagnet(url);
- }}
- cancel={() => {
- setShowModal(false);
- setMagnet(null);
- }} />
- >
+ useEffect(() => {
+ return customSetInterval(
+ async () =>
+ refreshTorrents().then(
+ () => {
+ setOtherError(null);
+ return 5000;
+ },
+ (e) => {
+ setOtherError({ text: "Error refreshing torrents", details: e });
+ console.error(e);
+ return 5000;
+ }
+ ),
+ 0
);
+ }, []);
+
+ const context: ContextType = {
+ setCloseableError,
+ refreshTorrents,
+ };
+
+ return (
+
+
+
{props.title}
+
+
+
+ );
};
-
-const FileInput = () => {
- const inputRef = useRef() as RefObject;
- const [file, setFile] = useState(null);
-
- const onFileChange = async () => {
- if (!inputRef?.current?.files) {
- return;
- }
- const file = inputRef.current.files[0];
- setFile(file);
- };
-
- const reset = () => {
- if (!inputRef?.current) {
- return;
- }
- inputRef.current.value = '';
- setFile(null);
- }
-
- const onClick = () => {
- if (!inputRef?.current) {
- return;
- }
- inputRef.current.click();
- }
-
- return (
- <>
-
-
- >
- );
-};
-
-const FileSelectionModal = (props: {
- onHide: () => void,
- listTorrentResponse: AddTorrentResponse | null,
- listTorrentError: Error | null,
- listTorrentLoading: boolean,
- data: string | File,
-}) => {
- let { onHide, listTorrentResponse, listTorrentError, listTorrentLoading, data } = props;
-
- const [selectedFiles, setSelectedFiles] = useState([]);
- const [uploading, setUploading] = useState(false);
- const [uploadError, setUploadError] = useState(null);
- const [unpopularTorrent, setUnpopularTorrent] = useState(false);
- const [outputFolder, setOutputFolder] = useState('');
- const ctx = useContext(AppContext);
- const API = useContext(APIContext);
-
- useEffect(() => {
- console.log(listTorrentResponse);
- setSelectedFiles(listTorrentResponse ? listTorrentResponse.details.files.map((_, id) => id) : []);
- setOutputFolder(listTorrentResponse?.output_folder || '');
- }, [listTorrentResponse]);
-
- const clear = () => {
- onHide();
- setSelectedFiles([]);
- setUploadError(null);
- setUploading(false);
- }
-
- const handleToggleFile = (toggledId: number) => {
- if (selectedFiles.includes(toggledId)) {
- setSelectedFiles(selectedFiles.filter((i) => i !== toggledId));
- } else {
- setSelectedFiles([...selectedFiles, toggledId]);
- }
- };
-
- const handleUpload = async () => {
- if (!listTorrentResponse) {
- return;
- }
- setUploading(true);
- let initialPeers = listTorrentResponse.seen_peers ? listTorrentResponse.seen_peers.slice(0, 32) : null;
- let opts: AddTorrentOptions = {
- overwrite: true,
- only_files: selectedFiles,
- initial_peers: initialPeers,
- output_folder: outputFolder,
- };
- if (unpopularTorrent) {
- opts.peer_opts = {
- connect_timeout: 20,
- read_write_timeout: 60,
- };
- }
- API.uploadTorrent(data, opts).then(() => {
- onHide();
- ctx.refreshTorrents();
- },
- (e) => {
- setUploadError({ text: 'Error starting torrent', details: e });
- }
- ).finally(() => setUploading(false));
- };
-
- const getBody = () => {
- if (listTorrentLoading) {
- return ;
- } else if (listTorrentError) {
- return ;
- } else if (listTorrentResponse) {
- return
- }
- };
-
- return (
-
-
- Add torrent
-
-
- {getBody()}
-
-
-
- {uploading && }
-
-
-
-
- );
-};
-
-const Buttons = () => {
- return (
-
-
-
-
- );
-};
-
-const RootContent = (props: {
- closeableError: ApiErrorDetails | null,
- otherError: ApiErrorDetails | null,
- torrents: Array | null,
- torrentsLoading: boolean
-}) => {
- let ctx = useContext(AppContext);
- return
- ctx.setCloseableError(null)} />
-
-
-
-
-};
-
-function formatBytes(bytes: number): string {
- if (bytes === 0) return '0 Bytes';
-
- const k = 1024;
- 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 getLargestFileName(torrentDetails: TorrentDetails): string {
- const largestFile = torrentDetails.files.filter(
- (f) => f.included
- ).reduce(
- (prev: any, current: any) => (prev.length > current.length) ? prev : current
- );
- return largestFile.name;
-}
-
-function getCompletionETA(stats: TorrentStats): string {
- let duration = stats?.live?.time_remaining?.duration?.secs;
- if (duration == null) {
- return 'N/A';
- }
- return formatSecondsToTime(duration);
-}
-
-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: number, unit: string) => (value > 0 ? `${value}${unit}` : '');
-
- if (hours > 0) {
- return `${formatUnit(hours, 'h')} ${formatUnit(minutes, 'm')}`.trim();
- } else if (minutes > 0) {
- return `${formatUnit(minutes, 'm')} ${formatUnit(remainingSeconds, 's')}`.trim();
- } else {
- return `${formatUnit(remainingSeconds, 's')}`.trim();
- }
-}
-
-// Run a function with initial interval, then run it forever with the interval that the
-// callback returns.
-// Returns a callback to clear it.
-export function customSetInterval(asyncCallback: () => Promise, initialInterval: number): () => void {
- let timeoutId: number;
- let currentInterval: number = initialInterval;
-
- const executeCallback = async () => {
- currentInterval = await asyncCallback();
- if (currentInterval === null || currentInterval === undefined) {
- throw 'asyncCallback returned null or undefined';
- }
- scheduleNext();
- }
-
- let scheduleNext = () => {
- timeoutId = setTimeout(executeCallback, currentInterval);
- }
-
- scheduleNext();
-
- return () => {
- clearTimeout(timeoutId);
- };
-}
-
-export function loopUntilSuccess(callback: () => Promise, interval: number): () => void {
- let timeoutId: number;
-
- const executeCallback = async () => {
- let retry = await callback().then(() => false, () => true);
- if (retry) {
- scheduleNext();
- }
- }
-
- let scheduleNext = (overrideInterval?: number) => {
- timeoutId = setTimeout(executeCallback, overrideInterval !== undefined ? overrideInterval : interval);
- }
-
- scheduleNext(0);
-
- return () => clearTimeout(timeoutId);
-}
\ No newline at end of file