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'; interface Error { text: string, details?: ApiErrorDetails, } 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}

if (statsResponse.finished) { return Completed; } } 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 finishedInterval = 10000; const nonLiveInterval = 10000; return API.getTorrentStats(torrent.id).then((stats) => { updateStatsResponse(stats); return stats; }).then((stats) => { if (stats.finished) { return finishedInterval; } 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 [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}
} 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. 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); }; } 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); }