mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-09 15:05:48 +01:00
implement queue info and pause/resume
This commit is contained in:
21
.eslintrc.js
21
.eslintrc.js
@@ -3,9 +3,9 @@ module.exports = {
|
||||
'mantine',
|
||||
'plugin:@next/next/recommended',
|
||||
'plugin:jest/recommended',
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/eslint-recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
],
|
||||
plugins: ['testing-library', 'jest', 'react-hooks', 'react', 'unused-imports'],
|
||||
overrides: [
|
||||
@@ -20,12 +20,13 @@ module.exports = {
|
||||
rules: {
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/no-children-prop': 'off',
|
||||
"unused-imports/no-unused-imports": "warn",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-imports": "off",
|
||||
"@typescript-eslint/no-unused-expressions": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-shadow": "off",
|
||||
"@typescript-eslint/no-use-before-define": "off",
|
||||
'unused-imports/no-unused-imports': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-imports': 'off',
|
||||
'@typescript-eslint/no-unused-expressions': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-shadow': 'off',
|
||||
'@typescript-eslint/no-use-before-define': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
},
|
||||
};
|
||||
|
||||
68
@types/react-i18next.d.ts
vendored
Normal file
68
@types/react-i18next.d.ts
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
import 'react-i18next';
|
||||
|
||||
import common from '../public/locales/en/common.json';
|
||||
import appShelf from '../public/locales/en/layout/app-shelf.json';
|
||||
import addServiceAppShelf from '../public/locales/en/layout/add-service-app-shelf.json';
|
||||
import appShelfMenu from '../public/locales/en/layout/app-shelf-menu.json';
|
||||
import commonSettings from '../public/locales/en/settings/common.json';
|
||||
import themeSelector from '../public/locales/en/settings/general/theme-selector.json';
|
||||
import configChanger from '../public/locales/en/settings/general/config-changer.json';
|
||||
import i18n from '../public/locales/en/settings/general/internationalization.json';
|
||||
import moduleEnabler from '../public/locales/en/settings/general/module-enabler.json';
|
||||
import searchEngine from '../public/locales/en/settings/general/search-engine.json';
|
||||
import widgetPositions from '../public/locales/en/settings/general/widget-positions.json';
|
||||
import colorSelector from '../public/locales/en/settings/customization/color-selector.json';
|
||||
import pageAppearance from '../public/locales/en/settings/customization/page-appearance.json';
|
||||
import shadeSelector from '../public/locales/en/settings/customization/shade-selector.json';
|
||||
import appWidth from '../public/locales/en/settings/customization/app-width.json';
|
||||
import opacitySelector from '../public/locales/en/settings/customization/opacity-selector.json';
|
||||
import commonModule from '../public/locales/en/modules/common.json';
|
||||
import dateModule from '../public/locales/en/modules/date.json';
|
||||
import calendarModule from '../public/locales/en/modules/calendar.json';
|
||||
import dlSpeedModule from '../public/locales/en/modules/dlspeed.json';
|
||||
import usenetModule from '../public/locales/en/modules/usenet.json';
|
||||
import searchModule from '../public/locales/en/modules/search.json';
|
||||
import torrentsModule from '../public/locales/en/modules/torrents-status.json';
|
||||
import weatherModule from '../public/locales/en/modules/weather.json';
|
||||
import pingModule from '../public/locales/en/modules/ping.json';
|
||||
import dockerModule from '../public/locales/en/modules/docker.json';
|
||||
import dashDotModule from '../public/locales/en/modules/dashdot.json';
|
||||
import overseerrModule from '../public/locales/en/modules/overseerr.json';
|
||||
import mediaCardsModule from '../public/locales/en/modules/common-media-cards.json';
|
||||
|
||||
declare module 'react-i18next' {
|
||||
interface CustomTypeOptions {
|
||||
defaultNS: 'common';
|
||||
resources: {
|
||||
common: typeof common;
|
||||
'layout/app-shelf': typeof appShelf;
|
||||
'layout/add-service-app-shelf': typeof addServiceAppShelf;
|
||||
'layout/app-shelf-menu': typeof appShelfMenu;
|
||||
'settings/common': typeof commonSettings;
|
||||
'settings/general/theme-selector': typeof themeSelector;
|
||||
'settings/general/config-changer': typeof configChanger;
|
||||
'settings/general/internationalization': typeof i18n;
|
||||
'settings/general/module-enabler': typeof moduleEnabler;
|
||||
'settings/general/search-engine': typeof searchEngine;
|
||||
'settings/general/widget-positions': typeof widgetPositions;
|
||||
'settings/customization/color-selector': typeof colorSelector;
|
||||
'settings/customization/page-appearance': typeof pageAppearance;
|
||||
'settings/customization/shade-selector': typeof shadeSelector;
|
||||
'settings/customization/app-width': typeof appWidth;
|
||||
'settings/customization/opacity-selector': typeof opacitySelector;
|
||||
'modules/common': typeof commonModule;
|
||||
'modules/date': typeof dateModule;
|
||||
'modules/calendar': typeof calendarModule;
|
||||
'modules/dlspeed': typeof dlSpeedModule;
|
||||
'modules/usenet': typeof usenetModule;
|
||||
'modules/search': typeof searchModule;
|
||||
'modules/torrents-status': typeof torrentsModule;
|
||||
'modules/weather': typeof weatherModule;
|
||||
'modules/ping': typeof pingModule;
|
||||
'modules/docker': typeof dockerModule;
|
||||
'modules/dashdot': typeof dashDotModule;
|
||||
'modules/overseerr': typeof overseerrModule;
|
||||
'modules/common-media-cards': typeof mediaCardsModule;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,42 @@
|
||||
"type": "Other",
|
||||
"icon": "https://c.tenor.com/o656qFKDzeUAAAAC/rick-astley-never-gonna-give-you-up.gif",
|
||||
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
||||
},
|
||||
{
|
||||
"id": "4cb1c2af-9f23-4f52-86e1-0eac25673b56",
|
||||
"type": "Sabnzbd",
|
||||
"name": "Sabnzbd",
|
||||
"icon": "/favicon.png",
|
||||
"url": "https://sabnzbd.jannesv.be",
|
||||
"apiKey": "2982e4afbc6d42d6bb5863751a354d20",
|
||||
"openedUrl": "https://sabnzbd.jannesv.be"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"searchUrl": "https://google.com/search?q="
|
||||
"searchUrl": "https://google.com/search?q=",
|
||||
"primaryColor": "grape"
|
||||
},
|
||||
"modules": {
|
||||
"Search Bar": {
|
||||
"enabled": true
|
||||
},
|
||||
"Download Speed": {
|
||||
"enabled": false
|
||||
},
|
||||
"Torrent": {
|
||||
"enabled": false
|
||||
},
|
||||
"Dash.": {
|
||||
"enabled": true
|
||||
},
|
||||
"Usenet": {
|
||||
"enabled": true
|
||||
},
|
||||
"usenet": {
|
||||
"enabled": true
|
||||
},
|
||||
"docker": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
35
public/locales/en/modules/usenet.json
Normal file
35
public/locales/en/modules/usenet.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"descriptor": {
|
||||
"name": "Usenet",
|
||||
"description": "Show the queue and history of supported services"
|
||||
},
|
||||
"tabs": {
|
||||
"queue": "Queue",
|
||||
"history": "History"
|
||||
},
|
||||
"info": {
|
||||
"sizeLeft": "Size left",
|
||||
"paused": "Paused"
|
||||
},
|
||||
"queue": {
|
||||
"header": {
|
||||
"name": "Name",
|
||||
"size": "Size",
|
||||
"eta": "ETA",
|
||||
"progress": "Progress"
|
||||
},
|
||||
"empty": "Queue is empty.",
|
||||
"error": "Some error has occured while fetching data:",
|
||||
"paused": "Paused"
|
||||
},
|
||||
"history": {
|
||||
"header": {
|
||||
"name": "Name",
|
||||
"size": "Size",
|
||||
"duration": "Download Duration"
|
||||
},
|
||||
"empty": "Queue is empty.",
|
||||
"error": "Some error has occured while fetching data:",
|
||||
"paused": "Paused"
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { IconAlertCircle } from '@tabler/icons';
|
||||
import { AxiosError } from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import { useGetUsenetHistory } from '../../tools/hooks/api';
|
||||
import { humanFileSize } from '../../tools/humanFileSize';
|
||||
@@ -28,6 +29,7 @@ const PAGE_SIZE = 10;
|
||||
|
||||
export const UsenetHistoryList: FunctionComponent<UsenetHistoryListProps> = ({ serviceId }) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const { t } = useTranslation('modules/usenet');
|
||||
|
||||
const { data, isLoading, isError, error } = useGetUsenetHistory({
|
||||
limit: PAGE_SIZE,
|
||||
@@ -50,7 +52,7 @@ export const UsenetHistoryList: FunctionComponent<UsenetHistoryListProps> = ({ s
|
||||
return (
|
||||
<Group position="center">
|
||||
<Alert icon={<IconAlertCircle size={16} />} my="lg" title="Error!" color="red" radius="md">
|
||||
Some error has occured while fetching data:
|
||||
{t('history.error')}
|
||||
<Code mt="sm" block>
|
||||
{(error as AxiosError)?.response?.data as string}
|
||||
</Code>
|
||||
@@ -62,13 +64,13 @@ export const UsenetHistoryList: FunctionComponent<UsenetHistoryListProps> = ({ s
|
||||
if (!data || data.items.length <= 0) {
|
||||
return (
|
||||
<Center style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Title order={3}>History is empty</Title>
|
||||
<Title order={3}>{t('history.empty')}</Title>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
<Table highlightOnHover style={{ tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
<col span={1} />
|
||||
@@ -77,9 +79,9 @@ export const UsenetHistoryList: FunctionComponent<UsenetHistoryListProps> = ({ s
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Size</th>
|
||||
<th>Download Duration</th>
|
||||
<th>{t('history.header.name')}</th>
|
||||
<th>{t('history.header.size')}</th>
|
||||
<th>{t('history.header.duration')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -113,14 +115,16 @@ export const UsenetHistoryList: FunctionComponent<UsenetHistoryListProps> = ({ s
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
<Pagination
|
||||
size="sm"
|
||||
position="center"
|
||||
mt="md"
|
||||
total={totalPages}
|
||||
page={page}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
size="sm"
|
||||
position="center"
|
||||
mt="md"
|
||||
total={totalPages}
|
||||
page={page}
|
||||
onChange={setPage}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
import { Group, Select, Tabs } from '@mantine/core';
|
||||
import { IconDownload } from '@tabler/icons';
|
||||
import { Badge, Button, Group, Select, Tabs } from '@mantine/core';
|
||||
import { IconDownload, IconPlayerPause, IconPlayerPlay } from '@tabler/icons';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import { IModule } from '../ModuleTypes';
|
||||
import { UsenetQueueList } from './UsenetQueueList';
|
||||
import { UsenetHistoryList } from './UsenetHistoryList';
|
||||
import { useGetServiceByType } from '../../tools/hooks/useGetServiceByType';
|
||||
import { useGetUsenetInfo, usePauseUsenetQueue, useResumeUsenetQueue } from '../../tools/hooks/api';
|
||||
import { humanFileSize } from '../../tools/humanFileSize';
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
export const UsenetComponent: FunctionComponent = () => {
|
||||
const downloadServices = useGetServiceByType('Sabnzbd');
|
||||
const { t } = useTranslation('modules/usenet');
|
||||
|
||||
const [selectedServiceId, setSelectedService] = useState<string | null>(downloadServices[0]?.id);
|
||||
const { data } = useGetUsenetInfo({ serviceId: selectedServiceId! });
|
||||
const { mutate: pause } = usePauseUsenetQueue({ serviceId: selectedServiceId! });
|
||||
const { mutate: resume } = useResumeUsenetQueue({ serviceId: selectedServiceId! });
|
||||
|
||||
if (!selectedServiceId) {
|
||||
return null;
|
||||
@@ -20,8 +31,26 @@ export const UsenetComponent: FunctionComponent = () => {
|
||||
<Tabs keepMounted={false} defaultValue="queue">
|
||||
<Group mb="md">
|
||||
<Tabs.List style={{ flex: 1 }}>
|
||||
<Tabs.Tab value="queue">Queue</Tabs.Tab>
|
||||
<Tabs.Tab value="history">History</Tabs.Tab>
|
||||
<Tabs.Tab value="queue">{t('tabs.queue')}</Tabs.Tab>
|
||||
<Tabs.Tab value="history">{t('tabs.history')}</Tabs.Tab>
|
||||
{data && (
|
||||
<Group position="right" ml="auto" mb="lg">
|
||||
<Badge>{humanFileSize(data?.speed)}/s</Badge>
|
||||
<Badge>
|
||||
{t('info.sizeLeft')}: {humanFileSize(data?.sizeLeft)}
|
||||
</Badge>
|
||||
{data.paused ? (
|
||||
<Button uppercase onClick={() => resume()} radius="xl" size="xs">
|
||||
<IconPlayerPlay size={16} style={{ marginRight: 5 }} /> {t('info.paused')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button uppercase onClick={() => pause()} radius="xl" size="xs">
|
||||
<IconPlayerPause size={16} style={{ marginRight: 5 }} />{' '}
|
||||
{dayjs.duration(data.eta, 's').format('HH:mm:ss')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Tabs.List>
|
||||
{downloadServices.length > 1 && (
|
||||
<Select
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Center,
|
||||
Code,
|
||||
Group,
|
||||
Pagination,
|
||||
Progress,
|
||||
Skeleton,
|
||||
Table,
|
||||
@@ -15,6 +17,7 @@ import { IconAlertCircle, IconPlayerPause, IconPlayerPlay } from '@tabler/icons'
|
||||
import { AxiosError } from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import { useGetUsenetDownloads } from '../../tools/hooks/api';
|
||||
import { humanFileSize } from '../../tools/humanFileSize';
|
||||
@@ -29,12 +32,15 @@ const PAGE_SIZE = 10;
|
||||
|
||||
export const UsenetQueueList: FunctionComponent<UsenetQueueListProps> = ({ serviceId }) => {
|
||||
const theme = useMantineTheme();
|
||||
const { t } = useTranslation('modules/usenet');
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const { data, isLoading, isError, error } = useGetUsenetDownloads({
|
||||
limit: PAGE_SIZE,
|
||||
offset: (page - 1) * PAGE_SIZE,
|
||||
serviceId,
|
||||
});
|
||||
const totalPages = Math.ceil((data?.total || 1) / PAGE_SIZE);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -50,7 +56,7 @@ export const UsenetQueueList: FunctionComponent<UsenetQueueListProps> = ({ servi
|
||||
return (
|
||||
<Group position="center">
|
||||
<Alert icon={<IconAlertCircle size={16} />} my="lg" title="Error!" color="red" radius="md">
|
||||
Some error has occured while fetching data:
|
||||
{t('queue.error')}
|
||||
<Code mt="sm" block>
|
||||
{(error as AxiosError)?.response?.data as string}
|
||||
</Code>
|
||||
@@ -62,71 +68,88 @@ export const UsenetQueueList: FunctionComponent<UsenetQueueListProps> = ({ servi
|
||||
if (!data || data.items.length <= 0) {
|
||||
return (
|
||||
<Center style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Title order={3}>Queue is empty</Title>
|
||||
<Title order={3}>{t('queue.empty')}</Title>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table highlightOnHover style={{ tableLayout: 'fixed' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 40 }} />
|
||||
<th style={{ width: '75%' }}>Name</th>
|
||||
<th style={{ width: 100 }}>Size</th>
|
||||
<th style={{ width: 100 }}>ETA</th>
|
||||
<th style={{ width: 200 }}>Progress</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((nzb) => (
|
||||
<tr key={nzb.id}>
|
||||
<td>
|
||||
{nzb.state === 'paused' ? (
|
||||
<IconPlayerPause fill="grey" stroke={0} size="16" />
|
||||
) : (
|
||||
<IconPlayerPlay fill="black" stroke={0} size="16" />
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<Tooltip position="top" label={nzb.name}>
|
||||
<Text
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
size="xs"
|
||||
>
|
||||
{nzb.name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</td>
|
||||
<td>
|
||||
<Text size="xs">{humanFileSize(nzb.size)}</Text>
|
||||
</td>
|
||||
<td>
|
||||
{nzb.eta <= 0 ? (
|
||||
<Text size="xs" color="dimmed">
|
||||
Paused
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="xs">{dayjs.duration(nzb.eta, 's').format('H:mm:ss')}</Text>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Text mr="sm">{nzb.progress.toFixed(1)}%</Text>
|
||||
<Progress
|
||||
radius="lg"
|
||||
color={nzb.eta > 0 ? theme.primaryColor : 'lightgrey'}
|
||||
value={nzb.progress}
|
||||
size="lg"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</td>
|
||||
<>
|
||||
<Table highlightOnHover style={{ tableLayout: 'fixed' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 50 }} />
|
||||
<th style={{ width: '75%' }}>{t('queue.header.name')}</th>
|
||||
<th style={{ width: 100 }}>{t('queue.header.size')}</th>
|
||||
<th style={{ width: 100 }}>{t('queue.header.eta')}</th>
|
||||
<th style={{ width: 200 }}>{t('queue.header.progress')}</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((nzb) => (
|
||||
<tr key={nzb.id}>
|
||||
<td>
|
||||
{nzb.state === 'paused' ? (
|
||||
<Button disabled color="gray" variant="subtle" radius="lg" size="xs" compact>
|
||||
<IconPlayerPlay size="16" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled variant="subtle" radius="lg" size="xs" compact>
|
||||
<IconPlayerPause size="16" />
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<Tooltip position="top" label={nzb.name}>
|
||||
<Text
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
size="xs"
|
||||
color={nzb.state === 'paused' ? 'dimmed' : undefined}
|
||||
>
|
||||
{nzb.name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</td>
|
||||
<td>
|
||||
<Text size="xs">{humanFileSize(nzb.size)}</Text>
|
||||
</td>
|
||||
<td>
|
||||
{nzb.eta <= 0 ? (
|
||||
<Text size="xs" color="dimmed">
|
||||
{t('queue.paused')}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="xs">{dayjs.duration(nzb.eta, 's').format('H:mm:ss')}</Text>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Text mr="sm">{nzb.progress.toFixed(1)}%</Text>
|
||||
<Progress
|
||||
radius="lg"
|
||||
color={nzb.eta > 0 ? theme.primaryColor : 'lightgrey'}
|
||||
value={nzb.progress}
|
||||
size="lg"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
size="sm"
|
||||
position="center"
|
||||
mt="md"
|
||||
total={totalPages}
|
||||
page={page}
|
||||
onChange={setPage}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,9 +8,11 @@ import { NotificationsProvider } from '@mantine/notifications';
|
||||
import { useHotkeys } from '@mantine/hooks';
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
import { appWithTranslation } from 'next-i18next';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ConfigProvider } from '../tools/state';
|
||||
import { theme } from '../tools/theme';
|
||||
import { ColorTheme } from '../tools/color';
|
||||
import { queryClient } from '../tools/queryClient';
|
||||
|
||||
function App(this: any, props: AppProps & { colorScheme: ColorScheme }) {
|
||||
const { Component, pageProps } = props;
|
||||
@@ -40,43 +42,44 @@ function App(this: any, props: AppProps & { colorScheme: ColorScheme }) {
|
||||
<Head>
|
||||
<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width" />
|
||||
</Head>
|
||||
|
||||
<ColorSchemeProvider colorScheme={colorScheme} toggleColorScheme={toggleColorScheme}>
|
||||
<ColorTheme.Provider value={colorTheme}>
|
||||
<MantineProvider
|
||||
theme={{
|
||||
...theme,
|
||||
components: {
|
||||
Checkbox: {
|
||||
styles: {
|
||||
input: { cursor: 'pointer' },
|
||||
label: { cursor: 'pointer' },
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ColorSchemeProvider colorScheme={colorScheme} toggleColorScheme={toggleColorScheme}>
|
||||
<ColorTheme.Provider value={colorTheme}>
|
||||
<MantineProvider
|
||||
theme={{
|
||||
...theme,
|
||||
components: {
|
||||
Checkbox: {
|
||||
styles: {
|
||||
input: { cursor: 'pointer' },
|
||||
label: { cursor: 'pointer' },
|
||||
},
|
||||
},
|
||||
Switch: {
|
||||
styles: {
|
||||
input: { cursor: 'pointer' },
|
||||
label: { cursor: 'pointer' },
|
||||
},
|
||||
},
|
||||
},
|
||||
Switch: {
|
||||
styles: {
|
||||
input: { cursor: 'pointer' },
|
||||
label: { cursor: 'pointer' },
|
||||
},
|
||||
},
|
||||
},
|
||||
primaryColor,
|
||||
primaryShade,
|
||||
colorScheme,
|
||||
}}
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
>
|
||||
<NotificationsProvider limit={4} position="bottom-left">
|
||||
<ModalsProvider>
|
||||
<ConfigProvider>
|
||||
<Component {...pageProps} />
|
||||
</ConfigProvider>
|
||||
</ModalsProvider>
|
||||
</NotificationsProvider>
|
||||
</MantineProvider>
|
||||
</ColorTheme.Provider>
|
||||
</ColorSchemeProvider>
|
||||
primaryColor,
|
||||
primaryShade,
|
||||
colorScheme,
|
||||
}}
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
>
|
||||
<NotificationsProvider limit={4} position="bottom-left">
|
||||
<ModalsProvider>
|
||||
<ConfigProvider>
|
||||
<Component {...pageProps} />
|
||||
</ConfigProvider>
|
||||
</ModalsProvider>
|
||||
</NotificationsProvider>
|
||||
</MantineProvider>
|
||||
</ColorTheme.Provider>
|
||||
</ColorSchemeProvider>
|
||||
</QueryClientProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,29 +3,28 @@ import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { Client } from 'sabnzbd-api';
|
||||
import { UsenetQueueItem } from '../../../../modules';
|
||||
import { getConfig } from '../../../../tools/getConfig';
|
||||
import { getServiceById } from '../../../../tools/hooks/useGetServiceByType';
|
||||
import { Config } from '../../../../tools/types';
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
export interface UsenetQueueRequestParams {
|
||||
export interface UsenetInfoRequestParams {
|
||||
serviceId: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface UsenetQueueResponse {
|
||||
items: UsenetQueueItem[];
|
||||
total: number;
|
||||
export interface UsenetInfoResponse {
|
||||
paused: boolean;
|
||||
sizeLeft: number;
|
||||
speed: number;
|
||||
eta: number;
|
||||
}
|
||||
|
||||
async function Get(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const configName = getCookie('config-name', { req });
|
||||
const { config }: { config: Config } = getConfig(configName?.toString() ?? 'default').props;
|
||||
const { limit, offset, serviceId } = req.query as any as UsenetQueueRequestParams;
|
||||
const { serviceId } = req.query as any as UsenetInfoRequestParams;
|
||||
|
||||
const service = getServiceById(config, serviceId);
|
||||
|
||||
@@ -36,29 +35,21 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!service.apiKey) {
|
||||
throw new Error(`API Key for service "${service.name}" is missing`);
|
||||
}
|
||||
const queue = await new Client(service.url, service.apiKey).queue(offset, limit);
|
||||
|
||||
const items: UsenetQueueItem[] = queue.slots.map((slot) => {
|
||||
const [hours, minutes, seconds] = slot.timeleft.split(':');
|
||||
const eta = dayjs.duration({
|
||||
hour: parseInt(hours, 10),
|
||||
minutes: parseInt(minutes, 10),
|
||||
seconds: parseInt(seconds, 10),
|
||||
} as any);
|
||||
const queue = await new Client(service.url, service.apiKey).queue(0, -1);
|
||||
|
||||
return {
|
||||
id: slot.nzo_id,
|
||||
eta: eta.asSeconds(),
|
||||
name: slot.filename,
|
||||
progress: parseFloat(slot.percentage),
|
||||
size: parseFloat(slot.mb) * 1000 * 1000,
|
||||
state: slot.status.toLowerCase() as any,
|
||||
};
|
||||
});
|
||||
const [hours, minutes, seconds] = queue.timeleft.split(':');
|
||||
const eta = dayjs.duration({
|
||||
hour: parseInt(hours, 10),
|
||||
minutes: parseInt(minutes, 10),
|
||||
seconds: parseInt(seconds, 10),
|
||||
} as any);
|
||||
|
||||
const response: UsenetQueueResponse = {
|
||||
items,
|
||||
total: queue.noofslots_total,
|
||||
const response: UsenetInfoResponse = {
|
||||
paused: queue.paused,
|
||||
sizeLeft: parseFloat(queue.mbleft) * 1024 * 1024,
|
||||
speed: parseFloat(queue.kbpersec) * 1000,
|
||||
eta: eta.asSeconds(),
|
||||
};
|
||||
|
||||
return res.status(200).json(response);
|
||||
|
||||
49
src/pages/api/modules/usenet/pause.ts
Normal file
49
src/pages/api/modules/usenet/pause.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { getCookie } from 'cookies-next';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { Client } from 'sabnzbd-api';
|
||||
import { getConfig } from '../../../../tools/getConfig';
|
||||
import { getServiceById } from '../../../../tools/hooks/useGetServiceByType';
|
||||
import { Config } from '../../../../tools/types';
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
export interface UsenetPauseRequestParams {
|
||||
serviceId: string;
|
||||
}
|
||||
|
||||
async function Post(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const configName = getCookie('config-name', { req });
|
||||
const { config }: { config: Config } = getConfig(configName?.toString() ?? 'default').props;
|
||||
const { serviceId } = req.query as any as UsenetPauseRequestParams;
|
||||
|
||||
const service = getServiceById(config, serviceId);
|
||||
|
||||
if (!service) {
|
||||
throw new Error(`Service with ID "${req.query.serviceId}" could not be found.`);
|
||||
}
|
||||
|
||||
if (!service.apiKey) {
|
||||
throw new Error(`API Key for service "${service.name}" is missing`);
|
||||
}
|
||||
|
||||
const result = await new Client(service.url, service.apiKey).queuePause();
|
||||
|
||||
return res.status(200).json(result);
|
||||
} catch (err) {
|
||||
return res.status(500).send((err as any).message);
|
||||
}
|
||||
}
|
||||
|
||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
// Filter out if the reuqest is a POST or a GET
|
||||
if (req.method === 'POST') {
|
||||
return Post(req, res);
|
||||
}
|
||||
return res.status(405).json({
|
||||
statusCode: 405,
|
||||
message: 'Method not allowed',
|
||||
});
|
||||
};
|
||||
79
src/pages/api/modules/usenet/queue.ts
Normal file
79
src/pages/api/modules/usenet/queue.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { getCookie } from 'cookies-next';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { Client } from 'sabnzbd-api';
|
||||
import { UsenetQueueItem } from '../../../../modules';
|
||||
import { getConfig } from '../../../../tools/getConfig';
|
||||
import { getServiceById } from '../../../../tools/hooks/useGetServiceByType';
|
||||
import { Config } from '../../../../tools/types';
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
export interface UsenetQueueRequestParams {
|
||||
serviceId: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface UsenetQueueResponse {
|
||||
items: UsenetQueueItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
async function Get(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const configName = getCookie('config-name', { req });
|
||||
const { config }: { config: Config } = getConfig(configName?.toString() ?? 'default').props;
|
||||
const { limit, offset, serviceId } = req.query as any as UsenetQueueRequestParams;
|
||||
|
||||
const service = getServiceById(config, serviceId);
|
||||
|
||||
if (!service) {
|
||||
throw new Error(`Service with ID "${req.query.serviceId}" could not be found.`);
|
||||
}
|
||||
|
||||
if (!service.apiKey) {
|
||||
throw new Error(`API Key for service "${service.name}" is missing`);
|
||||
}
|
||||
const queue = await new Client(service.url, service.apiKey).queue(offset, limit);
|
||||
|
||||
const items: UsenetQueueItem[] = queue.slots.map((slot) => {
|
||||
const [hours, minutes, seconds] = slot.timeleft.split(':');
|
||||
const eta = dayjs.duration({
|
||||
hour: parseInt(hours, 10),
|
||||
minutes: parseInt(minutes, 10),
|
||||
seconds: parseInt(seconds, 10),
|
||||
} as any);
|
||||
|
||||
return {
|
||||
id: slot.nzo_id,
|
||||
eta: eta.asSeconds(),
|
||||
name: slot.filename,
|
||||
progress: parseFloat(slot.percentage),
|
||||
size: parseFloat(slot.mb) * 1000 * 1000,
|
||||
state: slot.status.toLowerCase() as any,
|
||||
};
|
||||
});
|
||||
|
||||
const response: UsenetQueueResponse = {
|
||||
items,
|
||||
total: queue.noofslots,
|
||||
};
|
||||
|
||||
return res.status(200).json(response);
|
||||
} catch (err) {
|
||||
return res.status(500).send((err as any).message);
|
||||
}
|
||||
}
|
||||
|
||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
// Filter out if the reuqest is a POST or a GET
|
||||
if (req.method === 'GET') {
|
||||
return Get(req, res);
|
||||
}
|
||||
return res.status(405).json({
|
||||
statusCode: 405,
|
||||
message: 'Method not allowed',
|
||||
});
|
||||
};
|
||||
50
src/pages/api/modules/usenet/resume.ts
Normal file
50
src/pages/api/modules/usenet/resume.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { getCookie } from 'cookies-next';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { Client } from 'sabnzbd-api';
|
||||
import { getConfig } from '../../../../tools/getConfig';
|
||||
import { getServiceById } from '../../../../tools/hooks/useGetServiceByType';
|
||||
import { Config } from '../../../../tools/types';
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
export interface UsenetResumeRequestParams {
|
||||
serviceId: string;
|
||||
nzbId?: string;
|
||||
}
|
||||
|
||||
async function Post(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const configName = getCookie('config-name', { req });
|
||||
const { config }: { config: Config } = getConfig(configName?.toString() ?? 'default').props;
|
||||
const { serviceId } = req.query as any as UsenetResumeRequestParams;
|
||||
|
||||
const service = getServiceById(config, serviceId);
|
||||
|
||||
if (!service) {
|
||||
throw new Error(`Service with ID "${req.query.serviceId}" could not be found.`);
|
||||
}
|
||||
|
||||
if (!service.apiKey) {
|
||||
throw new Error(`API Key for service "${service.name}" is missing`);
|
||||
}
|
||||
|
||||
const result = await new Client(service.url, service.apiKey).queueResume();
|
||||
|
||||
return res.status(200).json(result);
|
||||
} catch (err) {
|
||||
return res.status(500).send((err as any).message);
|
||||
}
|
||||
}
|
||||
|
||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
// Filter out if the reuqest is a POST or a GET
|
||||
if (req.method === 'POST') {
|
||||
return Post(req, res);
|
||||
}
|
||||
return res.status(405).json({
|
||||
statusCode: 405,
|
||||
message: 'Method not allowed',
|
||||
});
|
||||
};
|
||||
@@ -3,8 +3,6 @@ import { GetServerSidePropsContext } from 'next';
|
||||
import { useEffect } from 'react';
|
||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import AppShelf from '../components/AppShelf/AppShelf';
|
||||
import LoadConfigComponent from '../components/Config/LoadConfig';
|
||||
import { Config } from '../tools/types';
|
||||
@@ -52,6 +50,7 @@ export async function getServerSideProps({
|
||||
'modules/date',
|
||||
'modules/calendar',
|
||||
'modules/dlspeed',
|
||||
'modules/usenet',
|
||||
'modules/search',
|
||||
'modules/torrents-status',
|
||||
'modules/weather',
|
||||
@@ -64,8 +63,6 @@ export async function getServerSideProps({
|
||||
return getConfig(configName as string, translations);
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export default function HomePage(props: any) {
|
||||
const { config: initialConfig }: { config: Config } = props;
|
||||
const { setConfig } = useConfig();
|
||||
@@ -77,11 +74,9 @@ export default function HomePage(props: any) {
|
||||
setConfig(migratedConfig);
|
||||
}, [initialConfig]);
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Layout>
|
||||
<AppShelf />
|
||||
<LoadConfigComponent />
|
||||
</Layout>
|
||||
</QueryClientProvider>
|
||||
<Layout>
|
||||
<AppShelf />
|
||||
<LoadConfigComponent />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,41 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { UsenetQueueRequestParams, UsenetQueueResponse } from '../../pages/api/modules/usenet';
|
||||
import { Results } from 'sabnzbd-api';
|
||||
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 '../queryClient';
|
||||
import { UsenetResumeRequestParams } from '../../pages/api/modules/usenet/resume';
|
||||
|
||||
export const useGetUsenetInfo = (params: UsenetInfoRequestParams) =>
|
||||
useQuery(
|
||||
['usenetInfo', params.serviceId],
|
||||
async () =>
|
||||
(
|
||||
await axios.get<UsenetInfoResponse>('/api/modules/usenet', {
|
||||
params,
|
||||
})
|
||||
).data,
|
||||
{
|
||||
refetchInterval: 1000,
|
||||
keepPreviousData: true,
|
||||
retry: 2,
|
||||
}
|
||||
);
|
||||
|
||||
export const useGetUsenetDownloads = (params: UsenetQueueRequestParams) =>
|
||||
useQuery(
|
||||
['usenetDownloads', ...Object.values(params)],
|
||||
async () =>
|
||||
(
|
||||
await axios.get<UsenetQueueResponse>('/api/modules/usenet', {
|
||||
await axios.get<UsenetQueueResponse>('/api/modules/usenet/queue', {
|
||||
params,
|
||||
})
|
||||
).data,
|
||||
@@ -37,3 +61,91 @@ export const useGetUsenetHistory = (params: UsenetHistoryRequestParams) =>
|
||||
retry: 2,
|
||||
}
|
||||
);
|
||||
|
||||
export const usePauseUsenetQueue = (params: UsenetPauseRequestParams) =>
|
||||
useMutation(
|
||||
['usenetPause', ...Object.values(params)],
|
||||
async () =>
|
||||
(
|
||||
await axios.post<Results>(
|
||||
'/api/modules/usenet/pause',
|
||||
{},
|
||||
{
|
||||
params,
|
||||
}
|
||||
)
|
||||
).data,
|
||||
{
|
||||
async onMutate() {
|
||||
await queryClient.cancelQueries(['usenetInfo', params.serviceId]);
|
||||
const previousInfo = queryClient.getQueryData<UsenetInfoResponse>([
|
||||
'usenetInfo',
|
||||
params.serviceId,
|
||||
]);
|
||||
|
||||
if (previousInfo) {
|
||||
queryClient.setQueryData<UsenetInfoResponse>(['usenetInfo', params.serviceId], {
|
||||
...previousInfo,
|
||||
paused: true,
|
||||
});
|
||||
}
|
||||
|
||||
return { previousInfo };
|
||||
},
|
||||
onError(err, _, context) {
|
||||
if (context?.previousInfo) {
|
||||
queryClient.setQueryData<UsenetInfoResponse>(
|
||||
['usenetInfo', params.serviceId],
|
||||
context.previousInfo
|
||||
);
|
||||
}
|
||||
},
|
||||
onSettled() {
|
||||
queryClient.invalidateQueries(['usenetInfo', params.serviceId]);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export const useResumeUsenetQueue = (params: UsenetResumeRequestParams) =>
|
||||
useMutation(
|
||||
['usenetResume', ...Object.values(params)],
|
||||
async () =>
|
||||
(
|
||||
await axios.post<Results>(
|
||||
'/api/modules/usenet/resume',
|
||||
{},
|
||||
{
|
||||
params,
|
||||
}
|
||||
)
|
||||
).data,
|
||||
{
|
||||
async onMutate() {
|
||||
await queryClient.cancelQueries(['usenetInfo', params.serviceId]);
|
||||
const previousInfo = queryClient.getQueryData<UsenetInfoResponse>([
|
||||
'usenetInfo',
|
||||
params.serviceId,
|
||||
]);
|
||||
|
||||
if (previousInfo) {
|
||||
queryClient.setQueryData<UsenetInfoResponse>(['usenetInfo', params.serviceId], {
|
||||
...previousInfo,
|
||||
paused: false,
|
||||
});
|
||||
}
|
||||
|
||||
return { previousInfo };
|
||||
},
|
||||
onError(err, _, context) {
|
||||
if (context?.previousInfo) {
|
||||
queryClient.setQueryData<UsenetInfoResponse>(
|
||||
['usenetInfo', params.serviceId],
|
||||
context.previousInfo
|
||||
);
|
||||
}
|
||||
},
|
||||
onSettled() {
|
||||
queryClient.invalidateQueries(['usenetInfo', params.serviceId]);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
3
src/tools/queryClient.ts
Normal file
3
src/tools/queryClient.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
export const queryClient = new QueryClient();
|
||||
Reference in New Issue
Block a user