From 1bf3b1312b6fdae738263b0dcc4d94e5b14e55ff Mon Sep 17 00:00:00 2001 From: Manuel Date: Wed, 18 Jan 2023 21:24:55 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20detail=20popover=20for=20torr?= =?UTF-8?q?ents=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .eslintrc.js | 1 + .../locales/en/modules/torrents-status.json | 14 ++ .../widgets/torrents/useGetTorrentData.tsx | 8 +- src/pages/api/modules/torrents.ts | 157 ++++++++++------- .../api/NormalizedTorrentListResponse.ts | 28 ++++ src/widgets/torrent/TorrentQueueItem.tsx | 158 ++++++++++++++++-- src/widgets/torrent/TorrentTile.tsx | 43 ++++- .../TorrentNetworkTrafficTile.tsx | 4 +- 8 files changed, 328 insertions(+), 85 deletions(-) create mode 100644 src/types/api/NormalizedTorrentListResponse.ts diff --git a/.eslintrc.js b/.eslintrc.js index bddf82eae..f9e126647 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -29,6 +29,7 @@ module.exports = { '@typescript-eslint/no-shadow': 'off', '@typescript-eslint/no-use-before-define': 'off', '@typescript-eslint/no-non-null-assertion': 'off', + 'no-continue': 'off', 'linebreak-style': 0, }, }; diff --git a/public/locales/en/modules/torrents-status.json b/public/locales/en/modules/torrents-status.json index fab92cd83..f06dd625b 100644 --- a/public/locales/en/modules/torrents-status.json +++ b/public/locales/en/modules/torrents-status.json @@ -16,6 +16,10 @@ } }, "card": { + "footer": { + "error": "Error", + "lastUpdated": "Last updated {{time}} ago" + }, "table": { "header": { "name": "Name", @@ -49,6 +53,16 @@ }, "loading": { "title": "Loading..." + }, + "popover": { + "introductionPrefix": "Managed by", + "metrics": { + "queuePosition": "Queue position - {{position}}", + "progress": "Progress - {{progress}}%", + "totalSelectedSize": "Total - {{totalSize}}", + "state": "State - {{state}}", + "completed": "Completed" + } } } } diff --git a/src/hooks/widgets/torrents/useGetTorrentData.tsx b/src/hooks/widgets/torrents/useGetTorrentData.tsx index b3136e03f..8027462e3 100644 --- a/src/hooks/widgets/torrents/useGetTorrentData.tsx +++ b/src/hooks/widgets/torrents/useGetTorrentData.tsx @@ -1,8 +1,6 @@ -import { NormalizedTorrent } from '@ctrl/shared-torrent'; import { Query, useQuery } from '@tanstack/react-query'; import axios from 'axios'; - -const POLLING_INTERVAL = 2000; +import { NormalizedTorrentListResponse } from '../../../types/api/NormalizedTorrentListResponse'; interface TorrentsDataRequestParams { appId: string; @@ -23,7 +21,7 @@ export const useGetTorrentData = (params: TorrentsDataRequestParams) => enabled: !!params.appId, }); -const fetchData = async (): Promise => { +const fetchData = async (): Promise => { const response = await axios.post('/api/modules/torrents'); - return response.data as NormalizedTorrent[]; + return response.data as NormalizedTorrentListResponse; }; diff --git a/src/pages/api/modules/torrents.ts b/src/pages/api/modules/torrents.ts index 81600cace..8e40e4851 100644 --- a/src/pages/api/modules/torrents.ts +++ b/src/pages/api/modules/torrents.ts @@ -1,79 +1,124 @@ -import Consola from 'consola'; import { Deluge } from '@ctrl/deluge'; import { QBittorrent } from '@ctrl/qbittorrent'; -import { NormalizedTorrent } from '@ctrl/shared-torrent'; +import { AllClientData } from '@ctrl/shared-torrent'; import { Transmission } from '@ctrl/transmission'; +import Consola from 'consola'; import { getCookie } from 'cookies-next'; import { NextApiRequest, NextApiResponse } from 'next'; import { getConfig } from '../../../tools/config/getConfig'; +import { NormalizedTorrentListResponse } from '../../../types/api/NormalizedTorrentListResponse'; +import { ConfigAppType, IntegrationType } from '../../../types/app'; -async function Post(req: NextApiRequest, res: NextApiResponse) { +const supportedTypes: IntegrationType[] = ['deluge', 'qBittorrent', 'transmission']; + +async function Post(req: NextApiRequest, res: NextApiResponse) { // Get the type of app from the request url const configName = getCookie('config-name', { req }); const config = getConfig(configName?.toString() ?? 'default'); - const qBittorrentApp = config.apps.filter((app) => app.integration?.type === 'qBittorrent'); - const delugeApp = config.apps.filter((app) => app.integration?.type === 'deluge'); - const transmissionApp = config.apps.filter((app) => app.integration?.type === 'transmission'); - const torrents: NormalizedTorrent[] = []; + const clientApps = config.apps.filter( + (app) => + app.integration && app.integration.type && supportedTypes.includes(app.integration.type) + ); - if (!qBittorrentApp && !delugeApp && !transmissionApp) { + if (clientApps.length < 1) { return res.status(500).json({ - statusCode: 500, - message: 'Missing apps', + allSuccess: false, + missingDownloadClients: true, + labels: [], + torrents: [], }); } - try { - await Promise.all( - qBittorrentApp.map((app) => - new QBittorrent({ - baseUrl: app.url, - 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) => { - const password = - app.integration?.properties.find((x) => x.field === 'password')?.value ?? undefined; - const test = new Deluge({ - baseUrl: app.url, - password, - }) - .getAllData() - .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 ?? 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); + const promiseList: Promise[] = []; + + for (let i = 0; i < clientApps.length; i += 1) { + const app = clientApps[i]; + const getAllData = getAllDataForClient(app); + if (!getAllData) { + continue; + } + const concatenatedPromise = async (): Promise => ({ + clientData: await getAllData, + appId: app.id, + }); + promiseList.push(concatenatedPromise()); } - Consola.debug(`Retrieved ${torrents.length} from all download clients`); - return res.status(200).json(torrents); + const settledPromises = await Promise.allSettled(promiseList); + const fulfilledPromises = settledPromises.filter( + (settledPromise) => settledPromise.status === 'fulfilled' + ); + + const fulfilledClientData: ConcatenatedClientData[] = fulfilledPromises.map( + (fulfilledPromise) => (fulfilledPromise as PromiseFulfilledResult).value + ); + + const notFulfilledClientData = settledPromises + .filter((x) => x.status === 'rejected') + .map( + (fulfilledPromise) => + (fulfilledPromise as PromiseRejectedResult) + ); + + notFulfilledClientData.forEach((result) => { + Consola.error(`Error while communicating with torrent download client: ${result.reason}`); + }); + + Consola.info( + `Successfully fetched data from ${fulfilledPromises.length} torrent download clients` + ); + + return res.status(200).json({ + labels: fulfilledClientData.flatMap((clientData) => clientData.clientData.labels), + torrents: fulfilledClientData.map((clientData) => ({ + appId: clientData.appId, + torrents: clientData.clientData.torrents, + })), + allSuccess: settledPromises.length === fulfilledPromises.length, + missingDownloadClients: false, + }); } +const getAllDataForClient = (app: ConfigAppType) => { + switch (app.integration?.type) { + case 'deluge': { + const password = + app.integration?.properties.find((x) => x.field === 'password')?.value ?? undefined; + return new Deluge({ + baseUrl: app.url, + password, + }).getAllData(); + } + case 'transmission': { + return new Transmission({ + baseUrl: app.url, + username: + app.integration!.properties.find((x) => x.field === 'username')?.value ?? undefined, + password: + app.integration!.properties.find((x) => x.field === 'password')?.value ?? undefined, + }).getAllData(); + } + case 'qBittorrent': { + return new QBittorrent({ + baseUrl: app.url, + username: + app.integration!.properties.find((x) => x.field === 'username')?.value ?? undefined, + password: + app.integration!.properties.find((x) => x.field === 'password')?.value ?? undefined, + }).getAllData(); + } + default: + Consola.error(`unable to find torrent client of type '${app.integration?.type}'`); + return undefined; + } +}; + +type ConcatenatedClientData = { + appId: string; + clientData: AllClientData; +}; + export default async (req: NextApiRequest, res: NextApiResponse) => { // Filter out if the reuqest is a POST or a GET if (req.method === 'POST') { diff --git a/src/types/api/NormalizedTorrentListResponse.ts b/src/types/api/NormalizedTorrentListResponse.ts new file mode 100644 index 000000000..1c615f159 --- /dev/null +++ b/src/types/api/NormalizedTorrentListResponse.ts @@ -0,0 +1,28 @@ +import { Label, NormalizedTorrent } from '@ctrl/shared-torrent'; + +export type NormalizedTorrentListResponse = { + /** + * Available labels on all torrent clients + */ + labels: Label[]; + + /** + * Feteched and normalized torrents of all download clients + */ + torrents: ConcatenatedTorrentList[]; + + /** + * Indicated wether all requests were a success + */ + allSuccess: boolean; + + /** + * Missing download clients + */ + missingDownloadClients: boolean; +}; + +type ConcatenatedTorrentList = { + appId: string; + torrents: NormalizedTorrent[]; +}; diff --git a/src/widgets/torrent/TorrentQueueItem.tsx b/src/widgets/torrent/TorrentQueueItem.tsx index a6b8329f5..ff791d3f3 100644 --- a/src/widgets/torrent/TorrentQueueItem.tsx +++ b/src/widgets/torrent/TorrentQueueItem.tsx @@ -1,14 +1,37 @@ +/* eslint-disable @next/next/no-img-element */ import { NormalizedTorrent } from '@ctrl/shared-torrent'; -import { Tooltip, Text, Progress, useMantineTheme } from '@mantine/core'; -import { useElementSize } from '@mantine/hooks'; +import { + Badge, + Flex, + Group, + MantineColor, + Popover, + Progress, + Text, + useMantineTheme, +} from '@mantine/core'; +import { useDisclosure, useElementSize } from '@mantine/hooks'; +import { + IconAffiliate, + IconDatabase, + IconDownload, + IconInfoCircle, + IconPercentage, + IconSortDescending, + IconUpload, +} from '@tabler/icons'; +import { useTranslation } from 'next-i18next'; import { calculateETA } from '../../tools/calculateEta'; import { humanFileSize } from '../../tools/humanFileSize'; +import { AppType } from '../../types/app'; interface TorrentQueueItemProps { torrent: NormalizedTorrent; + app?: AppType; } -export const BitTorrrentQueueItem = ({ torrent }: TorrentQueueItemProps) => { +export const BitTorrrentQueueItem = ({ torrent, app }: TorrentQueueItemProps) => { + const [popoverOpened, { open: openPopover, close: closePopover }] = useDisclosure(false); const MIN_WIDTH_MOBILE = useMantineTheme().breakpoints.xs; const { width } = useElementSize(); @@ -18,17 +41,29 @@ export const BitTorrrentQueueItem = ({ torrent }: TorrentQueueItemProps) => { return ( - - - {torrent.name} - - + + + + + +
+ + {torrent.name} + + {app && ( + + Managed by {app.name}, {torrent.ratio.toFixed(2)} ratio + + )} +
+
+
{humanFileSize(size)} @@ -60,3 +95,98 @@ export const BitTorrrentQueueItem = ({ torrent }: TorrentQueueItemProps) => { ); }; + +const TorrentQueuePopover = ({ torrent, app }: TorrentQueueItemProps) => { + const { t } = useTranslation('modules/torrents-status'); + const { colors } = useMantineTheme(); + + const RatioMetric = () => { + const color = (): MantineColor => { + if (torrent.ratio < 1) { + return colors.red[7]; + } + + if (torrent.ratio < 1.15) { + return colors.orange[7]; + } + + return colors.green[7]; + }; + return ( + + + + Ratio - + + + {torrent.ratio.toFixed(2)} + + + + ); + }; + + return ( + <> + {app && ( + + + {t('card.popover.introductionPrefix')} + + download client logo + + {app.name} + + + )} + + {torrent.name} + + + + + + {t('card.popover.metrics.queuePosition', { position: torrent.queuePosition })} + + + + + + {t('card.popover.metrics.progress', { progress: (torrent.progress * 100).toFixed(2) })} + + + + + + {t('card.popover.metrics.totalSelectedSize', { + totalSize: humanFileSize(torrent.totalSelected), + })} + + + + + + {humanFileSize(torrent.totalDownloaded)} + + + + {humanFileSize(torrent.totalUploaded)} + + + + + + {t('card.popover.metrics.state', { state: torrent.stateMessage })} + + + + {torrent.label && {torrent.label}} + {torrent.isCompleted && ( + + {t('card.popover.metrics.completed')} + + )} + + + ); +}; diff --git a/src/widgets/torrent/TorrentTile.tsx b/src/widgets/torrent/TorrentTile.tsx index 89dd90b90..cf2a53c1c 100644 --- a/src/widgets/torrent/TorrentTile.tsx +++ b/src/widgets/torrent/TorrentTile.tsx @@ -1,5 +1,6 @@ import { NormalizedTorrent } from '@ctrl/shared-torrent'; import { + Badge, Center, Flex, Group, @@ -20,6 +21,7 @@ import { useTranslation } from 'next-i18next'; import { useEffect, useState } from 'react'; import { useConfigContext } from '../../config/provider'; import { useGetTorrentData } from '../../hooks/widgets/torrents/useGetTorrentData'; +import { NormalizedTorrentListResponse } from '../../types/api/NormalizedTorrentListResponse'; import { AppIntegrationType } from '../../types/app'; import { defineWidget } from '../helper'; import { IWidget } from '../widgets'; @@ -76,7 +78,17 @@ function TorrentTile({ widget }: TorrentTileProps) { []; const [selectedAppId, setSelectedApp] = useState(downloadApps[0]?.id); - const { data, isError, isInitialLoading, dataUpdatedAt } = useGetTorrentData({ + const { + data, + isError, + isInitialLoading, + dataUpdatedAt, + }: { + data: NormalizedTorrentListResponse | undefined; + isError: boolean; + isInitialLoading: boolean; + dataUpdatedAt: number; + } = useGetTorrentData({ appId: selectedAppId!, refreshInterval: widget.properties.refreshInterval * 1000, }); @@ -127,7 +139,7 @@ function TorrentTile({ widget }: TorrentTileProps) { ); } - if (!data || data.length < 1) { + if (!data || Object.values(data.torrents).length < 1) { return (
{t('card.table.body.nothingFound')} @@ -153,7 +165,7 @@ function TorrentTile({ widget }: TorrentTileProps) { return ( - + @@ -166,15 +178,28 @@ function TorrentTile({ widget }: TorrentTileProps) { - {data.filter(filter).map((item: NormalizedTorrent, index: number) => ( - - ))} + {data.torrents.map((concatenatedTorrentList) => { + const app = config?.apps.find((x) => x.id === concatenatedTorrentList.appId); + return concatenatedTorrentList.torrents + .filter(filter) + .map((item: NormalizedTorrent, index: number) => ( + + )); + })}
- - Last updated {humanizedDuration} ago - + + {!data.allSuccess && ( + + {t('card.footer.error')} + + )} + + + {t('card.footer.lastUpdated', { time: humanizedDuration })} + +
); } diff --git a/src/widgets/torrentNetworkTraffic/TorrentNetworkTrafficTile.tsx b/src/widgets/torrentNetworkTraffic/TorrentNetworkTrafficTile.tsx index 59a5e3ca6..3b494d130 100644 --- a/src/widgets/torrentNetworkTraffic/TorrentNetworkTrafficTile.tsx +++ b/src/widgets/torrentNetworkTraffic/TorrentNetworkTrafficTile.tsx @@ -11,6 +11,7 @@ import { useEffect, useState } from 'react'; import { useConfigContext } from '../../config/provider'; import { useSetSafeInterval } from '../../hooks/useSetSafeInterval'; import { humanFileSize } from '../../tools/humanFileSize'; +import { NormalizedTorrentListResponse } from '../../types/api/NormalizedTorrentListResponse'; import { defineWidget } from '../helper'; import { IWidget } from '../widgets'; @@ -60,7 +61,8 @@ function TorrentNetworkTrafficTile({ widget }: TorrentNetworkTrafficTileProps) { axios .post('/api/modules/torrents') .then((response) => { - setTorrents(response.data); + const responseData: NormalizedTorrentListResponse = response.data; + setTorrents(responseData.torrents.flatMap((x) => x.torrents)); }) .catch((error) => { if (error.status === 401) return;