From a552564196ce50baeffac143f25963a9814cdbfe Mon Sep 17 00:00:00 2001 From: Artur Lozovski Date: Thu, 7 Dec 2023 16:43:41 +0000 Subject: [PATCH] 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