import { StrictMode, createContext, memo, useContext, useEffect, useRef, useState } from 'react'; import ReactDOM from 'react-dom/client'; import { ProgressBar, Button, Container, Row, Col, Alert, Modal, Form, Spinner, Table } from 'react-bootstrap'; // import 'bootstrap/dist/css/bootstrap.min.css'; // import './styles.scss'; // Define API URL and base path const apiUrl = (window.origin === 'null' || window.origin === 'http://localhost:3031') ? 'http://localhost:3030' : ''; interface ErrorDetails { id?: number, method?: string, path?: string, status?: number, statusText?: string, text: string, }; interface Error { text: string, details?: ErrorDetails, } interface ContextType { setCloseableError: (error: Error) => void, setOtherError: (error: Error) => void, makeRequest: (method: string, path: string, data: any) => Promise, requests: { getTorrentDetails: any, getTorrentStats: any, }, refreshTorrents: () => void, } const AppContext = createContext(null); // Interface for the Torrent API response interface TorrentId { id: number; info_hash: string; } interface TorrentFile { name: string; length: number; included: boolean; } // Interface for the Torrent Details API response interface TorrentDetails { info_hash: string, files: Array; } interface AddTorrentResponse { id: number | null; details: TorrentDetails; } // Interface for the Torrent Stats API response interface TorrentStats { snapshot: { have_bytes: number; downloaded_and_checked_bytes: number; downloaded_and_checked_pieces: number; fetched_bytes: number; uploaded_bytes: number; initially_needed_bytes: number; remaining_bytes: number; total_bytes: number; total_piece_download_ms: number; peer_stats: { queued: number; connecting: number; live: number; seen: number; dead: number; not_needed: number; }; }; average_piece_download_time: { secs: number; nanos: number; }; download_speed: { mbps: number; human_readable: string; }; all_time_download_speed: { mbps: number; human_readable: string; }; time_remaining: { human_readable: string; duration: { secs: number, } } | null; } const TorrentRow: React.FC<{ id: number, detailsResponse: TorrentDetails, statsResponse: TorrentStats }> = ({ id, detailsResponse, statsResponse }) => { const totalBytes = statsResponse.snapshot.total_bytes; const downloadedBytes = statsResponse.snapshot.have_bytes; const finished = totalBytes == downloadedBytes; const downloadPercentage = (downloadedBytes / totalBytes) * 100; let classes = [ ]; if (id % 2 == 0) { classes.push('bg-light'); } return (
{getLargestFileName(detailsResponse)}
{`${formatBytes(totalBytes)}`} {statsResponse.download_speed.human_readable} {getCompletionETA(statsResponse)} {`${statsResponse.snapshot.peer_stats.live} / ${statsResponse.snapshot.peer_stats.seen}`}
); } const Column: React.FC<{ label: string, size?: number, children?: any }> = ({ size, label, children }) => (
{label}
{children} ); const Torrent = ({ id, torrent }) => { const defaultDetails: TorrentDetails = { info_hash: '', files: [] }; const defaultStats: TorrentStats = { snapshot: { have_bytes: 0, downloaded_and_checked_bytes: 0, downloaded_and_checked_pieces: 0, fetched_bytes: 0, uploaded_bytes: 0, initially_needed_bytes: 0, remaining_bytes: 0, total_bytes: 0, total_piece_download_ms: 0, peer_stats: { queued: 0, connecting: 0, live: 0, seen: 0, dead: 0, not_needed: 0 } }, average_piece_download_time: { secs: 0, nanos: 0 }, download_speed: { mbps: 0, human_readable: '' }, all_time_download_speed: { mbps: 0, human_readable: '' }, time_remaining: { human_readable: '' } }; const [detailsResponse, updateDetailsResponse] = useState(defaultDetails); const [statsResponse, updateStatsResponse] = useState(defaultStats); let ctx = useContext(AppContext); // Update details once useEffect(() => { if (detailsResponse === defaultDetails) { return loopUntilSuccess(async () => { await ctx.requests.getTorrentDetails(torrent.id).then(updateDetailsResponse); }, 1000); } }, [detailsResponse]); // Update stats forever. const update = async () => { const errorInterval = 10000; const liveInterval = 500; const finishedInterval = 5000; return ctx.requests.getTorrentStats(torrent.id).then((stats) => { updateStatsResponse(stats); return torrentIsDone(stats) ? finishedInterval : liveInterval; }, (e) => { return errorInterval }) }; useEffect(() => { let clear = customSetInterval(update, 0); return clear; }, []); return } const TorrentsList = (props: { torrents: Array, 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) => )} ) }; const Root = () => { const [closeableError, setCloseableError] = useState(null); const [otherError, setOtherError] = useState(null); const [torrents, setTorrents] = useState>(null); const [torrentsLoading, setTorrentsLoading] = useState(false); const makeRequest = async (method: string, path: string, data: any): Promise => { console.log(method, path); const url = apiUrl + path; const options: RequestInit = { method, headers: { 'Accept': 'application/json', }, body: data, }; let error: ErrorDetails = { method: method, path: path, text: '' }; let response: Response; try { response = await fetch(url, options); } catch (e) { error.text = 'network error'; return Promise.reject(error); } error.status = response.status; error.statusText = response.statusText; if (!response.ok) { const errorBody = await response.text(); try { const json = JSON.parse(errorBody); error.text = json.human_readable !== undefined ? json.human_readable : JSON.stringify(json, null, 2); } catch (e) { error.text = errorBody; } return Promise.reject(error); } const result = await response.json(); return result; } const requests = { getTorrentDetails: (index: number): Promise => { return makeRequest('GET', `/torrents/${index}`, null); }, getTorrentStats: (index: number): Promise => { return makeRequest('GET', `/torrents/${index}/stats`, null); } }; const refreshTorrents = async () => { setTorrentsLoading(true); let torrents: { torrents: Array } = await makeRequest('GET', '/torrents', null).finally(() => setTorrentsLoading(false)); setTorrents(torrents.torrents); return torrents; }; useEffect(() => { let interval = 500; let clear = customSetInterval(async () => { try { await refreshTorrents(); setOtherError(null); return interval; } catch (e) { setOtherError({ text: 'Error refreshing torrents', details: e }); console.error(e); return 5000; } }, interval); return clear; }, []); const context: ContextType = { setCloseableError, setOtherError, makeRequest, requests, refreshTorrents, } return

rqbit web 0.0.1-alpha

} const ErrorDetails = (props: { details: ErrorDetails }) => { let { details } = props; if (!details) { return null; } return <> { details.status && ( {details.status} {details.statusText}: ) } {details.text} } const ErrorComponent = (props: { error: Error, remove?: () => void }) => { let { error, remove } = props; if (error == null) { return null; } return ( {error.text} ); }; const UploadButton = ({ buttonText, onClick, data, resetData, variant }) => { const [loading, setLoading] = useState(false); const [fileList, setFileList] = useState(null); const ctx = useContext(AppContext); const showModal = data !== null; // Get the torrent file list if there's data. useEffect(() => { if (data === null) { return; } let t = setTimeout(async () => { setLoading(true); try { const response: AddTorrentResponse = await ctx.makeRequest('POST', `/torrents?list_only=true&overwrite=true`, data); console.log(response); setFileList(response.details.files); } catch (e) { ctx.setCloseableError({ text: 'Error listing torrent', details: e }); clear(); } finally { setLoading(false); } }, 0); return () => clearTimeout(t); }, [data]); const clear = () => { resetData(); setFileList(null); setLoading(false); } return ( <> ); }; const MagnetInput = () => { let [magnet, setMagnet] = useState(null); const onClick = () => { const m = prompt('Enter magnet link or HTTP(s) URL'); setMagnet(m === '' ? null : m); }; return ( setMagnet(null)} /> ); }; const FileInput = () => { const inputRef = useRef(); const [file, setFile] = useState(null); const onFileChange = async () => { const file = inputRef.current.files[0]; setFile(file); }; const reset = () => { inputRef.current.value = ''; setFile(null); } const onClick = () => { inputRef.current.click(); } return ( <> ); }; const FileSelectionModal = (props: { show: boolean, onHide, fileList: Array | null, fileListLoading: boolean, data }) => { let { show, onHide, fileList, fileListLoading, data } = props; const [selectedFiles, setSelectedFiles] = useState([]); const [uploading, setUploading] = useState(false); const [uploadError, setUploadError] = useState(null); useEffect(() => { setSelectedFiles((fileList || []).map((_, id) => id)); }, [fileList]); fileList = fileList || []; let ctx = useContext(AppContext); const clear = () => { onHide(); setSelectedFiles([]); setUploadError(null); setUploading(false); } const handleToggleFile = (fileIndex: number) => { if (selectedFiles.includes(fileIndex)) { setSelectedFiles(selectedFiles.filter((index) => index !== fileIndex)); } else { setSelectedFiles([...selectedFiles, fileIndex]); } }; const handleUpload = async () => { const getSelectedFilesQueryParam = () => { let allPresent = true; fileList.map((_, id) => { allPresent = allPresent && selectedFiles.includes(id); }); return allPresent ? '' : '&only_files=' + selectedFiles.join(','); }; let url = `/torrents?overwrite=true${getSelectedFilesQueryParam()}`; setUploading(true); ctx.makeRequest('POST', url, data).then(() => { onHide() }, (e) => { setUploadError({ text: 'Error starting torrent', details: e }); }).finally(() => setUploading(false)); }; return ( Select Files {fileListLoading ? ( ) : ( <>
{fileList.map((file, index) => ( handleToggleFile(index)}> ))}
)}
{uploading && }
); }; const Buttons = () => { return (
); }; const RootContent = (props: { closeableError: ErrorDetails, otherError: ErrorDetails, torrents: Array, torrentsLoading: boolean }) => { let ctx = useContext(AppContext); return ctx.setCloseableError(null)} /> }; function torrentIsDone(stats: TorrentStats): boolean { return stats.snapshot.have_bytes == stats.snapshot.total_bytes; } // Render function to display all torrents async function displayTorrents() { // Get the torrents container const torrentsContainer = document.getElementById('app'); ReactDOM.createRoot(torrentsContainer).render(); } // Function to format bytes to GB function formatBytes(bytes) { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } // Function to get the name of the largest file in a torrent function getLargestFileName(torrentDetails: TorrentDetails): string { if (torrentDetails.files.length == 0) { return 'Loading...'; } const largestFile = torrentDetails.files.reduce((prev: any, current: any) => (prev.length > current.length) ? prev : current); return largestFile.name; } // Function to get the completion ETA of a torrent function getCompletionETA(stats: TorrentStats): string { if (stats.time_remaining && stats.time_remaining.duration) { return formatSecondsToTime(stats.time_remaining.duration.secs); } else { return 'N/A'; } } function formatSecondsToTime(seconds: number) { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const remainingSeconds = seconds % 60; const formatUnit = (value, unit) => (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(); } } function customSetInterval(asyncCallback: any, interval: number) { let timeoutId: number; let currentInterval: number = interval; 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 clearCustomInterval = () => { clearTimeout(timeoutId); } return clearCustomInterval; } function loopUntilSuccess(callback, interval: number) { let timeoutId: number; const executeCallback = async () => { let retry = await callback().then(() => { false }, () => { true }); if (retry) { scheduleNext(); } } let scheduleNext = (i?: number) => { timeoutId = setTimeout(executeCallback, i !== undefined ? i : interval); } scheduleNext(0); let clearCustomInterval = () => { clearTimeout(timeoutId); } return clearCustomInterval; } // List all torrents on page load and set up auto-refresh async function init(): Promise { await displayTorrents(); } // Call init function on page load document.addEventListener('DOMContentLoaded', init);