diff --git a/crates/librqbit/webui/src/components/Buttons.tsx b/crates/librqbit/webui/src/components/Buttons.tsx
new file mode 100644
index 0000000..914401b
--- /dev/null
+++ b/crates/librqbit/webui/src/components/Buttons.tsx
@@ -0,0 +1,11 @@
+import { MagnetInput } from "./MagnetInput";
+import { FileInput } from "./FileInput";
+
+export const Buttons = () => {
+ return (
+
+
+
+
+ );
+};
diff --git a/crates/librqbit/webui/src/components/Column.tsx b/crates/librqbit/webui/src/components/Column.tsx
new file mode 100644
index 0000000..9af2a82
--- /dev/null
+++ b/crates/librqbit/webui/src/components/Column.tsx
@@ -0,0 +1,12 @@
+import { Col } from "react-bootstrap";
+
+export const Column: React.FC<{
+ label: string;
+ size?: number;
+ children?: any;
+}> = ({ size, label, children }) => (
+
+ {label}
+ {children}
+
+);
diff --git a/crates/librqbit/webui/src/components/DeleteTorrentModal.tsx b/crates/librqbit/webui/src/components/DeleteTorrentModal.tsx
new file mode 100644
index 0000000..cc4f626
--- /dev/null
+++ b/crates/librqbit/webui/src/components/DeleteTorrentModal.tsx
@@ -0,0 +1,75 @@
+import { useContext, useState } from "react";
+import { Button, Modal, Form, Spinner } from "react-bootstrap";
+import { AppContext, APIContext } from "../context";
+import { Error } from "../rqbit-web";
+import { ErrorComponent } from "./ErrorComponent";
+
+export 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 && }
+
+
+
+
+ );
+};
diff --git a/crates/librqbit/webui/src/components/ErrorComponent.tsx b/crates/librqbit/webui/src/components/ErrorComponent.tsx
new file mode 100644
index 0000000..4b01ef0
--- /dev/null
+++ b/crates/librqbit/webui/src/components/ErrorComponent.tsx
@@ -0,0 +1,25 @@
+import { Alert } from "react-bootstrap";
+import { Error } from "../rqbit-web";
+
+export const ErrorComponent = (props: {
+ error: Error | null;
+ remove?: () => void;
+}) => {
+ let { error, remove } = props;
+
+ if (error == null) {
+ return null;
+ }
+
+ return (
+
+ {error.text}
+ {error.details?.statusText && (
+
+ {error.details?.statusText}
+
+ )}
+ {error.details?.text}
+
+ );
+};
diff --git a/crates/librqbit/webui/src/components/FileInput.tsx b/crates/librqbit/webui/src/components/FileInput.tsx
new file mode 100644
index 0000000..0315c69
--- /dev/null
+++ b/crates/librqbit/webui/src/components/FileInput.tsx
@@ -0,0 +1,49 @@
+import { RefObject, useRef, useState } from "react";
+import { UploadButton } from "./UploadButton";
+
+export 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 (
+ <>
+
+
+ >
+ );
+};
diff --git a/crates/librqbit/webui/src/components/FileSelectionModal.tsx b/crates/librqbit/webui/src/components/FileSelectionModal.tsx
new file mode 100644
index 0000000..db712e4
--- /dev/null
+++ b/crates/librqbit/webui/src/components/FileSelectionModal.tsx
@@ -0,0 +1,165 @@
+import { useContext, useEffect, useState } from "react";
+import { Button, Modal, Form, Spinner } from "react-bootstrap";
+import { AddTorrentResponse, AddTorrentOptions } from "../api-types";
+import { AppContext, APIContext } from "../context";
+import { ErrorComponent } from "./ErrorComponent";
+import { formatBytes } from "../helper/formatBytes";
+import { Error } from "../rqbit-web";
+
+export 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 && }
+
+
+
+
+ );
+};
diff --git a/crates/librqbit/webui/src/components/IconButton.tsx b/crates/librqbit/webui/src/components/IconButton.tsx
new file mode 100644
index 0000000..ac66835
--- /dev/null
+++ b/crates/librqbit/webui/src/components/IconButton.tsx
@@ -0,0 +1,23 @@
+import { MouseEventHandler } from "react";
+
+export 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 (
+
+ );
+};
diff --git a/crates/librqbit/webui/src/components/MagnetInput.tsx b/crates/librqbit/webui/src/components/MagnetInput.tsx
new file mode 100644
index 0000000..d7e48c1
--- /dev/null
+++ b/crates/librqbit/webui/src/components/MagnetInput.tsx
@@ -0,0 +1,35 @@
+import { useState } from "react";
+import { UploadButton } from "./UploadButton";
+import { UrlPromptModal } from "./UrlPromptModal";
+
+export 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);
+ }}
+ />
+ >
+ );
+};
diff --git a/crates/librqbit/webui/src/components/RootContent.tsx b/crates/librqbit/webui/src/components/RootContent.tsx
new file mode 100644
index 0000000..3a6a987
--- /dev/null
+++ b/crates/librqbit/webui/src/components/RootContent.tsx
@@ -0,0 +1,27 @@
+import { useContext } from "react";
+import { Container } from "react-bootstrap";
+import { TorrentId, ErrorDetails as ApiErrorDetails } from "../api-types";
+import { AppContext } from "../context";
+import { TorrentsList } from "./TorrentsList";
+import { ErrorComponent } from "./ErrorComponent";
+import { Buttons } from "./Buttons";
+
+export const RootContent = (props: {
+ closeableError: ApiErrorDetails | null;
+ otherError: ApiErrorDetails | null;
+ torrents: Array | null;
+ torrentsLoading: boolean;
+}) => {
+ let ctx = useContext(AppContext);
+ return (
+
+ ctx.setCloseableError(null)}
+ />
+
+
+
+
+ );
+};
diff --git a/crates/librqbit/webui/src/components/Speed.tsx b/crates/librqbit/webui/src/components/Speed.tsx
new file mode 100644
index 0000000..e1fb6f9
--- /dev/null
+++ b/crates/librqbit/webui/src/components/Speed.tsx
@@ -0,0 +1,43 @@
+import {
+ TorrentStats,
+ STATE_INITIALIZING,
+ STATE_PAUSED,
+ STATE_ERROR,
+} from "../api-types";
+import { formatBytes } from "../helper/formatBytes";
+
+export 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)})
+
+ )}
+
+ >
+ );
+};
diff --git a/crates/librqbit/webui/src/components/Torrent.tsx b/crates/librqbit/webui/src/components/Torrent.tsx
new file mode 100644
index 0000000..4e13748
--- /dev/null
+++ b/crates/librqbit/webui/src/components/Torrent.tsx
@@ -0,0 +1,81 @@
+import { useContext, useEffect, useState } from "react";
+import {
+ TorrentDetails,
+ TorrentId,
+ TorrentStats,
+ STATE_INITIALIZING,
+ STATE_LIVE,
+} from "../api-types";
+import { APIContext, RefreshTorrentStatsContext } from "../context";
+import { customSetInterval } from "../helper/customSetInterval";
+import { loopUntilSuccess } from "../helper/loopUntilSuccess";
+import { TorrentRow } from "./TorrentRow";
+
+export 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 (
+
+
+
+ );
+};
diff --git a/crates/librqbit/webui/src/components/TorrentActions.tsx b/crates/librqbit/webui/src/components/TorrentActions.tsx
new file mode 100644
index 0000000..cd845f8
--- /dev/null
+++ b/crates/librqbit/webui/src/components/TorrentActions.tsx
@@ -0,0 +1,97 @@
+import { useContext, useState } from "react";
+import { Row, Col } from "react-bootstrap";
+import { TorrentStats } from "../api-types";
+import { AppContext, APIContext, RefreshTorrentStatsContext } from "../context";
+import { IconButton } from "./IconButton";
+import { DeleteTorrentModal } from "./DeleteTorrentModal";
+
+export 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 && (
+
+ )}
+
+
+
+
+ );
+};
diff --git a/crates/librqbit/webui/src/components/TorrentRow.tsx b/crates/librqbit/webui/src/components/TorrentRow.tsx
new file mode 100644
index 0000000..c8d61a1
--- /dev/null
+++ b/crates/librqbit/webui/src/components/TorrentRow.tsx
@@ -0,0 +1,106 @@
+import { ProgressBar, Row, Spinner } from "react-bootstrap";
+import {
+ TorrentDetails,
+ TorrentStats,
+ STATE_INITIALIZING,
+ STATE_LIVE,
+ STATE_PAUSED,
+} from "../api-types";
+import { TorrentActions } from "./TorrentActions";
+import { Speed } from "./Speed";
+import { Column } from "./Column";
+import { formatBytes } from "../helper/formatBytes";
+import { getLargestFileName } from "../helper/getLargestFileName";
+import { getCompletionETA } from "../helper/getCompletionETA";
+
+export 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()}
+
+
+
+
+ >
+ ) : (
+
+
+
+ )}
+
+ );
+};
diff --git a/crates/librqbit/webui/src/components/TorrentsList.tsx b/crates/librqbit/webui/src/components/TorrentsList.tsx
new file mode 100644
index 0000000..e0f003e
--- /dev/null
+++ b/crates/librqbit/webui/src/components/TorrentsList.tsx
@@ -0,0 +1,31 @@
+import { Spinner } from "react-bootstrap";
+import { TorrentId } from "../api-types";
+import { Torrent } from "./Torrent";
+
+export 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) => (
+
+ ))}
+
+ );
+};
diff --git a/crates/librqbit/webui/src/components/UploadButton.tsx b/crates/librqbit/webui/src/components/UploadButton.tsx
new file mode 100644
index 0000000..a51cc2e
--- /dev/null
+++ b/crates/librqbit/webui/src/components/UploadButton.tsx
@@ -0,0 +1,71 @@
+import { useContext, useEffect, useState } from "react";
+import { Button } from "react-bootstrap";
+import {
+ AddTorrentResponse,
+ ErrorDetails as ApiErrorDetails,
+} from "../api-types";
+import { APIContext } from "../context";
+import { Error } from "../rqbit-web";
+import { FileSelectionModal } from "./FileSelectionModal";
+
+export 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 && (
+
+ )}
+ >
+ );
+};
diff --git a/crates/librqbit/webui/src/components/UrlPromptModal.tsx b/crates/librqbit/webui/src/components/UrlPromptModal.tsx
new file mode 100644
index 0000000..672cffe
--- /dev/null
+++ b/crates/librqbit/webui/src/components/UrlPromptModal.tsx
@@ -0,0 +1,46 @@
+import { useState } from "react";
+import { Button, Modal, Form } from "react-bootstrap";
+
+export 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);
+ }}
+ />
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/crates/librqbit/webui/src/context.tsx b/crates/librqbit/webui/src/context.tsx
new file mode 100644
index 0000000..1346e90
--- /dev/null
+++ b/crates/librqbit/webui/src/context.tsx
@@ -0,0 +1,35 @@
+import { createContext } from "react";
+import { RqbitAPI } from "./api-types";
+import { ContextType } from "./rqbit-web";
+
+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.");
+ },
+});
+export const AppContext = createContext({
+ setCloseableError: (_) => {},
+ refreshTorrents: () => {},
+});
+export const RefreshTorrentStatsContext = createContext({ refresh: () => {} });
diff --git a/crates/librqbit/webui/src/helper/customSetInterval.ts b/crates/librqbit/webui/src/helper/customSetInterval.ts
new file mode 100644
index 0000000..e3602b3
--- /dev/null
+++ b/crates/librqbit/webui/src/helper/customSetInterval.ts
@@ -0,0 +1,29 @@
+// 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);
+ };
+}
diff --git a/crates/librqbit/webui/src/helper/formatBytes.ts b/crates/librqbit/webui/src/helper/formatBytes.ts
new file mode 100644
index 0000000..99f0099
--- /dev/null
+++ b/crates/librqbit/webui/src/helper/formatBytes.ts
@@ -0,0 +1,10 @@
+export 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];
+}
diff --git a/crates/librqbit/webui/src/helper/formatSecondsToTime.ts b/crates/librqbit/webui/src/helper/formatSecondsToTime.ts
new file mode 100644
index 0000000..5230d2a
--- /dev/null
+++ b/crates/librqbit/webui/src/helper/formatSecondsToTime.ts
@@ -0,0 +1,19 @@
+export 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();
+ }
+}
diff --git a/crates/librqbit/webui/src/helper/getCompletionETA.ts b/crates/librqbit/webui/src/helper/getCompletionETA.ts
new file mode 100644
index 0000000..93d6eb9
--- /dev/null
+++ b/crates/librqbit/webui/src/helper/getCompletionETA.ts
@@ -0,0 +1,10 @@
+import { TorrentStats } from "../api-types";
+import { formatSecondsToTime } from "./formatSecondsToTime";
+
+export function getCompletionETA(stats: TorrentStats): string {
+ let duration = stats?.live?.time_remaining?.duration?.secs;
+ if (duration == null) {
+ return "N/A";
+ }
+ return formatSecondsToTime(duration);
+}
diff --git a/crates/librqbit/webui/src/helper/getLargestFileName.ts b/crates/librqbit/webui/src/helper/getLargestFileName.ts
new file mode 100644
index 0000000..cb9e69e
--- /dev/null
+++ b/crates/librqbit/webui/src/helper/getLargestFileName.ts
@@ -0,0 +1,10 @@
+import { TorrentDetails } from "../api-types";
+
+export 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;
+}
diff --git a/crates/librqbit/webui/src/helper/loopUntilSuccess.ts b/crates/librqbit/webui/src/helper/loopUntilSuccess.ts
new file mode 100644
index 0000000..a8cbc7a
--- /dev/null
+++ b/crates/librqbit/webui/src/helper/loopUntilSuccess.ts
@@ -0,0 +1,27 @@
+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);
+}
diff --git a/crates/librqbit/webui/src/main.tsx b/crates/librqbit/webui/src/main.tsx
index dde0bf5..ecea3a1 100644
--- a/crates/librqbit/webui/src/main.tsx
+++ b/crates/librqbit/webui/src/main.tsx
@@ -1,6 +1,8 @@
import { StrictMode, useEffect, useState } from "react";
import ReactDOM from "react-dom/client";
-import { RqbitWebUI, APIContext, customSetInterval } from "./rqbit-web";
+import { RqbitWebUI } from "./rqbit-web";
+import { customSetInterval } from "./helper/customSetInterval";
+import { APIContext } from "./context";
import { API } from "./http-api";
const RootWithVersion = () => {
diff --git a/crates/librqbit/webui/src/rqbit-web.tsx b/crates/librqbit/webui/src/rqbit-web.tsx
index 2a68103..c77d428 100644
--- a/crates/librqbit/webui/src/rqbit-web.tsx
+++ b/crates/librqbit/webui/src/rqbit-web.tsx
@@ -1,499 +1,19 @@
-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;
}
-interface ContextType {
+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);
@@ -547,518 +67,3 @@ export const RqbitWebUI = (props: { title: string }) => {
);
};
-
-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 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);
- }}
- />
- >
- );
-};
-
-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);
-}