From f978ad02fe96c5b038371477c92c942926fe3ad1 Mon Sep 17 00:00:00 2001 From: Artur Lozovski Date: Thu, 7 Dec 2023 16:33:37 +0000 Subject: [PATCH 1/2] Refactor the rqbit-web file by breaking it down into components and helper functions. Improve code organization and maintainability --- .../librqbit/webui/src/components/Buttons.tsx | 11 + .../librqbit/webui/src/components/Column.tsx | 12 + .../src/components/DeleteTorrentModal.tsx | 75 + .../webui/src/components/ErrorComponent.tsx | 25 + .../webui/src/components/FileInput.tsx | 49 + .../src/components/FileSelectionModal.tsx | 165 ++ .../webui/src/components/IconButton.tsx | 23 + .../webui/src/components/MagnetInput.tsx | 35 + .../webui/src/components/RootContent.tsx | 27 + .../librqbit/webui/src/components/Speed.tsx | 43 + .../librqbit/webui/src/components/Torrent.tsx | 81 + .../webui/src/components/TorrentActions.tsx | 97 + .../webui/src/components/TorrentRow.tsx | 106 ++ .../webui/src/components/TorrentsList.tsx | 31 + .../webui/src/components/UploadButton.tsx | 71 + .../webui/src/components/UrlPromptModal.tsx | 46 + crates/librqbit/webui/src/context.tsx | 35 + .../webui/src/helper/customSetInterval.ts | 29 + .../librqbit/webui/src/helper/formatBytes.ts | 10 + .../webui/src/helper/formatSecondsToTime.ts | 19 + .../webui/src/helper/getCompletionETA.ts | 10 + .../webui/src/helper/getLargestFileName.ts | 10 + .../webui/src/helper/loopUntilSuccess.ts | 27 + crates/librqbit/webui/src/main.tsx | 44 +- crates/librqbit/webui/src/rqbit-web.tsx | 1567 +++++++---------- 25 files changed, 1722 insertions(+), 926 deletions(-) create mode 100644 crates/librqbit/webui/src/components/Buttons.tsx create mode 100644 crates/librqbit/webui/src/components/Column.tsx create mode 100644 crates/librqbit/webui/src/components/DeleteTorrentModal.tsx create mode 100644 crates/librqbit/webui/src/components/ErrorComponent.tsx create mode 100644 crates/librqbit/webui/src/components/FileInput.tsx create mode 100644 crates/librqbit/webui/src/components/FileSelectionModal.tsx create mode 100644 crates/librqbit/webui/src/components/IconButton.tsx create mode 100644 crates/librqbit/webui/src/components/MagnetInput.tsx create mode 100644 crates/librqbit/webui/src/components/RootContent.tsx create mode 100644 crates/librqbit/webui/src/components/Speed.tsx create mode 100644 crates/librqbit/webui/src/components/Torrent.tsx create mode 100644 crates/librqbit/webui/src/components/TorrentActions.tsx create mode 100644 crates/librqbit/webui/src/components/TorrentRow.tsx create mode 100644 crates/librqbit/webui/src/components/TorrentsList.tsx create mode 100644 crates/librqbit/webui/src/components/UploadButton.tsx create mode 100644 crates/librqbit/webui/src/components/UrlPromptModal.tsx create mode 100644 crates/librqbit/webui/src/context.tsx create mode 100644 crates/librqbit/webui/src/helper/customSetInterval.ts create mode 100644 crates/librqbit/webui/src/helper/formatBytes.ts create mode 100644 crates/librqbit/webui/src/helper/formatSecondsToTime.ts create mode 100644 crates/librqbit/webui/src/helper/getCompletionETA.ts create mode 100644 crates/librqbit/webui/src/helper/getLargestFileName.ts create mode 100644 crates/librqbit/webui/src/helper/loopUntilSuccess.ts 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 ( +
+
+ Pick the files to download + {listTorrentResponse.details.files.map((file, index) => ( + + handleToggleFile(index)} + > + + ))} +
+
+ Options + + Output folder + setOutputFolder(e.target.value)} + /> + + + setUnpopularTorrent(!unpopularTorrent)} + > + + This might be useful for unpopular torrents with few peers. It + will slow down fast torrents though. + + +
+
+ ); + } + }; + + 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..19f6f2a --- /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..07aeeff 100644 --- a/crates/librqbit/webui/src/main.tsx +++ b/crates/librqbit/webui/src/main.tsx @@ -1,33 +1,27 @@ import { StrictMode, useEffect, useState } from "react"; -import ReactDOM from "react-dom/client"; +import ReactDOM from 'react-dom/client'; import { RqbitWebUI, APIContext, customSetInterval } from "./rqbit-web"; 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 2a68103..846dc26 100644 --- a/crates/librqbit/webui/src/rqbit-web.tsx +++ b/crates/librqbit/webui/src/rqbit-web.tsx @@ -1,1064 +1,829 @@ -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 { 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'; export interface Error { - text: string; - details?: ApiErrorDetails; + text: string, + details?: ApiErrorDetails, } interface ContextType { - setCloseableError: (error: Error | null) => void; - refreshTorrents: () => void; + 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."); - }, + 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: () => {}, + setCloseableError: (_) => { }, + refreshTorrents: () => { }, }); -const RefreshTorrentStatsContext = createContext({ refresh: () => {} }); +const RefreshTorrentStatsContext = createContext({ refresh: () => { } }); const IconButton: React.FC<{ - className: string; - onClick: () => void; - disabled?: boolean; - color?: string; + className: string, + onClick: () => void, + disabled?: boolean, + color?: string, }> = ({ className, onClick, disabled, color }) => { - const onClickStopPropagation: MouseEventHandler = (e) => { - e.stopPropagation(); - if (disabled) { - return; + const onClickStopPropagation: MouseEventHandler = (e) => { + e.stopPropagation(); + if (disabled) { + return; + } + onClick(); } - onClick(); - }; - return ( - - ); -}; + return +} const DeleteTorrentModal: React.FC<{ - id: number; - show: boolean; - onHide: () => void; + 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); + 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 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, - }); + const close = () => { + setDeleteFiles(false); + setError(null); setDeleting(false); - }); - }; + onHide(); + } - return ( - - Delete torrent - -
- - setDeleteFiles(!deleteFiles)} - > - -
- {error && } -
- - {deleting && } - - - + 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: number, statsResponse: TorrentStats }> = ({ id, statsResponse }) => { - let state = statsResponse.state; + let state = statsResponse.state; - let [disabled, setDisabled] = useState(false); - let [deleting, setDeleting] = useState(false); + let [disabled, setDisabled] = useState(false); + let [deleting, setDeleting] = useState(false); - let refreshCtx = useContext(RefreshTorrentStatsContext); + let refreshCtx = useContext(RefreshTorrentStatsContext); - const canPause = state == "live"; - const canUnpause = state == "paused" || state == "error"; + const canPause = state == 'live'; + const canUnpause = state == 'paused' || state == 'error'; - const ctx = useContext(AppContext); - const API = useContext(APIContext); + 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 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 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 startDeleting = () => { + setDisabled(true); + setDeleting(true); + } - const cancelDeleting = () => { - setDisabled(false); - setDeleting(false); - }; + const cancelDeleting = () => { + setDisabled(false); + setDeleting(false); + } - return ( - - - {canUnpause && ( - - )} - {canPause && ( - - )} - - - + 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; - } +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)}) - - )} -
+ 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: 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 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 ""; + const formatPeersString = () => { + let peer_stats = statsResponse?.live?.snapshot.peer_stats; + if (!peer_stats) { + return ''; + } + return `${peer_stats.live} / ${peer_stats.seen}`; } - return `${peer_stats.live} / ${peer_stats.seen}`; - }; - let classNames = []; + let classNames = []; - if (error) { - classNames.push("bg-warning"); - } else { - if (id % 2 == 0) { - classNames.push("bg-light"); + 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()} - - - - - - ) : ( - - - - )} -
- ); -}; + return ( + + + {detailsResponse ? + <> +
+ {getLargestFileName(detailsResponse)} +
+ {error &&

Error: {error}

} + + : } +
+ {statsResponse ? + <> + {`${formatBytes(totalBytes)} `} + + + + + + + {getCompletionETA(statsResponse)} + {formatPeersString()} + + + + + : + } + +
+ ); +} const Column: React.FC<{ - label: string; - size?: number; - children?: any; + label: string, + size?: number, + children?: any }> = ({ size, label, children }) => ( - -
{label}
- {children} - + +
{label}
+ {children} + ); const Torrent: React.FC<{ - id: number; - torrent: TorrentId; + 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 [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); + const forceStatsRefreshCallback = () => { + setForceStatsRefresh(forceStatsRefresh + 1); } - }, [detailsResponse]); - // Update stats once then forever. - useEffect( - () => - customSetInterval(async () => { + // 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) => { + return API.getTorrentStats(torrent.id).then((stats) => { updateStatsResponse(stats); return stats; - }) - .then( - (stats) => { - if ( - stats.state == STATE_INITIALIZING || - stats.state == STATE_LIVE - ) { + }).then((stats) => { + if (stats.state == STATE_INITIALIZING || stats.state == STATE_LIVE) { return liveInterval; - } - return nonLiveInterval; - }, - () => { - return errorInterval; } - ); - }, 0), - [forceStatsRefresh] - ); + return nonLiveInterval; + }, () => { + return errorInterval; + }); + }), 0), [forceStatsRefresh]); - return ( - - - - ); -}; + 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; - } +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) => ( - - ))} -
- ); + 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); - }; + 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 - ); - }, []); + 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, - }; + const context: ContextType = { + setCloseableError, + refreshTorrents, + } - return ( - -
-

{props.title}

- -
-
- ); -}; + return +
+

{props.title}

+ +
+
+} -const ErrorDetails = (props: { - details: ApiErrorDetails | null | undefined; -}) => { - let { details } = props; - if (!details) { - return null; - } - return ( - <> - {details.statusText && ( -

- {details.statusText} -

- )} -
{details.text}
+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; +export const ErrorComponent = (props: { error: Error | null, remove?: () => void }) => { + let { error, remove } = props; - if (error == null) { - return null; - } + if (error == null) { + return null; + } - return ( - - {error.text} + return ( + {error.text} - - - ); + + ); }; const UploadButton: React.FC<{ - buttonText: string; - onClick: () => void; - data: string | File | null; - resetData: () => void; - variant: string; + 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); + 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; + // 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); } - 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]); + return ( + <> + - const clear = () => { - resetData(); - setListTorrentError(null); - setListTorrentResponse(null); - setLoading(false); - }; - - return ( - <> - - - {data && ( - - )} - - ); + {data && } + + ); }; const UrlPromptModal: React.FC<{ - show: boolean; - setUrl: (_: string) => void; - cancel: () => void; + 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); - }} - /> - -
-
- - - - -
- ); -}; + 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 [magnet, setMagnet] = useState(null); - let [showModal, setShowModal] = useState(false); + let [showModal, setShowModal] = useState(false); - return ( - <> - { - setShowModal(true); - }} - data={magnet} - resetData={() => setMagnet(null)} - /> + return ( + <> + { + setShowModal(true); + }} + data={magnet} + resetData={() => setMagnet(null)} + /> - { - setShowModal(false); - setMagnet(url); - }} - cancel={() => { - setShowModal(false); - setMagnet(null); - }} - /> - - ); + { + setShowModal(false); + setMagnet(url); + }} + cancel={() => { + setShowModal(false); + setMagnet(null); + }} /> + + ); }; const FileInput = () => { - const inputRef = useRef() as RefObject; - const [file, setFile] = useState(null); + const inputRef = useRef() as RefObject; + const [file, setFile] = useState(null); - const onFileChange = async () => { - if (!inputRef?.current?.files) { - return; + 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 file = inputRef.current.files[0]; - setFile(file); - }; - const reset = () => { - if (!inputRef?.current) { - return; + const onClick = () => { + if (!inputRef?.current) { + return; + } + inputRef.current.click(); } - inputRef.current.value = ""; - setFile(null); - }; - const onClick = () => { - if (!inputRef?.current) { - return; - } - inputRef.current.click(); - }; - - return ( - <> - - - - ); + return ( + <> + + + + ); }; const FileSelectionModal = (props: { - onHide: () => void; - listTorrentResponse: AddTorrentResponse | null; - listTorrentError: Error | null; - listTorrentLoading: boolean; - data: string | File; + onHide: () => void, + listTorrentResponse: AddTorrentResponse | null, + listTorrentError: Error | null, + listTorrentLoading: boolean, + data: string | File, }) => { - let { - onHide, - listTorrentResponse, - listTorrentError, - listTorrentLoading, - data, - } = props; + 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); + 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]); + 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 clear = () => { + onHide(); + setSelectedFiles([]); + setUploadError(null); + setUploading(false); } - }; - 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 }); + const handleToggleFile = (toggledId: number) => { + if (selectedFiles.includes(toggledId)) { + setSelectedFiles(selectedFiles.filter((i) => i !== toggledId)); + } else { + setSelectedFiles([...selectedFiles, toggledId]); } - ) - .finally(() => setUploading(false)); - }; + }; - const getBody = () => { - if (listTorrentLoading) { - return ; - } else if (listTorrentError) { - return ; - } else if (listTorrentResponse) { - return ( -
-
- Pick the files to download - {listTorrentResponse.details.files.map((file, index) => ( - - handleToggleFile(index)} - > - - ))} -
-
- Options - - Output folder - setOutputFolder(e.target.value)} - /> - - - setUnpopularTorrent(!unpopularTorrent)} - > - - This might be useful for unpopular torrents with few peers. It - will slow down fast torrents though. - - -
-
- ); - } - }; + 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)); + }; - return ( - - - Add torrent - - - {getBody()} - - - - {uploading && } - - - - - ); + const getBody = () => { + if (listTorrentLoading) { + return ; + } else if (listTorrentError) { + return ; + } else if (listTorrentResponse) { + return
+
+ Pick the files to download + {listTorrentResponse.details.files.map((file, index) => ( + + handleToggleFile(index)}> + + + ))} +
+
+ Options + + Output folder + setOutputFolder(e.target.value)} + /> + + + setUnpopularTorrent(!unpopularTorrent)}> + + This might be useful for unpopular torrents with few peers. It will slow down fast torrents though. + +
+
+ } + }; + + return ( + + + Add torrent + + + {getBody()} + + + + {uploading && } + + + + + ); }; const Buttons = () => { - return ( -
- - -
- ); + return ( +
+ + +
+ ); }; const RootContent = (props: { - closeableError: ApiErrorDetails | null; - otherError: ApiErrorDetails | null; - torrents: Array | null; - torrentsLoading: boolean; + closeableError: ApiErrorDetails | null, + otherError: ApiErrorDetails | null, + torrents: Array | null, + torrentsLoading: boolean }) => { - let ctx = useContext(AppContext); - return ( - - ctx.setCloseableError(null)} - /> - - - + let ctx = useContext(AppContext); + return + ctx.setCloseableError(null)} /> + + + - ); }; function formatBytes(bytes: number): string { - if (bytes === 0) return "0 Bytes"; + if (bytes === 0) return '0 Bytes'; - const k = 1024; - const sizes = ["Bytes", "KB", "MB", "GB"]; + const k = 1024; + 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 getLargestFileName(torrentDetails: TorrentDetails): string { - const largestFile = torrentDetails.files - .filter((f) => f.included) - .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; + 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); + 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 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}` : ""; + 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(); - } + 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; +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"; + 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(); - }; - let scheduleNext = () => { - timeoutId = setTimeout(executeCallback, currentInterval); - }; - - scheduleNext(); - - return () => { - clearTimeout(timeoutId); - }; + return () => { + clearTimeout(timeoutId); + }; } -export function loopUntilSuccess( - callback: () => Promise, - interval: number -): () => void { - let timeoutId: number; +export function loopUntilSuccess(callback: () => Promise, interval: number): () => void { + let timeoutId: number; - const executeCallback = async () => { - let retry = await callback().then( - () => false, - () => true - ); - if (retry) { - scheduleNext(); + const executeCallback = async () => { + let retry = await callback().then(() => false, () => true); + if (retry) { + scheduleNext(); + } } - }; - let scheduleNext = (overrideInterval?: number) => { - timeoutId = setTimeout( - executeCallback, - overrideInterval !== undefined ? overrideInterval : interval - ); - }; + let scheduleNext = (overrideInterval?: number) => { + timeoutId = setTimeout(executeCallback, overrideInterval !== undefined ? overrideInterval : interval); + } - scheduleNext(0); + scheduleNext(0); - return () => clearTimeout(timeoutId); -} + return () => clearTimeout(timeoutId); +} \ No newline at end of file From a552564196ce50baeffac143f25963a9814cdbfe Mon Sep 17 00:00:00 2001 From: Artur Lozovski Date: Thu, 7 Dec 2023 16:43:41 +0000 Subject: [PATCH 2/2] add optional chaning to upload_speed human_readable --- .../librqbit/webui/src/components/Speed.tsx | 4 +- crates/librqbit/webui/src/main.tsx | 48 +- crates/librqbit/webui/src/rqbit-web.tsx | 872 ++---------------- 3 files changed, 86 insertions(+), 838 deletions(-) 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
-
- Pick the files to download - {listTorrentResponse.details.files.map((file, index) => ( - - handleToggleFile(index)}> - - - ))} -
-
- Options - - Output folder - setOutputFolder(e.target.value)} - /> - - - setUnpopularTorrent(!unpopularTorrent)}> - - This might be useful for unpopular torrents with few peers. It will slow down fast torrents though. - -
-
- } - }; - - 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