diff --git a/public/locales/en/modules/torrents-status.json b/public/locales/en/modules/torrents.json similarity index 80% rename from public/locales/en/modules/torrents-status.json rename to public/locales/en/modules/torrents.json index 7e8970a92..0d199a8f7 100644 --- a/public/locales/en/modules/torrents-status.json +++ b/public/locales/en/modules/torrents.json @@ -34,7 +34,14 @@ "noDownloadClients": { "title": "No supported download clients found!", "text": "Add a download service to view your current downloads" + }, + "generic": { + "title": "An unexpected error occured", + "text": "Homarr was unable to communicate with your download clients. Please check your configuration" } + }, + "loading": { + "title": "Loading..." } } } \ No newline at end of file diff --git a/src/hooks/api.ts b/src/hooks/widgets/dashDot/api.ts similarity index 91% rename from src/hooks/api.ts rename to src/hooks/widgets/dashDot/api.ts index a50119cb8..6e8496c85 100644 --- a/src/hooks/api.ts +++ b/src/hooks/widgets/dashDot/api.ts @@ -1,15 +1,15 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import axios from 'axios'; import { Results } from 'sabnzbd-api'; -import { UsenetQueueRequestParams, UsenetQueueResponse } from '../pages/api/modules/usenet/queue'; +import { UsenetQueueRequestParams, UsenetQueueResponse } from '../../../pages/api/modules/usenet/queue'; import { UsenetHistoryRequestParams, UsenetHistoryResponse, -} from '../pages/api/modules/usenet/history'; -import { UsenetInfoRequestParams, UsenetInfoResponse } from '../pages/api/modules/usenet'; -import { UsenetPauseRequestParams } from '../pages/api/modules/usenet/pause'; -import { queryClient } from '../tools/queryClient'; -import { UsenetResumeRequestParams } from '../pages/api/modules/usenet/resume'; +} from '../../../pages/api/modules/usenet/history'; +import { UsenetInfoRequestParams, UsenetInfoResponse } from '../../../pages/api/modules/usenet'; +import { UsenetPauseRequestParams } from '../../../pages/api/modules/usenet/pause'; +import { queryClient } from '../../../tools/queryClient'; +import { UsenetResumeRequestParams } from '../../../pages/api/modules/usenet/resume'; const POLLING_INTERVAL = 2000; diff --git a/src/hooks/widgets/torrents/useGetTorrentData.tsx b/src/hooks/widgets/torrents/useGetTorrentData.tsx new file mode 100644 index 000000000..4703d60be --- /dev/null +++ b/src/hooks/widgets/torrents/useGetTorrentData.tsx @@ -0,0 +1,29 @@ +import { NormalizedTorrent } from '@ctrl/shared-torrent'; +import { Query, useQuery } from '@tanstack/react-query'; +import axios from 'axios'; + +const POLLING_INTERVAL = 2000; + +interface TorrentsDataRequestParams { + appId: string; +} + +export const useGetTorrentData = (params: TorrentsDataRequestParams) => + useQuery({ + queryKey: ['torrentsData', params.appId], + queryFn: async () => fetchData(), + refetchOnWindowFocus: true, + refetchIntervalInBackground: POLLING_INTERVAL * 3, + refetchInterval(_: any, query: Query) { + if (query.state.fetchFailureCount < 3) { + return 5000; + } + return false; + }, + enabled: !!params.appId, + }); + +const fetchData = async (): Promise => { + const response = await axios.post('/api/modules/torrents'); + return response.data as NormalizedTorrent[]; +}; diff --git a/src/pages/api/modules/torrents.ts b/src/pages/api/modules/torrents.ts index dca2ca1bf..f47a80191 100644 --- a/src/pages/api/modules/torrents.ts +++ b/src/pages/api/modules/torrents.ts @@ -1,3 +1,4 @@ +import Consola from 'consola'; import { Deluge } from '@ctrl/deluge'; import { QBittorrent } from '@ctrl/qbittorrent'; import { NormalizedTorrent } from '@ctrl/shared-torrent'; @@ -22,43 +23,53 @@ async function Post(req: NextApiRequest, res: NextApiResponse) { message: 'Missing apps', }); } + try { await Promise.all( qBittorrentApp.map((app) => new QBittorrent({ baseUrl: app.url, - username: app.integration!.properties.find((x) => x.field === 'username')?.value, - password: app.integration!.properties.find((x) => x.field === 'password')?.value, + username: + app.integration!.properties.find((x) => x.field === 'username')?.value ?? undefined, + password: + app.integration!.properties.find((x) => x.field === 'password')?.value ?? undefined, }) .getAllData() .then((e) => torrents.push(...e.torrents)) ) ); await Promise.all( - delugeApp.map((app) => - new Deluge({ + delugeApp.map((app) => { + const password = app.integration?.properties.find((x) => x.field === 'password')?.value ?? undefined; + const test = new Deluge({ baseUrl: app.url, - password: app.integration!.properties.find((x) => x.field === 'password')?.value, + password, }) .getAllData() - .then((e) => torrents.push(...e.torrents)) - ) + .then((e) => torrents.push(...e.torrents)); + return test; + }) ); // Map transmissionApps await Promise.all( transmissionApp.map((app) => new Transmission({ baseUrl: app.url, - username: app.integration!.properties.find((x) => x.field === 'username')?.value, - password: app.integration!.properties.find((x) => x.field === 'password')?.value, + username: + app.integration!.properties.find((x) => x.field === 'username')?.value ?? undefined, + password: + app.integration!.properties.find((x) => x.field === 'password')?.value ?? undefined, }) .getAllData() .then((e) => torrents.push(...e.torrents)) ) ); } catch (e: any) { + Consola.error('Error while communicating with your torrent applications:\n', e); return res.status(401).json(e); } + + Consola.debug(`Retrieved ${torrents.length} from all download clients`); return res.status(200).json(torrents); } diff --git a/src/tools/calculateEta.ts b/src/tools/calculateEta.ts new file mode 100644 index 000000000..ab7c355ba --- /dev/null +++ b/src/tools/calculateEta.ts @@ -0,0 +1,15 @@ +export const calculateETA = (givenSeconds: number) => { + // If its superior than one day return > 1 day + if (givenSeconds > 86400) { + return '> 1 day'; + } + // Transform the givenSeconds into a readable format. e.g. 1h 2m 3s + const hours = Math.floor(givenSeconds / 3600); + const minutes = Math.floor((givenSeconds % 3600) / 60); + const seconds = Math.floor(givenSeconds % 60); + // Only show hours if it's greater than 0. + const hoursString = hours > 0 ? `${hours}h ` : ''; + const minutesString = minutes > 0 ? `${minutes}m ` : ''; + const secondsString = seconds > 0 ? `${seconds}s` : ''; + return `${hoursString}${minutesString}${secondsString}`; +}; diff --git a/src/tools/config/getConfig.ts b/src/tools/config/getConfig.ts index 7d4e29d50..979b431c1 100644 --- a/src/tools/config/getConfig.ts +++ b/src/tools/config/getConfig.ts @@ -1,3 +1,4 @@ +import Consola from 'consola'; import { BackendConfigType } from '../../types/config'; import { configExists } from './configExists'; import { getFallbackConfig } from './getFallbackConfig'; @@ -11,8 +12,9 @@ export const getConfig = (name: string): BackendConfigType => { // to the new format. const config = readConfig(name); if (config.schemaVersion === undefined) { - console.log('Migrating config file...', config); + Consola.log('Migrating config file...', config); return migrateConfig(config, name); } + return config; }; diff --git a/src/tools/config/getFrontendConfig.ts b/src/tools/config/getFrontendConfig.ts index 118249057..e736c298f 100644 --- a/src/tools/config/getFrontendConfig.ts +++ b/src/tools/config/getFrontendConfig.ts @@ -14,8 +14,8 @@ export const getFrontendConfig = (name: string): ConfigType => { properties: app.integration?.properties.map((property) => ({ ...property, - value: property.type === 'private' ? undefined : property.value, - isDefined: property.value != null, + value: property.type === 'private' ? null : property.value, + isDefined: property.value !== null, })) ?? [], }, })), diff --git a/src/tools/translation-namespaces.ts b/src/tools/translation-namespaces.ts index 2a8b50170..293a4ff35 100644 --- a/src/tools/translation-namespaces.ts +++ b/src/tools/translation-namespaces.ts @@ -23,7 +23,7 @@ export const dashboardNamespaces = [ 'modules/dlspeed', 'modules/usenet', 'modules/search', - 'modules/torrents-status', + 'modules/torrents', 'modules/weather', 'modules/ping', 'modules/docker', diff --git a/src/types/app.ts b/src/types/app.ts index 4e3d193b1..0f97d65ad 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -54,7 +54,7 @@ export type ConfigAppIntegrationType = Omit & export type AppIntegrationPropertyType = { type: 'private' | 'public'; field: IntegrationField; - value?: string | undefined; + value?: string | null; isDefined: boolean; }; diff --git a/src/widgets/bitTorrent/BitTorrentQueueItem.tsx b/src/widgets/bitTorrent/BitTorrentQueueItem.tsx new file mode 100644 index 000000000..d5d9ecb89 --- /dev/null +++ b/src/widgets/bitTorrent/BitTorrentQueueItem.tsx @@ -0,0 +1,62 @@ +import { NormalizedTorrent } from '@ctrl/shared-torrent'; +import { Tooltip, Text, Progress, useMantineTheme } from '@mantine/core'; +import { useElementSize } from '@mantine/hooks'; +import { calculateETA } from '../../tools/calculateEta'; +import { humanFileSize } from '../../tools/humanFileSize'; + +interface BitTorrentQueueItemProps { + torrent: NormalizedTorrent; +} + +export const BitTorrrentQueueItem = ({ torrent }: BitTorrentQueueItemProps) => { + const MIN_WIDTH_MOBILE = useMantineTheme().breakpoints.xs; + const { width } = useElementSize(); + + const downloadSpeed = torrent.downloadSpeed / 1024 / 1024; + const uploadSpeed = torrent.uploadSpeed / 1024 / 1024; + const size = torrent.totalSelected; + return ( + + + + + {torrent.name} + + + + + {humanFileSize(size)} + + {width > MIN_WIDTH_MOBILE && ( + + {downloadSpeed > 0 ? `${downloadSpeed.toFixed(1)} Mb/s` : '-'} + + )} + {width > MIN_WIDTH_MOBILE && ( + + {uploadSpeed > 0 ? `${uploadSpeed.toFixed(1)} Mb/s` : '-'} + + )} + {width > MIN_WIDTH_MOBILE && ( + + {torrent.eta <= 0 ? '∞' : calculateETA(torrent.eta)} + + )} + + {(torrent.progress * 100).toFixed(1)}% + + + + ); +}; diff --git a/src/widgets/bitTorrent/BitTorrentTile.tsx b/src/widgets/bitTorrent/BitTorrentTile.tsx index 44bb6e7a2..2bada13f7 100644 --- a/src/widgets/bitTorrent/BitTorrentTile.tsx +++ b/src/widgets/bitTorrent/BitTorrentTile.tsx @@ -1,16 +1,45 @@ +import { + Center, + Group, + Loader, + ScrollArea, + Stack, + Table, + Text, + Title, + useMantineTheme, +} from '@mantine/core'; +import { useElementSize } from '@mantine/hooks'; import { IconFileDownload } from '@tabler/icons'; +import { useTranslation } from 'next-i18next'; +import { useEffect, useState } from 'react'; +import { useConfigContext } from '../../config/provider'; +import { useGetTorrentData } from '../../hooks/widgets/torrents/useGetTorrentData'; +import { AppIntegrationType } from '../../types/app'; import { defineWidget } from '../helper'; import { IWidget } from '../widgets'; +import { BitTorrrentQueueItem } from './BitTorrentQueueItem'; + +const downloadAppTypes: AppIntegrationType['type'][] = ['deluge', 'qBittorrent', 'transmission']; const definition = defineWidget({ id: 'torrents-status', icon: IconFileDownload, - options: {}, + options: { + displayCompletedTorrents: { + type: 'switch', + defaultValue: true, + }, + displayStaleTorrents: { + type: 'switch', + defaultValue: true, + }, + }, gridstack: { - minWidth: 2, - minHeight: 2, - maxWidth: 2, - maxHeight: 2, + minWidth: 4, + minHeight: 5, + maxWidth: 12, + maxHeight: 14, }, component: BitTorrentTile, }); @@ -22,7 +51,87 @@ interface BitTorrentTileProps { } function BitTorrentTile({ widget }: BitTorrentTileProps) { - return null; + const { t } = useTranslation('modules/torrents'); + const MIN_WIDTH_MOBILE = useMantineTheme().breakpoints.xs; + const { width } = useElementSize(); + + const { config } = useConfigContext(); + const downloadApps = + config?.apps.filter((x) => x.integration && downloadAppTypes.includes(x.integration.type)) ?? + []; + + const [selectedAppId, setSelectedApp] = useState(downloadApps[0]?.id); + const { data, isFetching, isError } = useGetTorrentData({ appId: selectedAppId! }); + + useEffect(() => { + if (!selectedAppId && downloadApps.length) { + setSelectedApp(downloadApps[0].id); + } + }, [downloadApps, selectedAppId]); + + if (downloadApps.length === 0) { + return ( + + {t('card.errors.noDownloadClients.title')} + + {t('card.errors.noDownloadClients.text')} + + + ); + } + + if (isError) { + return ( + + {t('card.errors.generic.title')} + + {t('card.errors.generic.text')} + + + ); + } + + if (isFetching) { + return ( + + + + {t('card.loading.title')} + Homarr is establishing a connection... + + + ); + } + + if (!data || data.length < 1) { + return ( +
+ {t('card.table.body.nothingFound')} +
+ ); + } + + return ( + + + + + + + {width > MIN_WIDTH_MOBILE && } + {width > MIN_WIDTH_MOBILE && } + {width > MIN_WIDTH_MOBILE && } + + + + + {data.map((item, index) => ( + + ))} + +
{t('card.table.header.name')}{t('card.table.header.size')}{t('card.table.header.download')}{t('card.table.header.upload')}{t('card.table.header.estimatedTimeOfArrival')}{t('card.table.header.progress')}
+
+ ); } export default definition; diff --git a/src/widgets/useNet/UseNetTile.tsx b/src/widgets/useNet/UseNetTile.tsx index 8ce7cc6fb..c3c8d3272 100644 --- a/src/widgets/useNet/UseNetTile.tsx +++ b/src/widgets/useNet/UseNetTile.tsx @@ -17,7 +17,7 @@ import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; import { useTranslation } from 'next-i18next'; import { useConfigContext } from '../../config/provider'; -import { useGetUsenetInfo, usePauseUsenetQueue, useResumeUsenetQueue } from '../../hooks/api'; +import { useGetUsenetInfo, usePauseUsenetQueue, useResumeUsenetQueue } from '../../hooks/widgets/dashDot/api'; import { humanFileSize } from '../../tools/humanFileSize'; import { AppIntegrationType } from '../../types/app'; import { defineWidget } from '../helper'; diff --git a/src/widgets/useNet/UsenetHistoryList.tsx b/src/widgets/useNet/UsenetHistoryList.tsx index faab03846..119356dea 100644 --- a/src/widgets/useNet/UsenetHistoryList.tsx +++ b/src/widgets/useNet/UsenetHistoryList.tsx @@ -18,7 +18,7 @@ import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; import { useTranslation } from 'next-i18next'; import { FunctionComponent, useState } from 'react'; -import { useGetUsenetHistory } from '../../hooks/api'; +import { useGetUsenetHistory } from '../../hooks/widgets/dashDot/api'; import { humanFileSize } from '../../tools/humanFileSize'; import { parseDuration } from '../../tools/parseDuration'; diff --git a/src/widgets/useNet/UsenetQueueList.tsx b/src/widgets/useNet/UsenetQueueList.tsx index 3e4c33dce..3138048ee 100644 --- a/src/widgets/useNet/UsenetQueueList.tsx +++ b/src/widgets/useNet/UsenetQueueList.tsx @@ -21,7 +21,7 @@ import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; import { useTranslation } from 'next-i18next'; import { FunctionComponent, useState } from 'react'; -import { useGetUsenetDownloads } from '../../hooks/api'; +import { useGetUsenetDownloads } from '../../hooks/widgets/dashDot/api'; import { humanFileSize } from '../../tools/humanFileSize'; dayjs.extend(duration);