add torrent client

This commit is contained in:
Manuel Ruwe
2022-12-31 16:07:05 +01:00
parent 78bc883667
commit 4e097caf98
14 changed files with 264 additions and 29 deletions

View File

@@ -34,7 +34,14 @@
"noDownloadClients": { "noDownloadClients": {
"title": "No supported download clients found!", "title": "No supported download clients found!",
"text": "Add a download service to view your current downloads" "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..."
} }
} }
} }

View File

@@ -1,15 +1,15 @@
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import axios from 'axios'; import axios from 'axios';
import { Results } from 'sabnzbd-api'; import { Results } from 'sabnzbd-api';
import { UsenetQueueRequestParams, UsenetQueueResponse } from '../pages/api/modules/usenet/queue'; import { UsenetQueueRequestParams, UsenetQueueResponse } from '../../../pages/api/modules/usenet/queue';
import { import {
UsenetHistoryRequestParams, UsenetHistoryRequestParams,
UsenetHistoryResponse, UsenetHistoryResponse,
} from '../pages/api/modules/usenet/history'; } from '../../../pages/api/modules/usenet/history';
import { UsenetInfoRequestParams, UsenetInfoResponse } from '../pages/api/modules/usenet'; import { UsenetInfoRequestParams, UsenetInfoResponse } from '../../../pages/api/modules/usenet';
import { UsenetPauseRequestParams } from '../pages/api/modules/usenet/pause'; import { UsenetPauseRequestParams } from '../../../pages/api/modules/usenet/pause';
import { queryClient } from '../tools/queryClient'; import { queryClient } from '../../../tools/queryClient';
import { UsenetResumeRequestParams } from '../pages/api/modules/usenet/resume'; import { UsenetResumeRequestParams } from '../../../pages/api/modules/usenet/resume';
const POLLING_INTERVAL = 2000; const POLLING_INTERVAL = 2000;

View File

@@ -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<NormalizedTorrent[]> => {
const response = await axios.post('/api/modules/torrents');
return response.data as NormalizedTorrent[];
};

View File

@@ -1,3 +1,4 @@
import Consola from 'consola';
import { Deluge } from '@ctrl/deluge'; import { Deluge } from '@ctrl/deluge';
import { QBittorrent } from '@ctrl/qbittorrent'; import { QBittorrent } from '@ctrl/qbittorrent';
import { NormalizedTorrent } from '@ctrl/shared-torrent'; import { NormalizedTorrent } from '@ctrl/shared-torrent';
@@ -22,43 +23,53 @@ async function Post(req: NextApiRequest, res: NextApiResponse) {
message: 'Missing apps', message: 'Missing apps',
}); });
} }
try { try {
await Promise.all( await Promise.all(
qBittorrentApp.map((app) => qBittorrentApp.map((app) =>
new QBittorrent({ new QBittorrent({
baseUrl: app.url, baseUrl: app.url,
username: app.integration!.properties.find((x) => x.field === 'username')?.value, username:
password: app.integration!.properties.find((x) => x.field === 'password')?.value, app.integration!.properties.find((x) => x.field === 'username')?.value ?? undefined,
password:
app.integration!.properties.find((x) => x.field === 'password')?.value ?? undefined,
}) })
.getAllData() .getAllData()
.then((e) => torrents.push(...e.torrents)) .then((e) => torrents.push(...e.torrents))
) )
); );
await Promise.all( await Promise.all(
delugeApp.map((app) => delugeApp.map((app) => {
new Deluge({ const password = app.integration?.properties.find((x) => x.field === 'password')?.value ?? undefined;
const test = new Deluge({
baseUrl: app.url, baseUrl: app.url,
password: app.integration!.properties.find((x) => x.field === 'password')?.value, password,
}) })
.getAllData() .getAllData()
.then((e) => torrents.push(...e.torrents)) .then((e) => torrents.push(...e.torrents));
) return test;
})
); );
// Map transmissionApps // Map transmissionApps
await Promise.all( await Promise.all(
transmissionApp.map((app) => transmissionApp.map((app) =>
new Transmission({ new Transmission({
baseUrl: app.url, baseUrl: app.url,
username: app.integration!.properties.find((x) => x.field === 'username')?.value, username:
password: app.integration!.properties.find((x) => x.field === 'password')?.value, app.integration!.properties.find((x) => x.field === 'username')?.value ?? undefined,
password:
app.integration!.properties.find((x) => x.field === 'password')?.value ?? undefined,
}) })
.getAllData() .getAllData()
.then((e) => torrents.push(...e.torrents)) .then((e) => torrents.push(...e.torrents))
) )
); );
} catch (e: any) { } catch (e: any) {
Consola.error('Error while communicating with your torrent applications:\n', e);
return res.status(401).json(e); return res.status(401).json(e);
} }
Consola.debug(`Retrieved ${torrents.length} from all download clients`);
return res.status(200).json(torrents); return res.status(200).json(torrents);
} }

15
src/tools/calculateEta.ts Normal file
View File

@@ -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}`;
};

View File

@@ -1,3 +1,4 @@
import Consola from 'consola';
import { BackendConfigType } from '../../types/config'; import { BackendConfigType } from '../../types/config';
import { configExists } from './configExists'; import { configExists } from './configExists';
import { getFallbackConfig } from './getFallbackConfig'; import { getFallbackConfig } from './getFallbackConfig';
@@ -11,8 +12,9 @@ export const getConfig = (name: string): BackendConfigType => {
// to the new format. // to the new format.
const config = readConfig(name); const config = readConfig(name);
if (config.schemaVersion === undefined) { if (config.schemaVersion === undefined) {
console.log('Migrating config file...', config); Consola.log('Migrating config file...', config);
return migrateConfig(config, name); return migrateConfig(config, name);
} }
return config; return config;
}; };

View File

@@ -14,8 +14,8 @@ export const getFrontendConfig = (name: string): ConfigType => {
properties: properties:
app.integration?.properties.map((property) => ({ app.integration?.properties.map((property) => ({
...property, ...property,
value: property.type === 'private' ? undefined : property.value, value: property.type === 'private' ? null : property.value,
isDefined: property.value != null, isDefined: property.value !== null,
})) ?? [], })) ?? [],
}, },
})), })),

View File

@@ -23,7 +23,7 @@ export const dashboardNamespaces = [
'modules/dlspeed', 'modules/dlspeed',
'modules/usenet', 'modules/usenet',
'modules/search', 'modules/search',
'modules/torrents-status', 'modules/torrents',
'modules/weather', 'modules/weather',
'modules/ping', 'modules/ping',
'modules/docker', 'modules/docker',

View File

@@ -54,7 +54,7 @@ export type ConfigAppIntegrationType = Omit<AppIntegrationType, 'properties'> &
export type AppIntegrationPropertyType = { export type AppIntegrationPropertyType = {
type: 'private' | 'public'; type: 'private' | 'public';
field: IntegrationField; field: IntegrationField;
value?: string | undefined; value?: string | null;
isDefined: boolean; isDefined: boolean;
}; };

View File

@@ -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 (
<tr key={torrent.id}>
<td>
<Tooltip position="top" label={torrent.name}>
<Text
style={{
maxWidth: '30vw',
}}
size="xs"
lineClamp={1}
>
{torrent.name}
</Text>
</Tooltip>
</td>
<td>
<Text size="xs">{humanFileSize(size)}</Text>
</td>
{width > MIN_WIDTH_MOBILE && (
<td>
<Text size="xs">{downloadSpeed > 0 ? `${downloadSpeed.toFixed(1)} Mb/s` : '-'}</Text>
</td>
)}
{width > MIN_WIDTH_MOBILE && (
<td>
<Text size="xs">{uploadSpeed > 0 ? `${uploadSpeed.toFixed(1)} Mb/s` : '-'}</Text>
</td>
)}
{width > MIN_WIDTH_MOBILE && (
<td>
<Text size="xs">{torrent.eta <= 0 ? '∞' : calculateETA(torrent.eta)}</Text>
</td>
)}
<td>
<Text>{(torrent.progress * 100).toFixed(1)}%</Text>
<Progress
radius="lg"
color={torrent.progress === 1 ? 'green' : torrent.state === 'paused' ? 'yellow' : 'blue'}
value={torrent.progress * 100}
size="lg"
/>
</td>
</tr>
);
};

View File

@@ -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 { 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 { defineWidget } from '../helper';
import { IWidget } from '../widgets'; import { IWidget } from '../widgets';
import { BitTorrrentQueueItem } from './BitTorrentQueueItem';
const downloadAppTypes: AppIntegrationType['type'][] = ['deluge', 'qBittorrent', 'transmission'];
const definition = defineWidget({ const definition = defineWidget({
id: 'torrents-status', id: 'torrents-status',
icon: IconFileDownload, icon: IconFileDownload,
options: {}, options: {
displayCompletedTorrents: {
type: 'switch',
defaultValue: true,
},
displayStaleTorrents: {
type: 'switch',
defaultValue: true,
},
},
gridstack: { gridstack: {
minWidth: 2, minWidth: 4,
minHeight: 2, minHeight: 5,
maxWidth: 2, maxWidth: 12,
maxHeight: 2, maxHeight: 14,
}, },
component: BitTorrentTile, component: BitTorrentTile,
}); });
@@ -22,7 +51,87 @@ interface BitTorrentTileProps {
} }
function BitTorrentTile({ widget }: 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<string | null>(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 (
<Stack>
<Title order={3}>{t('card.errors.noDownloadClients.title')}</Title>
<Group>
<Text>{t('card.errors.noDownloadClients.text')}</Text>
</Group>
</Stack>
);
}
if (isError) {
return (
<Stack>
<Title order={3}>{t('card.errors.generic.title')}</Title>
<Group>
<Text>{t('card.errors.generic.text')}</Text>
</Group>
</Stack>
);
}
if (isFetching) {
return (
<Stack align="center">
<Loader />
<Stack align="center" spacing={0}>
<Text>{t('card.loading.title')}</Text>
<Text color="dimmed">Homarr is establishing a connection...</Text>
</Stack>
</Stack>
);
}
if (!data || data.length < 1) {
return (
<Center style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Title order={3}>{t('card.table.body.nothingFound')}</Title>
</Center>
);
}
return (
<ScrollArea sx={{ height: 300, width: '100%' }}>
<Table highlightOnHover p="sm">
<thead>
<tr>
<th>{t('card.table.header.name')}</th>
<th>{t('card.table.header.size')}</th>
{width > MIN_WIDTH_MOBILE && <th>{t('card.table.header.download')}</th>}
{width > MIN_WIDTH_MOBILE && <th>{t('card.table.header.upload')}</th>}
{width > MIN_WIDTH_MOBILE && <th>{t('card.table.header.estimatedTimeOfArrival')}</th>}
<th>{t('card.table.header.progress')}</th>
</tr>
</thead>
<tbody>
{data.map((item, index) => (
<BitTorrrentQueueItem key={index} torrent={item} />
))}
</tbody>
</Table>
</ScrollArea>
);
} }
export default definition; export default definition;

View File

@@ -17,7 +17,7 @@ import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration'; import duration from 'dayjs/plugin/duration';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useConfigContext } from '../../config/provider'; 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 { humanFileSize } from '../../tools/humanFileSize';
import { AppIntegrationType } from '../../types/app'; import { AppIntegrationType } from '../../types/app';
import { defineWidget } from '../helper'; import { defineWidget } from '../helper';

View File

@@ -18,7 +18,7 @@ import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration'; import duration from 'dayjs/plugin/duration';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { FunctionComponent, useState } from 'react'; import { FunctionComponent, useState } from 'react';
import { useGetUsenetHistory } from '../../hooks/api'; import { useGetUsenetHistory } from '../../hooks/widgets/dashDot/api';
import { humanFileSize } from '../../tools/humanFileSize'; import { humanFileSize } from '../../tools/humanFileSize';
import { parseDuration } from '../../tools/parseDuration'; import { parseDuration } from '../../tools/parseDuration';

View File

@@ -21,7 +21,7 @@ import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration'; import duration from 'dayjs/plugin/duration';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { FunctionComponent, useState } from 'react'; import { FunctionComponent, useState } from 'react';
import { useGetUsenetDownloads } from '../../hooks/api'; import { useGetUsenetDownloads } from '../../hooks/widgets/dashDot/api';
import { humanFileSize } from '../../tools/humanFileSize'; import { humanFileSize } from '../../tools/humanFileSize';
dayjs.extend(duration); dayjs.extend(duration);