mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-09 15:05:48 +01:00
✨ add torrent client
This commit is contained in:
@@ -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..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
29
src/hooks/widgets/torrents/useGetTorrentData.tsx
Normal file
29
src/hooks/widgets/torrents/useGetTorrentData.tsx
Normal 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[];
|
||||||
|
};
|
||||||
@@ -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
15
src/tools/calculateEta.ts
Normal 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}`;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
62
src/widgets/bitTorrent/BitTorrentQueueItem.tsx
Normal file
62
src/widgets/bitTorrent/BitTorrentQueueItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user