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..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 ( -
-
- 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); -}